B-Book by Netting — One-Pager
Verdict: B-Book routing is the existing netting engine applied at a second scope. Add one decision function (decideKeep), one new table (BookExposure, mirrors MarketExposure), and the policy triple (L, μ, f_cap) per scope. Delete cascade tiers, terminal-retain, forwarding-matrix rules, and the derived BBookPosition columns.
Primitive — every bet is a P&L vector
For a bet on a market with N outcomes, build v ∈ R^N where v[i] = platform P&L if outcome i wins.
Back bet, stake
s, oddso, selectionk:v[k] = −s·(o−1),v[i] = +sfori ≠ k. Lay = flip signs. Ladder = step function. Already lives inOrder.acceptedPmaxContribution.
State per scope — running tally
For each scope (player, agent, book) × marketKey:
Pmax = Σ v(sum of vectors of all open bets in scope)Emax = min(Pmax)(worst-case P&L; negative when at risk)
Stored as MarketExposure (player today) and new BookExposure (book/agent). Same (version, SELECT FOR UPDATE) concurrency.
Three risk projections
Worst case:
Emax = min(Pmax)— drives locked capital and headroom. Best case:Epot = max(Pmax)— denormalised for reads. Expected (platform take):pt(v, p) = Σ p[i]·v[i]wherepis sharp-derived probability (devigged Pinnacle/Betfair sharp).
Two-phase netting math (unchanged)
Accept (worst-case only enters):
Pmax_a[i] = Pmax[i] + min(v[i], 0)acr = Emax_old − Emax_a (≥0, locked capital)Match (best-case only enters):Pmax_m[i] = Pmax_a[i] + max(v[i], 0)rc = Emax_m − Emax_a (≥0, released capital)Settle (reverse the full vector at index k):Pmax −= v_kept; realised = v_kept[k]
Invariants the engine already enforces: acr ≥ 0, rc ≥ 0, keptFraction ∈ [0,1].
The three policy knobs
| Knob | Meaning | Plain English |
|---|---|---|
L_worstCase | Cap on |Emax| per scope | "How big is my truck?" |
μ_minPt | Required pt / stake to keep | "How much margin do I demand per £?" |
f_cap | Max fraction kept per bet | "Don't over-concentrate one trade." |
Tuned per scope and per user/market.
The routing decision — decideKeep
f_risk = headroom(Pmax, v, L)= largestf ∈ [0,1]s.t.min(Pmax + f·min(v,0)) ≥ −LClosed form per outcome:f_i = (Pmax[i] + L) / (−v[i])forv[i] < 0;f_risk = clip(min_i f_i, 0, 1).
f_pt = 1 if pt(v, p) ≥ μ · notional(v) else 0keptFraction = min(f_risk, f_pt, f_cap)v_book = keptFraction · v,v_exchange = (1 − keptFraction) · v
Then onAcceptAndMatch(BookPmax, v_book) exactly as the player layer does today.
Sharp routing as policy lookups
| Concern | Mechanism |
|---|---|
| Sharp odds reflected | p = devigged sharp feed; refreshed continuously |
| Platform's edge | falls out of pt(v, p_sharp) when offered odds < sharp |
| Sharp user → exchange | μ(user) raised so pt < μ·stake always fails |
| Untrusted user / thin market | μ raised; f_cap lowered |
Uncertainty in p | Use conservative p (lower bound for backs, upper for lays) |
No separate "sharp user → 100% exchange" boolean. No cascade tiers. Same engine, different (L, μ, f_cap, p).
Hedging — decideKeep inverted
Among exchange-available bets
b, pickbmaximisingrc / costwhererc = onAcceptAndMatch(BookPmax, v_b).rc
The existing netting math scores hedge candidates.
Schema deltas
New:
BookExposure(scope_type, scope_id, market_key, pmax, emax, num_outcomes, structure, version, settled_at, reversed_at)— same shape asMarketExposure. Order add columns:kept_fraction,pt_sharp,acr_book,rc_book. Drop:BBookPosition.platformPotentialWin,platformLiability(derive fromv × kept_fraction). Drop config:defaultForwardPercentage,terminalRetainPercentage,topSlicePercentage,V3ForwardingMatrixRule.
End-to-end flow
placeOrder(bet)
v ← buildPnlVector(bet) existing
p_sharp ← devig(sharpFeed[bet.marketKey]) new
pt ← Σ p_sharp[i] · v[i] new, persisted
{ keptFraction } ← decideKeep(BookPmax, v, p_sharp, policy)
BEGIN TX (FOR UPDATE on MarketExposure[player] + BookExposure[book + agents in scope])
playerResult ← onAcceptAndMatch(PlayerPmax, v) existing
debitPlayer(playerResult.acr − playerResult.rc)
v_book ← keptFraction · v
bookResult ← onAcceptAndMatch(BookPmax, v_book) same fn, different scope
debitRiskPool(bookResult.acr − bookResult.rc)
persistOrder(keptFraction, pt, bookResult.acr, bookResult.rc)
repeat book layer for each agent in upline
COMMIT
if keptFraction < 1: sendExchange((1 − keptFraction) · v)
Settlement mirrors via onReverseAcceptAndMatch at every scope.
Migration order
- Add
BookExposure+decideKeep+pt. Read-only / shadow.- Dual-write from
orderService.placeOrder. Existing cascade still routes.- Parity test: replay last 30 days of orders, log divergences, tune
(L, μ, f_cap).- Cutover by market structure:
discretefirst,laddersecond.- Drop cascade columns + derived
BBookPositioncolumns.
Reversible at every step.
What collapses
| Before | After |
|---|---|
Cascade forwardPercentage / terminalRetain / topSlice | (L, μ, f_cap) per scope; min across scopes inside decideKeep |
| Forwarding-matrix rule lookup | μ(sport, market, source) table |
| Sharp-user routing flag | μ(user) set high enough that f_pt = 0 |
platformPotentialWin / platformLiability columns | max(v_kept) / −min(v_kept) on read |
| Cascade try/catch (the win-cap bypass bug) | Cap breach → f_risk = 0 hard, no try/catch |
settlementOutcome enum branches | v_kept[k] index access |
Net code change: two new pure functions (pt, decideKeep) next to nettingEngine.ts, one new table mirroring MarketExposure, one policy table, and a second onAcceptAndMatch call inside placeOrder at the book scope. Removes hundreds of lines of cascade logic and the related config tables.
Mental model: One engine (netting), two scopes (player + book), three knobs (L, μ, f_cap), one decision (min of three fractions). Everything else is bookkeeping.