Bet Builder — Player Shot / SOT Teammate-Correlation Engine
The design record for modelling correlation between player shot selections in the bet builder. Written 2026-07-09.
Date: 2026-07-09 Status: Design — awaiting review Author: James McCoy (+ Claude)
Problem & goal
The Bet Builder currently padlocks every same-team player shots / shots-on-target (SOT) multi, because we had no model for how teammates' shot involvement correlates. Backing "3 City players each to have a shot" is disallowed rather than priced.
We now have empirical calibration from ~5 seasons of match data (two data-team reports:
teammate-shot-correlation-report.md, teammate-correlation-followup-report.md,
sot-parlay-correction-engine.md). The goal: un-lock these combos and price them with a
measured correction factor R, instead of the naive independent product that currently
runs for player legs.
Guiding principle (unchanged): only price what we've measured; hold the rest conservative.
Direction of the correction (stated once, because it is easy to invert)
Legs carry a fair probability p_i. The naive combined probability is ∏ p_i.
The true joint is:
P_true = R × ∏ p_i → fair combined odds = (1/R) × (naive product odds)- R < 1 (cannibalisation — same-role forwards, scarce shots): true joint lower → naive product shows odds too short; we lengthen them.
- R > 1 (co-firing — diverse attackers, high-volume games): true joint higher → naive product shows odds too long; we shorten them.
Scope decisions (agreed)
- Price both shots and SOT. SOT factors are pulled slightly toward neutral (thinner sample) and carry a wider margin.
- Cannibalisation is threshold-proportionate. 3 forwards at 2+ SOT each cannibalise far more than at 1+ each; the factor deepens as the threshold rises.
- Full engine, including the team-SOT-line conditioning — we already have team SOT odds (fixture page → odds tab); they are simply not yet piped into the builder.
- Missing team line → fallback line 3.5 (neutral, R≈1.0; no invented line-push).
- Badge: reuse the existing "correlation applied" treatment, showing the signed delta.
Not a full model yet
We build a scalar correction factor R looked up per same-team player cluster — a
direct application of the measured numbers. We do not build the full player Poisson
"team-budget" model (multi-xPlayer) in this pass; the module is structured so that model
can replace the lookup later.
What un-locks vs stays locked
| Combo | Today | After |
|---|---|---|
| Same-team shots multi (e.g. 3 players 1+ shot) | 🔒 locked | ✅ priced with R |
| Same-team SOT multi | 🔒 locked | ✅ priced with R (conservative) |
| Two stats on the same player | 🔒 locked | 🔒 unchanged (nested, no model) |
| Attack player ↔ team goals/corners | 🔒 locked | 🔒 unchanged (cross-link, no model) |
| Discipline player ↔ team cards | 🔒 locked | 🔒 unchanged |
| Different-team players | ✅ product | ✅ unchanged (independent, R=1) |
Only the specific sameStat / sameTeamFamily branch for the attack / shots+SOT
case in bet-builder-compat.ts is relaxed.
Architecture
4.1 New module — src/lib/bet-builder-player-correlation.ts
A pure, independently-testable unit. One public function:
playerClusterFactor(
legs: PricedLeg[], // all picked legs
ctx: { teamSotLine?: Map<number, number>; teamShotsLine?: Map<number, number> }
): number // multiplicative R to apply to the player-leg productResponsibilities:
- Select player legs (
compat.player != null), group byplayer.team. - Groups of size 1 → contribute factor 1. Cross-team groups are independent (their factors multiply).
- For each same-team group of size ≥2, classify and look up
R(section 5). - Return the product of all group factors.
It reads only leg.compat.player (name/team/family/stat/role) + leg.fairOdds + the
line context. No side effects, no fetch.
4.2 Pricing injection — bet-builder-correlation.ts
Today player legs are a flat product at lines 284–285:
let pRest = 1;
for (const l of restLegs) pRest *= 1 / l.fairOdds!;Change: split restLegs into player legs vs true-rest. Keep true-rest as the flat
product. For player legs compute the same product and multiply by
playerClusterFactor(playerLegs, ctx). The existing legFloor guard (combined never
shorter than the longest single leg) stays and is the final safety net.
combinedFairOdds gains a ctx param carrying the team-line maps (threaded from the page).
4.3 Un-lock — bet-builder-compat.ts
In playerCorrelationPriceable (~lines 204–207), the branch that returns false for
sameStat || sameTeamFamily is relaxed for the attack family shots/SOT stats:
those pairs become compatible (pickable). Same-player and cross-family links are untouched.
4.4 Role onto the leg — bet-builder.ts
buildPlayerPropGroups copies the already-available coarse position onto the descriptor:
role: g.position ("F" | "M" | "D" | "G" | null). Zero joins. The player descriptor
type in bet-builder-compat.ts gains role?: string | null.
Coarse F/M/D is enough to identify the forward stack (the safety-critical case). Winger-vs-central (WIDE) granularity is out of scope (needs a lineup-position join).
4.5 Team SOT / shots line — ingestion
The odds-comparison feed already reaching bet-builder.tsx carries
Team Shots on Target Home/Away and Team Shots Home/Away. Add a small extraction (not a
full market group — we don't want them as selectable markets, just as pricing context)
that reads the central over/under line per team and builds teamSotLine /
teamShotsLine maps, passed into the pricing ctx. Missing → omitted (engine falls back
to 3.5 for SOT).
The correction-factor model
Per same-team group, decide a composition class from roles, then look up R by
(stat, class, threshold, N, line). Computed as an explicit decision tree (not a product
of multipliers — avoids compounding blow-ups). All constants are v1, anchored to the
reports and marked measured (M) or conservative-estimate (E).
5.1 Composition class
fwd-stack— all legs are roleF.same-role-other— all legs share a non-forward role (e.g. allM).diverse— mixed roles (or role unknown for any leg → treat as diverse, the milder case).
Mixed-stat groups (e.g. one 2+ shots leg + one 1+ SOT leg on the same team) are keyed as the shots family (the broader shared resource) and priced conservatively via the heterogeneous-threshold interpolation in §5.6.
5.2 Threshold key
Per group, take the leg thresholds (each leg is ≥1 / ≥2 / ≥3 on its line). Summarise by the
mean threshold step t (1, 2, 3), rounded. Heterogeneous groups interpolate.
5.3 SOT — line-conditioned base (arbitrary/diverse), ≥1 threshold (M)
Interpolate linearly in the team SOT line L (clamp to [2.5, 6.5]; fallback 3.5):
| L | R₂ (pair) | R₃ (triple) |
|---|---|---|
| 2.5 | 0.77 | 0.65 |
| 3.5 | 1.00 | 0.99 |
| 4.5 | 1.08 | 1.12 |
| 5.5 | 1.18 | 1.25 |
| 6.5 | 1.28 | 1.28 |
Tails (L ≤ 2.5 or L ≥ 6.5): use the clamped value and flag thin=true → wider
margin; the shown factor is pulled 25% toward 1.0 (thin-sample haircut on our confidence).
5.4 Shots — structural base (no line table exists), ≥1 threshold (M)
| Class | Pair | Triple |
|---|---|---|
| diverse | 1.02 | 1.03 |
| (main-attacker mix) | 1.00 | 1.00 |
Shots are not line-conditioned (we lack a shots line×R table); rely on structural class + threshold + leg-count.
5.5 Forward-stack override (both stats) — cannibalisation, ≥1 (M/E)
Forward stacks stay negative regardless of line (they compete for the same chances):
| Stat | Pair | Triple |
|---|---|---|
| shots | 0.89 | 0.835 (M) |
| SOT (conservative) | 0.88 | 0.82 (E: measured 0.84/0.76, pulled toward 1) |
Line easing for SOT forward-stack: at L ≥ 5.5 ease +0.03 (more shots to share); at
L ≤ 2.5 deepen −0.04.
5.6 Threshold scaling (E, directional — widen margin)
Let d = 1 − R_(≥1) be the distance below 1 for a cannibalising group (forward-stack
or SOT base < 1). Each threshold step deepens it:
R_(≥t) = 1 − d × 1.4^(t − 1) # t = 1,2,3 (forward stacks / cannibalising)For co-firing groups (R_(≥1) > 1 — diverse), use the measured positive uplifts, SOT tempered 50% toward 1.0:
| Combo | R | note |
|---|---|---|
| shots ≥2 triple, diverse | 1.14 | M |
| shots ≥3 triple, diverse | 1.33 | M, thin → widen margin |
| SOT ≥2 triple, diverse | 1.09 | E (measured 1.18, tempered) |
Heterogeneous trebles (e.g. one 2+ anchor + two 1+) interpolate toward the anchor's threshold; rule of thumb from the report: one ≥2 anchor + two ≥1 ≈ 1.05 (diverse).
5.7 Leg-count decay (M)
For N ≥ 4: multiply the result by 0.98^(N − 3).
5.8 Same-role-other (all mids etc.) (M)
R ≈ 0.98, threshold-insensitive. (Weak, but not co-firing.)
5.9 Final clamps
R clamped to [0.55, 1.30]. The legFloor in combinedFairOdds remains the hard
backstop (combined odds never shorter than the longest single leg).
Data flow
bet-builder.tsx
├─ fetch /api/value-bets-v2 ─────────────► buildPlayerPropGroups
│ (player props: name, teamId, └─ leg.compat.player{name,team,family,stat,ROLE}
│ marketType, line, scannerFairOdds)
├─ fetch /api/event/[id]/odds-comparison ─► extract team SOT/shots lines
│ (Team Shots on Target Home/Away) └─ teamSotLine / teamShotsLine maps
└─ combinedFairOdds(picked, models, CTX{lines})
└─ playerClusterFactor(playerLegs, CTX) ──► R
└─ finalProb = joint × (playerProduct × R)
└─ legFloor guardTesting (TDD)
Unit tests on playerClusterFactor and end-to-end through combinedFairOdds, each
encoding a report value:
- Forward stack, SOT ≥1 triple, mid line → ≈0.82 (conservative).
- Forward stack, SOT ≥2 triple → deepened (~0.75) vs ≥1 — proves proportionality.
- Forward stack, shots ≥1 triple → ≈0.835.
- Diverse trio, SOT ≥1, line 3.5 → ≈0.99; line 5.5 → ≈1.25.
- Diverse trio, shots ≥2 triple → ≈1.14.
- Leg-count: same-attacker 5-leg < 3-leg (decay).
- Different-team players → factor exactly 1.0.
- Missing line → falls back to 3.5 (neutral), role/threshold still apply.
- Tail line (≤2.5 / ≥6.5) → clamped +
thinmargin flag. - Single player leg → factor 1.0 (unchanged price).
- legFloor still holds (no combined below longest leg) even when R<1 pushes up.
- Compat: same-team SOT pair now compatible (un-locked); same-player still locked; attack↔goals still locked.
Existing bet-builder tests must stay green (the ones that asserted the old lock get updated to the new priced behaviour, deliberately).
Out of scope (this pass)
- Winger-vs-central role granularity (lineup-position join).
- The Kambi ~10% per-leg "hot" haircut — does not apply: our legs use our own
scannerFairOdds, and v2 already drops Kambi SOT. (Our scanner's own SOT calibration is a separate concern, not a hardcoded factor.) - Player ↔ team-market cross correlation (scorer ⇒ team scored, etc.).
- The full Poisson team-budget model.
- Shots line-conditioning (no measured table; structural only).
Risks / caveats
- Line-conditioning is one season of Kambi; mid-lines solid, tails thin (clamped + margin). Structural role/threshold/leg-count factors are full-5-season and robust.
- Threshold-scaling for cannibalisation (§5.6) is directional, not directly measured for the role×threshold interaction — conservative and margin-widened, flagged for later calibration against our own data.
- Constants are v1; the module isolates them so recalibration is a data change, not a rewrite.