MarketTrace
M1PositioningM2FootprintM3LiquidationsM4FundingM5Volume Profile
Methodology · v2.0.0 · updated 2026-07-04

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.

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:

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:

Limitations

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.