Skip to main content

Exposure Netting — Integration Design (V1)

Status: Implemented (PR #385) Date: 2026-04-22 Scalar invariant update: 2026-08-03 — Emax is capped at zero and Wmax is floored at zero. Scope V1: Betfair-ex (match_odds, over_under) and Bifrost (all netting-eligible markets) — unified netting is unconditionally on across both providers. Deferred to V2: B-Book NNR integration, agent booking netting, cross-provider canonical netting.


1. Purpose and Scope​

Exposure netting computes per-outcome PnL vectors for each (user, market) pair so that multiple bets on the same market are financially netted. Instead of summing per-bet liabilities, the platform reserves only the worst-case outcome (Emax). Hedging bets correctly return capital when the new bet improves the worst case.

What this replaces: flat stake × (odds − 1) liability summation in availableService.ts and exposurePnlService.ts.

What is NOT netting-eligible: Pinnacle (credit-based, different settlement model, no MarketMeta cache).


2. Terminology (LOCKED)​

TermDefinition
outcomeSpace'discrete' or 'ladder' — the shape of the outcome index space for a market
Pmax vectorDecimal[numOutcomes] — per-outcome PnL if that outcome wins, summed across all bets for (user, market)
Emaxmin(0, min(Pmax[i])) — capped worst-case player P&L. Guaranteed open-position profit remains unavailable until settlement.
Pmin / WmaxWin-side mirror. Wmax = max(0, max(Pmin[i])); Pmin stays signed and only the scalar is floored.
ACr (Accept-Credit)Capital locked when a new bet worsens Emax: ACr = Emax_old − Emax_new_after_accept. Always ≥ 0.
RC (Released Capital)Capital released when a bet improves Emax: RC = Emax_new_after_match − Emax_after_accept. Always ≥ 0.
MarketKey4-segment string: `${fixtureId}
MarketMetaRedis-cached canonical market shape: numOutcomes, outcomeSpace, outcomeOrder, outcomeIndexMap
MarketExposurePostgres row (one per user per market) storing live Pmax/Pmin vectors and their Emax/Wmax scalars
winnerIdxIndex into Pmax that identifies the winning outcome at settlement

3. Architecture Overview​

3.1 Data Flow​

POST /api/orders
├─ canBookmakerNet(bookmaker)? ← eligibility.ts (pre-fetch gate)
│ ├─ true → getMarketMeta(bookmaker, marketId) ← Redis
│ │ └─ cache miss → throw DOMAIN_INTEGRITY (decline bet)
│ └─ false (pinnacle) → legacy flat credit path
│
├─ isNettingEligible(bookmaker, marketType, outcomeSpace)?
│ └─ false → legacy path
│
└─ Prisma TX (SELECT FOR UPDATE: user row + MarketExposure row)
├─ buildPnlVector(betType, stake, odds, outcomeSpace, outcomeIndex|line)
├─ onAccept(currentPmax, pnlVector) → {pmax_new, emax_new, acr}
├─ canAfford(availableBalance, acr)?
├─ UPDATE users SET balancePoints -= acr
├─ UPSERT market_exposures (pmaxVector, emax)
└─ INSERT orders (acceptedPmaxContribution, acceptedAcr, ...)

Exchange match event (Betfair stream / Bifrost BetOutcomeSnapshot)
└─ onMatch(currentPmax, pnlVector) → {pmax_new, emax_new, rc}
├─ UPDATE users SET balancePoints += rc
└─ UPDATE orders (matchedPmaxContribution, matchedRc)

Settlement
└─ settleMarketForUser(userId, marketKey, winnerIdx)
├─ credit = realizedPnl - emax (normal result)
├─ adjustment = -preSettlementLedgerTotal (first-result void)
├─ UPDATE users SET balancePoints += credit
└─ soft-delete market_exposures by setting settledAt

3.2 Key Stores​

StoreTechnologyPurpose
ordersPostgres (Prisma)Self-describing netting fields: acceptedPmaxContribution, matchedPmaxContribution, acceptedAcr, matchedRc, outcomeSpace, numOutcomes, outcomeIndex, marketType
market_exposuresPostgres (Prisma)Live Pmax/Pmin vectors + Emax/Wmax per (user, market). Authority for settlement; soft-closed with settledAt.
market:<bookmaker>:<marketId>Redis (noeviction)MarketMeta: shape, numOutcomes, outcomeOrder, outcomeIndexMap. Cache miss = DECLINE.

4. Outcome Space Taxonomy​

4.1 Discrete​

Enumerated outcomes indexed 0..numOutcomes-1. A winning outcome is identified by a single outcomeId mapping to an integer index.

  • outcomeIndexMap: Record<string, number> provides O(1) outcomeId → index lookup.
  • outcomeOrder: MarketMetaOutcome[] is the stable sorted list (by provider selectionId for Betfair).
  • Used for: all Betfair markets, Bifrost ODDS-type markets (e.g. WICKET_IN_OVER, OVER_RUNS_ODD_OR_EVEN), Bifrost match_odds.

PnL vector (discrete, back on outcome k, stake S, odds O):

P[k] = S × (O − 1)   ← profit if k wins
P[j] = −S ← loss for all j ≠ k

Lay on outcome k flips signs: P[k] = −S × (O − 1), P[j] = +S for j ≠ k.

4.2 Integer (Ladder)​

Integer-indexed 0..numOutcomes-1 representing feasible final run/wicket totals at settlement. A bet is placed at a line value (e.g. 148.5 runs); the split point is floor(line).

  • Back ("over line") wins when actual total ≥ floor(line) + 1: P[i ≥ floor(line)+1] = S × (O − 1), P[i ≤ floor(line)] = −S.
  • Lay ("under line") wins when actual total ≤ floor(line): P[i ≤ floor(line)] = +S, P[i ≥ floor(line)+1] = −S × (O − 1).
  • Multiple bets at different lines net into a single Pmax vector — the market identity is marketId, not (marketId, line).
  • numOutcomes is typically 1000 for Bifrost fancy markets (session_runs, batsman_runs, innings_runs, wickets).

MarketMeta for ladder space: outcomeOrder and outcomeIndexMap are not required. ladderMin, ladderMax, ladderStep define the feasible range; the vector is zero-padded on widening.


5. Netting Math​

5.1 Pmax Vector Construction​

// backend/src/services/nettingEngine.ts
function buildPnlVector(args: BuildPnlVectorArgs): Decimal[]

Arguments: betType, stake, odds, numOutcomes, outcomeSpace, outcomeIndex (discrete only), line (ladder only).

Validation: stake > 0, odds > 1.0, numOutcomes ≥ 2, outcomeIndex ∈ [0, numOutcomes) for discrete, floor(line) ∈ [-1, numOutcomes-1] for ladder. Any violation throws FINANCIAL_INTEGRITY or DOMAIN_INTEGRITY — no silent defaults.

5.2 Emax, ACr, RC​

Emax     = min(0, min(Pmax[i]))   for i in 0..numOutcomes-1
Wmax = max(0, max(Pmin[i])) for i in 0..numOutcomes-1

Accept:
Pmax_new[i] = Pmax_old[i] + min(pnl[i], 0)
ACr = Emax_old − Emax_new (≥ 0 by construction)

Match (two-phase, or step 2 of instant-match):
Pmax_new[i] = Pmax_after_accept[i] + max(pnl[i], 0)
RC = Emax_new − Emax_after_accept (≥ 0 by construction)

Net balance delta for placement = −ACr + RC

ACr ≥ 0 and RC ≥ 0 are invariants. Both functions throw FINANCIAL_INTEGRITY if violated.

5.3 Algorithm 4 General (odds-change match)​

When a bet matches at odds Om that differ from placement odds Op:

delta[i]    = matchedPnl[i] − min(acceptedPnl[i], 0)
Pmax_new[i] = currentPmax[i] + delta[i]

balanceDelta = Emax_new − Emax_old
positive → RC released
negative → additional ACr needed

Equivalence: when Om == Op, delta[i] = max(pnl[i], 0), which reduces to onMatch. This is the authoritative transition for Bifrost odds-shift handling.


6. Market Coverage Table​

MarketBookmakeroutcomeSpacenumOutcomesNetting eligibleNotes
match_oddsbetfair-exdiscrete2 (cricket/tennis) or 3 (soccer)yes (always on)2-way: home/away. 3-way: home/draw/away
over_underbetfair-exdiscrete2yes (always on)line encoded in marketId; 2 outcomes per line
asian_handicapbetfair-exdiscrete2not yet (V1.5)team1_at_line vs team2_at_line
correct_scorebetfair-exdiscreteN (typically 18+)not yet (V1.5)per Betfair runner list
both_teams_scorebetfair-exdiscrete2not yet (V1.5)yes/no
double_chancebetfair-exdiscrete3not yet (V1.5)home_or_draw / draw_or_away / home_or_away
draw_no_betbetfair-exdiscrete2not yet (V1.5)home / away (draw voids)
moneylinebetfair-exdiscrete2 or 3not yet (V1.5)sport-dependent
period variants (first_half_*, etc.)betfair-exdiscreteper marketnot yet (V1.5)same shape as full-match cousins
session_runsbifrostladder1000yes (always on)winnerIdx = floor(winnerLine) + 1
batsman_runsbifrostladder1000yes (always on)winnerIdx derived from winning back bet's line
innings_runsbifrostladder1000yes (always on)
wicketsbifrostladder1000yes (always on)winnerIdx = floor(winnerLine) + 1
match_odds (bifrost)bifrostdiscrete3yes (always on)same shape as betfair match_odds
WICKET_IN_OVERbifrostdiscrete2yes (always on)binary ODDS-type market
OVER_RUNS_ODD_OR_EVENbifrostdiscrete2yes (always on)binary ODDS-type market
all marketspinnaclen/an/aNOcredit-based settlement; no MarketMeta cache

7. Lifecycle Primitives​

All functions live in backend/src/services/nettingEngine.ts. All arithmetic uses Decimal.js. No native JS math on financial values.

7.1 onAccept (two-phase: Betfair)​

Called at bet placement before exchange confirmation. Locks worst-case exposure only.

function onAccept(currentPmax: Decimal[], pnlVector: Decimal[]): AcceptResult
// Returns: { pmax, emax, acr } — acr >= 0
// Pmax_new[i] = currentPmax[i] + min(pnlVector[i], 0)
// ACr = oldEmax - newEmax

Stores acceptedPmaxContribution and acceptedAcr on the order row.

7.2 onMatch​

Called when Betfair confirms a match. Lifts the positive (win-side) contribution.

function onMatch(currentPmax: Decimal[], pnlVector: Decimal[]): MatchResult
// Returns: { pmax, emax, rc } — rc >= 0
// Pmax_new[i] = currentPmax[i] + max(pnlVector[i], 0)
// RC = newEmax - oldEmax

Stores matchedPmaxContribution and matchedRc on the order row. Balance credited by RC.

7.3 onCancel​

Reverses the accept contribution (unmatched bet cancelled). Used at Betfair cancel and during partial-match cleanup at settlement.

function onCancel(currentPmax: Decimal[], pnlVector: Decimal[]): CancelResult
// Returns: { pmax, emax, rc } — rc >= 0
// Pmax_new[i] = currentPmax[i] - min(pnlVector[i], 0)
// RC = newEmax - oldEmax

7.4 onAcceptAndMatch (instant: Bifrost)​

Two-step computation in one call. Used when accept and match are simultaneous (Bifrost, and V1 collapsed Betfair flow).

function onAcceptAndMatch(currentPmax: Decimal[], pnlVector: Decimal[]): AcceptAndMatchResult
// Returns: { pmax, emax, acr, rc } — both >= 0, throws if invariant violated
// Step 1: Pmax_after_accept[i] = currentPmax[i] + min(pnlVector[i], 0); ACr = oldEmax - emaxAfterAccept
// Step 2: Pmax_new[i] = Pmax_after_accept[i] + max(pnlVector[i], 0); RC = newEmax - emaxAfterAccept

Net balance delta = −ACr + RC. RC > 0 when the new bet hedges an existing position.

7.5 onReverseAcceptAndMatch​

Full reversal of a previously accepted-and-matched bet (admin void, re-settlement, Bifrost market unsettlement).

function onReverseAcceptAndMatch(
currentPmax: Decimal[], pnlVector: Decimal[]
): { pmax: Decimal[]; emax: Decimal; balanceDelta: Decimal }
// Pmax_new[i] = currentPmax[i] - pnlVector[i] (subtracts full vector)
// balanceDelta = newEmax - oldEmax
// positive → user owes (removed hedge increased exposure)
// negative → user is owed (removed bet decreased exposure)

7.6 onMatchWithOddsChange (Bifrost odds-shift)​

When Bifrost updates odds on an accepted-but-not-yet-settled order, the accept contribution must be replaced with the contribution at new odds.

function onMatchWithOddsChange(
currentPmax: Decimal[], acceptedPnl: Decimal[], matchedPnl: Decimal[]
): { pmax: Decimal[]; emax: Decimal; balanceDelta: Decimal }
// delta[i] = matchedPnl[i] - min(acceptedPnl[i], 0)
// Pmax_new[i] = currentPmax[i] + delta[i]
// balanceDelta = newEmax - oldEmax

Cache-miss discipline: If getMarketMeta throws during the Bifrost odds-change consumer path (Redis miss/corrupt), the consumer MUST propagate the error. The Prisma transaction rolls back, leaving order.odds at placement value. Do NOT warn-and-skip while writing new odds — stale acceptedPmaxContribution + new order.odds causes over/underpayment at settlement.

7.7 onPartialMatch​

Betfair partial fill: matchedFraction matched at Om, unmatchedFraction still open at Op.

function onPartialMatch(
currentPmax: Decimal[],
fullPlacedPnl: Decimal[], // PnL at placement stake/odds (the original accept contribution)
matchedPnl: Decimal[], // PnL for matched portion at Om
unmatchedPnl: Decimal[], // PnL for remaining unmatched portion
): { pmax: Decimal[]; emax: Decimal; balanceDelta: Decimal }
// delta[i] = matchedPnl[i] + min(unmatchedPnl[i], 0) - min(fullPlacedPnl[i], 0)
// Equivalent: cancel full accept, re-accept unmatched, match matched portion

8. Settlement​

8.1 Cache-Miss Discipline (Order-Self-Describing Resilience)​

Settlement and reversalService prefer order.outcomeSpace and order.numOutcomes directly from the DB. Redis MarketMeta is used only as a fallback for pre-netting orders (those missing acceptedPmaxContribution).

Priority:

  1. Netting orders (marketType + acceptedPmaxContribution non-null): attempt getMarketMeta(bookmaker, marketId). On cache miss, reconstruct minimal meta from stored order fields (outcomeSpace, numOutcomes, outcomeIndex on each order, marketType). If reconstruction fails (no orders with stored contributions), throw DOMAIN_INTEGRITY.
  2. Legacy pre-netting orders (null acceptedPmaxContribution): route to per-order settleOrderFromBetsApi path — unaffected by netting.

This removes Redis as a correctness dependency for post-migration orders. A Redis eviction or corruption results in reconstruction from the orders table, not a wrong settlement.

8.2 Formula per settlementOutcome​

For each (user, marketKey), after loading and locking the MarketExposure row:

emax = min(0, min(pmaxVector[i]))

normal result: settlementCredit = realizedPnl − emax
first-result void: voidBalanceAdjustment = −preSettlementLedgerTotal

realizedPnl is the sum of locally derived per-order P&L for the result (including half-result treatment). A void is deliberately ledger-based: Emax describes open market risk, not every order-scoped cash movement. The adjustment returns the pre-settlement cash ledger to zero.

For ladder markets (Bifrost session/batsman/innings/wicket): winnerIdx = floor(winnerLine) + 1, derived from the winning back bet's line field. Bifrost sends no actualRuns — the winning BACK bet's line gives a tight lower bound, and pmaxVector[i] is constant above the split point, so floor(winnerLine) + 1 is the correct index. Validate winnerIdx ∈ [0, numOutcomes).

For discrete markets (Betfair): winnerIdx = meta.outcomeIndexMap[winningOutcomeId]. Throw if not found.

Idempotency: MarketExposure.settledAt is stamped inside the settlement transaction. A second run finds no active row and returns early without touching balances.


9. Database Schema​

9.1 Order — netting fields added in this PR​

model Order {
// ... existing fields ...

// Netting integration — null for pre-netting orders
marketType String? @map("market_type") // canonical marketType snapshot at placement
acceptedAcr Decimal? @map("accepted_acr") @db.Decimal(18, 4)
matchedRc Decimal? @map("matched_rc") @db.Decimal(18, 4)
acceptedPmaxContribution Json? @map("accepted_pmax_contribution") // Decimal[] length = numOutcomes
matchedPmaxContribution Json? @map("matched_pmax_contribution") // Decimal[] length = numOutcomes

// Self-describing outcome-space (settlement/reversal reads order directly, not Redis)
outcomeSpace String? @map("outcome_space") // 'discrete' | 'ladder'
numOutcomes Int? @map("num_outcomes") // 2/3 discrete; 1000 ladder

// Per-attempt idempotency for settlement audit
settlementAttemptId String? @map("settlement_attempt_id")
@@index([settlementAttemptId])

// Betfair two-phase tracking
outcomeIndex Int? @map("outcome_index") // 0-based, discrete only
sizeMatched Decimal? @map("size_matched") @db.Decimal(18, 2)
sizeRemaining Decimal? @map("size_remaining") @db.Decimal(18, 2)
sizeLapsed Decimal? @map("size_lapsed") @db.Decimal(18, 2)
sizeCancelled Decimal? @map("size_cancelled") @db.Decimal(18, 2)

// For ladder markets
line Decimal? @db.Decimal(10, 4) // user's placed line (e.g. 148.5)
}

9.2 MarketExposure — new table​

model MarketExposure {
id String @id @default(uuid())
userId String
marketKey String // 4-segment: see §11
pmaxVector Json // Decimal[] serialized as strings
emax Decimal @db.Decimal(18, 4)
numOutcomes Int
structure String // 'discrete' | 'ladder'
version Int @default(0) // optimistic lock fallback
updatedAt DateTime @updatedAt

user User @relation(fields: [userId], references: [id])

@@unique([userId, marketKey])
@@index([userId])
@@index([marketKey])
}

One row per user per market. Deleted on settlement. Rebuilt via reconstructPmax on reversal.


10. MarketMeta Shape (Redis)​

Cache key: market:<bookmaker>:<marketId> Eviction policy: noeviction — these keys MUST persist until the next catalogue refresh. A miss is loud (thrown error), not silent.

// backend/src/services/marketMetaCache.ts
interface MarketMeta {
marketId: string;
fixtureId: string;
bookmaker: 'betfair-ex' | 'bifrost' | 'pinnacle';
marketType: string; // canonical marketType
marketName: string;
line: number | null; // for O/U style markets
numOutcomes: number;
outcomeSpace: 'discrete' | 'ladder'; // Doc3 §2.1 canonical
outcomeOrder: MarketMetaOutcome[]; // stable sorted list; required for discrete
outcomeIndexMap: Record<string, number>; // O(1) outcomeId → index; materialized at write time
ladderMin?: number; // ladder markets only
ladderMax?: number;
ladderStep?: number;
updatedAt: number; // epoch ms
}

interface MarketMetaOutcome {
outcomeId: string;
index: number;
name: string;
}

Write: setMarketMeta(meta) — validates bookmaker, marketId, marketType, numOutcomes > 0. For discrete: validates numOutcomes === outcomeOrder.length. No TTL.

Read: getMarketMeta(bookmaker, marketId) — throws on miss or corrupt JSON. Caller MUST propagate the error (decline the bet or reconstruct from orders). Back-compat on read: legacy cache entries written with 'integer' or an older structure field are silently normalized to 'ladder'; the next catalogue refresh rewrites them in canonical form.


11. MarketKey Format (4-segment)​

marketKey = `${fixtureId}|${bookmaker}|${marketType}|${marketId}`

Why bookmaker is in the key: Different bookmakers have incompatible settlement semantics, cancel/void rules, and min-stake thresholds. Netting across providers would require atomic cross-provider settlement — impossible given different void lifecycles and partial-fill behavior. bookmaker isolates each provider's exposure.

Why line is NOT in the key: For ladder markets, the placed line shapes the PnL vector, not the market identity. Multiple bets at different lines on the same market net into a single MarketExposure row. Encoding line in the key would create N separate rows and break cross-line netting.

Why marketId is in the key (in addition to marketType): Handles re-issued markets (e.g. a Betfair market voided and re-created with the same type). The marketId guarantees uniqueness.

// backend/src/services/netting/marketKey.ts
function buildMarketKey(fixtureId, bookmaker, marketType, marketId): string
function parseMarketKey(marketKey): { fixtureId, bookmaker, marketType, marketId }

Both throw DOMAIN_INTEGRITY on empty segments or wrong segment count.

V2 extension: A parallel crossProviderKey = ${fixtureId}|${marketType}|${marketId} will be added as a read-only analytics column for monitoring aggregate per-fixture exposure across providers, without affecting the netting math.


12. Rollout​

Unified netting is unconditionally on for both Betfair-ex and Bifrost. There is no kill-switch: the UNIFIED_NETTING_V1 environment flag and the isBifrostNettingEnabled() helper were removed in PR #385 (commit 4999ea7b), before any real users were on prod.

Rationale for removing the flag:

  • No users in prod at merge time — a regression had no blast radius.
  • A dormant kill-switch is its own maintenance cost: every new netting feature has to reason about the flag-off path, and tests have to cover both modes.
  • If a regression surfaces now, the fix is a revert of the offending commit, not a flag flip.

Verification done before flag removal:

  1. MarketExposure rows correctly accumulated for Betfair match_odds and Bifrost session/runs markets.
  2. Settlement payout = pmaxVector[winnerIdx] − emax matched manual calculation on sample orders.
  3. orderService.placeOrder and reverseSettlement vector-length invariants held across discrete and ladder spaces.

Historical rollout plan (for archaeology): Betfair was always-on, Bifrost was briefly behind UNIFIED_NETTING_V1 with an isBifrostNettingEnabled() helper that short-circuited the MarketMeta Redis lookup when false. See git history at commit 4267a6c3 (flag introduced) and 4999ea7b (flag removed) for the original implementation.


13. Invariants and Guards​

The following invariants are enforced by nettingEngine.ts and throw if violated:

InvariantEnforcement
ACr ≥ 0onAcceptAndMatch throws FINANCIAL_INTEGRITY if acr.isNegative()
RC ≥ 0onAcceptAndMatch throws FINANCIAL_INTEGRITY if rc.isNegative()
Pmax.length == numOutcomesvalidateVectorLengths called at every primitive entry
outcomeIndex ∈ [0, numOutcomes) for discretebuildPnlVector throws DOMAIN_INTEGRITY
floor(line) ∈ [-1, numOutcomes-1] for ladderbuildPnlVector throws DOMAIN_INTEGRITY
winnerIdx ∈ [0, numOutcomes) at settlementprocessSettlement throws DOMAIN_INTEGRITY
stake > 0buildPnlVector throws FINANCIAL_INTEGRITY
odds > 1.0buildPnlVector throws FINANCIAL_INTEGRITY
Bifrost odds-change cache miss → TX rollbackConsumer must propagate getMarketMeta error; do NOT warn-and-continue
Mixed bookmakers on same (fixture, market) → throwprocessSettlement throws if bookmakers.size !== 1
MarketExposure numOutcomes mismatch at settlement → throwsettleMarketForUser validates against meta.numOutcomes

Error prefix convention:

  • FINANCIAL_INTEGRITY: — invalid financial value (stake, odds, Pmax NaN/negative invariant)
  • DOMAIN_INTEGRITY: — invalid domain value (missing marketId, wrong outcomeIndex, wrong outcomeSpace)

14. Migration Path​

Hard cutover — no dual-write period. Prod/dev DBs are reset fresh at migration time.

Pre-migration orders (null acceptedPmaxContribution, null outcomeSpace) are routed to the legacy per-order settleOrderFromBetsApi path during settlement. The partition check in processSettlement:

const nettingOrders = orders.filter(o => o.marketType && o.acceptedPmaxContribution);
const legacyOrders = orders.filter(o => !o.marketType || !o.acceptedPmaxContribution);

Legacy orders settle per-bet using winningOutcomeId comparison. Netting orders settle using pmaxVector[winnerIdx] − emax. Both paths coexist in the same processSettlement call — no separate migration script needed.

Redis back-compat on read: getMarketMeta normalizes legacy cache entries — entries written with outcomeSpace: 'integer' or with only a structure field (no outcomeSpace) are mapped to outcomeSpace: 'ladder' on read. No cache flush required; entries are silently upgraded until the next catalogue refresh rewrites them in canonical form.


15. Open Questions / V2 Deferrals​

  1. Cross-provider netting (V2): Add parallel crossProviderKey = ${fixtureId}|${marketType}|${marketId} as a read-only analytics column for monitoring aggregate exposure per-fixture across providers. Does not change netting math.

  2. B-Book NNR integration (V2): bbook/v3/noNewRisk.ts already computes per-outcome worst-case internally. Merge with user-level Pmax in V2 to eliminate double accounting.

  3. Agent booking netting (V2): bookingExposureService.ts uses flat exposure. Needs migration to read from MarketExposure.

  4. Correct Score provider-specific ordering (V2): 18+ outcomes; Betfair supplies a stable runner ordering per market, which MarketMeta.outcomeOrder captures. A sport-level canonical score lookup (e.g. "1-0", "2-1", "any other home win") is deferred — V1 uses Betfair's ordering directly.

  5. Redis eviction protection: Operators must configure Redis with noeviction policy (or a policy that excludes market:* keys). A dedicated Redis DB index or separate instance is the hardened approach. Infra decision deferred.

  6. Bifrost 14.x sportsbook family validation: Sportsbook (14.x) INNINGS_RUNS_SB, MATCH_ODDS_SB markets were not observed in strykrdev preprod capture (2026-04-11). Schema-derived description only. Confirm against real prod capture before V2 Bifrost full rollout.

  7. Portfolio netting across outcomes (ACr engine note): Post-prod audit identified a class of low-probability high-odds correlated bets where the current per-market netting underestimates capital efficiency. Cross-market ACr optimization is V3+ scope.

  8. matchedAcr field: The task brief mentions matchedAcr as a DB field; the actual schema uses matchedRc (Released Capital on match). They are inverses of each other. The schema is authoritative — matchedRc is the correct field name.