adr: 0031 title: "Event + stat ripple engine (role-weighted, both teams, all event/stat types)" status: accepted implementation-status: implemented date: 2026-06-09 implemented-date: 2026-06-09 implemented-in: - ftl-backend feat/all-event-price-variation deciders: [@amalkrsihna] affects-specs: [pricing-ripple-engine, amm-pricing, event-ingestion] affects-code: - ftl-backend/internal/sportmonks/propagation_matrix.go - ftl-backend/internal/sportmonks/event_propagation.go - ftl-backend/internal/sportmonks/ticker.go - ftl-backend/internal/sportmonks/scoring.go - ftl-backend/internal/handler/admin.go - ftl-backend/cmd/api-server/main.go supersedes: null superseded-by: null
ADR-0031: Event + stat ripple engine¶
This is the source-of-truth decision for cross-team / cross-role price propagation as designed and built on 2026-06-09. It is independent of the earlier WC26-cycle proposal ADR-0028 (position-aware 8-knob propagation), which targeted a different branch family (
integration/wc26-player-market-platform, a Postgresevent_impact_configtable, behaviour-preserving 1.5/2.0/3.0 seeds) and has not shipped to this integration line. This ADR ships onfeat/pre-cooldown-rebuildwith admin-overlay config (no new table) and the redesigned Option-C magnitudes. Where both eventually meet, this ADR is the live behaviour on this line.
Context¶
Two structural gaps made on-chart prices feel disconnected from the match:
- Most events moved only the actor. Only
goal/penaltypropagated across the match (the hardcodedgoalTeamFallbackPct/goalConcedePct/goalConcedeGKPctconstants inevent_propagation.go). The other 18 discrete event types and every per-player stat delta moved at most the single actor — and when Sportmonks omitted theplayer_id, the bump was silently dropped entirely (ticker.goearly-return), so e.g. a team-only yellow card produced a chart marker with no price move. - 15 stat-delta types had no configured bump.
detectStatDeltaspushed a chart marker for 22 stat types butEventBump()returned 0 for 15 of them (tackle, shot_on_target, key_pass, …) → marker with no price effect.
Separately, the stat-delta marker push wrote createdAt in seconds while
discrete events used milliseconds; after the frontend chart standardised on
Date(createdAt) (no ×1000), every stat marker landed at ~1970 and was dropped
by the chart's visible-window filter.
Decision¶
Replace the goal-only propagation with a general, type- and role-aware ripple engine. Every event and every marker-eligible stat increment moves prices on EVERY player on BOTH teams, scaled by:
- event/stat type — a
RippleMatrixrow, - relationship to the actor — teammate (same direction) vs opponent (opposite),
- role — GK / DEF / MID / FWD.
The opposing role most directly responsible for an event (the goalkeeper on a
goal) takes the heaviest hit, locked at 0.75 of primary magnitude ("Option
C"). Full matrices live in specs/pricing-ripple-engine.md.
Key pieces:
propagation_matrix.go—RippleMatrix(8 factors), a unifieddefaultRippletable keyed bynormalizeEventTypecovering all 20 events + the marker-eligible stat types,RippleFor(type),canonicalRole(position),orphanTeamFactor, andSetEventRipplehot-reload (mirrorsSetEventBumps).applyRipple(event_propagation.go) — fans a primary bump out to both teams' rosters by role via the existingbumpInstrumentEvent(durableevent_score+ transientmicro_bump, clamped atPercentOfBaseCap). The actor instrument is skipped (already got the primary). A per-pollrosterCachecollapses the active-squad PG lookup to one query per team per poll.redistributePrimary— when the actor'splayer_idis unresolved, spread the primary across the acting team byorphanTeamFactor[role]so the event still moves prices.- Wiring —
processEventsanddetectStatDeltasboth: apply the actor's primary (transientmicro_bump; durable capture comes from the matchScore path, so no double-count) thenapplyRippleacross both teams.processPlayerStatsis intentionally NOT rippled (it would double-count the same stats and fan out ~22× per poll). scoring.go—defaultEventBumpsextended with the 15 missing stat types.detectStatDeltas—createdAtfixed toUnixMilli;MaxEventsPerFixtureraised 100 → 500 so stat markers don't trim goals off the feed tail.- Admin —
score_event_points.event_ripple_matricesoverlay (validated inadmin.go, applied inmain.go), same omit-keeps-default semantics asevent_bumps.
Alternatives considered¶
- Postgres
event_impact_configtable (ADR-0028 shape). Rejected for this line: heavier (migrations, a new table, a config-version journal) than the admin-overlay JSON already used forevent_bumps. The overlay reaches parity for the tuning surface operators need. - Ripple
processPlayerStats(continuous matchScore delta). Rejected: double-counts the discrete stat deltas already rippled indetectStatDeltasand costs ~22× the Redis fan-out per poll. Discrete paths only. - Behaviour-preserving seeds (1.5/2.0/3.0). Rejected in favour of the redesigned Option-C magnitudes the product owner specified (goal +6%, opposing GK −4.5%).
Consequences¶
- Positive: every event and marker-eligible stat now moves the whole match, role-differentiated; player-less events no longer vanish; stat markers render again (createdAt fix). All bumps remain clamped per player.
- Cost: more Redis writes per poll during busy live windows (bounded by the roster cache + discrete-only rippling). Measured acceptable at the 50-fixture live ceiling.
- Limitation: continuous low-signal stats (passes, touches, duels) move only the actor's own price via matchScore — they don't ripple and don't get markers. Re-evaluate post-launch.
Migration / rollout¶
No schema change. event_ripple_matrices is another optional block in the
existing admin_settings.score_event_points JSONB. Ships with baked-in defaults;
admins opt into overrides. Going-forward only — no backfill of past matches.