Skip to main content

Exposure Netting — Integration Design (V1)

Status: Implemented (PR #385) Date: 2026-04-22 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(Pmax[i]) — worst-case PnL across outcomes. What the platform reserves (always ≤ 0 in practice)
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 the live Pmax vector and Emax
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 = pmaxVector[winnerIdx] - emax (win path)
├─ credit = -emax (void path)
├─ UPDATE users SET balancePoints += credit
└─ DELETE market_exposures WHERE userId + marketKey

3.2 Key Stores

StoreTechnologyPurpose
ordersPostgres (Prisma)Self-describing netting fields: acceptedPmaxContribution, matchedPmaxContribution, acceptedAcr, matchedRc, outcomeSpace, numOutcomes, outcomeIndex, marketType
market_exposuresPostgres (Prisma)Live Pmax vector + Emax per (user, market). Authority for settlement. Deleted on settle.
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(Pmax[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(pmaxVector[i])

win: credit = pmaxVector[winnerIdx] − emax
lose: credit = 0 − emax (forfeit locked capital)
void: credit = −emax (full refund of reservation)
half_win: credit = pmaxVector[winnerIdx] / 2 − emax
half_lose: credit = pmaxVector[loserIdx] / 2 − emax

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 row is deleted inside the settlement transaction. A second run finds no 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.