Skip to main content

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, odds o, selection k: v[k] = −s·(o−1), v[i] = +s for i ≠ k. Lay = flip signs. Ladder = step function. Already lives in Order.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] where p is 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

KnobMeaningPlain English
L_worstCaseCap on |Emax| per scope"How big is my truck?"
μ_minPtRequired pt / stake to keep"How much margin do I demand per £?"
f_capMax 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) = largest f ∈ [0,1] s.t. min(Pmax + f·min(v,0)) ≥ −L Closed form per outcome: f_i = (Pmax[i] + L) / (−v[i]) for v[i] < 0; f_risk = clip(min_i f_i, 0, 1).

f_pt = 1 if pt(v, p) ≥ μ · notional(v) else 0 keptFraction = 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

ConcernMechanism
Sharp odds reflectedp = devigged sharp feed; refreshed continuously
Platform's edgefalls 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 pUse 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, pick b maximising rc / cost where rc = 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 as MarketExposure. Order add columns: kept_fraction, pt_sharp, acr_book, rc_book. Drop: BBookPosition.platformPotentialWin, platformLiability (derive from v × 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

  1. Add BookExposure + decideKeep + pt. Read-only / shadow.
  2. Dual-write from orderService.placeOrder. Existing cascade still routes.
  3. Parity test: replay last 30 days of orders, log divergences, tune (L, μ, f_cap).
  4. Cutover by market structure: discrete first, ladder second.
  5. Drop cascade columns + derived BBookPosition columns.

Reversible at every step.

What collapses

BeforeAfter
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 columnsmax(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 branchesv_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.