adr: 0022 title: "Comprehensive event-bump coverage; key normalisation in EventBump" status: accepted implementation-status: implemented date: 2026-06-04 implemented-date: 2026-06-04 implemented-in: - ftl-backend feat/event-bump-coverage deciders: [@vaishnav-s-01] affects-specs: [amm-pricing] affects-code: - ftl-backend/internal/sportmonks/scoring.go - ftl-backend/internal/sportmonks/events_feed.go - ftl-backend/migrations/000040_normalize_and_extend_event_bumps.up.sql supersedes: null superseded-by: null
ADR-0022: Comprehensive event-bump coverage; key normalisation in EventBump¶
Context¶
The match chart's marker overlay (PR ftl-frontend#132, PriceChart.tsx) draws
an icon for every Sportmonks event the ticker writes to match_events:<fixture>
— goals, cards, subs, but also corner, foul, foul_drawn, shot_blocked,
dispossessed, long_ball, error_leading_to_goal, save, and so on. The
icons are drawn off a single REST poll (GET /api/matches/:id/events) that
returns the entire feed list without filtering.
The price path, however, only nudged a subset of those types. defaultEventBumps
in ftl-backend/internal/sportmonks/scoring.go (line 570) shipped 18 entries —
goals, penalties, assists, cards, subs, corners, free kicks, throw-ins, fouls,
offsides, VAR — and missed every stat-delta type (foul_drawn, save,
dispossessed, long_ball, error_leading_to_goal, shot_blocked). When
EventBump(typeName) returned 0 for those, ticker.go:374 did a continue
that skipped the entire price-update block. The user-visible result was the
chart showing event icons land while the price line stayed flat — a
contradiction of the project requirement that "if any event occurs, +ve → up,
-ve → down".
A second issue surfaced while triaging the first: the existing keys were a
mix of dashes (own-goal, penalty-missed, throw-in) and spaces
(yellow card, free kick), but the actual lookup at ticker.go:373 called
EventBump with the raw Sportmonks Type.Name ("Yellow Card", "Own Goal",
"Penalty Missed"). EventBump did a single strings.ToLower and looked up
the result. So eventBumps["own goal"] (input lowercased) missed the
"own-goal" key, and the same applied to several other types — they were
nominally "in the table" but never actually fired.
The noise generator already enforces the requirement on the cosmetic side
(ADR-0021); the durable channel — event_score → live_base_price — was
the path that needed both expansion and a key-normalisation fix.
Decision¶
We will expand
defaultEventBumpsto cover every event type the chart can render, and route every lookup throughnormalizeEventType, so a raw Sportmonks string always resolves to the canonical underscored key.
Concrete changes:
-
Underscored canonical keys.
defaultEventBumpsis rewritten using keys that matchnormalizeEventTypeoutput (lowercase, dashes and spaces collapsed to underscores):goal,penalty,own_goal,assist,red_card,yellow_card,penalty_missed,substitution,corner,free_kick,throw_in,foul,offside,var. The duplicate dash-and-space variants are dropped — one canonical key per type. -
Stat-delta entries added. Six new keys cover the events that currently produce icons but no price move:
| Event | Bump | Rationale |
|---|---|---|
foul_drawn |
+0.15 % | Minor positive: defensive value created. |
shot_blocked |
+0.10 % | Minor positive: defensive action. |
dispossessed |
-0.10 % | Minor negative: possession lost. |
long_ball |
+0.05 % | Minimal positive: chance-creation attempt. |
error_leading_to_goal |
-3.0 % | High negative: direct concession responsibility. |
save |
+1.5 % | Keeper-specific positive (stats path). |
Magnitudes are deliberately conservative for the stat-delta types so they don't drown out goal/card signals; the headline events keep their original sizes.
-
EventBumpnormalises before lookup. The function now passes the input throughnormalizeEventType(the same canonical-form helper already used at the events-feed write path inevents_feed.go:43-62) before the map read. This makes the events-path and the stats-path call sites symmetric — both can pass any spelling variant and hit the same row. -
SetEventBumpsandcloneBumpsnormalise stored keys too. An admin who types"Yellow Card"in the admin UI now stores it underyellow_card, matching the runtime lookup. Previously the override would land under"yellow card"(lowercased only) and silently miss. -
Migration 40 runs
jsonb_seton theevent_bumpshalf ofadmin_settings.score_event_pointsso existing deployments converge to the same keyspace.position_multipliersis untouched. Additive only — no DROP / type changes — and idempotent.
Consequences¶
- Positive — parity with the chart overlay. Every icon the chart can draw now corresponds to a real, signed price nudge. The "marker but no move" class of bug is closed at the source.
- Positive — bug fix is bundled. The latent key-mismatch
(
own-goaletc.) is resolved as part of the same change rather than shipped separately. Tests pin the new behaviour (TestEventBumpNormalization,TestSetEventBumps_NormalisesOverrideKeys). - Positive — admin UX is forgiving. Admins can save keys in whatever spelling Sportmonks happens to expose; the runtime folds the form before storing.
- Cost — slightly more pub/sub volume during chatty matches. A live
fixture with frequent
foul_drawn/dispossesseddeltas now publishes micro-bumps for the holders of those instruments. Bounded by the existing activity gate (ADR-0018 B1): only live-match players + recently traded players publish; everyone else stays quiet. At full tournament scale this falls inside the same envelope the noise loop already spends. - Neutral — no schema change. Migration 40 only updates one JSONB
row. Rollback is via the
.down.sql(not run in CI per migration-safety §3) or via the admin UI.
Alternatives considered¶
Alternative A: Filter the marker overlay instead of expanding the bumps¶
match/events.go:GetEvents could continue past any row whose
EventBump(dto.Type) == 0, so the chart only ever draws icons that have
a corresponding price move. Cheapest possible diff — one if branch.
Rejected. It hides information from the player. Knowing that a
foul_drawn or shot_blocked happened is useful match context even if
the price impact is small. The product intent is "more signal, not
less", so the right answer is to give those events a small signed bump
rather than suppress them.
Alternative B: Add bumps as admin-only overrides (don't change defaults)¶
Leave defaultEventBumps alone and write the new types into
admin_settings.score_event_points only. A fresh dev install would
still have the old keys; staging / prod would carry the expanded set.
Rejected. Defaults are the in-binary safety net for when the DB row is missing or partially populated. Letting a fresh install silently produce flat charts for stat-delta events recreates the bug on every new environment. The defaults and the seed migration should agree.
Alternative C: Defer the key-normalisation fix to a separate PR¶
Ship the expansion now; fix own-goal / penalty-missed / red_card
keying later.
Rejected. The two fixes share the same test surface and the same ADR rationale; splitting them doubles the review burden without reducing risk. The user explicitly chose to bundle.
Verification¶
- Unit tests.
go test ./internal/sportmonks/...passes including the newTestEventBumpNormalization(raw Sportmonks strings in 20+ variants must resolve to the right signed value) andTestSetEventBumps_NormalisesOverrideKeys(admin overrides with non-canonical keys take effect). - Migration applies cleanly.
migrate upfrom version 39 → 40 against local Postgres;SELECT version, dirty FROM schema_migrationsshows40 | f;jsonb_pretty(value->'event_bumps')shows the 20-key payload with all underscored keys. - Live behaviour. With the api-server rebooted to pick up the new
defaults, watch a live-match player's chart. A
foul_drawn,dispossessed, orsaveevent from Sportmonks should now produce a small visible step on the price line in addition to its existing icon. Verifymicro_bumpandevent_scorechange inHMGET instrument:<uuid> ...for that player. - Admin UI parity.
GET /api/admin/settingsreturns the row with all 20 keys; the Admin → Scoring form renders an input per key with no frontend change needed (the form is data-driven —Admin.tsx:adminListSettings).