Skip to main content

Odds Generation — soccer · goal · regular

Measured 0-0 bias — replacement proposed, pending team approval

Measured against Saba's own Correct Score boards twice on disjoint samples (59 + 124 real prematch matches, 2026-07-16), this page's λ₃ shock over-prices 0-0 in 81–86% of matches (≈ +0.45 pt, ≈ e^λ₃) — books price 0-0 at independence and keep their draw surplus in the mid/high diagonal. The diagonal-inflated BP carries exactly that shape (0-0 unbiased, 1X2 draw exact) and is proposed as the replacement (ADR-029 addenda) — this bivariate-Poisson engine remains the default until the team approves the switch.

Enter a period's Handicap and Total in Malay and its 1X2 in decimal — margins included — plus, in live, the current score (default 0-0). The engine:

  1. strips the margins to fair probabilities (Power two-way, Shin 1X2);
  2. back-solves the goal rates (λₕ, λₐ) by nested bisection — Total fixes how many goals, Handicap who scores them;
  3. optionally fits the bivariate-Poisson common shock λ₃ (Karlis–Ntzoufras) so the grid leans toward the 1X2 while both anchors stay exact;
  4. prices every market off the last stage.

Live. The grid stays in remaining goals: the rest-of-match Handicap reads it unchanged, the absolute Total line drops by the goals already scored, and final-result markets settle on current + remaining (the Asian/Myanmar Handicap boards stay on the rest). The λ₃ fit needs a level score — the shock can only lift the centred draw — so the 1X2 quote greys out while a side leads (step 4 explains); the two candidate pages' shape stages act on the final-result regions directly and keep fitting live, a genuine comparison axis. At 0-0 the pipeline is byte-identical to prematch.

Candidates — the same pipeline with step 4 swapped for an alternative shape mechanism: diagonal-inflated BP (λ₃ + θ) — proposed default, pending approval φ/σ tilt (φ draw + σ home/away)

The shock is one structural knob — a shared Poisson stream in both scores (X = W₁ + W₃, Y = W₂ + W₃). It cancels in the goal margin but counts twice in the total, so the anchored re-solve shrinks λ₁ + λ₂ — a lower-scoring, more-clustered match, which draws more; step 4 unrolls the consequences. Each period — full match, 1st half, 2nd half — is its own inversion; timing and cross-half markets are out of scope (see Markets).

Verified against a bookmaker's live prematch boards (Saba, July 2026: 54 matches, ≈ 2,900 out-of-sample quotes at the book's own lines and margins): the score grid lands on their Correct Score board at a quarter of a tick per cell (median |Δ fair prob| 0.0025 over 1,404 cells), 3-Way Handicap and Winning Margin at ~half a tick. The λ₃ draw-lift's known cost shows as a −2.3 pt hollow in the 2–3 goal band against their CS board, and the book's auxiliary prop boards are internally inconsistent with its own CS board — so residuals there measure the book's shading, not the model.

Score (live) 0-0 = prematch
Primary Quotes
Handicap (Malay)
Total (Malay)
1X2 quote (Decimal) optional ·

/ steps a field, Shift steps bigger; on a Malay pair the partner moves the opposite way. Handicap/Total boards show 16 lines in quarter steps around your line; team-total, 3-way and which-team lines are illustrative — probabilities are exact given λ.

Valid inputs: Malay in [−1, +1] and never 0, 1X2 decimals above 1, and each book must carry margin (implied probabilities summing over 1). The score is whole goals (0-0 = prematch); in live enter the quotes as the book states them — Handicap rest-of-match, Total absolute (its line must sit above the current total), 1X2 on the final result. The 1X2 + λ₃ fit needs a level score and greys out while a side leads (the shock cannot reach an off-centre draw — step 4 explains).

λ₁ 1.226178λ₂ 0.653620λ₃ 0.439953μ 1.666131/1.093573λtotal 2.759704anchors Δp 6.0e-11 / 6.6e-111X2 Δ -9.2e-3 / -4.4e-7 / +9.2e-3draw 0.2485990.302297grid N=5 · Poisson → BP λ₃
Score grid
Score grid — P(home = h, away = a) ×100
Poisson
λ₁ 1.608939λ₂ 1.065121draw 0.248599
A=0A=1A=2A=3A=4A=5+
H=06.907.353.911.390.370.10
H=111.1011.826.292.230.600.15
H=28.939.515.061.800.480.12
H=34.795.102.720.960.260.07
H=41.932.051.090.390.100.03
H=5+0.830.890.470.170.040.01
home Σ 0.500000 draw Σ 0.248599 away Σ 0.251401
BP λ₃
λ₁ 1.226178λ₂ 0.653620λ₃ 0.439953μ 1.666131/1.093573draw 0.302297
A=0A=1A=2A=3A=4A=5+
H=09.836.422.100.460.070.01
H=112.0512.205.401.480.290.05
H=27.3910.136.002.100.510.11
H=33.025.233.941.740.510.13
H=40.931.931.780.970.350.11
H=5+0.280.720.820.560.260.12
home Σ 0.500000 draw Σ 0.302297 away Σ 0.197703
shade ∝ probability · each stage re-solves (λ₁, λ₂) against the same fair targets; λ₃ couples the pair · cell = joint pmf at these rates (0-0 and 1-1 worked + any-score calculator in breakdown step 4)
Markets
Handicap
HDP-2/2.5-2.0-1.5/2-1.5-1/1.5-1.0-0.5/1-0.5-0/0.50.0+0/0.5+0.5+0.5/1+1.0+1/1.5+1.5
H-0.15-0.16-0.25-0.35-0.41-0.50-0.77+0.96+0.66+0.37+0.28+0.22+0.13+0.05+0.05+0.04
A+0.07+0.08+0.17+0.27+0.33+0.42+0.69+0.96-0.74-0.45-0.36-0.30-0.21-0.13-0.13-0.12
Over/Under
Goal0.5/11.01/1.51.51.5/22.02/2.52.52.5/33.03/3.53.53.5/44.04/4.54.5
Over+0.10+0.12+0.24+0.36+0.43+0.53+0.75+0.96-0.85-0.67-0.57-0.50-0.40-0.30-0.28-0.26
Under-0.18-0.20-0.32-0.44-0.51-0.61-0.83+0.96+0.77+0.59+0.49+0.42+0.32+0.22+0.20+0.18
1X2
OutcomeOdds
Home1.960
Draw3.064
Away4.685
Home is the −0.5 Handicap price (+0.96 Malay — the favourite, same event, same odds); the Draw and Away share the rest of the book to 1 + margin.
Correct Score
1-08.015
2-01344
2-19.518
3-031177
3-11862
3-22444
4-095672
4-148255
4-252163
4-392160
0-0
9.8
1-1
7.9
2-2
16
3-3
53
4-4
222
AOS
30
19 more in scope
The four primary markets above always show; pick any other in-scope market here to price it off the same final grid.
1 · Strip the bookmaker margins — Power (two-way) · Shin (three-way)
Handicap -0.50 → P(home covers -0.50)
HomeAway
Malay quote+0.96+0.96
Decimal dd1.9600001.960000
Fair p=q1/xp = q^{1/x}0.5000000.500000
Fair decimal 1/p1/p2.0000002.000000
0.5102041/x+0.5102041/x=1    x=0.9708540.510204^{1/x} + 0.510204^{1/x} = 1 \;\Rightarrow\; x = 0.970854
Total 2.50 → P(over 2.50)
OverUnder
Malay quote+0.95+0.95
Decimal dd1.9500001.950000
Fair p=q1/xp = q^{1/x}0.5000000.500000
Fair decimal 1/p1/p2.0000002.000000
0.5128211/x+0.5128211/x=1    x=0.9634740.512821^{1/x} + 0.512821^{1/x} = 1 \;\Rightarrow\; x = 0.963474
1X2 (Decimal) → P(home win), P(draw), P(away win) — Shin
HomeDrawAway
Decimal quote dd1.8900003.1300004.900000
Fair pp (Shin)0.5092410.3022980.188461
Fair decimal 1/p1/p1.9637083.3079965.306125
ipi(z)=1    z=0.026465\textstyle\sum_i p_i(z) = 1 \;\Rightarrow\; z = 0.026465

Try the strips standalone — Margin — two-way (Power) · Margin — multi-way (Shin): the same math on any quotes, with the bisection narrated.

2 · Size — recover λ_total from the Total

Step 1 left two hard fair targets — Handicap and Total — for the scoring rates λh,λa\lambda_h, \lambda_a. The optional 1X2 is a soft shape target for the common shock λ₃ in step 4, not a third hard rate constraint. Model each side’s goals as Poisson — P(k;λ)=eλλk/k!P(k;\lambda) = e^{-\lambda}\lambda^{k}/k! — and independent. Independent Poissons add — X+YPoisson(λh+λa)X + Y \sim \mathrm{Poisson}(\lambda_h + \lambda_a) — so the match total depends only on λtotal=λh+λa\lambda_{\text{total}} = \lambda_h + \lambda_a: the Total quote pins the match size on its own, before caring who scores (step 3’s job). Settling any line sorts that one total’s mass — point masses P(total=t)=eλλt/t!P(\text{total} = t) = e^{-\lambda}\lambda^{t}/t! — into three buckets: win (over hits), mass WW; lose, mass LL; push (an exact tie, stake refunded), mass RR — so W+L+R=1W + L + R = 1. A refund is a no-action bet, so the fair price conditions on the decided outcomes alone: P(overdecided)=WW+L=W1RP(\text{over} \mid \text{decided}) = \frac{W}{W + L} = \frac{W}{1 - R}. That makes size a one-dimensional root-find, laid out below in the order a solver runs it — target, search, then the evaluation every iteration repeats:

target (step 1): pover  =  fair P(over 2.50)  =  0.50000000p^{\ast}_{\text{over}} \;=\; \text{fair } P(\text{over } 2.50) \;=\; 0.50000000
Eight decimals on purpose — step 1’s table shows this same number at 4 dp, a rounding. The λ-inverse is stiff (a wobble in the target’s 4th decimal moves the recovered λ by several units in its 4th decimal), so the cross-checks below and step 3’s target carry full precision.
search: bisect λtotal[0.02,8]   until   W/(1R)=pover    λtotal=2.674060\text{bisect } \lambda_{\text{total}} \in [0.02,\, 8] \;\text{ until }\; W/(1-R) = p^{\ast}_{\text{over}} \;\Rightarrow\; \lambda_{\text{total}} = 2.674060
Cross-check it — the fair over is the win mass over the non-push mass, W/(1R)W/(1-R), written as raw Poisson terms. Paste into the Bisection Calculator and solve for λ\lambda:
1 - exp(-x)*(1 + x + x^2/2) = 0.50000000
A .5 line cannot push (R=0R = 0), so this is the plain tail P(X3)P(X \ge 3) — the Poisson Calculator inverse-solves P(X3)=0.50000000P(X \ge 3) = 0.50000000 in one field.
Where the pasted expression comes from

Totals are whole numbers, so total ii carries mass P(total=i)=exxi/i!P(\text{total} = i) = e^{-x}x^{i}/i!. Each win tail runs on forever, so flip it to the complement — exe^{-x} times the start of ex=1+x+x22+e^{x} = 1 + x + \tfrac{x^2}{2} + \cdots cut at the line (keep every term and exex=1e^{-x}e^{x} = 1, the whole distribution). For the 2.50 line:

