Skip to content

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:

  1. Offset reset every window. selectPattern returned offset = 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.

  2. Offset wiped every poll. ticker.processPlayerStats HDEL-ed noise_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.

  3. 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_scorelive_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.

  1. The offset carries. loadOrSelectPattern seeds 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.

  2. Gentle global mean-reversion. Each tick, offset -= offset / tauTicks where tau = 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 = 0 disables reversion (offset then bounded only by the clamp).

  3. Tight noise clamp. clampOffset bounds 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.

  4. Near-real-time live-match ticks. processPlayerStats stamps live_match_until = now + 30 s each poll (instead of wiping the noise state). The noise loop ticks any player with now < live_match_until every cycle, even between trades/events; idle non-match players stay activity-gated (no wasted fan-out). NOISE_DELTA_SKIP_PCT default drops 1 % → 0 so every tick whose rounded price changed publishes.

  5. Direction is symmetric. trend_up/trend_down share 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_PCT if 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/sportmonks tests run the real stepPattern / 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 run 0 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.