Market positioning (OBI × CVD)
How the OBI × CVD quadrant is sourced, sized and read: depth-derived imbalance, taker-flow CVD, rolling P95 normalisation, zone classification, HTF regime, freshness rules.
See the live widget at /perpetuals/positioning.
Data sources
Two Binance USDT-M futures streams feed a single per-asset microstructure daemon. The quadrant is single-source at v1.0.0; cross-exchange expansion (per-venue OBI and CVD plus a divergence flag) is planned for a follow-up release.
- Depth.
wss://fstream.binance.com/stream?streams=<symbol>@depth@100ms— diff stream applied to a REST-snapshotted book (1000 levels). The maintained book backs both the OBI calc and the broader depth surfaces. - Taker prints.
wss://fstream.binance.com/market/ws/<symbol>@aggTrade— every aggregated taker print, with theis_buyer_makerflag that gives the sign of flow.
The daemon scripts/microstructure_daemon.py writes a 30 s heartbeat snapshot to data/<asset>/microstructure_snapshot.json and a per-minute bar to data/<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. Levels outside the band are excluded because they do not affect a trade that takes the spread.
mid = (best_bid + best_ask) / 2 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 50 levels per side that fall inside the band. Plotted as the X-axis of the quadrant, unsmoothed. The widget shows OBI as a tag with three decimals so a 0.01 move is visible.
Cumulative volume delta (CVD)
CVD sums signed taker notional over a rolling window. The sign convention matches industry tape-reading: taker buys lift, taker sells press.
for each aggTrade event: notional = qty * price // USD if is_buyer_maker == false: 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. Per-event throughput sits at ~10–50 prints / s depending on venue 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.pyon the snapshot daemon'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 daemon. 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
- Single-source. v1.0.0 reads Binance only. A quadrant verdict therefore reflects Binance microstructure, not aggregate cross-exchange pressure. Cross-exchange OBI and CVD with a divergence indicator are planned for a follow-up release.
- Hard quadrant boundaries. A position 0.01 either side of zero on OBI flips the verbal verdict. No dead-band and no low-flow guard at v1.0.0 — interpret near-origin positions as undecided, not as a strict zone read.
- ±0.2 % band is a fixed knob. The OBI band is the same width across all assets. 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.
- Taker-side sign convention is exchange-dependent. The
is_buyer_makerflag on Binance aggTrade is the authoritative signal here. Cross-venue extensions will need to normalise side semantics first (Bybit and OKX encode side differently). - 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.
Versioning
Methodology version v2.0.0 · updated 2026-05-12. Material changes (added venues, formula tweaks, threshold changes) bump the version and update dateModified in the structured data above.