Skip to content

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:

  1. 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).
  2. 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.lua write instrument:<id>.last_trade_at (epoch s) on every trade.
  • HydrateRedis seeds tier + form_index into 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.1 and now − last_trade_at > NOISE_ACTIVITY_QUIET_THRESHOLD_S) it still refreshes last_noisy_price (for slippage checks) but skips the price:* 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_index favours trend-up, low favours trend-down; a fresh micro_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.