Bet Builder — Single-Player Shots + Shots-on-Target Joint Pricing
The design record for the joint shots/shots-on-target model behind bet-builder pricing. Written 2026-07-10.
Date: 2026-07-10 Status: Design — approved shape, awaiting spec review Author: James McCoy (+ Claude)
Problem & goal
The builder currently padlocks any two legs on the same player (e.g. "Palmer 3+ shots" + "Palmer 1+ on target"). We want to un-lock the shots + shots-on-target (SOT) pairing and price it correctly — the exact case a user asks for with "2 or 3 shots and one of them on target".
The joint is governed by a mechanical fact: SOT is a strict subset of shots (every shot
on target is a shot). A database validation over ~5 seasons / 3.5M player-matches
(player-shots-sot-thinning-model.md) confirmed:
- SOT | shots ~ Binomial(shots, q) is exact (residual dispersion ~2%, vanishes with a
player-specific
q). No correction factor needed for the on-target step. - The shots marginal must NOT be modelled as Poisson (real shots are over-dispersed, VMR≈1.26 within-player; Poisson under-prices 3+ shot legs by 9–22%). Use the player's own market/history for the shots distribution.
qis player-specific (0.32→0.37 by shot volume; F 0.39 / M 0.32 / D 0.28 by position; a persistent skill, reliability 0.63). Back it from the player's own data, shrunk to a position prior when thin.
Scope decisions (agreed)
- Price genuine pairings, lock exact-duplicates. Redundancy is exactly the containment boundary: lock the shots leg iff SOT threshold ≥ shots threshold (then it's implied and the combo = the SOT leg); price every case where SOT threshold < shots threshold.
- Binomial thinning for the on-target step (no correction).
qper player. - Shots marginal comes from the market, never Poisson.
- Anchor each leg to its own market fair odds; model only the dependence (consistent with the goals/teammate work). Clamp the joint to the Fréchet bounds of the two market marginals.
Not in scope
- Player goals + shots/SOT (a goal is also a subset — a natural future extension via the same thinning; different market, deferred).
- Cross-player interactions beyond the existing teammate engine.
- Re-deriving marginals — we trust each leg's
scannerFairOdds.
What un-locks vs locks (compat)
bet-builder-compat.ts playerCorrelationPriceable, same-player branch (currently
a.name === b.name → lock):
| Same-player pair | Rule | Result |
|---|---|---|
| shots (thr a) + SOT (thr b), b < a | genuine two-sided | un-lock → priced |
| shots (thr a) + SOT (thr b), b ≥ a | SOT implies shots (exact duplicate) | lock the shots leg |
| any other same-player pair (shots+goals, +tackles, two SOT lines…) | no model | lock (unchanged) |
Threshold of a leg = floor(ou.line) + 1 (Over 0.5 → 1+, Over 1.5 → 2+, Over 2.5 → 3+).
Both stats are in SHOT_CORR_STATS; the branch reads player.stat + ou.line.
The pricing model
New module src/lib/bet-builder-player-shot-joint.ts, pure & unit-tested.
For a same-player pair "shots ≥ a" (market prob pS = 1/fairShots) and "SOT ≥ b"
(pT = 1/fairSOT):
joint_raw = pS × E_shots[ P(Binomial(s, q) ≥ b) | s ≥ a ]
joint = clamp( joint_raw, max(0, pS + pT − 1), min(pS, pT) ) // Fréchet bounds- Anchors the shots marginal to the market (
pS) exactly; the empirical shot-count distribution is used only for the conditional shape "given ≥ a shots, how many extra". - Binomial tail
P(Bin(s,q) ≥ b) = 1 − Σ_{j<b} C(s,j) qʲ (1−q)^{s−j}— the exact, validated on-target step. - Fréchet clamp keeps the joint consistent with both displayed leg prices and mirrors
the existing
combineProbbounds.
4.1 Shot-count distribution (the conditional shape)
Primary: the player's recent per-game shot counts (recentGames[].stat on the shots
group — up to 30 starter games; already over-dispersed, report-endorsed "own history").
Truncate to s ≥ a for the conditional expectation. Fallbacks: last30Stats, then the
model mean lambdaNumber (with a negative-binomial shape at VMR≈1.26) if history is absent.
4.2 Conversion rate q
Primary: q = sotLambda / shotsLambda from models[].lambdaNumber (both groups carry it).
Fallbacks in order: empirical Σsot/Σshots from aligned recent games → position prior
(F 0.39 / M 0.32 / D 0.28, from the leg's role) shrunk toward 0.34. Clamp q ∈ [0.15, 0.60].
Integration & data flow
5.1 Context (built on the page)
Extend PlayerCorrelationCtx with per-player shot data, keyed by lowercased player name:
playerShots?: Map<string, { shotCounts: number[]; q: number }>Built in bet-builder.tsx from the full groups (which hold every line + models +
recentGames), so combinedFairOdds (which only sees picked legs) has the distribution
and q. A helper buildPlayerShotCtx(v2groups) lives in bet-builder.ts.
5.2 Pricer (combinedFairOdds, bet-builder-correlation.ts)
Replace the flat player-leg product with a group-by-player pass:
byPlayer = group picked player legs by name
pPlayers = 1 ; repLegs = [] // repLegs = one leg per player
for (player, legs) of byPlayer:
if legs has a shots leg + an SOT leg: // genuine pair (b<a; b≥a was locked)
pPlayers *= playerShotSotJoint(a, b, ctx.playerShots[name])
repLegs.push(the SOT leg) // representative for teammate step
else:
for l in legs: pPlayers *= 1 / l.fairOdds
repLegs.push(legs[0])
pPlayers *= playerClusterFactor(repLegs, ctx) // teammate engine, across DISTINCT playersThis composes cleanly with the teammate engine (§ round 5) with no double-count: the
single-player joint handles within-player; the teammate factor sees one representative per
player. For the common one-leg-per-player case, repLegs === playerLegs (no change). The
existing legFloor (combined never shorter than the longest leg) remains the backstop.
Testing (TDD)
Unit (bet-builder-player-shot-joint.test.ts), encoding the report's validated numbers:
- 3-shot player, q≈0.354: P(exactly 1 SOT)≈0.43, P(1 or 2)≈0.67, P(≥1)≈0.72, P(3)≈0.05.
- binomialTail correctness (k=0..s edge cases; s=0 → 0 for b≥1).
- Joint "3+ shots AND 1+ SOT" — shots and SOT are positively dependent (more shots ⇒
likelier to have one on target), so the joint prob > pS·pT and the combined odds are
shorter than the naive product. Assert: joint within the Fréchet bounds, joint
< min(pS,pT), and combined odds< fairShots × fairSOT. - Fréchet clamp binds when q/shape disagree with the SOT market price.
- q fallbacks: lambdas → empirical → position prior; clamp.
- Redundancy lock (compat): "2+ shots + 2+ SOT" locks the shots leg; "3+ shots + 1+ SOT" is compatible; "shots + goals" still locks.
- Composition: "Palmer shots+SOT AND Foden shots" prices Palmer's pair jointly and applies the teammate factor once across the two distinct players (no double-count).
- No regression: teammate-only and single-leg cases unchanged;
legFloorholds.
Risks / notes
- The shot-count shape is from recent history (may lag current form); the level is anchored to this match's market price, so the marginal is always current. Acceptable.
qfrom lambdas assumes both models are present; position-prior fallback covers gaps.- Tail (≥5 shot) legs are directional (thinner data) — the Fréchet clamp + legFloor keep them sane.
- Constants (position prior, VMR, clamps) isolated in the module for easy recalibration.
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.
Bet Builder — Team-Budget (multiXPlayer) Model for Same-Team SOT Multis
The design record for the team shots-on-target budget model the bet builder prices against. Written 2026-07-10.