Real-Time vs Post-Match
Our data partner publishes OBV post-match only. The canonical numbers — what professional clubs and national teams consume — are derived after the match ends and full event data is processed. For trading an in-play market, that’s too slow.
SportsPerp runs a parallel real-time pipeline that estimates OBV live during matches, then reconciles with the official post-match value once it’s published. This page explains how.
The three regimes
A market in SportsPerp exists in one of three states at any moment:
| Regime | When | Index source | Trader impact |
|---|---|---|---|
| Dormant | Between matches | Last batch cycle’s value (unchanged) | Price moves only when form/results shift over multiple matchdays |
| Live | From kick-off to final whistle | Baseline + real-time OBV overlay | Price moves on every meaningful event (goals, key passes, big saves) |
| Reconciling | Full-time → T+4h | 4-hour EMA blend of live estimate and official OBV | Price smoothly converges to the partner’s post-match authoritative value |
The transitions between these regimes are the critical design points.
Live regime: the real-time OBV overlay
When a match kicks off, the engine’s live-processor subscribes to the live event feed (GraphQL subscriptions) for that match. Live events come back keyed by live entity ids, which live in a different ID space from the canonical REST ids roster.json is keyed on. The id-bridge translates live → canonical ids fail-closed — any unmapped id drops the event and increments a counter rather than mis-attributing it. The translation is per-match: live-side ids are not guaranteed stable across matches for the same entity. V2 of the bridge is the current production setting and is verified periodically by a systemd timer on the production host.
After translation, every event is annotated with an OBV delta via the obv-engine Python sidecar (XGBoost PV-GF / PV-GA models, deployed on Hetzner port 8100), reachable over HTTP at POST /api/live-obv/matches/{id}/{start,events,end} and gated by the ENABLE_REALTIME_OBV env var. Authoritative team & player totals plus a per-(category × {net, gf, ga}) cross-tab are mirrored into engine/obv-store.ts.
The live index uses a pinned z-score population — the mean and standard deviation from the last batch cycle’s season stats are frozen and reused. Only teams and players actually on the pitch move; every other market’s index is mathematically invariant during the live window. This is a deliberate design choice:
- Without pinning: one live match’s events would shift the league’s mean/stdev, which would re-z-score every other team/player — creating ghost price movement in markets whose underlying players aren’t even playing. Unacceptable.
- With pinning: playing teams’ indices move cleanly in response to what happens on the pitch. Non-playing markets don’t move at all. The math is a clean delta, not a re-baseline.
See engine/live-index-overlay.ts for the implementation.
Tiered fallback chain
Live pipelines fail. The obv-engine sidecar can time out, the GraphQL connection can drop, or the upstream event stream can lag. SportsPerp handles this with a three-tier source-priority fallback (fallback-chain.ts):
| Tier | Label | Source | Used when |
|---|---|---|---|
| 1 | authoritative | Per-event OBV deltas from the Python XGBoost sidecar | Sidecar is healthy and responding within SLA |
| 2 | aggregated | Running season-rate estimate scaled by elapsed time | Sidecar unreachable but live event stream is working |
| 3 | heuristic | Signal reconstructed from shots, goals, and major defensive actions in the stream | GraphQL live stream partially degraded |
| 4 | frozen | Last-known index value, flagged stale | All live sources down — oracle continues to tick but with liveness=false |
Each tick carries the source tier in its metadata. Traders and integrators can see which tier is driving the current price — critical for risk management and for any downstream strategy that should throttle during degraded data quality.
Reconciling regime: the 4-hour EMA blend
When full-time is reached and the data partner publishes the official OBV for the match (typically ~30 minutes after the final whistle), the engine doesn’t snap the index to the official value. That would create a discontinuous jump that is both:
- Bad UX — the index just leapt 3% for no live reason
- Bad for positions — a jump across a liquidation threshold would trigger spurious liquidations
Instead, the engine smoothly blends from the live estimate to the official value over 4 hours using an exponential moving average:
α(t) = 1 − exp(−λ × t)
blended(t) = α(t) × official + (1 − α(t)) × live_estimate
where λ = ln(20) / 4h ≈ 7.45 × 10⁻⁵ per secondThis gives a specific convergence schedule:
| Time after official | α (blend weight) | What the index is mostly showing |
|---|---|---|
| 0 min | 0.00 | 100% live estimate |
| 30 min | 0.33 | 33% official, 67% live |
| 1 hour | 0.53 | 53% official, 47% live |
| 2 hours | 0.78 | 78% official — dominated by official |
| 4 hours | 0.95 | 95% official — converged |
| 8 hours | 0.998 | Effectively fully converged |
The constants are defined in engine/types.ts: EMA_LAMBDA = Math.log(20) / (4 * 3600).
Single-update cap
Blending alone isn’t enough. If the live estimate diverged sharply from the official value (rare but possible), even a gradual EMA blend could move the index faster than is comfortable for leveraged traders.
SportsPerp caps any single blended update at 3% of the previous value (EMA_MAX_CHANGE_FRACTION = 0.03):
const maxChange = Math.max(1.0, Math.abs(previousValue) * 0.03);
if (Math.abs(blended − previousValue) > maxChange) {
blended = previousValue + sign(blended − previousValue) × maxChange;
}This means a large live-vs-official gap resolves over multiple crank cycles rather than a single jump. Combined with the 5-minute crank cadence, the worst-case single-tick move is bounded; sustained convergence unfolds over the 4-hour window. See engine/ema-blender.ts.
Why this matters for traders
Three practical consequences:
- Live matches are the high-signal window. Most price discovery happens during live matches when the overlay is active. Between matches, indices drift slowly on form updates from completed fixtures.
- Post-match settlement is smooth. A position you hold through a match will not be jolted by a sudden snap-to-official at full-time. The index you see at 90+5 and the index you see at T+4h are connected by a continuous curve, not a step function.
- Data-source transparency. Every tick is labeled with its tier (
authoritative/aggregated/heuristic/frozen) and its blend progress (for post-match reconciling positions). Integrators and sophisticated traders can gate their own strategies on these flags.
Further reading
- Composite Index Design — the underlying formula that both live and batch paths feed.
- Data Pipeline — the REST and live-event sources that drive each regime.
- Mark Price — the 150-second EMA blend of oracle and vAMM that actually settles your PnL.