W  =  P(total>2.5)  =  1P(total2)  =  1ex(1+x+x22)W \;=\; P(\text{total} > 2.5) \;=\; 1 - P(\text{total} \le 2) \;=\; 1 - e^{-x}\left(1 + x + \tfrac{x^{2}}{2}\right)
R  =  0(no whole-goal total equals 2.5)R \;=\; 0 \quad \text{(no whole-goal total equals 2.5)}

Substitute WW (with R=0R = 0 the denominator is 1) into W/(1R)=poverW/(1-R) = p^{\ast}_{\text{over}} and write exe^{-x} as exp(-x) — that is the pasted line, term for term; the calculator solves it for x=λtotalx = \lambda_{\text{total}}.

The evaluation the search repeats every iteration is that settlement rule under Poisson(λ). At the converged λtotal=2.674060\lambda_{\text{total}} = 2.674060 it reproduces the win mass WW, push mass RR, and the fair check back to the target — the figures a re-implementation should match:

W  =  P(total>2.50)  =  1t=02eλλtt!  =  0.500000W \;=\; P(\text{total} > 2.50) \;=\; 1 - \textstyle\sum_{t=0}^{2} \tfrac{e^{-\lambda}\lambda^{t}}{t!} \;=\; 0.500000
R  =  P(total=2.50)  =  0    (no push: totals are integers)R \;=\; P(\text{total} = 2.50) \;=\; 0 \;\; \text{(no push: totals are integers)}
fair P(over 2.50)  =  W1R  =  0.50000010.000000  =  0.500000  =  pover    \text{fair } P(\text{over } 2.50) \;=\; \frac{W}{1-R} \;=\; \frac{0.500000}{1 - 0.000000} \;=\; 0.500000 \;=\; p^{\ast}_{\text{over}} \;\; \checkmark
recovered size:λtotal  =  2.674060\text{recovered size:}\quad \lambda_{\text{total}} \;=\; 2.674060

(Additivity is exact only for the plain independent-Poisson grid, so this λ is the Poisson stage’s; step 4’s λ₃ shock perturbs it (total = W₁ + W₂ + 2W₃), so that stage re-solves size and split against the same hard targets — both recovered λ pairs land in the per-stage table, step 4.)

3 · Split — recover λₕ and λₐ from the Handicap

The Handicap decides who scores them — and settling it needs the full scoreline distribution, the score grid: independence makes each cell (h,a)(h, a) a plain product of the two Poisson masses,

J[h][a]  =  P(home=h;λh)P(away=a;λa)h,a=05J[h][a] \;=\; P(\text{home} = h;\, \lambda_h) \cdot P(\text{away} = a;\, \lambda_a) \qquad h, a = 0 \ldots 5

— exactly the grid the score-grid panel’s Poisson stage displays. Hold λtotal\lambda_{\text{total}} fixed and slide the home share λh\lambda_h (with λa=λtotalλh\lambda_a = \lambda_{\text{total}} - \lambda_h). Home covers ⟺ h+L>ah + L > a, an exact h+L=ah + L = a is a push — step 2’s one rule again, with the masses now read off the grid: W=h+L>aJ[h][a]W = \textstyle\sum_{h + L > a} J[h][a] and R=h+L=aJ[h][a]R = \textstyle\sum_{h + L = a} J[h][a] (½-weighted across a quarter line’s two components), fair cover probability W/(1R)W/(1-R) — which rises with λh\lambda_h, so the same solver shape applies:

target (step 1): pcover  =  fair P(home covers 0.50)  =  0.50000000p^{\ast}_{\text{cover}} \;=\; \text{fair } P(\text{home covers } -0.50) \;=\; 0.50000000
search: bisect λh(0,λtotal)   until   W/(1R)=pcover    λh=1.608939,    λa=λtotalλh=1.065121\text{bisect } \lambda_h \in (0,\, \lambda_{\text{total}}) \;\text{ until }\; W/(1-R) = p^{\ast}_{\text{cover}} \;\Rightarrow\; \lambda_h = 1.608939,\;\; \lambda_a = \lambda_{\text{total}} - \lambda_h = 1.065121
Cross-check it — the fair cover is the same win-over-non-push mass W/(1R)W/(1-R) as step 2, with the away score conditioned out so the whole cover is one function of x=λhx = \lambda_h (λa=λtotalx\lambda_a = \lambda_{\text{total}} - x, and λtotal=2.674060\lambda_{\text{total}} = 2.674060 held fixed from step 2). One line per away score aa up to N=5N = 5 — paste into the Bisection Calculator, interval [0,λtotal][0,\, \lambda_{\text{total}}], tolerance 1e-8, and solve for xx:
  P(0, 2.674060-x)*Ptail(1, x)
+ P(1, 2.674060-x)*Ptail(2, x)
+ P(2, 2.674060-x)*Ptail(3, x)
+ P(3, 2.674060-x)*Ptail(4, x)
+ P(4, 2.674060-x)*Ptail(5, x)
+ P(5, 2.674060-x)*Ptail(6, x)
= 0.50000000
A .5 line cannot push (R=0R = 0): each line is one away score — its Poisson mass times the home tail Ptail(a+1;x)\mathrm{Ptail}(a + 1;\, x) that covers it. Its search lands on λh=1.608889\lambda_h = 1.608889 — within Δ=5.0×105\Delta = 5.0 \times 10^{-5} of the grid solve above (why not exact: the away-corner fold, explained below).
Where the pasted expression comes from

Step 2's Total was a sum of the two goal counts (one rate, one Ptail); the Handicap turns on their difference — the goal margin XYX - Y (home goals minus away goals). A margin depends on both rates at once — its distribution is a Skellam, the difference of two Poissons — so no single Ptail in λtotal\lambda_{\text{total}} can express it. The trick is to condition on the away score aa: once aa is pinned, only the home goals still vary, and the cover collapses to one plain home tail (Ptail(k;λ)=P(Xk)\mathrm{Ptail}(k;\, \lambda) = P(X \ge k)). Home covers     ha+1\iff h \ge a + 1 (a half-line cannot push), so each away score contributes its Poisson mass times that tail:

W  =  P(home covers 0.50)  =  a0P(away=a;λa)Ptail(a+1;λh)W \;=\; P(\text{home covers } -0.50) \;=\; \sum_{a \ge 0} P(\text{away} = a;\, \lambda_a)\,\mathrm{Ptail}(a + 1;\, \lambda_h)
-5-4-3-2-10+1+2+3+4+5+6goal margin m = home − away
The goal margin m=ham = h - a — a Skellam, the difference of two Poissons. Shaded by how the 0.50-0.50 line settles: home covers (m+1m \ge +1), away.

This margin picture is λ₃-invariant. Step 4’s common shock adds the same W3W_3 to both scores, so it cancels in the difference — XY=(W1+W3)(W2+W3)=W1W2X - Y = (W_1 + W_3) - (W_2 + W_3) = W_1 - W_2 — a Skellam in (λ1,λ2)(\lambda_1, \lambda_2) alone. The figure therefore holds for both stages once you read it at that stage’s (λ1,λ2)(\lambda_1, \lambda_2); every handicap cover and the raw win/draw/loss split are functions of those two rates, never of λ3\lambda_3.

Now substitute the search variable x=λhx = \lambda_h (with λa=λtotalx\lambda_a = \lambda_{\text{total}} - x) so the whole cover is one function of xx:

W  =  a0P(a;λtotalx)Ptail(a+1;x)W \;=\; \sum_{a \ge 0} P(a;\, \lambda_{\text{total}} - x)\,\mathrm{Ptail}(a + 1;\, x)
fair cover  =  W(no push, so R=0)\text{fair cover} \;=\; W \quad (\text{no push, so } R = 0)

Truncating the away sum at N = 5 — the grid’s size — gives the pasted equation. At the solved x=λh=1.608889x = \lambda_h = 1.608889 the 6 terms evaluate and add straight up to the target (λa=λtotalλh=1.065171\lambda_a = \lambda_{\text{total}} - \lambda_h = 1.065171):

0.275697+0.175465+0.042809+0.005557+0.000447+0.000024  =  0.500000  =  pcover    0.275697 + 0.175465 + 0.042809 + 0.005557 + 0.000447 + 0.000024 \;=\; 0.500000 \;=\; p^{\ast}_{\text{cover}} \;\; \checkmark

Why the pasted λh\lambda_h is only almost exactly the 1.6089391.608939 from the grid solve (Δ=5.0×105\Delta = 5.0 \times 10^{-5}): the grid is mass-complete, so almost all of the old truncation gap is gone — the home tail folds into the h=5h = 5 row, so for every away score the grid reproduces the closed-form home tail above exactly. The one remaining sliver is the away corner: the grid folds the a5a \ge 5 mass onto the a=5a = 5 column as a cumulative bucket, while this closed-form paste keeps aa there as an exact point mass — and the margin hah - a mis-scores that folded corner. That corner is Δ\Delta, and it shrinks with the away rate. The figure below shows the fold on the home side — its 5\ge 5 tail lands in the 5+ bucket, so no mass is lost; the away side folds the same way.

012345+6789goals, one side
One side's goal count (Poisson). The 5+ bucket folds in the faded tail past 5 (0.62% of the mass), so the grid keeps 100% — nothing is dropped. That mass-completeness is what lets the discrete grid land on the closed-form λ\lambda.

At the converged split the grid masses and the fair check are:

W=P(h0.50>a)=0.500000W = P(h - 0.50 > a) = 0.500000
R=P(h0.50=a)=0.000000R = P(h - 0.50 = a) = 0.000000
fair P(home covers 0.50)  =  W1R  =  0.50000010.000000  =  0.500000  =  pcover    \text{fair } P(\text{home covers } -0.50) \;=\; \frac{W}{1-R} \;=\; \frac{0.500000}{1 - 0.000000} \;=\; 0.500000 \;=\; p^{\ast}_{\text{cover}} \;\; \checkmark
    λh=1.608939λa=1.065121\Longrightarrow\;\; \lambda_h = 1.608939 \qquad \lambda_a = 1.065121

The recovered pair (λh,λa)(\lambda_h,\, \lambda_a) is the Poisson stage’s chips in the grid panel — and with it both quotes are reproduced exactly. That closes the fit, but not the model: the two quotes pinned just two numbers, size and split, while the market boards above price whole ladders off the grid’s shape — and that shape rests entirely on the independence assumption. The next step relaxes exactly that assumption with one structural knob — the common shock λ₃ — then re-solves so the two quotes still hold (step 4).

4 · Pull the shape toward the 1X2 (λ₃ common shock) — optional

How the search finds λ₃ — in pictures. One worked story at the page’s default quotes, from the fair-1X2 target to the landed knob; the live search below then re-runs the same story on this page’s quotes. Click a thumbnail to enlarge, then slide:

Inputs — every value this step consumes, and where each one comes from. Nothing else enters the fit:

inputvaluecomputed in
market lines (Handicap h, Total L)-0.50 · 2.50the quote board above
hard anchor P(home covers -0.50)0.50000000step 1 — Power on the Handicap
hard anchor P(over 2.50)0.50000000step 1 — Power on the Total
soft targets (pH,pD,pA)(p_H^{\ast},\,p_D^{\ast},\,p_A^{\ast})0.509241 · 0.302298 · 0.188461step 1 — Shin on the 1X2
baseline rates (λₕ, λₐ), the λ₃ = 0 solve1.608939 · 1.065121steps 2–3
its 1X2 legs (home · draw · away)0.500000 · 0.248599 · 0.251401score grid Σ, Poisson column
score grid shape6×6, the ≥5 tail folded into the 5+ row/column (Σ = 1)page config — steps 2–4 share it

