adr: 0019 title: "Match-event propagation — team-fallback, opponent drop, durable event_score" status: superseded superseded-by: 0028 implementation-status: implemented date: 2026-06-03 implemented-date: 2026-06-03 implemented-in: - ftl-backend feat/event-propagation - ftl-frontend feat/reactive-event-markers deciders: [@amalkrsihna] affects-specs: [amm-pricing, cfd-pricing-and-wallet] affects-code: - ftl-backend/internal/sportmonks/event_propagation.go - ftl-backend/internal/sportmonks/ticker.go - ftl-backend/internal/sportmonks/postmatch.go - ftl-frontend/src/pages/Trade.tsx supersedes: null superseded-by: null
ADR-0019: Match-event propagation — team-fallback, opponent drop, durable event_score¶
Context¶
A goal is the single biggest market signal in football, but in the live ticker it barely moved prices. Three gaps, all confirmed in code:
- Unresolved scorer → no movement.
processEventslooked upsportmonks_player:<evt.PlayerID>and, on a miss, did a silentcontinue. The event's player id sometimes doesn't resolve to one of our instruments even though the player is in our squad (Sportmonks can emit a secondary id on the event vs the one we synced under) — so the bump was dropped and the scoring team didn't react. - Opponent never moved. There was zero cross-team code. A conceding side
only degrades via its own
GoalsConcededstat, and one goal yieldsGoalsConceded/2 = 0(integer division) — so a single goal moved the opponent by nothing. - Short-term only. Even for a resolved scorer, the event bump was the
transient
micro_bump, which decays at 0.85/cycle and is gone in ~140 s. The operator's requirement: live events must contribute to price long-term, not just for a couple of minutes.
Decision¶
In processEvents, on a goal/penalty (evaluated before the
scorer-resolution early-returns):
- Scorer unresolved → lift the whole scoring team as a fallback (+1.5 %
to every active instrument of that team). A resolved scorer is left to the
existing path (transient
micro_bumppop + durablematchScore→EWMA), so no double-handling. - Always → drop every active opponent (−2 % outfield, −3 % goalkeeper).
Team membership resolves via the participants payload (team name) and the
active-instruments-by-club/country query. Magnitudes are percent-of-anchor
constants (goalTeamFallbackPct, goalConcedePct, goalConcedeGKPct).
Durable contribution. Each propagated move writes two parts:
- a durable
instrument:<id>.event_score(new hash field) that is folded intolive_base_priceon every poll (processPlayerStats) and does not decay during the match, and - the existing transient
micro_bumppop.
So the move shows immediately and stays for the rest of the match instead of
fading in ~140 s. event_score is capped at ±MicroBumpCapPercentOfBase of the
anchor and is reset at full-time and absence-decay (postmatch.go) — the
match outcome it reflected is by then folded into the new base_price via EWMA,
so the cross-match carry comes from the existing form-index path, not from
double-counting event_score.
Frontend. Trade.tsx polls getMatchEvents every 20 s while the player's
club is in a live match, so a goal scored after the page opened annotates the
chart (and the price move it drives is visible) without navigating away.
Consequences¶
- Positive: a goal now lifts the scoring side (the scorer precisely, or the whole team on fallback) and visibly sinks the opponent — the market reacts to the match, durably.
- Positive: events that never resolve to a specific instrument no longer vanish; the team-wide fallback guarantees a reaction.
- Neutral: a goal triggers one PG query per affected team (~26 rows) plus a handful of Redis writes — negligible for an event as rare as a goal.
- Risk: a match first detected mid-progress with many historical goals replays them all on the first poll (dedup gates re-processing thereafter). The one-time cost is bounded and goals are infrequent; accepted.
- Risk: propagation only fires on event types
isGoalEventrecognises (goal,penalty) — aligned with the existingEventBumptable. Own-goals and missed penalties are intentionally left to the stat path.
Alternatives considered¶
Alternative A: Auto-register the unresolved scorer instead of a team fallback¶
When the event's player id is unknown, create an instrument for them on the fly.
Rejected (per operator). The players already exist in our squad — the event just can't be resolved to a specific instrument. A team-wide fallback guarantees the side reacts regardless, and never silently loses the signal, without spawning duplicate instruments. Auto-register stays out of scope.
Alternative B: Make event_score feed the FT EWMA too (cross-match carry)¶
Fold event_score into the form-index EWMA at full-time so events carry into
future matches.
Rejected. matchScore (from the goal/assist stats) already feeds the EWMA,
so adding event_score on top would double-count a goal in the cross-match
carry. event_score is deliberately a within-match durable layer (reset at
FT); long-term cross-match value comes from the existing performance-driven EWMA.
Alternative C: Bigger, decaying opponent bump via micro_bump only¶
Just apply a large negative micro_bump to opponents and skip event_score.
Rejected. micro_bump decays in ~140 s — exactly the "short-term only"
problem the operator called out. The durable event_score is what makes the
opponent's drop persist for the match.
Verification¶
Unit tests (miniredis + pure): isGoalEvent, participant-name/opponent
resolution, clampAbs, isGoalkeeper, and bumpInstrumentEvent (durable +
transient + recomputed live_base_price + cap). golangci-lint 2.12.2 clean;
backend + frontend deployed to staging.