Skip to content

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 at PercentOfBaseCap(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 carries dir — the sign of the event/stat's configured bump (+1 / −1 / 0), stamped by the backend in processEvents, 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). dir is 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. processPlayerStats now pushes a performance marker when |matchScore Δ| ≥ PerfMarkerThreshold (1.0 score pt) so the move is explained. dir = sign(Δ).
  • createdAt is milliseconds. Stat-delta markers previously used time.Now().Unix() (seconds) — after the chart standardised on Date(createdAt), those landed at ~1970 and were dropped. Now UnixMilli, 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. substitution and var carry no genuine price direction (a sub isn't clearly good or bad for either side), so the backend stamps dir = 0 (eventDir/neutralEventTypes in scoring.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 (niceStep in PriceChart.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, not NOW(): created_at > (MAX(created_at) for this instrument) − INTERVAL 'N minutes'. So a finished match still shows its last N minutes (a naive NOW() − N min would 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 via instruments.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):

currentPrice = live_base_price + k_mod * (net_position_imbalance + permanent_impact)

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

  1. Unit (Go)go test ./internal/sportmonks/ -run 'Ripple' produces a pass for:
  2. Goal event fires on all 22 players with expected magnitudes
  3. Yellow card with no playerId redistributes correctly + opposing ripple fires
  4. Cap enforcement: 10 yellow cards in a row don't push past -5% × base
  5. Stat-class classifier routes each of 43 PlayerMatchStats fields to the correct class
  6. Integration (local stack) — inject a synthetic event with no playerId via redis-cli LPUSH match_events:<fixture>, trigger a poll, then read redis-cli HGET instrument:<id> micro_bump for 5 random players on each side. All 22 should show non-zero micro_bump.
  7. Repro M. Saleh — same fixture (19709279, Palestine yellow_card with PlayerID=0). After fix, every Palestine + Kyrgyz player's micro_bump is non-zero.
  8. 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 s byte 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 through k * 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.
  • 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 — the goal matrix row reproduces ADR-0019's behaviour)
  • ADR-0022 — Comprehensive Event-Bump Coverage (the 20-row defaultEventBumps table this spec builds on)
  • ADR-0024 — Per-Event Admin Config (the score_event_points admin setting surface this spec extends)
  • ADR-0025 — Per-Event Decay Persistence Split (micro_bump decay vs event_score persistence; ripple writes feed both via bumpInstrumentEvent)