The gap to close: baseline draw 0.248599 vs target 0.302298 (+5.4e-2) — and steps 2–3 already spent both rates on the hard anchors, so a new parameter is needed. (Search bounds and tolerances sit in the contract fold below.)

Replay this fit standalone — the Shock Fit lab is this step as its own calculator (the same engine call, fitBP). The link carries the table above at full precision, so the lab reproduces the λ's below digit for digit — then every input is yours to bend.

One new knob. λ3[0,3]\lambda_3 \in [0,\,3] — a third Poisson stream W₃ that scores for both sides at once (X = W₁ + W₃, Y = W₂ + W₃). Every trial re-solves (λ₁, λ₂) so the Handicap and Total hold exactly, and is scored miss(λ3)=(PHpH)2+(PDpD)2\mathrm{miss}(\lambda_3) = \big(P_H - p_H^{\ast}\big)^2 + \big(P_D - p_D^{\ast}\big)^2 against the Shin 1X2 — the away leg follows from Σ = 1 and is never scored (adding it re-weights the legs and lands a different λ₃). Spec, notation, constants and the pmf sit in the fold; the search itself runs on stage right below.

The contract — spec, notation, constants, pmf
optimizesλ3[0,3]\lambda_3 \in [0,\,3] — the shared-goal rate, this step’s one new parameter
re-solved every trial(λ₁, λ₂) — not constants inherited from steps 2–3 but free variables, re-solved from scratch at each candidate λ₃ so both anchors hold at every trial; only the λ₃ = 0 solve equals the baseline (λₕ, λₐ)
held exactly (hard)P(home covers -0.50) and P(over 2.50) — a trial missing either by ≥ 10⁻⁶ is infeasible and discarded
pulled toward (soft)the Shin 1X2 (pH,pD)(p_H^{\ast},\, p_D^{\ast}); the away leg follows from Σ = 1 and stays a cross-check
objectivemiss(λ3)=(PHpH)2+(PDpD)2\mathrm{miss}(\lambda_3) = \big(P_H - p_H^{\ast}\big)^2 + \big(P_D - p_D^{\ast}\big)^2 — least squares over the two free legs
returnsthe winning (λ₁, λ₂, λ₃) and its 6×6 grid P(h, a) — everything below prices off that grid
degenerate casesno 1X2 quote → step skipped (λ₃ = 0); quoted draw at/below the baseline draw → the fit pins at λ₃ = 0 (the shock only adds draw)

Notation. λ₁ and λ₂ are the private stream rates, λ₃ the shared one; the marginal scoring means are μₕ = λ₁ + λ₃ and μₐ = λ₂ + λ₃ — don’t read λ₁ as “home’s mean”. At λ₃ = 0 the private rates are exactly steps 2–3’s (λₕ, λₐ). In the engine (markets-bp.ts) these are the result fields l1 l2 l3 mh ma; each recorded search trial is one BPFitStep row of the unroll fold below.

Constants. λ₃ is scanned at 17 points over [0, 3] (step 0.187500), then golden-sectioned to bracket width 10⁻⁶ (≤ 60 cuts); each trial’s (λ₁, λ₂) re-solve runs the steps 2–3 bisections at 64 halvings / value tolerance 10⁻¹⁰; hard-anchor acceptance is 10⁻⁶ per trial.

The pmf. A grid cell is one function of (λ₁, λ₂, λ₃), summing over how many of the min(h,a)\min(h,a) matched goals came from the shared W₃:

P(h,a)  =  e(λ1+λ2+λ3)k=0min(h,a)λ1hk(hk)!λ2ak(ak)!λ3kk!P(h,a) \;=\; e^{-(\lambda_1+\lambda_2+\lambda_3)}\sum_{k=0}^{\min(h,a)} \frac{\lambda_1^{\,h-k}}{(h-k)!}\,\frac{\lambda_2^{\,a-k}}{(a-k)!}\,\frac{\lambda_3^{\,k}}{k!}

At λ₃ = 0 this is exactly steps 2–3’s independent product, cell for cell. The worked 0-0 / 1-1 cells and the any-score calculator at the end of this step recompute it — use them to unit-test an implementation.

Why (λ₁, λ₂) must move: bolt this fit’s λ₃ onto the frozen baseline rates and the Total anchor breaks outright — the margin never sees the shock, the total counts it twice:

P(total>2.50)=0.658423    0.500000(Δ=+1.6×101)P(\text{total} > 2.50) = 0.658423 \;\ne\; 0.500000 \qquad (\Delta = +1.6 \times 10^{-1})

So λ₁ and λ₂ are re-solved at every trial — watch λ₁ + λ₂ give way as 2λ₃ grows in the readout below.

The fit. The whole thing is a nested loop — an outer search proposing λ₃, an inner solver restoring both anchors before anything is scored:

scan + golden   (outer loop)    — proposes the next trial λ₃
  └─ solveAt    (inner solver)  — re-solves (λ₁, λ₂) until Handicap + Total hold again
       └─ grid                  — bivariate-Poisson pmf at (λ₁, λ₂, λ₃)
            └─ score            — P_H = Σ h>a,  P_D = Σ h=a
                 └─ miss(λ₃)    — (P_H − p*H)² + (P_D − p*D)²  →  back to the outer loop

Watch the search run — every dot is one full (λ₁, λ₂) re-solve against the untouched anchors, graded on the 1X2; a trial that misses an anchor by ≥ 10⁻⁶ is discarded before scoring. Press ▶, or step with ⏭ / the arrow keys:

miss(λ₃) across the scan of [0, 3] — log y; every dot is one full (λ₁, λ₂) re-solve1e-41e-31e-2Δ²home — the −0.50 pin's floor0.000.751.502.253.00discarded — 2λ₃ already overshoots the Total×439,204cut 27/27 · width ×0.618λ₃ = 0.4399530.4399490.439957
shipped
λ₃ 0.439953
λ₁ 1.226178 · λ₂ 0.653620 · 2λ₃ 0.879906 — tick: λₕ+λₐ = 2.674060
P_D 0.3022970.302298
miss(λ₃)8.5e-5
Hcp ✓ 6.0e-11Tot ✓ 6.6e-11
μ_h = λ₁+λ₃1.666131
μ_a = λ₂+λ₃1.093573

the best trial seen ships — λ₃ = 0.439953; one more look inside its solve below

Then step inside one trial. Every dot above hides a full nested re-solve — ~33 size probes, each running its own split bisection (~33 halvings in the first) — repeated 33× across the search, plus the discarded trials. Here is the winning one, dissected — the very solve whose (λ₁, λ₂) ship:

λ₃ frozen at 0.439953 — the winning trial
size ∈ [0.02, 8]size λ₁+λ₂ — bisected on the Total: P(over 2.50) = 0.50000000width 1.9e-91.8797981.8797981.879798λ₁ = 1.226178 · P(over) = 0.500000split λ₁ (λ₂ = size − λ₁) — bisected on the Handicap: P(cover -0.50) = 0.50000000λ₁ = 1.226178inner: 32 halvings0size = 1.879798 · λ₂ = size − λ₁ = 0.653620
size probe 1.879798inner: 32 halvings → λ₁ 1.226178P(over) = 0.50000000 vs 0.50000000✓ both anchors within 10⁻¹⁰
    λ1=1.226178λ2=0.653620(size λ1+λ2=1.879798)\Longrightarrow\;\; \lambda_1 = 1.226178 \qquad \lambda_2 = 0.653620 \qquad (\text{size } \lambda_1+\lambda_2 = 1.879798)
P(home covers 0.50)    6.0×1011P(over 2.50)    6.6×1011P(\text{home covers } -0.50)\;\checkmark\; 6.0 \times 10^{-11} \qquad P(\text{over } 2.50)\;\checkmark\; 6.6 \times 10^{-11}
μh=λ1+λ3=1.666131μa=λ2+λ3=1.093573    shipped\mu_h = \lambda_1 + \lambda_3 = 1.666131 \qquad \mu_a = \lambda_2 + \lambda_3 = 1.093573 \;\longrightarrow\; \text{shipped}

converged — both anchors hold at 10⁻¹⁰; this trial's (λ₁, λ₂) are the shipped rates

Unroll this solve — all 33 outer size probes, plus the first probe’s inner split bisection (the stepper above, as numbers)

Given λ₃ = 0.439953 (frozen) · unknowns (λ₁, λ₂) · constraints P(home covers -0.50) = 0.50000000 and P(over 2.50) = 0.50000000. The outer loop bisects the size λ₁ + λ₂ on the Total; every size probe runs a full inner bisection of the split λ₁ on the Handicap. First, the inner loop inside the first outer probe (size = 4.010000, so λ₂ = size − λ₁ throughout):

i[lo, hi]λ₁ probeP(cover)move
0[0.010000, 4.000000]2.0050000.39128158λ₁ ↑ (lo ← mid)
1[2.005000, 4.000000]3.0025000.76954727λ₁ ↓ (hi ← mid)
2[2.005000, 3.002500]2.5037500.58727216λ₁ ↓ (hi ← mid)
3[2.005000, 2.503750]2.2543750.48860158λ₁ ↑ (lo ← mid)
4[2.254375, 2.503750]2.3790620.53806990λ₁ ↓ (hi ← mid)
5[2.254375, 2.379062]2.3167190.51333075λ₁ ↓ (hi ← mid)
25 more halvings …
31[2.283126, 2.283126]2.2831260.50000000λ₁ ↓ (hi ← mid)
32[2.283126, 2.283126]2.2831260.50000000✓ within 10⁻¹⁰

33 halvings to split size 4.010000 so the Handicap holds; the P(over) of that solved pair then grades the outer probe below. Every outer row hides one such inner run (its length in the “inner” column):

k[lo, hi]size probeinner λ₁innerP(over)move
0[0.020000, 8.000000]4.0100002.283126330.84244869size ↓ (hi ← mid)
1[0.020000, 4.010000]2.0150001.290776310.52901261size ↓ (hi ← mid)
2[0.020000, 2.015000]1.0175000.830520310.30731040size ↑ (lo ← mid)
3[1.017500, 2.015000]1.5162501.055320300.41947609size ↑ (lo ← mid)
4[1.516250, 2.015000]1.7656251.172036320.47503762size ↑ (lo ← mid)
5[1.765625, 2.015000]1.8903121.231184320.50227895size ↓ (hi ← mid)
25 more halvings …
31[1.879798, 1.879798]1.8797981.226178320.50000000size ↑ (lo ← mid)
32[1.879798, 1.879798]1.8797981.226178320.50000000✓ within 10⁻¹⁰
    λ1=1.226178λ2=0.653620\Longrightarrow\;\; \lambda_1 = 1.226178 \qquad \lambda_2 = 0.653620

— landing exactly on the shipped rates. The replay runs the same code path as steps 2–3 (size ∈ [0.02, 8], split ∈ (0, size), 64-halving cap, value tolerance 10⁻¹⁰) — the only change is that every P( · ) is read off the λ₃ grid.

The search in three numbers — miss 3.0e-3 (the λ₃ = 0 baseline) → 1.9e-4 (best scan trial) → 8.5e-5 (shipped); the hard anchors were never traded. Everything below — the stage table, the score grid, every market board — prices off the winning trial’s grid.

