adr: 0021 title: "Mean-reverting random-walk noise offset; near-real-time live-match ticks" status: superseded superseded-by: 0030 implementation-status: implemented date: 2026-06-03 implemented-date: 2026-06-03 implemented-in: - ftl-backend feat/noise-mean-reverting-walk deciders: [@amalkrsihna] affects-specs: [amm-pricing] affects-code: - ftl-backend/internal/sportmonks/noise.go - ftl-backend/internal/sportmonks/noise_pattern.go - ftl-backend/internal/sportmonks/ticker.go - ftl-backend/internal/config/config.go supersedes: null superseded-by: null
ADR-0021: Mean-reverting random-walk noise offset; near-real-time live-match ticks¶
Context¶
The pattern-based synthetic noise (ADR-0018) produces a fractional OFFSET from
the AMM-shifted price (noisy = ammBase * (1 + offset)). In live play the chart
"stayed in a range" and "came back to centre" no matter how a player trended, and
looked frozen between trades. Three causes:
-
Offset reset every window.
selectPatternreturnedoffset = 0, so at every pattern-window boundary the displayed price snapped back to the AMM anchor. A trend could never accumulate beyond a single ~10–60-tick window. -
Offset wiped every poll.
ticker.processPlayerStatsHDEL-ednoise_pattern_offset(and the rest of the pattern state) on every Sportmonks poll for each live-fixture player — i.e. every ~2 s for exactly the players being watched. Even with cause 1 fixed, the live player's offset was wiped before it could build. -
Sparse, non-real-time publishes. The 1 % delta-skip suppressed most sub-percent ticks, and the activity gate (ADR-0018 B1) silenced any player with no trade in the last 60 s and no live event — so a live-match player with a quiet minute stopped ticking.
The offset is cosmetic — the durable, dramatic price moves come from events
(event_score → live_base_price) and trades (net_position_imbalance,
permanent_impact), all of which enter ammBase and are independent of the
noise offset.
Decision¶
Model the noise offset as a mean-reverting random walk that carries across windows, and make live-match players tick in near-real-time.
-
The offset carries.
loadOrSelectPatternseeds a freshly-rolled pattern with the persisted offset instead of 0. Patterns compose additively with the carry: breakout/spike apply their kick with+=(were absolute sets that wiped the carry); consolidation oscillates around the carried offset with a mild settle; reversal shifts both its target and offset by the carry so its half-window crossing still fires. -
Gentle global mean-reversion. Each tick,
offset -= offset / tauTickswheretau = NOISE_MEANREVERT_MINUTES(default 10 min;tauTicks = minutes × 60000 / NOISE_INTERVAL_MS). The offset is free to trend for 1–2 min (window ≪ tau) but the pure-noise component nets ~0 over ~tau.NOISE_MEANREVERT_MINUTES = 0disables reversion (offset then bounded only by the clamp). -
Tight noise clamp.
clampOffsetbounds the offset to ±0.08 (was ±0.25). 8 % is a plausible "this player is hot" overlay; ±25 % let a sustained-trend S-tier player pin at a front-runnable distortion that dwarfed the real signal. The big moves are uncapped because they live outside the offset. -
Near-real-time live-match ticks.
processPlayerStatsstampslive_match_until = now + 30 seach poll (instead of wiping the noise state). The noise loop ticks any player withnow < live_match_untilevery cycle, even between trades/events; idle non-match players stay activity-gated (no wasted fan-out).NOISE_DELTA_SKIP_PCTdefault drops 1 % → 0 so every tick whose rounded price changed publishes. -
Direction is symmetric.
trend_up/trend_downshare an equal base weight and breakout/spike/reversal pick a random sign, so a fresh walk goes up or down with equal probability.
Consequences¶
- Positive: a player's price can genuinely trend for a couple of minutes and hold, instead of snapping back to the anchor each window — the core complaint.
- Positive: live-match players tick every ~2 s for a real-time feel.
- Positive: events and trades persist and stack on top of the noise; only the noise mean-reverts, so a goal/trade move stays while the jitter washes out.
- Neutral: the worst-case sustained-trend overlay is ±8 % (clamp). Acceptable
for a cosmetic layer; tune via the clamp constant or
NOISE_MEANREVERT_MINUTES. - Cost: with delta-skip 0, every live-match player publishes each ~2 s.
Bounded by the activity gate to live-match + recently-traded players; throttle
via
NOISE_DELTA_SKIP_PCTif pub/sub volume needs capping at full tournament scale. Cheap players (price ≈ 1.00) self-throttle — sub-cent jitter rounds to the same value and the exact-0 delta suppresses the publish.
Alternatives considered¶
Alternative A: Keep resetting the offset but anchor on the live price¶
Reset the offset each window but recompute around the latest live_base_price.
Rejected. That still snaps the noise back to 0 each window — the visible
"comes back to centre". Carrying the offset is what lets a trend persist; the
anchor already updates independently via live_base_price.
Alternative B: Pure random walk, no mean-reversion (clamp only)¶
Let the offset random-walk, bounded solely by the ±0.08 clamp.
Rejected. Without reversion the offset biases toward the clamp under any
sustained drift and lingers there; the user explicitly wanted the noise to "cancel
out" over ~15–20 min. Mean-reversion provides that long-term pull while the clamp
caps the tail. (Kept as the NOISE_MEANREVERT_MINUTES = 0 opt-out.)
Alternative C: Re-roll the pattern on every real event (keep the HDEL)¶
Preserve the ticker's per-poll pattern wipe so a real event restarts the noise.
Rejected. The wipe fired every poll, not only on events, and the event's move
already lands durably in live_base_price; the fractional offset rides on top
correctly without a reset. Wiping it was the dominant snap-back for live players.
Verification¶
internal/sportmonkstests run the realstepPattern/loadOrSelectPattern: the offset carries on a window re-roll (not reset to 0); a held offset mean-reverts toward 0 over ~tau; a trend accumulates a visible move over a short window; and a 5-window same-direction worst case stays within the ±0.08 clamp while remaining elevated (carry preserved).go test ./...green;golangci-lint run0 issues.- On staging during a live match: a watched player's chart ticks every ~2 s, can trend for 1–2 min, and the pure-noise component washes out over ~10–20 min while a goal/trade move stays.