adr: 0018 title: "Activity-gated, pattern-based synthetic noise" status: superseded superseded-by: 0030 implementation-status: implemented date: 2026-06-03 implemented-date: 2026-06-03 implemented-in: - ftl-backend feat/noise-activity-gate - ftl-backend feat/noise-pattern-engine 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/sportmonks/postmatch.go - ftl-backend/internal/redis/lua/position_open.lua - ftl-backend/internal/redis/lua/position_close.lua - ftl-backend/internal/instrument/service.go - ftl-backend/internal/config/config.go supersedes: null superseded-by: null
ADR-0018: Activity-gated, pattern-based synthetic noise¶
Context¶
The server-side synthetic-noise loop (which keeps every client's chart moving between real Sportmonks ticks) had two problems found in play-testing:
- It fired everywhere. Every instrument in
instrument_idx(hundreds) got a published tick every 2 s, including bench players with no match-day activity — wasteful fan-out and unrealistic (idle players "traded" as actively as the pitch). - It was boring. The price was memoryless uniform jitter
(
noisy = base × (1 ± amplitude)) — a flat ±0.2 % band with no trends, breakouts, or character. The chart never went anywhere.
(This is distinct from ADR-0016/0017. The prior noise hotfix only made the loop publish at all, and ADR-0017 made it anchor on the AMM-shifted price.)
Decision¶
Two changes, shipped as paired PRs (B1 then B2), both env-flagged.
B1 — Activity gate¶
Publish noise only for instruments with real activity:
position_open.lua/position_close.luawriteinstrument:<id>.last_trade_at(epoch s) on every trade.HydrateRedisseedstier+form_indexinto the instrument hash (also B2 inputs; read in the same noise HMGET).- The noise loop skips instruments that are explicitly inactive
(
is_active∈ {0,false}), and for "quiet" instruments (|micro_bump| < 0.1andnow − last_trade_at > NOISE_ACTIVITY_QUIET_THRESHOLD_S) it still refresheslast_noisy_price(for slippage checks) but skips theprice:*fan-out. - Config:
NOISE_ACTIVITY_GATE_ENABLED(default true),NOISE_ACTIVITY_QUIET_THRESHOLD_S(default 60).
B2 — Pattern engine¶
Replace uniform jitter with a per-instrument trading-pattern state machine.
Each instrument follows a randomly-chosen pattern for a window of ticks, then
re-rolls: trend_up, trend_down, breakout (gap + drift), consolidation
(tight band), reversal, wash (range-bound), spike (shock + revert).
- Selection is a weighted roll biased by live signals: high
form_indexfavours trend-up, low favours trend-down; a freshmicro_bump(goal/card) favours breakout/spike; a long-quiet instrument favours consolidation/wash; position nudges volatility (FWD spikier, GK calmer). Amplitude scales per tier (S 1.5× … D 0.6×). - The pattern produces a fractional offset applied to the AMM-shifted anchor (ADR-0017), so it composes additively with trades and permanent impact. The offset is clamped (±0.25) and the clamped value is what persists.
- State lives in the instrument hash (
noise_pattern+ 5 fields, ~80 bytes) and is read/written in the same noise pipeline — zero extra round-trips. - A real player event (
ticker.go), full-time, or absence-decay (postmatch.go) HDels the pattern so it re-rolls off the new live price. - Config:
NOISE_PATTERN_ENABLED(default true; kill switch reverts to legacy uniform jitter),NOISE_PATTERN_MIN_WINDOW_TICKS(10),NOISE_PATTERN_MAX_WINDOW_TICKS(60).
Consequences¶
- Positive: pub/sub fan-out drops from every instrument to the handful with real activity. Charts look like a live market — varied, directional, with character — instead of a flat jitter band.
- Positive: every behaviour is env-flagged, so the gate or the pattern engine can be turned off independently on staging without a code change.
- Neutral: the pattern adds ~80 bytes/instrument of Redis hash state and a bit of per-tick CPU; both are negligible at the instrument cardinality.
- Risk: the pattern selector's weighting is a feel decision; the moderate default (≈±1.8 % max trend over ~2 min for an S-tier player) is a starting point and tunable via amplitude.
Alternatives considered¶
Alternative A: Keep uniform noise, just gate it¶
Ship only B1 (the activity gate) and leave the jitter memoryless.
Rejected. The gate fixes the fan-out waste but not the "boring chart" complaint — the whole point of the redesign is a market that looks like a market. The gate and the pattern engine are complementary; both shipped.
Alternative B: Drive patterns from a central scheduler, not per-instrument¶
Pick one global market "regime" and apply it to all instruments.
Rejected. Real markets aren't synchronized — different players trend differently. Per-instrument state (biased by that player's own form, events, tier) produces a believable, decorrelated tape. The per-instrument cost is trivial.
Alternative C: Compute noise client-side¶
Have each client generate its own jitter.
Rejected. Cross-user price consistency requires a single server-side source of truth (two users must see the same price). That is the whole reason the noise loop is server-side; moving it to the client reintroduces divergence.
Verification¶
Unit tests (miniredis): activity gate (quiet→not published but last_noisy_price
updated, recent-trade→published, micro_bump→published, inactive→skipped);
pattern transitions across 120 ticks with the offset staying clamped; a forced
trend_up moves price net up; tier amplitude multipliers; deterministic weighted
pick; kill-switch reverts to uniform. golangci-lint clean; deployed to staging.