Why this moves the draw at all: the shock cancels in the margin (XY=W1W2X - Y = W_1 - W_2), so cover events never see λ₃ — and on this −0.50 line the cover event is the home win, so the home leg is pinned for every trial (assert it: Δ home is identical down every unroll row). But the Total counts the shock twice (X+Y=W1+W2+2W3X + Y = W_1 + W_2 + 2W_3), so re-anchoring shrinks λ₁ + λ₂ — a lower-scoring, more clustered match, which draws more.

What the fit did:

1X2 legλ₃ = 0 gridpulled1X2 targetΔ
Home0.5000000.5000000.509241-9.2e-3
Draw0.2485990.3022970.302298-4.4e-7
Away0.2514010.1977030.188461+9.2e-3

landed at λ3=0.439953\lambda_3 = 0.439953. The −0.50 Handicap makes the home leg the cover event P(h>a)P(h>a), pinned by the hard anchor — λ3\lambda_3 cannot touch it, so the fit spends entirely on the draw and Δ home is the pure home-book vs 1X2-book gap.

Even-total bias. Shock goals arrive two at a time, so λ3>0\lambda_3 > 0 shifts mass onto even totals — here 0.5017570.5117820.501757 \to 0.511782. See Odd/Even or Exact Total in the markets dropdown.

Unroll the λ₃ search — 6 feasible scan points, then a golden-section squeeze (the theater above, as numbers)

One row per recorded trial, in order — every row is one solveAt(λ₃) + score. Scan rows carry no bracket; golden rows record [lo, hi] after the cut (width ×0.618 per row); infeasible trials never appear (every grid point from λ₃ = 1.125000 up); miss = Δ²home + Δ²draw is the objective the search descends; the last row is the winner — exactly the (λ₁, λ₂, λ₃) shipped below. Δ home never moves (the −0.50 pin made visible), so every improvement is Δ draw.

iphaseλ₃λ₁λ₂lohiΔ homeΔ drawmiss
0scan0.0000001.6089391.065121-9.2e-3-5.4e-23.0e-3
1scan0.1875001.4472800.894162-9.2e-3-3.5e-21.3e-3
2scan0.3750001.2831830.716603-9.2e-3-1.0e-21.9e-4
3scan0.5625001.1192630.533067-9.2e-3+2.3e-26.0e-4
4scan0.7500000.9606860.346246-9.2e-3+6.7e-24.6e-3
5scan0.9375000.8151690.161825-9.2e-3+1.3e-11.6e-2
6golden0.4739751.1963750.6203590.3307370.562500-9.2e-3+5.8e-31.2e-4
7golden0.3854491.2740090.7065190.3307370.473975-9.2e-3-8.7e-31.6e-4
8golden0.4401611.2259960.6534170.3854490.473975-9.2e-3+3.4e-58.5e-5
9golden0.4530761.2146750.6408110.4192630.473975-9.2e-3+2.2e-39.0e-5
10golden0.4321781.2329960.6611950.4192630.453076-9.2e-3-1.3e-38.7e-5
11golden0.4450941.2216710.6486050.4321780.453076-9.2e-3+8.6e-48.6e-5
12golden0.4371121.2286700.6563900.4321780.445094-9.2e-3-4.7e-48.6e-5
13golden0.4420451.2243440.6515800.4371120.445094-9.2e-3+3.5e-48.6e-5
14golden0.4389961.2270170.6545530.4371120.442045-9.2e-3-1.6e-48.5e-5
15golden0.4408811.2253650.6527160.4389960.442045-9.2e-3+1.5e-48.5e-5
16golden0.4397161.2263860.6538510.4389960.440881-9.2e-3-4.0e-58.5e-5
17golden0.4404361.2257550.6531490.4397160.440881-9.2e-3+8.0e-58.5e-5
18golden0.4399911.2261450.6535830.4397160.440436-9.2e-3+5.9e-68.5e-5
19golden0.4398861.2262370.6536850.4397160.440161-9.2e-3-1.2e-58.5e-5
20golden0.4400561.2260880.6535200.4398860.440161-9.2e-3+1.7e-58.5e-5
21golden0.4399511.2261800.6536220.4398860.440056-9.2e-3-8.1e-78.5e-5
22golden0.4399261.2262020.6536460.4398860.439991-9.2e-3-5.0e-68.5e-5
23golden0.4399661.2261670.6536070.4399260.439991-9.2e-3+1.8e-68.5e-5
24golden0.4399411.2261890.6536310.4399260.439966-9.2e-3-2.4e-68.5e-5
25golden0.4399571.2261750.6536160.4399410.439966-9.2e-3+1.7e-78.5e-5
26golden0.4399471.2261840.6536260.4399410.439957-9.2e-3-1.4e-68.5e-5
27golden0.4399531.2261780.6536200.4399470.439957-9.2e-3-4.4e-78.5e-5
28golden0.4399541.2261770.6536190.4399510.439957-9.2e-3-2.0e-78.5e-5
29golden0.4399521.2261790.6536210.4399510.439954-9.2e-3-5.8e-78.5e-5
30golden0.4399541.2261780.6536190.4399520.439954-9.2e-3-3.5e-78.5e-5
31golden0.4399531.2261790.6536200.4399520.439954-9.2e-3-4.9e-78.5e-5
32golden0.4399531.2261780.6536200.4399530.439954-9.2e-3-4.0e-78.5e-5

The golden rows’ miss column is not monotone — golden-section keeps the bracket shrinking, not every probe improving; the best trial seen anywhere is what ships. The last row lands on it.

Prefer bisection? Choosing λ₃ against the 1X2 is a minimum — no sign to read — but once (λ1,λ2)(\lambda_1,\lambda_2) are frozen at the final row, the Total anchor alone pins λ₃ as a root. The total is W1+W2+2W3W_1+W_2+2W_3 — the shock scores twice — so P(total=t)  =  e(A+x)2jtxjj!At2j(t2j)!P(\text{total}=t) \;=\; e^{-(A+x)}\textstyle\sum_{2j\le t}\frac{x^{j}}{j!}\,\frac{A^{t-2j}}{(t-2j)!} with A=λ1+λ2=1.879798A = \lambda_1+\lambda_2 = 1.879798 and x=λ3x = \lambda_3. Substituting A and step 1’s over target writes the anchor as one expression — paste into the Bisection Calculator:

1 - exp(-(1.879798 + x))*(4.646619 + 1.000000*x) = 0.50000000

More shock ⇒ more goals ⇒ the fair over rises, so the flip is unique — scan [0,3][0,\,3] and the bisection lands back on λ3=0.439953\lambda_3 = 0.439953.

The same fit as code — solveAt / evaluate / scan / golden, plus the edge cases

The recipe above, executable-shaped. Constants and names are the real ones from markets-bp.ts; hcpTarget / totTarget are step 1’s hard anchors, targetH / targetD the Shin 1X2 legs:

// constants
const L3_MIN = 0, L3_MAX = 3, SCAN_POINTS = 17      // coarse-scan step 0.1875
const ANCHOR_OK  = 1e-6                             // per-trial hard-anchor acceptance
const SOFT_SOLVE = { iters: 64, tol: 1e-10 }        // the steps 2-3 bisections, run tighter
const GOLDEN_TOL = 1e-6                             // bracket-width stop, at most 60 cuts

function solveAt(l3) {                      // ONE trial: steps 2-3 re-run on the l3 grid
  const s = solveLambdas(hcpTarget, totTarget,      // outer: l1+l2 in [0.02, 8]  vs the Total
                         bpFairAt(l3), SOFT_SOLVE)  // inner: l1   in (0, l1+l2)  vs the Handicap
  if (!s.ok) return null                            // solver failed outright
  const grid = bpJoint(s.l1, s.l2, l3, N, capTail)  // 6x6, the >=5 tail folded  =>  sum = 1
  return { l1: s.l1, l2: s.l2, grid, pH, pD }       // pH, pD summed off the grid
}

function evaluate(l3) {                     // solveAt + feasibility gate + score
  const t = solveAt(l3)
  if (!t) return null
  if (errHcp(t) >= ANCHOR_OK || errTot(t) >= ANCHOR_OK) return null // infeasible: miss = +Inf
  return { ...t, miss: (t.pH - targetH) ** 2 + (t.pD - targetD) ** 2 } // NO away term
}

let best = evaluate(0)                      // l3 = 0 always converges: the fallback trial
const scan = []                             // 1) coarse scan - 17 trials over [0, 3]
for (let i = 0; i < SCAN_POINTS; i++) {
  const t = evaluate((L3_MAX * i) / (SCAN_POINTS - 1))
  if (t) { scan.push(t); if (t.miss < best.miss) best = t } // strict <: a tie keeps smaller l3
}

if (scan.length >= 2) {                     // 2) golden-section around the best scan trial
  let a = scanNeighbourLeft(best), b = scanNeighbourRight(best) // clamped at the feasible ends
  let c = b - 0.618 * (b - a), d = a + 0.618 * (b - a)
  for (let i = 0; i < 60 && b - a > GOLDEN_TOL; i++) {
    if (miss(c) <= miss(d)) { b = d; d = c; c = b - 0.618 * (b - a) } // one NEW evaluate per
    else                    { a = c; c = d; d = a + 0.618 * (b - a) } //   cut (trials memoized)
    best = minByMiss(best, trialAt(c), trialAt(d))  // an infeasible probe is +Inf: never wins
  }
}
return best                                 // the best trial SEEN ships -
                                            //   not the final bracket midpoint

The edge cases, exactly as the engine resolves them:

  • No 1X2 quote — the step never runs: λ₃ = 0, fit: null, the steps 2–3 grid ships unchanged.
  • Quoted draw at/below the baseline draw — no trial beats λ₃ = 0 (the shock only adds draw mass), so the fit reports pinnedAtZero and the grid equals steps 2–3’s. Live twist: at a non-level score the draw is an off-centre margin cell, and the shock’s clustering can move it the wrong way (typically when the leading side also covers the Handicap) — even a draw quoted above the baseline would pin λ₃ at 0. That is why this page disables the 1X2 fit outright while a side leads and ships the two-quote inversion.
  • Fewer than two feasible scan trials — no bracket to refine: golden is skipped and the surviving trial ships as-is.
  • Best scan trial at an end of the feasible run — the bracket clamps to that end and golden squeezes one-sided from there.
  • Golden probes an infeasible λ₃ — its miss reads +∞, so the cut always discards that side and the probe is never recorded (feasible λ₃ form a prefix [0, λ₃ᵐᵃˣ): once 2λ₃ overshoots the Total, every larger λ₃ does too — so this only bites at the bracket’s upper rim).
  • Repeat λ₃ — every distinct trial is evaluated once and memoized; golden’s reused interior probe costs nothing.

Every non-null evaluate() is one recorded row — the unroll fold above is this code’s log, in trial order.

Where the search landed — baseline vs pulled, and the recovered means:

Stageλ₁λ₂λ₃μₕμₐΔ coverΔ overΔ homeΔ drawΔ away
Poisson1.6089391.0651211.6089391.0651213.8e-97.1e-9
BP λ₃1.2261780.6536200.4399531.6661311.0935736.0e-116.6e-11-9.2e-3-4.4e-7+9.2e-3
    λ1=1.226178λ2=0.653620λ3=0.439953\Longrightarrow\;\; \lambda_1 = 1.226178 \qquad \lambda_2 = 0.653620 \qquad \lambda_3 = 0.439953
