spec: pricing-ripple-engine status: active last-updated: 2026-06-09 owners: [@amalkrsihna] code-refs: - ftl-backend/internal/sportmonks/propagation_matrix.go - ftl-backend/internal/sportmonks/propagation.go - ftl-backend/internal/sportmonks/ticker.go - ftl-backend/internal/sportmonks/event_propagation.go - ftl-backend/internal/sportmonks/scoring.go decisions: [0019, 0022, 0024, 0025]
Pricing Ripple Engine — Living Spec¶
Scope. Defines how every Sportmonks event and every per-player stat-delta moves prices on every player on both teams of the involved fixture. Augments the AMM core (
amm-pricing.md) with cross-team / cross-role propagation. Replaces the goal-only team-fallback (ADR-0019) with a general matrix-driven engine that fires on all 20 discrete event types and all marker-eligible stat-class deltas.
TL;DR for an intern¶
A goal scored by Brazil moves the scorer's price up the most, the Brazil forwards' prices up a smaller amount, Brazil midfielders less, defenders less, GK least. On the Argentina side, the goalkeeper's price drops the MOST (it's most their fault), defenders next, midfielders, then forwards. The same shape applies to every event (yellow card, sub, tackle won, save) and every stat-update — the magnitudes and the "who's hardest hit" differ but the structure is the same.
If Sportmonks tells us a yellow card happened but doesn't say WHO got it, the engine still moves prices — the actor's-team portion gets split across the team by role (defenders take more, GK takes least, forwards moderate). The opposing team's ripple fires unchanged. No event ever produces zero price movement.
Glossary¶
- Primary — the price move applied to the named actor of an event (or the player whose stat updated).
- Ripple — the secondary price move applied to every OTHER player on both teams, in a magnitude derived from the primary × a role-specific factor.
- Actor team — the team whose player triggered the event. The ripple on this team moves prices in the SAME direction as the primary.
- Opposing team — the other team in the fixture. Ripple moves OPPOSITE the primary.
- Hardest-hit role — the role on the opposing team most directly responsible for / affected by the event. For a goal, it's the GK; for a tackle, it's the dispossessed forward / midfielder. Locked at 75% of primary magnitude per Option C of the design dialogue.
- micro_bump — the per-player transient bump accumulator stored on the Redis
instrument:{id}hash. The ripple writes into this. Capped atPercentOfBaseCap(basePrice)= 5% of base. - Marker-eligible — a stat-class whose deltas push a marker entry to the
match_events:{fixtureID}Redis list so the FE chart shows an arrow.
Pricing flow (high-level)¶
Sportmonks ticker poll
│
├─→ processEvents (per discrete event in fixture.events[])
│ │
│ ├─→ pushMatchEvent (always — FE marker source)
│ ├─→ if PlayerID > 0:
│ │ apply primary to actor (existing per-player bump path)
│ │ else:
│ │ redistribute primary across actor's team by role-weight
│ └─→ applyRipple(actorTeam, opposingTeam, primaryPct, matrix)
│
├─→ processPlayerStats (per LineupEntry returned from poll)
│ │
│ ├─→ compute matchScore from lineup.Details × PositionMultipliers
│ ├─→ apply micro_bump delta to actor (existing path)
│ └─→ applyRipple(actorTeam, opposingTeam, deltaPct, statClassMatrix)
│
└─→ detectStatDeltas (per per-stat increment between polls)
│
├─→ for each marker-eligible stat-class delta:
│ pushMatchEvent (synthetic MatchEventDTO — FE renders marker)
│ applyRipple(actorTeam, opposingTeam, deltaPct, statClassMatrix)
└─→ for non-marker-eligible: applyRipple only (no marker)
applyRipple walks both teams' rosters from Postgres
(SELECT id, position FROM instruments WHERE is_active AND (club=$1 OR country=$1),
cached per-poll per-fixture to avoid N+1), applies role-weighted bumps via the existing
bumpInstrumentEvent (event_propagation.go:136), and pipelines all writes.
Ripple matrix — Discrete events¶
Columns:
- Pri % — magnitude applied to the actor's micro_bump (sign per event).
- T-{role} — factor × Pri %, applied SAME-DIRECTION to each teammate at that role.
- O-{role} — factor × Pri %, applied OPPOSITE-DIRECTION to each opponent at that role.
The factor at the hardest-hit role on the opposing team is locked at 0.75 of primary magnitude. Other rows show smaller factors that decay by role distance from the event's natural impact.
| event | Pri % | T-GK | T-DEF | T-MID | T-FWD | O-GK | O-DEF | O-MID | O-FWD |
|---|---|---|---|---|---|---|---|---|---|
goal |
+6.00 | 0.05 | 0.12 | 0.20 | 0.30 | 0.75 | 0.55 | 0.35 | 0.25 |
penalty |
+6.00 | 0.05 | 0.12 | 0.20 | 0.30 | 0.75 | 0.55 | 0.35 | 0.25 |
own_goal |
-4.00 | 0.30 | 0.55 | 0.35 | 0.25 | 0.05 | 0.12 | 0.20 | 0.30 |
assist |
+3.00 | 0.05 | 0.10 | 0.20 | 0.25 | 0.50 | 0.35 | 0.20 | 0.15 |
red_card |
-4.00 | 0.10 | 0.30 | 0.20 | 0.05 | 0.10 | 0.20 | 0.35 | 0.50 |
yellow_card |
-1.50 | 0.05 | 0.25 | 0.15 | 0.05 | 0.05 | 0.15 | 0.25 | 0.40 |
penalty_missed |
-5.00 | 0.10 | 0.15 | 0.20 | 0.30 | 0.75 | 0.55 | 0.35 | 0.25 |
substitution |
+0.50 | 0.05 | 0.05 | 0.05 | 0.05 | 0.02 | 0.02 | 0.02 | 0.02 |
corner |
+0.30 | 0.05 | 0.10 | 0.20 | 0.30 | 0.50 | 0.35 | 0.15 | 0.05 |
free_kick |
+0.20 | 0.05 | 0.10 | 0.20 | 0.30 | 0.50 | 0.35 | 0.15 | 0.05 |
throw_in |
+0.10 | 0.03 | 0.05 | 0.10 | 0.20 | 0.25 | 0.15 | 0.10 | 0.05 |
foul |
-0.20 | 0.05 | 0.20 | 0.10 | 0.05 | 0.05 | 0.15 | 0.30 | 0.40 |
offside |
-0.10 | 0.03 | 0.03 | 0.10 | 0.20 | 0.20 | 0.15 | 0.10 | 0.05 |
var |
+0.50 | 0.05 | 0.05 | 0.05 | 0.05 | 0.05 | 0.05 | 0.05 | 0.05 |
foul_drawn |
+0.15 | 0.05 | 0.10 | 0.25 | 0.20 | 0.05 | 0.20 | 0.30 | 0.40 |
shot_blocked |
+0.10 | 0.30 | 0.25 | 0.10 | 0.05 | 0.05 | 0.10 | 0.30 | 0.40 |
dispossessed |
-0.10 | 0.05 | 0.15 | 0.20 | 0.30 | 0.10 | 0.20 | 0.30 | 0.50 |
long_ball |
+0.05 | 0.02 | 0.03 | 0.10 | 0.20 | 0.25 | 0.15 | 0.10 | 0.05 |
error_leading_to_goal |
-3.00 | 0.30 | 0.55 | 0.35 | 0.20 | 0.05 | 0.20 | 0.35 | 0.50 |
save |
+1.50 | 0.05 | 0.40 | 0.20 | 0.10 | 0.05 | 0.10 | 0.30 | 0.75 |
The 20 rows are keyed by the canonical type names produced by normalizeEventType
(events_feed.go:43). Admin overrides via score_event_points.event_ripple_matrices
overlay on top of these defaults.
Worked example: Brazil scores a goal vs Argentina¶
Base price 100 for every player (illustrative — actual base differs per instrument).
Primary = +6.00. All bumps below feed into micro_bump and clamp at PercentOfBaseCap = 5.
| player | role | factor | Pri % × factor | bump applied |
|---|---|---|---|---|
| Brazil scorer | FWD | actor primary | 1.00 × 6.00 | +6.00 |
| Brazil GK | T-GK | 0.05 | 6.00 × 0.05 | +0.30 |
| Brazil DEF (each) | T-DEF | 0.12 | 6.00 × 0.12 | +0.72 |
| Brazil MID (each) | T-MID | 0.20 | 6.00 × 0.20 | +1.20 |
| Brazil FWD (other) | T-FWD | 0.30 | 6.00 × 0.30 | +1.80 |
| Argentina GK | O-GK | 0.75 | 6.00 × 0.75 | -4.50 |
| Argentina DEF (each) | O-DEF | 0.55 | 6.00 × 0.55 | -3.30 |
| Argentina MID (each) | O-MID | 0.35 | 6.00 × 0.35 | -2.10 |
| Argentina FWD (each) | O-FWD | 0.25 | 6.00 × 0.25 | -1.50 |
Cap clamps individual moves above 5.00 (or below -5.00) per player. The Argentina GK's
-4.50 lands within cap. After a second goal the GK's accumulated micro_bump would hit
the -5.00 floor and further drops would be capped (bumpInstrumentEvent already
enforces this).
Ripple matrix — Stat classes¶
All 43 fields on PlayerMatchStats group into 13 classes. Each class shares one ripple
matrix. The primary contribution to the actor stays as the existing
PositionMultipliers × stat_count_delta math; the ripple multiplies that delta further
by the class factor before applying it to teammates and opponents.
| stat class | example fields | T-GK | T-DEF | T-MID | T-FWD | O-GK | O-DEF | O-MID | O-FWD |
|---|---|---|---|---|---|---|---|---|---|
attack-major |
Goal, BigChancesCreated | 0.05 | 0.12 | 0.20 | 0.30 | 0.75 | 0.55 | 0.35 | 0.25 |
attack-mid |
Assist, KeyPass, ChancesCreated, ShotOnTarget, HitWoodwork | 0.05 | 0.10 | 0.20 | 0.25 | 0.50 | 0.35 | 0.20 | 0.15 |
attack-build |
Dribble, FinalThird | 0.05 | 0.10 | 0.20 | 0.25 | 0.40 | 0.30 | 0.15 | 0.05 |
defensive-major |
TackleWon, Tackle, Interception, Clearance, BlockedShots, BallRecovery, AerialsWon | 0.30 | 0.25 | 0.12 | 0.05 | 0.05 | 0.10 | 0.30 | 0.50 |
goalkeeping |
Saves, SavesPer3, PenaltySave | 0.05 | 0.40 | 0.20 | 0.10 | 0.05 | 0.10 | 0.30 | 0.75 |
discipline-actor-negative |
YellowCards, RedCards, Fouls, Offsides, ErrorLeadToGoal, ErrorLeadToShot | 0.05 | 0.25 | 0.15 | 0.05 | 0.05 | 0.15 | 0.25 | 0.40 |
possession-loss |
Dispossessed, PossLost, DribbledPast, ShotOffTarget, ShotsBlockedByOpp | 0.05 | 0.15 | 0.20 | 0.30 | 0.10 | 0.20 | 0.30 | 0.50 |
passing-quality |
PassAccuracy, BackwardPasses | 0.02 | 0.05 | 0.10 | 0.05 | 0.05 | 0.10 | 0.05 | 0.02 |
gated-percentage |
AerialsWonPct, DuelsWonPct | 0.05 | 0.10 | 0.08 | 0.05 | 0.03 | 0.05 | 0.08 | 0.10 |
duels |
DuelsWon, DuelsLost | 0.08 | 0.15 | 0.12 | 0.08 | 0.05 | 0.10 | 0.15 | 0.20 |
catastrophe-actor-negative |
OwnGoals, PenaltiesMissed | 0.30 | 0.55 | 0.35 | 0.25 | 0.05 | 0.20 | 0.35 | 0.50 |
goals-conceded-penalty |
GoalsConcPer2 (GK/DEF only) | 0.40 | 0.30 | 0.10 | 0.05 | 0.05 | 0.10 | 0.30 | 0.40 |
clean-sheet |
CleanSheet | 0.20 | 0.30 | 0.15 | 0.05 | 0.05 | 0.10 | 0.20 | 0.30 |
Per-player only (zero ripple — these are individual achievements / participation):
Appearance, Over60Min, RatingScale, Captain.
Marker-eligible stat classes (FE chart receives an arrow)¶
When the per-poll detectStatDeltas produces a delta on one of these classes, the
backend pushes a synthetic MatchEventDTO to the same match_events:{fixtureID}
Redis list that discrete events use. The FE renders a chart marker without code change.
Eligible (push marker):
- attack-major
- attack-mid
- attack-build
- defensive-major
- goalkeeping
- discipline-actor-negative
- possession-loss
- catastrophe-actor-negative
- clean-sheet
Not eligible (ripple price only, no marker):
- passing-quality
- gated-percentage
- duels
- goals-conceded-penalty
Synthetic MatchEventDTO shape:
{
"id": <hash(playerId|typeID|prev|new)>,
"fixtureId": <fixture>,
"minute": <current minute>,
"type": "<stat-class-key>",
"playerId": <sportmonks player id>,
"playerName": "<name>",
"teamSide": "home" | "away",
"createdAt": <UnixMilli()>
}
MaxEventsPerFixture raised from 500 to 2000 to accommodate ~90 min of marker traffic
across both teams. LTRIM keeps newest 2000; oldest stat-deltas roll off but the chart
only renders within its visible time window so this is fine.
Missing-playerId redistribution¶
When a Sportmonks event arrives with PlayerID == 0, the engine still moves prices.
The actor's primary slot redistributes by role-weight across the actor's team:
| role | share of primary |
|---|---|
| GK | 0.05 |
| each DEF (×4) | 0.07 |
| each MID (×4) | 0.0875 |
| each FWD (×2) | 0.16 |
The opposing-team ripple from the matrix fires unchanged.
Example — yellow_card (primary -1.50) with no playerId, applied to Palestine team:
- Palestine GK: -1.50 × 0.05 = -0.075
- Palestine DEF (each): -1.50 × 0.07 = -0.105
- Palestine MID (each): -1.50 × 0.0875 = -0.131
- Palestine FWD (each): -1.50 × 0.16 = -0.24
- Plus the standard T- ripple from the yellow_card row
- Plus the standard O- ripple from the yellow_card row applied to Kyrgyz
This change unblocks the silently-dropped class identified in the M. Saleh investigation
(ticker.go:396-399 early-return). After this spec ships, no event with a configured
event_bump produces zero price movement.
Chart marker rendering (2026-06-09 update)¶
The chart draws one marker per event/stat. Behaviour, after the marker-direction overhaul:
- Authoritative direction (
dir). Every marker carriesdir— the sign of the event/stat's configured bump (+1 / −1 / 0), stamped by the backend inprocessEvents,detectStatDeltas, and the performance path. The chart no longer guesses direction from the price line (which produced directionless circles when the adjacent tick was flat).diris the ACTING player's direction; the chart flips it when the opened player is on the opposing team (ownerDir = opposing ? −dir : dir). - Performance marker. The matchScore channel (appearance bonus, passes,
rating) moves price every poll with no discrete event.
processPlayerStatsnow pushes aperformancemarker when|matchScore Δ| ≥ PerfMarkerThreshold(1.0 score pt) so the move is explained.dir = sign(Δ). createdAtis milliseconds. Stat-delta markers previously usedtime.Now().Unix()(seconds) — after the chart standardised onDate(createdAt), those landed at ~1970 and were dropped. NowUnixMilli, consistent with discrete events.- Same-tick collapse. Contested events (tackle, long ball, duel) fire for both the winner AND the loser, so the opened player's chart gets an own-team (up) and an opposing-team (down) event at the same poll tick. The chart collapses all events on one tick into a single glyph: an arrow when they agree on direction, a circle when they conflict (net-ambiguous) or carry no direction.
- Glyphs only on the 5M timeframe. Wider views (6H/1D/ALL) pack far more events into the window, so they show the dashed anchor + type label only; the arrow/circle glyph renders on 5M.
- Neutral types render a circle.
substitutionandvarcarry no genuine price direction (a sub isn't clearly good or bad for either side), so the backend stampsdir = 0(eventDir/neutralEventTypesinscoring.go) and the chart draws a neutral circle — never a misleading arrow (e.g. an opposing team's sub as a red down-arrow on a rising line). The FE also force-neutralises these types so markers stamped before the backend change still render a circle. - Stable y-axis on wide views. The auto-range bounds snap to a "nice" step
(
niceStepinPriceChart.tsx) so the axis holds steady between discrete steps instead of re-fitting on every live tick — without it the 6H/1D/ALL views drift-zoom as a transient event spike decays.
Ripple moves persist as ticks + publish (chart for the passive side)¶
A player moved ONLY by the ripple (the passive / opposing side — they have no
stat of their own when the other team acts) must still get a chart and a live
update. bumpInstrumentEvent only writes the Redis hash + dirty:instruments,
so each poll PollLivescores collects the ripple-moved instruments (pollMoved,
recorded in bumpInstrumentEvent) and flushRippleTicks publishes price:{id}
+ writes a price_ticks row for each — skipping any that processPlayerStats
already ticked (pollTicked). Without this, opposing-team players drifted in the
hash but had an empty chart (the chart reads price_ticks) and no real-time
WS push. See ADR-0031.
Chart timeframe windows (game-relative)¶
The chart filter set is 5M / 15M / 30M / 90M / FULL (replacing the old 5M/1H/6H/1D/ALL). They are game-relative, not wall-clock:
- 5M / 15M / 30M / 90M — the last N minutes of the GAME.
GetPriceHistory(internal/handler/routes.go) anchors the rolling window to the player's latest tick, notNOW():created_at > (MAX(created_at) for this instrument) − INTERVAL 'N minutes'. So a finished match still shows its last N minutes (a naiveNOW() − N minwould be empty after full time). - FULL — this match, kickoff → full time. Lower-bounded by the player's
most-recent started match's
matches.scheduled_at(joined viainstruments.club/country ↔ matches.home_team/away_team); upper bound is the last tick (FT, or now if live). Falls back to lifetime ticks (LIMIT 500) when the player has no match row yet, so the chart never renders empty.
Frontend (PriceChart.tsx): marker glyphs still render on 5M only (shortest
window); no window spans >1 day, so X-axis labels are always HH:MM.
Live price uses the CFD formula (trade-impact persistence)¶
processPlayerStats now computes the published/stored price with the SAME
formula the trade Lua uses (position_open.lua:351):
Previously it used the legacy buy-and-hold AMM (live_base_price + k *
net_shares_sold), which ignored the CFD imbalance — so every ~10s poll erased a
trade's price impact (the "trade spike snaps back instantly" bug, and the
5M-shows-spike / 6H-flat inconsistency). With the formula aligned, an open
position's price impact persists across polls and decays gradually only after
close, via permanent_impact (ADR-0017). See amm-pricing.md.
Caps + safety¶
Unchanged from existing engine:
| safety | location | purpose |
|---|---|---|
PercentOfBaseCap(basePrice) = 5% of base |
scoring.go:621 |
per-player micro_bump cap |
FloorPrice / CeilingPrice constants |
scoring.go |
absolute floor/ceiling on live_base_price |
seen_events:{fixtureID} Redis SET |
ticker.go processEvents |
per-event idempotency across redeploys |
dirty:instruments SADD on every bump |
bumpInstrumentEvent |
flusher picks up only changed players |
The ripple engine writes through bumpInstrumentEvent so all caps apply uniformly. No
new caps required.
Admin tuning¶
The existing score_event_points admin setting (table admin_settings, key
score_event_points) is extended with two optional overlay blocks:
{
"event_bumps": { "goal": 6.0, "yellow_card": -1.5, ... },
"position_multipliers": { "Goal": 7.0, ... },
"event_ripple_matrices": {
"goal": { "T-GK": 0.05, "T-DEF": 0.12, ..., "O-FWD": 0.25 },
"yellow_card": { "O-FWD": 0.45 }
},
"stat_ripple_matrices": {
"attack-major": { "O-GK": 0.80 },
"defensive-major": { "T-GK": 0.32 }
}
}
Same overlay semantics as event_bumps: admin payload OVERRIDES specific cells; unset
cells keep the spec default. To zero a cell, set it explicitly to 0. Reloading is
hot — no api-server restart required.
Code structure (target)¶
| file | role |
|---|---|
internal/sportmonks/propagation_matrix.go |
NEW. RippleMatrix struct, defaultEventRipple map, defaultStatClassRipple map, hot-reload helpers (SetEventRipple, SetStatRipple) matching the SetEventBumps pattern. |
internal/sportmonks/propagation.go |
REFACTOR. applyTeamEventBump generalised to applyRipple(ctx, actorTeam, opposingTeam, actorInstrumentID, basePct, matrix). Postgres team-roster lookup retained. |
internal/sportmonks/ticker.go |
WIRE. processEvents + processPlayerStats + detectStatDeltas all call applyRipple. Missing-playerId guard replaced with redistribution. Per-poll roster cache added. |
internal/sportmonks/event_propagation_test.go |
UPDATE. Existing goal-fallback tests adapted to new signature. |
internal/sportmonks/propagation_test.go |
NEW. Comprehensive miniredis-backed tests for the ripple engine. |
internal/sportmonks/scoring.go |
EXTEND. score_event_points admin payload parser learns the two new overlay blocks. |
Verification¶
- Unit (Go) —
go test ./internal/sportmonks/ -run 'Ripple'produces a pass for: - Goal event fires on all 22 players with expected magnitudes
- Yellow card with no playerId redistributes correctly + opposing ripple fires
- Cap enforcement: 10 yellow cards in a row don't push past
-5% × base - Stat-class classifier routes each of 43
PlayerMatchStatsfields to the correct class - Integration (local stack) — inject a synthetic event with no playerId via
redis-cli LPUSH match_events:<fixture>, trigger a poll, then readredis-cli HGET instrument:<id> micro_bumpfor 5 random players on each side. All 22 should show non-zeromicro_bump. - Repro M. Saleh — same fixture (19709279, Palestine yellow_card with PlayerID=0).
After fix, every Palestine + Kyrgyz player's
micro_bumpis non-zero. - Chart markers for stats — open a player chart on a live match; confirm marker-eligible stat classes (tackle won, shot on target, save, etc.) produce arrow markers in addition to the existing 20 discrete events.
Out of scope¶
- Chart marker rendering for the 4 non-marker-eligible stat classes — re-evaluate if user feedback wants them visible after launch.
- FE WS
sbyte signalling "ripple-driven" vs "self-driven" tick — would let the chart visually distinguish ripple moves from primary moves. Defer. - Trade-driven AMM bumps (
position_open.lua,position_close.lua) — unchanged. Trade ticks still flow throughk * netImbalance + perm_impact. - Position-multiplier override UI in admin panel — config endpoint exists; UI add is a separate ticket.
- Backfill / replay of historical matches with new ripple math. Going-forward only.
Related ADRs¶
- ADR-0017 — AMM Permanent Price Impact (introduces
perm_impact) - ADR-0019 — Event Propagation Durable Event Score (introduces
event_score, goal-team-fallback path; superseded for the goal-only case by this spec — thegoalmatrix row reproduces ADR-0019's behaviour) - ADR-0022 — Comprehensive Event-Bump Coverage (the 20-row
defaultEventBumpstable this spec builds on) - ADR-0024 — Per-Event Admin Config (the
score_event_pointsadmin setting surface this spec extends) - ADR-0025 — Per-Event Decay Persistence Split (
micro_bumpdecay vsevent_scorepersistence; ripple writes feed both viabumpInstrumentEvent)