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)
| Term | Definition |
|---|---|
outcomeSpace | 'discrete' or 'ladder' — the shape of the outcome index space for a market |
| Pmax vector | Decimal[numOutcomes] — per-outcome PnL if that outcome wins, summed across all bets for (user, market) |
| Emax | min(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. |
| MarketKey | 4-segment string: `${fixtureId} |
| MarketMeta | Redis-cached canonical market shape: numOutcomes, outcomeSpace, outcomeOrder, outcomeIndexMap |
| MarketExposure | Postgres row (one per user per market) storing the live Pmax vector and Emax |
| winnerIdx | Index 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
| Store | Technology | Purpose |
|---|---|---|
orders | Postgres (Prisma) | Self-describing netting fields: acceptedPmaxContribution, matchedPmaxContribution, acceptedAcr, matchedRc, outcomeSpace, numOutcomes, outcomeIndex, marketType |
market_exposures | Postgres (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 → indexlookup.outcomeOrder: MarketMetaOutcome[]is the stable sorted list (by providerselectionIdfor 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). numOutcomesis 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
| Market | Bookmaker | outcomeSpace | numOutcomes | Netting eligible | Notes |
|---|---|---|---|---|---|
| match_odds | betfair-ex | discrete | 2 (cricket/tennis) or 3 (soccer) | yes (always on) | 2-way: home/away. 3-way: home/draw/away |
| over_under | betfair-ex | discrete | 2 | yes (always on) | line encoded in marketId; 2 outcomes per line |
| asian_handicap | betfair-ex | discrete | 2 | not yet (V1.5) | team1_at_line vs team2_at_line |
| correct_score | betfair-ex | discrete | N (typically 18+) | not yet (V1.5) | per Betfair runner list |
| both_teams_score | betfair-ex | discrete | 2 | not yet (V1.5) | yes/no |
| double_chance | betfair-ex | discrete | 3 | not yet (V1.5) | home_or_draw / draw_or_away / home_or_away |
| draw_no_bet | betfair-ex | discrete | 2 | not yet (V1.5) | home / away (draw voids) |
| moneyline | betfair-ex | discrete | 2 or 3 | not yet (V1.5) | sport-dependent |
| period variants (first_half_*, etc.) | betfair-ex | discrete | per market | not yet (V1.5) | same shape as full-match cousins |
| session_runs | bifrost | ladder | 1000 | yes (always on) | winnerIdx = floor(winnerLine) + 1 |
| batsman_runs | bifrost | ladder | 1000 | yes (always on) | winnerIdx derived from winning back bet's line |
| innings_runs | bifrost | ladder | 1000 | yes (always on) | |
| wickets | bifrost | ladder | 1000 | yes (always on) | winnerIdx = floor(winnerLine) + 1 |
| match_odds (bifrost) | bifrost | discrete | 3 | yes (always on) | same shape as betfair match_odds |
| WICKET_IN_OVER | bifrost | discrete | 2 | yes (always on) | binary ODDS-type market |
| OVER_RUNS_ODD_OR_EVEN | bifrost | discrete | 2 | yes (always on) | binary ODDS-type market |
| all markets | pinnacle | n/a | n/a | NO | credit-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:
- Netting orders (
marketType+acceptedPmaxContributionnon-null): attemptgetMarketMeta(bookmaker, marketId). On cache miss, reconstruct minimal meta from stored order fields (outcomeSpace,numOutcomes,outcomeIndexon each order,marketType). If reconstruction fails (no orders with stored contributions), throwDOMAIN_INTEGRITY. - Legacy pre-netting orders (null
acceptedPmaxContribution): route to per-ordersettleOrderFromBetsApipath — 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:
- MarketExposure rows correctly accumulated for Betfair match_odds and Bifrost session/runs markets.
- Settlement payout =
pmaxVector[winnerIdx] − emaxmatched manual calculation on sample orders. orderService.placeOrderandreverseSettlementvector-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:
| Invariant | Enforcement |
|---|---|
ACr ≥ 0 | onAcceptAndMatch throws FINANCIAL_INTEGRITY if acr.isNegative() |
RC ≥ 0 | onAcceptAndMatch throws FINANCIAL_INTEGRITY if rc.isNegative() |
Pmax.length == numOutcomes | validateVectorLengths called at every primitive entry |
outcomeIndex ∈ [0, numOutcomes) for discrete | buildPnlVector throws DOMAIN_INTEGRITY |
floor(line) ∈ [-1, numOutcomes-1] for ladder | buildPnlVector throws DOMAIN_INTEGRITY |
winnerIdx ∈ [0, numOutcomes) at settlement | processSettlement throws DOMAIN_INTEGRITY |
stake > 0 | buildPnlVector throws FINANCIAL_INTEGRITY |
odds > 1.0 | buildPnlVector throws FINANCIAL_INTEGRITY |
| Bifrost odds-change cache miss → TX rollback | Consumer must propagate getMarketMeta error; do NOT warn-and-continue |
| Mixed bookmakers on same (fixture, market) → throw | processSettlement throws if bookmakers.size !== 1 |
MarketExposure numOutcomes mismatch at settlement → throw | settleMarketForUser 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
-
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. -
B-Book NNR integration (V2):
bbook/v3/noNewRisk.tsalready computes per-outcome worst-case internally. Merge with user-level Pmax in V2 to eliminate double accounting. -
Agent booking netting (V2):
bookingExposureService.tsuses flat exposure. Needs migration to read fromMarketExposure. -
Correct Score provider-specific ordering (V2): 18+ outcomes; Betfair supplies a stable runner ordering per market, which
MarketMeta.outcomeOrdercaptures. A sport-level canonical score lookup (e.g. "1-0", "2-1", "any other home win") is deferred — V1 uses Betfair's ordering directly. -
Redis eviction protection: Operators must configure Redis with
noevictionpolicy (or a policy that excludesmarket:*keys). A dedicated Redis DB index or separate instance is the hardened approach. Infra decision deferred. -
Bifrost 14.x sportsbook family validation: Sportsbook (14.x)
INNINGS_RUNS_SB,MATCH_ODDS_SBmarkets were not observed in strykrdev preprod capture (2026-04-11). Schema-derived description only. Confirm against real prod capture before V2 Bifrost full rollout. -
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.
-
matchedAcrfield: The task brief mentionsmatchedAcras a DB field; the actual schema usesmatchedRc(Released Capital on match). They are inverses of each other. The schema is authoritative —matchedRcis the correct field name.