StatsHub Docs
Specs

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.
  • q is 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). q per 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 pairRuleResult
shots (thr a) + SOT (thr b), b < agenuine two-sidedun-lock → priced
shots (thr a) + SOT (thr b), b ≥ aSOT implies shots (exact duplicate)lock the shots leg
any other same-player pair (shots+goals, +tackles, two SOT lines…)no modellock (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 combineProb bounds.

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 players

This 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:

  1. 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.
  2. binomialTail correctness (k=0..s edge cases; s=0 → 0 for b≥1).
  3. 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.
  4. Fréchet clamp binds when q/shape disagree with the SOT market price.
  5. q fallbacks: lambdas → empirical → position prior; clamp.
  6. Redundancy lock (compat): "2+ shots + 2+ SOT" locks the shots leg; "3+ shots + 1+ SOT" is compatible; "shots + goals" still locks.
  7. 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).
  8. No regression: teammate-only and single-leg cases unchanged; legFloor holds.

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.
  • q from 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.

On this page