recovered goal means:μh=λ1+λ3=1.666131μa=λ2+λ3=1.093573\text{recovered goal means:}\quad \mu_h = \lambda_1 + \lambda_3 = 1.666131 \qquad \mu_a = \lambda_2 + \lambda_3 = 1.093573

Hard-anchor gaps: Handicap 6.0e-11, Total 6.6e-11 — never traded. The means μh,μa\mu_h,\mu_a rise with λ3\lambda_3 while λ1+λ2\lambda_1+\lambda_2 falls, keeping the Total fixed.

How to use P(h, a) — in pictures. The same worked story at the default quotes, continued: from the fitted engine into one grid cell, then out to the market sums. Click to enlarge:

Read a grid cell off these numbers. These are the BP column’s λ chips, and every cell there is the joint pmf at these rates. At h=a=0h = a = 0 only k=0k = 0 survives — all three shocks silent at once:

P(0,0)  =  e(λ1+λ2+λ3)  =  e2.319751  =  0.098298P(0,0) \;=\; e^{-(\lambda_1+\lambda_2+\lambda_3)} \;=\; e^{-2.319751} \;=\; 0.098298

— the grid prints it ×100 as 9.83. Mind the exponent: the latent sum λ1+λ2+λ3=2.319751\lambda_1+\lambda_2+\lambda_3 = 2.319751, not the marginal sum μh+μa=2.759704\mu_h+\mu_a = 2.759704 — the independent product eμheμa=0.063310e^{-\mu_h}e^{-\mu_a} = 0.063310 undercounts 0-0 because it prices the shared shock’s silence twice.

1-1 has two routes — own goals (k=0k = 0) or one shared shock (k=1k = 1):

P(1,1)  =  e(λ1+λ2+λ3)(λ1λ2+λ3)  =  0.098298(0.801455+0.439953)  =  0.122028P(1,1) \;=\; e^{-(\lambda_1+\lambda_2+\lambda_3)}\big(\lambda_1\lambda_2 + \lambda_3\big) \;=\; 0.098298\,(0.801455 + 0.439953) \;=\; 0.122028

The pmf as code — interior cells verbatim; mind the boundary:

function cell(h, a, l1, l2, l3) {      // exact scores - any cell below the N = 5 cap
  let s = 0
  for (let k = 0; k <= Math.min(h, a); k++)
    s += (l1 ** (h - k) / fact(h - k)) * (l2 ** (a - k) / fact(a - k)) * (l3 ** k / fact(k))
  return Math.exp(-(l1 + l2 + l3)) * s
}
// row/column 5 is NOT this formula: it holds the folded >=5 tail (build it from
// tail-capped per-stream pmfs) - that fold is what keeps sum(grid) = 1 exactly.

Price any cell yourself. Pick a final score (0–4 each; the grid’s 5+ row/column is the folded 5\ge 5 tail, priced as one-minus-the-rest):

What each symbol in the pmf means, at this cell:

symbolmeaninghere
h, ah,\ athe exact final score being priced — the grid cell at row H = h, column A = a2-1
λ1\lambda_1rate of goals the home side scores on its own — its private Poisson W1Pois(λ1)W_1 \sim \mathrm{Pois}(\lambda_1)1.226178
λ2\lambda_2the same for the away side — W2Pois(λ2)W_2 \sim \mathrm{Pois}(\lambda_2)0.653620
λ3\lambda_3rate of the shared shock W3W_3 — one event that adds a goal to both scores at once (step 4’s fitted knob)0.439953
kkthe summation index, not an input: how many goals arrived shared. Each firing puts one goal in each score, so kmin(h,a)k \le \min(h,a); the terms are exclusive ways to compose the score, so they add.0min(2,1)=10 \ldots \min(2,1) = 1
e(λ1+λ2+λ3)e^{-(\lambda_1+\lambda_2+\lambda_3)}each Poisson pmf carries an eλe^{-\lambda}; the three collect into one constant up front0.098298
n!n!factorial, from the Poisson pmf P(W=n)=eλλn/n!P(W = n) = e^{-\lambda}\lambda^{n}/n! — with 0!=10! = 1 and x0=1x^{0} = 1, an empty component contributes factor 1

Each term multiplies three Poisson probabilities — home hkh-k alone, away aka-k alone, shock fires kk. The raw pmf:

P(h,a)  =  e(λ1+λ2+λ3)k=0min(h,a)λ1hk(hk)!λ2ak(ak)!λ3kk!P(h,a) \;=\; e^{-(\lambda_1+\lambda_2+\lambda_3)}\sum_{k=0}^{\min(h,a)} \frac{\lambda_1^{\,h-k}}{(h-k)!}\,\frac{\lambda_2^{\,a-k}}{(a-k)!}\,\frac{\lambda_3^{\,k}}{k!}

then substitute the fitted rates and this score — min(2,1)=1\min(2,1) = 1 caps the sum:

P(2,1)  =  e2.319751k=01  1.2261782k(2k)!  0.6536201k(1k)!  0.439953kk!P(2,1) \;=\; e^{-2.319751}\sum_{k=0}^{1}\; \tfrac{1.226178^{\,2-k}}{(2-k)!}\;\tfrac{0.653620^{\,1-k}}{(1-k)!}\;\tfrac{0.439953^{\,k}}{k!}
kW₁ / W₂ / W₃home factoraway factorshock factorterm
02 / 1 / 01.22617822!=0.751757\tfrac{1.226178^{2}}{2!} = 0.7517570.65362011!=0.653620\tfrac{0.653620^{1}}{1!} = 0.6536200.43995300!=1.000000\tfrac{0.439953^{0}}{0!} = 1.0000000.491363
11 / 0 / 11.22617811!=1.226178\tfrac{1.226178^{1}}{1!} = 1.2261780.65362000!=1.000000\tfrac{0.653620^{0}}{0!} = 1.0000000.43995311!=0.439953\tfrac{0.439953^{1}}{1!} = 0.4399530.539461
Σ over k1.030824
P(2,1)  =  0.098298×1.030824  =  0.101328P(2,1) \;=\; 0.098298 \times 1.030824 \;=\; 0.101328

— the score grid’s H=2, A=1 cell, printed ×100 as 10.13.

Caveats: one covariance parameter lifts the whole diagonal — it cannot pin a single correct score, inflates even totals, and is floored at 0 (a draw below the independent baseline is unreachable). Compare the φ/σ-tilt and diagonal-inflated BP candidates.

Check a port. Seven assertions at this page’s quotes — every value re-renders live, so any quote set entered above is a fresh test vector:

assertexpectedhow close
(λ₁, λ₂, λ₃)1.226178 · 0.653620 · 0.439953match to 6 dp
P(home covers -0.50) off the fitted grid0.500000within 10⁻⁶ (this fit: 6.0e-11)
P(over 2.50) off the fitted grid0.500000within 10⁻⁶ (this fit: 6.6e-11)
1X2 split (P_H · P_D · P_A)0.500000 · 0.302297 · 0.197703match to 6 dp
Σ P(h, a) over all 36 cells1 — the tail is folded, not truncatedfloat rounding only (here |1 − Σ| = 0.0e+0)
P(0, 0) · P(1, 1)0.098298 · 0.122028match to 6 dp
re-run with λ₃ frozen at 0λ₁ = λₕ = 1.608939, λ₂ = λₐ = 1.065121equals steps 2–3’s grid, cell for cell

The two classic ways a port fails these: truncating the ≥5 tail instead of folding it (the anchors are then read off a grid that leaks mass, so the re-solved rates — row 1 — drift with it), and scoring the away leg in the miss (rows 1, 4, 6 land on a different λ₃).

The pipeline in one file

Everything the breakdown above does — steps 1–4, same constants, tolerances, iteration counts and loop order as the live engine — as one dependency-free JavaScript file, written to be read top-to-bottom and ported. Run it with Node, no install:

node soccer-goal-regular.js

It prices this page's default quotes on the same 6×6 tail-folded grid and prints the step-4 Check a port table with PASS/FAIL per assertion; it reproduces the engine's numbers to the last float bit, not just to 6 dp. Edit the DEMO block at the bottom to price any other quote set — including a live one via score: [home, away]. Download soccer-goal-regular.js, or read it here:

soccer-goal-regular.js — the complete listing
/* ═══════════════════════════════════════════════════════════════════════════
* soccer-goal-regular.js — the goal · regular pricing pipeline in one file
*
* A dependency-free JavaScript port of the docs page's live engine, covering
* the full breakdown, steps 1–4:
*
* step 1 · Strip the bookmaker margins Power (two-way) · Shin (1X2)
* step 2 · Size recover λ_total from the Total
* step 3 · Split recover (λ₁, λ₂) from the Handicap
* step 4 · Pull the shape toward the 1X2 fit the common shock λ₃
*
* Source of truth: docs/src/lib/odds/core.ts + docs/src/lib/odds/markets-bp.ts
* (the page /odds-generation/soccer-goal-regular/ renders that engine live).
* Every constant, tolerance, iteration count and loop order below matches the
* engine exactly, so this file reproduces the page's numbers to the digit.
* The only thing omitted is the page's trace instrumentation (it records every
* solver trial for the on-page tables); dropping it changes no number.
*
* Run it:
*
* node soccer-goal-regular.js
*
* It prices the page's DEFAULT quotes — Handicap −0.50 @ +0.96/+0.96,
* Total 2.50 @ +0.95/+0.95, 1X2 1.89/3.13/4.90, score 0-0 — on the page's own
* grid (N = 5, ≥5 tail folded in), then prints the step-4 "Check a port" table
* with PASS/FAIL per assertion. Edit DEMO at the bottom to price other quotes
* (the four literal-expectation rows apply to the default quotes only; the
* live page recomputes its table for any quotes you enter).
*
* LIVE mode: set `score: [home, away]` in DEMO (default [0, 0] = prematch).
* The grid always models the goals STILL TO COME; the score maps each quote
* into that frame by its own convention — the Malay Handicap is rest-of-match
* (line unchanged), the Total is absolute (line drops by the goals already
* scored), the 1X2 is the final result (regions shift by the deficit
* d = away − home: home win ⟺ h − a > d). At 0-0 all three frames coincide
* and the pipeline is byte-identical to prematch.
*
* The model in three sentences. Scores are built from three hidden Poisson
* streams — W₁ home-only, W₂ away-only, W₃ shared — as X = W₁ + W₃ and
* Y = W₂ + W₃ (Karlis–Ntzoufras bivariate Poisson). The shared stream cancels
* in the margin (X − Y = W₁ − W₂) but counts twice in the total
* (X + Y = W₁ + W₂ + 2W₃), which is exactly how λ₃ can lift the draw while
* the re-solved (λ₁, λ₂) keep the Handicap and Total anchors exact. At λ₃ = 0
* the model is plain independent Poisson — that is the steps 2–3 baseline.
* ═══════════════════════════════════════════════════════════════════════════
*/

'use strict'

/* ─────────────────────────────────────────────────────────────────────────
* Section 0 — Poisson primitives
*
* Everything downstream is assembled from the plain Poisson pmf. The grid is
* finite (scores 0…N), so there is also a "capped" pmf whose last value soaks
* up the whole upper tail — the page's grids are mass-complete (Σ = 1), never
* truncated. Truncating instead of folding is the #1 way a port drifts: the
* anchors are then read off a grid that leaks mass, and every rate the solver
* returns moves with it.
* ───────────────────────────────────────────────────────────────────────── */

