StatsHub Docs
Specs

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

ComboTodayAfter
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✅ productunchanged (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 product

Responsibilities:

  1. Select player legs (compat.player != null), group by player.team.
  2. Groups of size 1 → contribute factor 1. Cross-team groups are independent (their factors multiply).
  3. For each same-team group of size ≥2, classify and look up R (section 5).
  4. 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-stackall legs are role F.
  • same-role-other — all legs share a non-forward role (e.g. all M).
  • 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):

LR₂ (pair)R₃ (triple)
2.50.770.65
3.51.000.99
4.51.081.12
5.51.181.25
6.51.281.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)

ClassPairTriple
diverse1.021.03
(main-attacker mix)1.001.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):

StatPairTriple
shots0.890.835 (M)
SOT (conservative)0.880.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:

ComboRnote
shots ≥2 triple, diverse1.14M
shots ≥3 triple, diverse1.33M, thin → widen margin
SOT ≥2 triple, diverse1.09E (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 guard

Testing (TDD)

Unit tests on playerClusterFactor and end-to-end through combinedFairOdds, each encoding a report value:

  1. Forward stack, SOT ≥1 triple, mid line → ≈0.82 (conservative).
  2. Forward stack, SOT ≥2 triple → deepened (~0.75) vs ≥1 — proves proportionality.
  3. Forward stack, shots ≥1 triple → ≈0.835.
  4. Diverse trio, SOT ≥1, line 3.5 → ≈0.99; line 5.5 → ≈1.25.
  5. Diverse trio, shots ≥2 triple → ≈1.14.
  6. Leg-count: same-attacker 5-leg < 3-leg (decay).
  7. Different-team players → factor exactly 1.0.
  8. Missing line → falls back to 3.5 (neutral), role/threshold still apply.
  9. Tail line (≤2.5 / ≥6.5) → clamped + thin margin flag.
  10. Single player leg → factor 1.0 (unchanged price).
  11. legFloor still holds (no combined below longest leg) even when R<1 pushes up.
  12. 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.

On this page