Order flow methodology — OBI × CVD quadrant
How the order-flow quadrant is sourced, sized and read: depth-derived order-book imbalance, taker-flow CVD, rolling P95 normalisation, zone classification, HTF regime, freshness rules.
See the live widget at /perpetuals/positioning.
Data sources
The quadrant aggregates resting depth and taker flow across four perpetual venues — Binance, Bybit, OKX and Hyperliquid — for both axes. The runtime is all-Rust; there are no Python daemons on the hot path.
- Depth ingest. The
mt-ingestbinary subscribes to each venue's futures depth feed for all six assets, maintains a per-venue book atTOP_LEVELS_PER_SIDE = 200levels per side, and publishes each book toprod:<venue>:<asset>:depth. These per-venue books back the OBI calc. - Taker flow / CVD. The
mt-cvdbinary consumes every venue's trade stream into a cross-venue trade tape (prod:trades:*) and a running signed CVD (prod:*:cvd). Per-venue taker-side semantics are normalised here — Bybit, OKX and Hyperliquid encode the aggressor side differently from Binance, and all are mapped to one sign convention. - Cross-exchange aggregation. The
mt-aggdbinary joins the per-venue books and flows at 10 Hz, aligning venues on exchange-stampedevent_ts, and publishes the merged snapshot toprod:agg:<asset>:*. - Overlay + bars. The
mt-microstructurebinary computes the OBI/CVD overlay (prod:agg:*:micro) and writes one bar per minute todata/<asset>/microstructure_bars.jsonl— the trail history surface.
The backend /ws/market WebSocket pushes a snapshot every ~10 s and a trail/normaliser frame on subscribe and on window change.
Order-book imbalance (OBI)
OBI measures resting liquidity skew inside a tight band around the mid. It is computed per venuefirst, over that venue's own book; the cross-venue blend is described in the next section. Levels outside the band are excluded because they do not affect a trade that takes the spread.
mid = (best_bid + best_ask) / 2 // per venue band = [mid * 0.998, mid * 1.002] // ±0.2 % bid_qty = Σ qty of bids inside band ask_qty = Σ qty of asks inside band OBI = (bid_qty - ask_qty) / (bid_qty + ask_qty) Range: [-1, +1] +1 → asks empty, all weight on bids -1 → bids empty, all weight on asks 0 → balanced inside band
Computed over the top 200 levels per side that fall inside the band (TOP_LEVELS_PER_SIDE = 200). The plotted X-axis is the cross-venue blend below, unsmoothed at this stage; the widget shows OBI as a tag with three decimals so a 0.01 move is visible.
Cross-venue OBI aggregation (flow-weighted)
Each venue's per-venue OBI (above) is combined into a single cross-venue OBI weighted by that venue's recent taker flow— flow-weighted (FW) — not by inverse variance. A venue's weight tracks where trades are actually printing, so quiet books contribute little and active books drive the consensus.
On 2026-05-26 the aggregation was swapped from inverse-variance weighting (IVW) to FW. Under IVW, Hyperliquid dominated the blend: its nSigFigs-quantized (coarse) book produces an artificially low-variance OBI, which IVW rewards with a high weight regardless of how little size sits behind it. Flow-weighting ties weight to realised aggressor volume instead, so a coarse-quantization venue can no longer dominate the merged reading.
Cumulative volume delta (CVD)
CVD sums signed taker notional over a rolling window, across all four venues. The sign convention matches industry tape-reading: taker buys lift, taker sells press. Each venue's aggressor side is normalised at ingest (Bybit, OKX and Hyperliquid encode it differently from Binance), so every print contributes the same sign.
for each taker print (any venue): notional = qty * price // USD if aggressor == buy: delta = +notional // taker bought else: delta = -notional // taker sold append (ts, delta) to a 2-hour deque cvd_30m_usd = Σ delta for ts in [now - 30m, now] cvd_2h_usd = Σ delta for ts in [now - 2h, now]
The 2 h deque is the longest horizon; the 30 m window is sliced from the same buffer on each write. The bars file persists one row per minute with both windows so the trail can replay history after a restart. Aggregate throughput across the four venues sits at ~40–200 prints / s depending on activity.
Y-axis normaliser
Raw 30 m CVD magnitudes vary by two orders of magnitude across BTC ($1 M-class moves), ETH, SOL and the smaller perps. Plotting raw USD would compress smaller assets into a flat line. The Y-axis therefore divides by a rolling 7-day P95 of |cvd_30m_usd| per asset and clamps the result into [-1, +1].
y_norm = clamp(cvd_30m_usd / p95_30m_usd, -1, +1)
p95_30m_usd: rolling 7-day percentile, refreshed on the snapshot endpoint
fallback: 2_000_000 USD when the rolling percentile is not yet available
(cold start or thin sample)Endpoint /api/market/cvd-normalizer?asset=… serves the per-asset P95 number, mirrored over WebSocket as a normalizer frame so the FE can rescale the trail consistently when the value lands after the trail data.
Zone classification (v2)
The raw quadrant rule (sign of OBI, sign of CVD) is wrapped in a three-layer pipeline so the verdict doesn't flap on sub-bp noise. Each layer fires sequentially; only signals that survive all three become the displayed zone.
Layer 1 — EMA on OBI (CVD is already a sliding-window aggregate)
obi_ema(t) = α · obi_raw(t) + (1 - α) · obi_ema(t-1)
α = 1 - exp(-dt / span_seconds)
span_seconds: 30 s on the 30m trail (default; per-asset tunable)
Layer 2 — Magnitude deadband (Schmitt-trigger lower limb)
In the deadband, the candidate zone equals the current zone:
candidate_zone = current_zone iff |obi_ema| < OBI_DB
OR |cvd| < CVD_DB_PCT × p95_30m_usd
Otherwise quadrant of (obi_ema, cvd) is the candidate.
OBI_DB per asset: BTC/ETH 0.05, SOL 0.07, XRP 0.08, BNB 0.10, DOGE 0.12
CVD_DB_PCT 10 % of the asset's rolling 7d p95 |cvd_30m|
Layer 3 — Minimum tenure (Schmitt-trigger upper limb)
A new candidate must hold ≥ MIN_TENURE_S before becoming current.
MIN_TENURE_S 60 s on 30m trail; 300 s on 4h; 1800 s on 24h.Quadrant naming is unchanged from v1.0:
x >= 0 AND y >= 0 → "Buyers in control" x < 0 AND y < 0 → "Sellers dominating" x < 0 AND y >= 0 → "Demand absorbing" x >= 0 AND y < 0 → "Book supports"
While a candidate is waiting out its tenure, the FE renders an explicit candidate_pendingsub-line under the verdict (“Sellers · held for 4m 22s · Buyers pending 18s of 60s”). The classifier is a visible state machine, not a black box.
Parameters are tuned per asset from a 7-day grid sweep (scripts/replay_classifier.py); the selected values live in backend/classifier/config.py and may be env-overridden (e.g. MICRO_DEADBAND_DOGE=0.20) for hot-patching without redeploy. State (OBI EMA, candidate timers, entered-at) is persisted to Redis between aggregator cycles so a daemon restart resumes within the same zone instead of cold-starting.
Trail history
The trail draws a fade-by-age polyline of the last N quadrant positions. Selectable windows are 30m, 4h and 24h. Each window pulls a downsampled series from the bars file so a 24 h replay does not stream 1440 raw minutes:
- 30 m. 1-minute buckets.
- 4 h. 5-minute buckets.
- 24 h. 30-minute buckets.
The FE caps the rendered trail at 60 points so the 24 h window still fits without overplotting. Live snapshots append to the same buffer, leaving a wake behind the bucketed series. On asset switch the local buffer resets so a fresh quadrant is not contaminated by the previous asset's trail.
HTF regime tag
The Trend BULL/BEAR/NEUTRAL tag in the widget header is computed by htf_regime() in scripts/btc_monitor.py(a kept cron writer) on the snapshot cron's 5 m cadence:
pulls 300 1-minute candles (requires >= 289 valid bars) pct_4h = (close - close_4h_ago) / close_4h_ago * 100 pct_24h = (close - close_24h_ago) / close_24h_ago * 100 BULL if pct_4h > +0.5 OR pct_24h > +1.5 BEAR if pct_4h < -0.5 OR pct_24h < -1.5 NEUTRAL otherwise (also when sample is insufficient)
The tag is HTF context only, not a flow read. It exists so the quadrant's short-window verdicts (30 m CVD) can be sanity-checked against multi-hour direction.
Long/short ratio
The L/S stat reads Binance's globalLongShortAccountRatio on the 15 m period, polled every ~5 m by the snapshot cron. Values above 1.0 mean retail accounts are net long; values below 1.0 mean net short. This is positioning of accounts, not flow — it sits alongside OBI and CVD in the side panel rather than in the quadrant plane.
Freshness
Three independent signals can mark the widget as not-live:
micro_book_state≠"ok"— daemon is mid-resync after a WS reconnect; OBI is stale until the REST snapshot lands again. The status pill switches toDaemon <state>.micro_stale— heartbeat is older than its window. FE stops appending new points to the trail.- Trail flagged
partial: true— the jsonl backing the window is shorter than the requested lookback (typical right after a daemon restart). An amber pill appears on the canvas.
Limitations
- Cross-exchange temporal skew. The aggregator joins venues on exchange-stamped
event_ts, but at 10 Hz the alignment window is only ~100 ms wide. During a fast move the same price event can land in adjacent snapshots on different venues, so a single frame's cross-venue OBI can briefly disagree with itself. - Coarse-quantization venues. Hyperliquid publishes a
nSigFigs-quantized book, so its per-venue OBI is coarse. Flow-weighting (which replaced inverse-variance weighting on 2026-05-26) caps how much it can move the blend, but it still enters the consensus — read HL's contribution as directional, not precise. - ±0.2 % band is a fixed knob. The OBI band is the same width across all assets and venues. Tight-spread majors (BTC, ETH) see a deeper book inside the band than thin-book alts, which is part of why CVD is the primary verdict and OBI is the supporting axis.
- L/S is Binance retail positioning. The ratio counts accounts on Binance, not aggregated cross-venue, and weights all accounts equally regardless of size. Use it as a soft context, not a flow input.
- HTF regime is BTC-style thresholds for every asset. The 4 h ±0.5 % / 24 h ±1.5 % thresholds were tuned against BTC. For higher-vol assets (DOGE, SOL) BULL/BEAR fires earlier than feels natural — treat the tag as a coarse context, not a calibrated trend filter.
- The quadrant is descriptive, not predictive. It reads current order-book and taker pressure; it is not a forecast of the next move. Treat a zone as a snapshot of where flow sits now.
Versioning
Methodology version v2.0.0 · updated 2026-07-04. Material changes (added venues, formula tweaks, threshold changes) bump the version and update dateModified in the structured data above.