/** Poisson pmf P(k; λ), computed iteratively (no factorials, no overflow). */
function pois(k, lam) {
if (lam <= 0) return k === 0 ? 1 : 0
let p = Math.exp(-lam)
for (let i = 1; i <= k; i++) p *= lam / i
return p
}

/** Poisson pmf with the upper tail folded onto k = N: below N it is the plain
* pmf; at k = N it returns P(X ≥ N), so a distribution capped at N keeps its
* full mass (Σ over 0…N = 1). The N-th value then reads "N or more". */
function poisCapped(k, lam, N) {
if (k < N) return pois(k, lam)
let below = 0
for (let i = 0; i < N; i++) below += pois(i, lam)
return Math.max(0, 1 - below)
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 1 — step 1 · Strip the bookmaker margins
*
* Quotes arrive with the margin baked in: the implied probabilities
* q = 1/decimal sum to Q > 1. Step 1 removes it — Power method for the
* two-way pairs (Handicap, Total), Shin for the three-way 1X2 — leaving fair
* probabilities that sum to exactly 1. Those become the solver's targets:
* two HARD anchors (P home-covers, P over) and three SOFT targets (the 1X2).
* ───────────────────────────────────────────────────────────────────────── */

/** Exact Malay → decimal: +m pays 1+m per unit; −m risks |m| to win 1, i.e.
* decimal 1 + 1/|m|. Domain is [−1, +1] excluding 0; null when outside. */
function malayToDecimalExact(m) {
if (!Number.isFinite(m) || m === 0 || m < -1 || m > 1) return null
return m > 0 ? 1 + m : 1 - 1 / m
}

/** Power-method margin strip for a two-way pair. The priced implied
* probabilities (q1, q2), q1 + q2 = Q > 1, are deflated along the power
* family p = q^(1/x): solve q1^(1/x) + q2^(1/x) = 1 for the exponent
* x ∈ (0, 1) with Newton's method. Unlike proportional scaling, the Power
* strip removes more margin from the longshot side (favourite–longshot
* bias), which is how the two-way books here are assumed to be built. */
function powerStrip(q1, q2) {
if (!(q1 > 0 && q2 > 0)) return null
if (q1 + q2 <= 1) return null // no overround — nothing to strip
const lnQ1 = Math.log(q1)
const lnQ2 = Math.log(q2)
let x = 0.9 // warm start near "almost fair"
for (let i = 0; i < 200; i++) {
const inv = 1 / x
const e1 = Math.pow(q1, inv)
const e2 = Math.pow(q2, inv)
const f = e1 + e2 - 1 // root function: fair probs must sum to 1
if (Math.abs(f) < 1e-14) break
const fp = (-e1 * lnQ1 - e2 * lnQ2) / (x * x) // df/dx
if (fp === 0) break
x = x - f / fp
if (x < 1e-6) x = 1e-6 // keep the exponent in (0, 1)
if (x > 1 - 1e-12) x = 1 - 1e-12
}
const inv = 1 / x
return { p1: Math.pow(q1, inv), p2: Math.pow(q2, inv), exponent: x }
}

/** Strip a two-way Malay pair to fair probabilities (+ the diagnostics the
* page shows). `pHome` is the fair probability of the FIRST side — home
* cover for the Handicap pair, over for the Total pair. */
function stripTwoWayMalay(mHome, mAway) {
const dH = malayToDecimalExact(mHome)
const dA = malayToDecimalExact(mAway)
if (dH === null || dA === null) return null
const qH = 1 / dH
const qA = 1 / dA
if (qH + qA <= 1) return null
const strip = powerStrip(qH, qA)
if (!strip) return null
return {
pHome: strip.p1,
pAway: strip.p2,
exponent: strip.exponent,
decimalHome: dH,
decimalAway: dA,
pricedHome: qH,
pricedAway: qA,
overround: qH + qA,
}
}

/** Inverse Shin — strip an n-way priced book (implied probs qᵢ = 1/decimal,
* Σ qᵢ = Q > 1) back to fair probabilities by recovering the insider
* fraction z. Shin's forward model prices qᵢ = √Q · √(pᵢ((1−z)pᵢ + z));
* inverting one price gives
*
* pᵢ(z) = [√(z² + 4(1−z)·qᵢ²/Q) − z] / (2(1−z))
*
* and z is found by bisection so that Σ pᵢ(z) = 1. Σ pᵢ falls monotonically
* in z (from √Q > 1 at z = 0), so the root is unique. Used for the 1X2:
* like Power, Shin shaves proportionally more off the longshot legs.
* Returns null when the book is too flat for Shin to deflate to 1. */
function shinStrip(qs) {
if (qs.length < 2 || !qs.every((q) => Number.isFinite(q) && q > 0)) return null
const Q = qs.reduce((a, b) => a + b, 0)
if (!(Q > 1)) return null
const pAt = (z) => qs.map((q) => (Math.sqrt(z * z + (4 * (1 - z) * q * q) / Q) - z) / (2 * (1 - z)))
const sumAt = (z) => pAt(z).reduce((a, b) => a + b, 0)
if (sumAt(1 - 1e-9) > 1) return null // even z → 1 cannot deflate this book
let lo = 0
let hi = 1 - 1e-9
for (let i = 0; i < 100; i++) {
const mid = (lo + hi) / 2
const s = sumAt(mid)
if (Math.abs(s - 1) < 1e-14) {
lo = hi = mid
break
}
if (s > 1) lo = mid // still over-full — need more insider weight
else hi = mid
}
const z = (lo + hi) / 2
const p = pAt(z)
const s = p.reduce((a, b) => a + b, 0)
return { p: p.map((v) => v / s), z } // final exact renormalize (Σ = 1)
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 2 — the score grid P(h, a)
*
* The joint distribution over final scores, on a finite (N+1)×(N+1) grid.
* `capTail = true` (the page's setting) folds each latent stream's ≥N tail
* onto its N-th value and maps overflowing sums onto the boundary, so the
* grid keeps ALL the probability mass: row/column N mean "N or more" and the
* whole grid sums to 1. Interior cells (h < N and a < N) still equal the
* exact untruncated pmf. At λ₃ = 0 this is exactly the independent-Poisson
* product grid.
* ───────────────────────────────────────────────────────────────────────── */

/** Bivariate-Poisson joint over (home, away) goals — the common-shock
* construction X = W₁ + W₃, Y = W₂ + W₃ with independent Poisson streams
* (λ₁, λ₂, λ₃). λ₃ couples the scores: both marginal means become λᵢ + λ₃
* and Cov(X, Y) = λ₃ ≥ 0. NB the shock cancels in the margin
* (X − Y = W₁ − W₂), so every handicap event and the win/draw/loss split
* depend on (λ₁, λ₂) alone — λ₃ reaches the 1X2 only through the Total. */
function bivariatePoissonJoint(l1, l2, l3, N, capTail = false) {
if (
!(Number.isFinite(l1) && Number.isFinite(l2) && Number.isFinite(l3) && l1 >= 0 && l2 >= 0 && l3 >= 0)
) {
return null
}
const J = []
for (let h = 0; h <= N; h++) J.push(new Array(N + 1).fill(0))
if (capTail) {
// Enumerate the three capped streams and route every (w1, w2, w3) combo to
// the score cell it lands on; sums past N belong on the N boundary.
const pk = (k, lam) => poisCapped(k, lam, N)
for (let w3 = 0; w3 <= N; w3++) {
const p3 = pk(w3, l3)
if (p3 === 0) continue
for (let w1 = 0; w1 <= N; w1++) {
const p13 = p3 * pk(w1, l1)
if (p13 === 0) continue
const h = Math.min(N, w1 + w3)
for (let w2 = 0; w2 <= N; w2++) J[h][Math.min(N, w2 + w3)] += p13 * pk(w2, l2)
}
}
} else {
// Plain truncated pmf per cell: the score (h, a) can be composed with
// k = 0…min(h, a) shared goals, and the routes are mutually exclusive:
// P(h, a) = Σ_k P(h−k; λ₁) · P(a−k; λ₂) · P(k; λ₃)
for (let h = 0; h <= N; h++) {
for (let a = 0; a <= N; a++) {
let s = 0
for (let k = 0; k <= Math.min(h, a); k++) s += pois(h - k, l1) * pois(a - k, l2) * pois(k, l3)
J[h][a] = s
}
}
}
return J
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 3 — fair market readers (Handicap / Total off a grid)
*
* The solver needs "what does THIS grid say the Handicap / Total fair
* probability is". Half/integer/quarter lines are handled uniformly: a
* quarter line (±0.25, ±0.75, …) is priced as half a stake on each adjacent
* half-step line, and pushes are removed by conditioning — fair
* p = E[W] / (1 − E[R]) with W the stake-weighted win fraction and R the
* stake-weighted push (refund) fraction of each scoreline.
* ───────────────────────────────────────────────────────────────────────── */

/** Quarter line ⟺ 4·x is an odd integer (±0.25, ±0.75, …). */
function isQuarterLine(x) {
const q4 = Math.round(x * 4)
if (Math.abs(q4 - x * 4) > 1e-9) return false
return q4 % 2 !== 0
}

/** A quarter line splits into its two neighbouring half-step lines at half
* stake each; any other line is itself at full stake. */
function componentLines(line) {
if (isQuarterLine(line)) {
const q4 = Math.round(line * 4)
return [
{ line: (q4 - 1) / 4, weight: 0.5 },
{ line: (q4 + 1) / 4, weight: 0.5 },
]
}
return [{ line, weight: 1 }]
}

/** Fair home/away probability at a HANDICAP line read off a grid. Home covers
* when h − a + line > 0, pushes when it lands exactly on 0. */
function fairHcpDetail(joint, N, line) {
const comps = componentLines(line)
let Wbar = 0 // E[win fraction of the stake]
let Rbar = 0 // E[refunded (push) fraction of the stake]
for (let h = 0; h <= N; h++) {
for (let a = 0; a <= N; a++) {
const p = joint[h][a]
if (!(p > 0)) continue
let W = 0
let R = 0
for (const c of comps) {
const diff = h - a + c.line
if (diff > 1e-9) W += c.weight
else if (diff > -1e-9) R += c.weight
}
Wbar += p * W
Rbar += p * R
}
}
const denom = 1 - Rbar // condition on "no push"
if (denom <= 1e-12) return null
return { components: comps, Wbar, Rbar, pHome: Wbar / denom, pAway: (1 - Wbar - Rbar) / denom }
}

/** Fair over/under probability at a TOTAL line — same push/quarter handling,
* with the win event h + a > line. */
function fairTotalDetail(joint, N, line) {
const comps = componentLines(line)
let Wbar = 0
let Rbar = 0
for (let h = 0; h <= N; h++) {
for (let a = 0; a <= N; a++) {
const p = joint[h][a]
if (!(p > 0)) continue
const total = h + a
let W = 0
let R = 0
for (const c of comps) {
const diff = total - c.line
if (diff > 1e-9) W += c.weight
else if (diff > -1e-9) R += c.weight
}
Wbar += p * W
Rbar += p * R
}
}
const denom = 1 - Rbar
if (denom <= 1e-12) return null
return { components: comps, Wbar, Rbar, pOver: Wbar / denom, pUnder: (1 - Wbar - Rbar) / denom }
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 4 — steps 2–3 · recover the goal rates by nested bisection
*
* Two unknowns, two equations, both solved by 1-D bisection (no gradients):
*
* OUTER (step 2 · Size) — bisect λ_total ∈ [0.02, 8] until the grid's
* fair P(over) hits the Total target. More goals ⇒ more overs, so
* P(over) is increasing in λ_total: a clean bisection knob.
*
* INNER (step 3 · Split) — at each candidate λ_total, bisect the home
* share λh ∈ [pad, λ_total − pad] until fair P(home covers) hits the
* Handicap target (λa = λ_total − λh). Shifting goals toward home raises
* every cover probability, so this is monotone too.
*
* `computeFair(lh, la)` abstracts the grid: the caller decides what grid the
* rates parameterize (step 4 passes a bivariate grid with its trial λ₃ baked
* in — that is ALL it takes for the same solver to serve both stages).
* Returns { lh, la, fair } with fair the achieved { pHcp, pTot }.
* ───────────────────────────────────────────────────────────────────────── */

function solveLambdas(targetHcpP, targetTotP, computeFair, opts = {}) {
const lambdaMin = opts.lambdaMin || 0.02
const lambdaMax = opts.lambdaMax || 8.0
const iters = opts.iters || 50
const tol = opts.tol || 1e-6

// Step 3 · Split — solve the home share at a FIXED λ_total.
function innerSolve(lambdaTotal) {
// Keep both rates strictly positive: pad shrinks with tiny totals.
const pad = Math.min(0.01, lambdaTotal * 0.01)
let lo = pad
let hi = lambdaTotal - pad
if (hi <= lo) {
// Degenerate bracket (λ_total ≈ 0) — split evenly and report.
const mid0 = lambdaTotal / 2
return { lh: mid0, fair: computeFair(mid0, lambdaTotal - mid0) }
}
const fLo = computeFair(lo, lambdaTotal - lo)
const fHi = computeFair(hi, lambdaTotal - hi)
if (!fLo || !fHi) return null
// Target outside the reachable range — clamp to the nearer end rather
// than bisect toward a root that is not there.
if (fLo.pHcp >= targetHcpP) return { lh: lo, fair: fLo }
if (fHi.pHcp <= targetHcpP) return { lh: hi, fair: fHi }
let mid = (lo + hi) / 2
let f = fLo
for (let i = 0; i < iters; i++) {
mid = (lo + hi) / 2
f = computeFair(mid, lambdaTotal - mid)
if (!f) return null
if (Math.abs(f.pHcp - targetHcpP) < tol) break // value-tolerance stop
if (f.pHcp < targetHcpP) lo = mid // too few home goals — raise the share
else hi = mid
}
return { lh: mid, fair: f }
}

// Step 2 · Size — bisect λ_total; every probe runs a full inner solve so
// the Handicap holds at each candidate size.
let lo = lambdaMin
let hi = lambdaMax
let bestMid = (lo + hi) / 2
let bestInner = innerSolve(bestMid)
for (let k = 0; k < iters; k++) {
const mid = (lo + hi) / 2
const step = innerSolve(mid)
if (!step || !step.fair) {
// Invalid trial (grid failed) — treat as "too big" and shrink.
hi = mid
continue
}
bestMid = mid
bestInner = step
if (Math.abs(step.fair.pTot - targetTotP) < tol) break
if (step.fair.pTot < targetTotP) lo = mid // too few goals — grow the size
else hi = mid
}
if (!bestInner || !bestInner.fair) return { ok: false, reason: 'no convergence' }
return {
ok: true,
lh: bestInner.lh,
la: Math.max(0, bestMid - bestInner.lh),
fair: bestInner.fair,
}
}

/* ─────────────────────────────────────────────────────────────────────────
* Section 5 — step 4 · fit the common shock λ₃ (and run the whole pipeline)
*
* One knob (λ₃) against two soft targets (the Shin-stripped home and draw —
* away follows from Σ = 1 and is deliberately NOT scored; adding it
* double-counts and lands on a different λ₃). Every trial λ₃ re-runs the
* steps 2–3 solver from scratch — (λ₁, λ₂) are free variables again, NOT the
* frozen baseline rates — so both hard anchors hold at every candidate. The
* search is a dense feasibility-gated scan over λ₃ ∈ [0, 3] followed by
* golden-section refinement inside the best scan bracket.
* ───────────────────────────────────────────────────────────────────────── */

// Solver budgets: the baseline-only run matches the independent-Poisson stage
// exactly; with a 1X2 in play the anchors are re-solved tighter so the shape
// search never trades them away.
const BASE_SOLVE_OPTS = { iters: 50, tol: 1e-8 }
const SOFT_SOLVE_OPTS = { iters: 64, tol: 1e-10 }

const L3_MIN = 0
const L3_MAX = 3
const SCAN_POINTS = 17 // scan step = 3/16 = 0.1875
const GOLDEN_TOL = 1e-6 // stop when the bracket is narrower than this
// A trial only counts if BOTH hard anchors still hold. The solver's ok flag is
// not enough: when 2λ₃ alone overshoots the Total, the outer bisection just
// drives λ_total to its floor and reports "ok" with a large Total error.
const ANCHOR_OK = 1e-6

/** Run the whole pipeline for one period's quote set.
*
* input = {
* hcpLine, hcpMalay: [home, away], // Handicap line + Malay pair
* totLine, totMalay: [over, under], // Total line + Malay pair
* N?, // grid size (scores 0…N); default 8
* capTail?, // fold the ≥N tail (page: true)
* oneX2Decimal?: [home, draw, away], // optional 1X2 → enables the λ₃ fit
* score?: [home, away], // current score (live); default [0, 0]
* }
*
* Without a 1X2 quote the answer is the steps 2–3 baseline (λ₃ = 0).
*/
function priceMatchBP(input) {
const N = input.N ?? 8
const capTail = input.capTail ?? false
const fail = (reason) => ({ ok: false, reason })

// ── step 1 · strip the margins ──────────────────────────────────────────
const hcpStrip = stripTwoWayMalay(input.hcpMalay[0], input.hcpMalay[1])
const totStrip = stripTwoWayMalay(input.totMalay[0], input.totMalay[1])
if (!hcpStrip) return fail('HCP pair invalid (Malay ∈ [−1,+1] non-zero, overround Q > 1)')
if (!totStrip) return fail('TOTAL pair invalid (Malay ∈ [−1,+1] non-zero, overround Q > 1)')

// ── the live frame — the grid holds the goals still to come ─────────────
// The Handicap (Malay, rest-of-match) reads the grid unchanged; the absolute
// Total line drops by the goals already scored; the 1X2 regions shift by the
// deficit. At 0-0 every line below reduces to the prematch pipeline.
const [scoreHome, scoreAway] = input.score ?? [0, 0]
if (
!(Number.isInteger(scoreHome) && scoreHome >= 0 && Number.isInteger(scoreAway) && scoreAway >= 0)
) {
return fail('score must be whole non-negative goals')
}
const effTotLine = input.totLine - scoreHome - scoreAway
if (!(effTotLine > 0)) {
return fail(`Total line must sit above the current total (${scoreHome + scoreAway} goals in)`)
}
const scoreDiff = scoreAway - scoreHome // final home win ⟺ h − a > scoreDiff

// Optional 1X2 → Shin-strip the three-way book. Home and draw legs become
// the least-squares target; away follows from Σ = 1 and stays a cross-check.
let oneX2Strip = null
if (input.oneX2Decimal) {
const ds = input.oneX2Decimal
if (ds.some((d) => !(d > 1))) return fail('1X2 decimal invalid (each price > 1)')
const strip = shinStrip(ds.map((d) => 1 / d))
if (!strip) return fail('1X2 book invalid (overround Q > 1, not too flat for Shin)')
oneX2Strip = { pHome: strip.p[0], pDraw: strip.p[1], pAway: strip.p[2], z: strip.z }
}

// The fair map at a fixed λ₃: build the bivariate grid, read both anchors
// off it. This closure is what plugs steps 2–3 into step 4 — the solver
// never knows a λ₃ exists.
const computeFair = (l3) => (l1, l2) => {
if (!(l1 >= 0 && l2 >= 0)) return null
const grid = bivariatePoissonJoint(l1, l2, l3, N, capTail)
if (!grid) return null
const hcp = fairHcpDetail(grid, N, input.hcpLine)
const tot = fairTotalDetail(grid, N, effTotLine)
if (!hcp || !tot) return null
return { pHcp: hcp.pHome, pTot: tot.pOver }
}

// 1X2 legs of a grid — final-result region sums on the remaining-goals grid:
// home win ⟺ h − a > scoreDiff (prematch: h>a wins, h=a draws, h<a loses).
const splitOneX2 = (grid) => {
let pHome = 0
let pDraw = 0
let pAway = 0
for (let h = 0; h <= N; h++) {
for (let a = 0; a <= N; a++) {
const m = h - a
if (m > scoreDiff) pHome += grid[h][a]
else if (m < scoreDiff) pAway += grid[h][a]
else pDraw += grid[h][a]
}
}
return { pHome, pDraw, pAway }
}

// ── steps 2–3 at a frozen λ₃ (one "solveAt" trial) ──────────────────────
const solveAt = (l3) => {
const opts = oneX2Strip == null ? BASE_SOLVE_OPTS : SOFT_SOLVE_OPTS
const sol = solveLambdas(hcpStrip.pHome, totStrip.pHome, computeFair(l3), opts)
if (!sol.ok || sol.lh == null || sol.la == null || !sol.fair) return null
const grid = bivariatePoissonJoint(sol.lh, sol.la, l3, N, capTail)
if (!grid) return null
return { l1: sol.lh, l2: sol.la, fair: sol.fair, grid, oneX2: splitOneX2(grid) }
}

const base0 = solveAt(0)
if (!base0) return fail('solver failed to converge')

// No 1X2 → the baseline IS the answer: λ₃ = 0, plain independent Poisson.
if (oneX2Strip == null) {
return {
ok: true,
N,
l1: base0.l1,
l2: base0.l2,
l3: 0,
mh: base0.l1, // marginal means; at λ₃ = 0 they are the rates themselves
ma: base0.l2,
ltot: base0.l1 + base0.l2,
errHcp: Math.abs(base0.fair.pHcp - hcpStrip.pHome),
errTot: Math.abs(base0.fair.pTot - totStrip.pHome),
joint: base0.grid,
hcpStrip,
totStrip,
oneX2Strip: null,
oneX2Achieved: null,
oneX2Residual: null,
fit: null,
}
}

// ── step 4 · the λ₃ search ──────────────────────────────────────────────
const target = oneX2Strip
// Package one evaluated trial: its residuals against the soft targets and
// whether the hard anchors survived (feasible).
const pointFrom = (solved, l3) => {
const achieved = solved.oneX2
const residual = {
home: achieved.pHome - target.pHome,
draw: achieved.pDraw - target.pDraw,
away: achieved.pAway - target.pAway,
sse: 0,
}
// The objective: home² + draw² ONLY (away follows from Σ = 1).
residual.sse = residual.home * residual.home + residual.draw * residual.draw
const errHcp = Math.abs(solved.fair.pHcp - hcpStrip.pHome)
const errTot = Math.abs(solved.fair.pTot - totStrip.pHome)
return { l3, solved, residual, feasible: errHcp < ANCHOR_OK && errTot < ANCHOR_OK }
}
// Memoize trials — golden-section revisits interval endpoints, and the scan
// neighbours reappear as the initial bracket.
const cache = new Map()
const pointAt = (l3) => {
if (!(l3 >= L3_MIN - 1e-12 && l3 <= L3_MAX + 1e-12)) return null
const key = l3.toPrecision(16)
if (cache.has(key)) return cache.get(key) ?? null
const solved = l3 === 0 ? base0 : solveAt(l3)
const point = solved ? pointFrom(solved, l3) : null
cache.set(key, point)
return point
}

// Infeasible trials read as +∞ so the search is repelled from them.
const sseOf = (point) => (point && point.feasible ? point.residual.sse : Infinity)

// 1) Coarse scan over [0, 3] — 17 evenly spaced trials. λ₃ = 0 is always
// the fallback answer; ties keep the SMALLER λ₃ (strict <).
const feasible = []
let best = pointFrom(base0, 0)
for (let i = 0; i < SCAN_POINTS; i++) {
const l3 = L3_MIN + ((L3_MAX - L3_MIN) * i) / (SCAN_POINTS - 1)
const point = pointAt(l3)
if (!point || !point.feasible) continue
feasible.push(point)
if (point.residual.sse < best.residual.sse) best = point
}

// 2) Golden-section refinement between the best scan point's feasible
// neighbours (clamped at the ends). Skipped when fewer than two feasible
// points exist — there is nothing to bracket.
const bestIdx = feasible.findIndex((p) => p.l3 === best.l3)
let bracketLo = best.l3
let bracketHi = best.l3
if (feasible.length >= 2 && bestIdx >= 0) {
bracketLo = feasible[Math.max(0, bestIdx - 1)].l3
bracketHi = feasible[Math.min(feasible.length - 1, bestIdx + 1)].l3
const gr = (Math.sqrt(5) - 1) / 2 // 0.618… — the golden ratio complement
let a = bracketLo
let b = bracketHi
let c = b - gr * (b - a) // two interior probes at 0.618 of the width;
let d = a + gr * (b - a) // each iteration reuses one of them
let pc = pointAt(c)
let pd = pointAt(d)
let fc = sseOf(pc)
let fd = sseOf(pd)
for (let iter = 0; iter < 60 && b - a > GOLDEN_TOL; iter++) {
if (fc <= fd) {
// Minimum is left of d — drop [d, b], reuse c as the new d.
b = d
d = c
fd = fc
pd = pc
c = b - gr * (b - a)
pc = pointAt(c)
fc = sseOf(pc)
} else {
// Mirror image — drop [a, c], reuse d as the new c.
a = c
c = d
fc = fd
pc = pd
d = a + gr * (b - a)
pd = pointAt(d)
fd = sseOf(pd)
}
// Best-SEEN ships, not the last probe — a shrinking bracket can step
// over the minimum near the tolerance floor.
for (const p of [pc, pd]) if (p && p.feasible && p.residual.sse < best.residual.sse) best = p
}
bracketLo = a
bracketHi = b
}

const fitted = best
return {
ok: true,
N,
l1: fitted.solved.l1,
l2: fitted.solved.l2,
l3: fitted.l3,
mh: fitted.solved.l1 + fitted.l3, // home marginal mean λ₁ + λ₃
ma: fitted.solved.l2 + fitted.l3, // away marginal mean λ₂ + λ₃
ltot: fitted.solved.l1 + fitted.solved.l2 + 2 * fitted.l3, // E[total goals]
errHcp: Math.abs(fitted.solved.fair.pHcp - hcpStrip.pHome),
errTot: Math.abs(fitted.solved.fair.pTot - totStrip.pHome),
joint: fitted.solved.grid,
hcpStrip,
totStrip,
oneX2Strip,
oneX2Achieved: fitted.solved.oneX2,
oneX2Residual: fitted.residual,
// Baseline 1X2 legs (λ₃ = 0), for the before/after comparison.
homeBase: base0.oneX2.pHome,
drawBase: base0.oneX2.pDraw,
awayBase: base0.oneX2.pAway,
fit: {
pinnedAtZero: fitted.l3 < 1e-6, // quoted draw at/below the baseline
scanCount: feasible.length,
bracketLo,
bracketHi,
},
}
}

/* ─────────────────────────────────────────────────────────────────────────
* Demo — the page's default quotes, plus the step-4 "Check a port" table
* ───────────────────────────────────────────────────────────────────────── */

const DEMO = {
hcpLine: -0.5, // Handicap −0.50 @ +0.96 / +0.96 (Malay)
hcpMalay: [0.96, 0.96],
totLine: 2.5, // Total 2.50 @ +0.95 / +0.95 (Malay)
totMalay: [0.95, 0.95],
oneX2Decimal: [1.89, 3.13, 4.9], // 1X2 (decimal) — enables the λ₃ fit
score: [0, 0], // current score — [0, 0] is prematch; e.g. [1, 0] prices live
N: 5, // the page grid: scores 0…5
capTail: true, // fold P(score ≥ 5) into the 5-row/column (Σ = 1)
}

function main() {
const f6 = (x) => x.toFixed(6)
const f8 = (x) => x.toFixed(8)
const e1 = (x) => x.toExponential(1)
const log = console.log

const r = priceMatchBP(DEMO) // the full fit (steps 1–4)
const baseline = priceMatchBP({ ...DEMO, oneX2Decimal: undefined }) // steps 1–3 only
if (!r.ok || !baseline.ok) {
console.error('solve failed:', (r.ok ? baseline : r).reason)
process.exit(1)
}

log('soccer-goal-regular.js — pricing the page defaults on the 6×6 tail-folded grid\n')

log('step 1 · strip the margins')
const hs = r.hcpStrip
const ts = r.totStrip
log(` Handicap ${DEMO.hcpLine.toFixed(2)} @ +0.96/+0.96 → P(home covers) = ${f8(hs.pHome)}`)
log(` (Power x = ${f6(hs.exponent)}, overround ${hs.overround.toFixed(4)})`)
log(` Total ${DEMO.totLine.toFixed(2)} @ +0.95/+0.95 → P(over) = ${f8(ts.pHome)}`)
log(` (Power x = ${f6(ts.exponent)}, overround ${ts.overround.toFixed(4)})`)
const t = r.oneX2Strip
log(` 1X2 1.89/3.13/4.90 → (p*_H, p*_D, p*_A) = ${f6(t.pHome)} · ${f6(t.pDraw)} · ${f6(t.pAway)}`)
log(` (Shin z = ${f6(t.z)})\n`)

log('steps 2–3 · the λ₃ = 0 baseline (independent Poisson)')
log(` λh = ${f6(baseline.l1)}, λa = ${f6(baseline.l2)} (λ_total = ${f6(baseline.ltot)})`)
log(` its 1X2 legs: ${f6(r.homeBase)} · ${f6(r.drawBase)} · ${f6(r.awayBase)}`)
log(` — the draw wants ${f6(t.pDraw)}; no baseline knob aims at it\n`)

log('step 4 · fit the common shock λ₃')
log(` scan: ${r.fit.scanCount} feasible of ${SCAN_POINTS} trials;`)
log(` golden bracket → [${r.fit.bracketLo.toFixed(6)}, ${r.fit.bracketHi.toFixed(6)}]`)
const pinned = r.fit.pinnedAtZero ? ' [pinned at 0]' : ''
log(` (λ₁, λ₂, λ₃) = ${f6(r.l1)} · ${f6(r.l2)} · ${f6(r.l3)}${pinned}`)
log(` marginal means μh = ${f6(r.mh)}, μa = ${f6(r.ma)}; E[total] = ${f6(r.ltot)}`)
const a = r.oneX2Achieved
const res = r.oneX2Residual
log(` achieved 1X2: ${f6(a.pHome)} · ${f6(a.pDraw)} · ${f6(a.pAway)}`)
log(` residuals: home ${res.home.toExponential(2)} (the pinned book gap)` +
` · draw ${res.draw.toExponential(2)}\n`)

// ── the page's "Check a port" table ──────────────────────────────────────
// The rows quoting 6-dp literals pin this file to the page's DEFAULT-quote
// values; the error/closed-form rows are invariants that hold for ANY quotes.
const sum = r.joint.flat().reduce((s, v) => s + v, 0)
const p00 = r.joint[0][0]
const p11 = r.joint[1][1]
// Closed forms for the two worked cells (interior cells are the exact pmf):
// P(0,0) = e^−(λ₁+λ₂+λ₃) — all three streams silent at once — and
// P(1,1) = P(0,0) · (λ₁λ₂ + λ₃) — routes k = 0 (1/1/0) and k = 1 (0/0/1).
const p00Closed = Math.exp(-(r.l1 + r.l2 + r.l3))
const p11Closed = p00Closed * (r.l1 * r.l2 + r.l3)
// λ₃ = 0 reduction: the bivariate grid at the baseline rates must equal the
// independent product grid cell for cell.
const bpAt0 = bivariatePoissonJoint(baseline.l1, baseline.l2, 0, DEMO.N, true)
let maxDiff0 = 0
for (let h = 0; h <= DEMO.N; h++) {
for (let aa = 0; aa <= DEMO.N; aa++) {
const prod = poisCapped(h, baseline.l1, DEMO.N) * poisCapped(aa, baseline.l2, DEMO.N)
maxDiff0 = Math.max(maxDiff0, Math.abs(bpAt0[h][aa] - prod))
}
}

const rates = `${f6(r.l1)} · ${f6(r.l2)} · ${f6(r.l3)}`
const split = `${f6(a.pHome)} · ${f6(a.pDraw)} · ${f6(a.pAway)}`
const baseRates = `${f6(baseline.l1)} · ${f6(baseline.l2)}`
const rows = [
['(λ₁, λ₂, λ₃)', rates, rates === '1.226178 · 0.653620 · 0.439953'],
[`P(home covers ${DEMO.hcpLine.toFixed(2)})`, `err ${e1(r.errHcp)}`, r.errHcp < 1e-6],
[`P(over ${DEMO.totLine.toFixed(2)})`, `err ${e1(r.errTot)}`, r.errTot < 1e-6],
['1X2 split (P_H · P_D · P_A)', split, split === '0.500000 · 0.302297 · 0.197703'],
[
`Σ P(h, a), all ${(DEMO.N + 1) ** 2} cells`,
`|1 − Σ| = ${e1(Math.abs(1 - sum))}`,
Math.abs(1 - sum) < 1e-12,
],
[
'P(0, 0) · P(1, 1) vs closed form',
`${f6(p00)} · ${f6(p11)}`,
Math.abs(p00 - p00Closed) < 1e-15 && Math.abs(p11 - p11Closed) < 1e-15,
],
[
're-run with λ₃ frozen at 0',
`λ₁ · λ₂ = ${baseRates}; grid Δ ${e1(maxDiff0)}`,
baseRates === '1.608939 · 1.065121' && maxDiff0 < 1e-15,
],
]
log('check a port — seven assertions (page defaults)')
let allPass = true
for (const [label, shown, pass] of rows) {
allPass = allPass && pass
log(` ${pass ? 'PASS' : 'FAIL'} ${String(label).padEnd(34)} ${shown}`)
}
if (allPass) log('\nall assertions hold — the port matches the page.')
else {
log("\nsome assertions FAILED — the page's step 4 names the two classic causes:")
log('truncating the ≥N tail instead of folding it, or scoring the away leg in the miss.')
process.exitCode = 1
}
}

if (require.main === module) main()

module.exports = {
pois,
poisCapped,
malayToDecimalExact,
powerStrip,
stripTwoWayMalay,
shinStrip,
bivariatePoissonJoint,
isQuarterLine,
componentLines,
fairHcpDetail,
fairTotalDetail,
solveLambdas,
priceMatchBP,
}