fcfc136384
New microstructure/ module with pure-function analytics: microstructure/book.py: microprice() — depth-weighted mid price mid_price() — simple bid/ask midpoint order_book_imbalance() — ranged [-1, 1] volume skew depth_imbalance() — imbalance at fixed price distance spread_stats() — spread, spread_bps, mid, bid, ask depth_resiliency() — bid/ask volume within impact radius queue_depletion_prob() — Poisson fill probability at level batch_book_stats() — aggregate stats across snapshots microstructure/trades.py: classify_lee_ready() — Lee-Ready aggressor classification classify_bulk_lee_ready() — batch classification with mids/bids/asks compute_markouts() — forward mid-price change at configurable horizons markout_summary() — mean/std/t-stat per side per horizon trade_volume_profile() — size bucket distribution trade_arrival_rate() — rolling trades/sec with burst detection microstructure/toxicity.py: compute_vpin() — volume-synchronized informed trading probability compute_vpin_time_series() — rolling VPIN with alarm threshold fill_toxicity() — adverse price movement post-trade adverse_selection_ratio() — per-side adverse selection liquidation_clustering() — cluster detection in liquidation events microstructure/funding.py: funding_regime() — classify regime (neutral/positive/negative/high) funding_predictability() — AR(1) autocorrelation analysis funding_carry_pnl() — cumulative carry PnL estimation basis_spread() — perp premium over spot (bps) basis_convergence_speed() — mean-reversion half-life via AR(1) microstructure/signals.py: composite_signal() — weighted OBI + trade + VPIN + funding signal SignalPipeline — stateful pipeline accumulating book/trade updates detect_hft_regime() — regime classifier for HFT strategy selection Bug fixes in Phase 1: - data/latency.py: proper linear-interpolation percentiles - data/normalizer.py: UTC timezone for naive datetimes - data/normalizer.py: detect_sequence_gap returns gap-1 (missing count) - microstructure/toxicity.py: consistent vpin_value key in compute_vpin 81 tests across 4 test files (store, normalizer, latency, microstructure)
236 lines
7.4 KiB
Python
236 lines
7.4 KiB
Python
"""
|
|
Order book microstructure analytics.
|
|
|
|
Functions operate on book snapshots (bids/asks dicts or DataFrames)
|
|
and return time-series or summary stats.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from collections import deque
|
|
from typing import Optional
|
|
|
|
import numpy as np
|
|
|
|
|
|
# ── Microprice ───────────────────────────────────────────────
|
|
|
|
def microprice(
|
|
bids: dict[float, float],
|
|
asks: dict[float, float],
|
|
weight_bid: float = 0.5,
|
|
) -> float:
|
|
"""Weighted mid based on depth imbalance.
|
|
|
|
microprice = weight * bid_side + (1-weight) * ask_side
|
|
where weight = bid_depth / (bid_depth + ask_depth)
|
|
|
|
Falls back to simple mid if no depth.
|
|
"""
|
|
bid_prices = sorted(bids.keys(), reverse=True)
|
|
ask_prices = sorted(asks.keys())
|
|
|
|
if not bid_prices or not ask_prices:
|
|
return 0.0
|
|
|
|
best_bid = bid_prices[0]
|
|
best_ask = ask_prices[0]
|
|
|
|
bid_vol = sum(bids[px] for px in bid_prices[:10])
|
|
ask_vol = sum(asks[px] for px in ask_prices[:10])
|
|
total = bid_vol + ask_vol
|
|
if total == 0:
|
|
return (best_bid + best_ask) / 2.0
|
|
|
|
w = bid_vol / total
|
|
return w * best_bid + (1 - w) * best_ask
|
|
|
|
|
|
def mid_price(bids: dict[float, float], asks: dict[float, float]) -> float:
|
|
bid_prices = sorted(bids.keys(), reverse=True)
|
|
ask_prices = sorted(asks.keys())
|
|
if not bid_prices or not ask_prices:
|
|
return 0.0
|
|
return (bid_prices[0] + ask_prices[0]) / 2.0
|
|
|
|
|
|
# ── Order-book imbalance ─────────────────────────────────────
|
|
|
|
def order_book_imbalance(
|
|
bids: dict[float, float],
|
|
asks: dict[float, float],
|
|
levels: int = 10,
|
|
) -> float:
|
|
"""OBI = (bid_vol - ask_vol) / (bid_vol + ask_vol). Range [-1, 1]."""
|
|
bid_prices = sorted(bids.keys(), reverse=True)[:levels]
|
|
ask_prices = sorted(asks.keys())[:levels]
|
|
bid_vol = sum(bids[px] for px in bid_prices)
|
|
ask_vol = sum(asks[px] for px in ask_prices)
|
|
total = bid_vol + ask_vol
|
|
if total == 0:
|
|
return 0.0
|
|
return (bid_vol - ask_vol) / total
|
|
|
|
|
|
def depth_imbalance(
|
|
bids: dict[float, float],
|
|
asks: dict[float, float],
|
|
price_distance_pct: float = 0.01,
|
|
) -> float:
|
|
"""Imbalance at a fixed price distance from mid (percentage-based)."""
|
|
mid = mid_price(bids, asks)
|
|
if mid <= 0:
|
|
return 0.0
|
|
lo = mid * (1 - price_distance_pct)
|
|
hi = mid * (1 + price_distance_pct)
|
|
bid_vol = sum(sz for px, sz in bids.items() if px >= lo)
|
|
ask_vol = sum(sz for px, sz in asks.items() if px <= hi)
|
|
total = bid_vol + ask_vol
|
|
if total == 0:
|
|
return 0.0
|
|
return (bid_vol - ask_vol) / total
|
|
|
|
|
|
# ── Spread statistics ────────────────────────────────────────
|
|
|
|
def spread_stats(
|
|
bids: dict[float, float],
|
|
asks: dict[float, float],
|
|
) -> dict:
|
|
bid_prices = sorted(bids.keys(), reverse=True)
|
|
ask_prices = sorted(asks.keys())
|
|
if not bid_prices or not ask_prices:
|
|
return {"spread": 0, "spread_bps": 0, "mid": 0, "bid": 0, "ask": 0}
|
|
|
|
best_bid = bid_prices[0]
|
|
best_ask = ask_prices[0]
|
|
mid = (best_bid + best_ask) / 2.0
|
|
spread = best_ask - best_bid
|
|
spread_bps = (spread / mid * 10000) if mid > 0 else 0
|
|
return {
|
|
"spread": round(spread, 2),
|
|
"spread_bps": round(spread_bps, 2),
|
|
"mid": round(mid, 2),
|
|
"best_bid": best_bid,
|
|
"best_ask": best_ask,
|
|
}
|
|
|
|
|
|
# ── Depth resiliency ──────────────────────────────────────────
|
|
|
|
def depth_resiliency(
|
|
bids: dict[float, float],
|
|
asks: dict[float, float],
|
|
impact_bps: float = 10.0,
|
|
) -> dict:
|
|
"""How much size sits within N bps of mid on each side.
|
|
|
|
Returns volume and level count within the impact radius, plus
|
|
a resiliency score: bid_depth / ask_depth (ratio).
|
|
"""
|
|
mid = mid_price(bids, asks)
|
|
if mid <= 0:
|
|
return {"bid_vol": 0, "ask_vol": 0, "bid_levels": 0, "ask_levels": 0, "resiliency": 0}
|
|
|
|
radius = mid * impact_bps / 10000
|
|
bid_lo = mid - radius
|
|
ask_hi = mid + radius
|
|
|
|
bid_vol = sum(sz for px, sz in bids.items() if px >= bid_lo)
|
|
ask_vol = sum(sz for px, sz in asks.items() if px <= ask_hi)
|
|
bid_levels = sum(1 for px in bids if px >= bid_lo)
|
|
ask_levels = sum(1 for px in asks if px <= ask_hi)
|
|
|
|
resiliency = bid_vol / ask_vol if ask_vol > 0 else float("inf")
|
|
return {
|
|
"bid_vol": round(bid_vol, 8),
|
|
"ask_vol": round(ask_vol, 8),
|
|
"bid_levels": bid_levels,
|
|
"ask_levels": ask_levels,
|
|
"resiliency": round(resiliency, 4),
|
|
}
|
|
|
|
|
|
def queue_depletion_prob(
|
|
bids: dict[float, float],
|
|
asks: dict[float, float],
|
|
level_distance: int = 0,
|
|
trade_rate_per_sec: float = 1.0,
|
|
avg_trade_size: float = 0.01,
|
|
) -> float:
|
|
"""Probability queued order at best level(s) gets filled within 1 second.
|
|
|
|
Simple Poisson model: P(fill) = 1 - exp(-λ)
|
|
where λ = trade_rate * avg_trade_size / depth_at_level
|
|
|
|
level_distance=0 means best bid/ask, 1 = one level behind, etc.
|
|
"""
|
|
bid_prices = sorted(bids.keys(), reverse=True)
|
|
ask_prices = sorted(asks.keys())
|
|
if not bid_prices or not ask_prices:
|
|
return 0.0
|
|
|
|
px = bid_prices[min(level_distance, len(bid_prices) - 1)]
|
|
depth = bids.get(px, 0)
|
|
if depth <= 0:
|
|
return 0.0
|
|
|
|
lam = trade_rate_per_sec * avg_trade_size / depth
|
|
prob = 1.0 - math.exp(-lam)
|
|
return round(min(prob, 0.9999), 6)
|
|
|
|
|
|
# ── Batch processing ─────────────────────────────────────────
|
|
|
|
def batch_book_stats(
|
|
snapshots: list[dict],
|
|
levels: int = 10,
|
|
) -> dict:
|
|
"""Process a list of book snapshots and return aggregate stats.
|
|
|
|
Each snapshot: {"bids": {price: size, ...}, "asks": {price: size, ...}}
|
|
"""
|
|
obis = []
|
|
spreads_bps = []
|
|
micros = []
|
|
depths = []
|
|
resiliencies = []
|
|
|
|
for snap in snapshots:
|
|
bids = snap.get("bids", {})
|
|
asks = snap.get("asks", {})
|
|
if not bids or not asks:
|
|
continue
|
|
obis.append(order_book_imbalance(bids, asks, levels))
|
|
ss = spread_stats(bids, asks)
|
|
spreads_bps.append(ss["spread_bps"])
|
|
micros.append(microprice(bids, asks))
|
|
dr = depth_resiliency(bids, asks)
|
|
depths.append(dr["bid_vol"] + dr["ask_vol"])
|
|
resiliencies.append(dr["resiliency"])
|
|
|
|
def _summarize(vals):
|
|
if not vals:
|
|
return {"mean": 0, "median": 0, "std": 0, "min": 0, "max": 0, "count": 0}
|
|
a = np.array(vals, dtype=float)
|
|
a = a[np.isfinite(a)]
|
|
return {
|
|
"mean": round(float(np.mean(a)), 4),
|
|
"median": round(float(np.median(a)), 4),
|
|
"std": round(float(np.std(a)), 4),
|
|
"min": round(float(np.min(a)), 4),
|
|
"max": round(float(np.max(a)), 4),
|
|
"count": len(a),
|
|
}
|
|
|
|
return {
|
|
"obi": _summarize(obis),
|
|
"spread_bps": _summarize(spreads_bps),
|
|
"microprice_ratio": _summarize([m / mid_price(s["bids"], s["asks"])
|
|
if mid_price(s["bids"], s["asks"]) > 0 else 1.0
|
|
for s, m in zip(snapshots, micros)]),
|
|
"depth_total": _summarize(depths),
|
|
"resiliency": _summarize([r for r in resiliencies if r < 1e6]),
|
|
}
|