Skip to content

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:

  1. Unresolved scorer → no movement. processEvents looked up sportmonks_player:<evt.PlayerID> and, on a miss, did a silent continue. 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.
  2. Opponent never moved. There was zero cross-team code. A conceding side only degrades via its own GoalsConceded stat, and one goal yields GoalsConceded/2 = 0 (integer division) — so a single goal moved the opponent by nothing.
  3. 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_bump pop + durable matchScore→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 into live_base_price on every poll (processPlayerStats) and does not decay during the match, and
  • the existing transient micro_bump pop.

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 isGoalEvent recognises (goal, penalty) — aligned with the existing EventBump table. 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.