MarketTrace
M1PositioningM2Order BookM3LiquidationsM4Funding
Methodology · v2.0.0 · updated 2026-05-12

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.

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:

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:

Limitations

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.