Hannibal B-Book System Design
Architecture and Implementation Specification
Version 1.0 | January 2026
✅ IMPLEMENTATION STATUS: COMPLETE
This system has been fully implemented. See
backend/src/services/bbook/for the implementation:
bbookConfigService.ts- Configuration managementbbookFillService.ts- B-book fill executionbbookSettlementService.ts- Settlement processingbbookStateService.ts- Position and exposure trackingfilterEngine.ts- Order routing decisionssharpUserService.ts- Sharp user management
Executive Summary
The B-Book is an optional, independent module that allows Hannibal to take the opposite side of user bets using its own capital pool, rather than routing all bets to external exchanges (Betfair, Pinnacle). When enabled, the B-Book captures margin from recreational bettors while protecting the platform through configurable liability limits and sharp user exclusion.
Key Design Principles:
- Optional and Toggleable: Admin can enable/disable the entire B-Book with a single switch
- Independent Module: Operates as a separate service that plugs into the order flow
- Never Exceeds Limits: Hard liability cap that cannot be breached under any circumstance
- Currency Agnostic: Works with Hannibal's points system but abstracts currency for future flexibility
- Accounting Isolation: B-Book P&L is tracked separately; agent settlements only apply to exchange orders
- Sharp Protection: Known sharp users are always routed to exchanges, never B-Booked
Table of Contents
- What is the B-Book?
- System Architecture
- Configuration and Admin Controls
- Order Flow Integration
- The Filter Engine
- Liability Tracking
- Sharp User Management
- Settlement and P&L
- Accounting Integration
- Database Schema
- API Endpoints
- Risk Monitoring and Alerts
- Error Handling and Edge Cases
- Currency Abstraction Layer
- Performance Considerations
- Testing Strategy
- Deployment and Operations
- Migration Path
- Security Considerations
- Glossary
- Open Questions
- Appendix
1. What is the B-Book?
1.1 The Core Concept
When a user places a bet, there are two fundamental approaches:
| Approach | Description | Platform Role |
|---|---|---|
| A-Book | Pass the bet to an external exchange (Betfair, Pinnacle) | Intermediary (earns commission) |
| B-Book | Take the opposite side of the bet using platform capital | Counterparty (wins or loses with user) |
Example:
User bets 1,000 points BACK on Mumbai to Win at odds 2.50.
| Approach | What Happens | If Mumbai Wins | If Mumbai Loses |
|---|---|---|---|
| A-Book | Bet goes to Betfair | Betfair pays 1,500 points (via platform) | Platform collects from user |
| B-Book | Platform takes opposite | Platform pays user 1,500 points | Platform keeps 1,000 points |
1.2 Why B-Book?
The B-Book exists because recreational bettors lose money on average. By taking the opposite side of these bets, the platform captures the statistical edge that would otherwise go to the exchange.
Expected Value Breakdown:
| User Type | Long-term EV | B-Book Outcome |
|---|---|---|
| Recreational | Negative (loses ~5-15%) | Platform profits |
| Sharp/Professional | Positive (wins consistently) | Platform loses |
This is why sharp users must be excluded from the B-Book—they would drain the pool over time.
1.3 The Pool
The B-Book operates from a fixed capital pool with a hard liability limit. This limit represents the maximum amount the platform is willing to risk at any given moment.
Pool Characteristics:
- Starting Capital: Configured by admin (e.g., 10,000,000 points)
- Maximum Liability: Hard cap that can never be exceeded (e.g., 10,000,000 points)
- Self-Replenishing: Winning B-Book positions return funds to the pool
- Real-Time Tracking: Current exposure is calculated continuously
2. System Architecture
2.1 High-Level Architecture
The B-Book is designed as an independent, pluggable module that integrates with the existing order flow.
2.2 Module Independence
The B-Book module is designed to be completely independent:
| Aspect | Implementation |
|---|---|
| Deployment | Can run as a separate service or within the monolith |
| State | Maintains its own position state (Redis + PostgreSQL) |
| Configuration | Own configuration table, not mixed with other settings |
| Toggle | Single bbook_enabled flag controls all B-Book behavior |
| Failure Isolation | If B-Book fails, orders fall back to exchange routing |
2.3 Component Responsibilities
| Component | Responsibility |
|---|---|
| Filter Engine | Decides routing: B-Book vs Exchange based on rules |
| B-Book State Manager | Tracks positions, exposures, and pool utilization |
| Liability Tracker | Enforces all limits (global, per-market, per-selection, per-user) |
| B-Book Fill Handler | Executes B-Book fills and creates position records |
| B-Book P&L Tracker | Calculates and records B-Book profit/loss |
2.4 Data Flow
Order Placement Flow:
1. User submits order
2. Order Service validates (balance, market open, etc.)
3. IF B-Book disabled → Route entirely to exchange
4. IF B-Book enabled:
a. Filter Engine checks sharp list
b. IF sharp → Route entirely to exchange
c. IF not sharp → Calculate B-Book capacity
d. Split order: B-Book fill + Exchange fill
5. Execute B-Book portion (update positions, deduct user balance)
6. Execute Exchange portion (place on Betfair/Pinnacle)
7. Return combined fill result to user
Settlement Flow:
1. Event result received (Mumbai won/lost)
2. B-Book Settlement:
a. Calculate P&L for all B-Book positions on this selection
b. Credit/debit platform B-Book pool
c. Credit/debit user balances
d. Mark positions as settled
3. Exchange Settlement (existing flow):
a. Receive settlement from exchange
b. Update user balances
c. Record in agent accounting system
3. Configuration and Admin Controls
3.1 Master Switch
The B-Book has a single master switch that enables or disables all functionality:
| Setting | Type | Default | Description |
|---|---|---|---|
bbook_enabled | Boolean | false | Master switch for B-Book functionality |
When bbook_enabled = false:
- All orders route to exchanges (Betfair/Pinnacle)
- Filter Engine is bypassed entirely
- B-Book state is preserved but not updated
- Existing B-Book positions remain until settled
When bbook_enabled = true:
- Filter Engine evaluates every order
- Orders may be split between B-Book and exchange
- All limits and rules are enforced
3.2 Liability Limits
These limits define the maximum exposure the platform will accept:
| Setting | Type | Default | Description |
|---|---|---|---|
global_pool_limit | Decimal | 10,000,000 | Maximum total net exposure across all selections |
per_market_limit | Decimal | 2,000,000 | Maximum net exposure per market |
per_selection_limit | Decimal | 500,000 | Maximum net exposure per selection |
per_user_limit | Decimal | 50,000 | Maximum exposure from any single user |
Limit Hierarchy:
Global Pool Limit (10,000,000)
└── Per-Market Limit (2,000,000)
└── Per-Selection Limit (500,000)
└── Per-User Limit (50,000)
All limits are checked on every order. The most restrictive limit applies.
3.3 Routing Controls
These settings control how orders are split between B-Book and exchanges:
| Setting | Type | Default | Description |
|---|---|---|---|
max_bbook_percentage | Decimal | 70% | Maximum percentage of any order that can go to B-Book |
min_exchange_percentage | Decimal | 0% | Minimum percentage that must go to exchange |
prefer_bbook | Boolean | true | If true, maximize B-Book fill within limits |
Example:
Order: 100,000 points BACK Mumbai at 2.50
With max_bbook_percentage = 70%:
- Maximum B-Book: 70,000 points
- Minimum Exchange: 30,000 points (if
min_exchange_percentage = 0%)
3.4 Risk Controls
| Setting | Type | Default | Description |
|---|---|---|---|
side_imbalance_threshold | Decimal | 3.0 | Ratio at which to restrict heavy side (e.g., 3:1 BACK:LAY) |
auto_hedge_threshold | Decimal | 80% | Pool % at which auto-hedging activates |
emergency_shutdown_threshold | Decimal | 95% | Pool % at which B-Book suspends |
daily_loss_limit | Decimal | 500,000 | Daily loss that triggers B-Book suspension |
3.5 Admin API for Configuration
All settings are managed via admin API endpoints:
GET /admin/bbook/config - Get all B-Book configuration
PUT /admin/bbook/config - Update configuration
POST /admin/bbook/enable - Enable B-Book
POST /admin/bbook/disable - Disable B-Book
GET /admin/bbook/status - Get current pool state and exposure
4. Order Flow Integration
4.1 Integration Point
The B-Book integrates at the order routing stage, after validation but before execution:
4.2 Order Data Extensions
The existing Order model requires new fields to track B-Book routing:
| Field | Type | Description |
|---|---|---|
bbook_fill | Decimal | Amount filled via B-Book |
exchange_fill | Decimal | Amount filled via exchange |
routing_venue | Enum | Primary venue: 'bbook', 'exchange', 'split' |
is_sharp_at_order_time | Boolean | User's sharp status when order placed |
bbook_position_id | UUID | Reference to B-Book position (if any) |
4.3 Fill Response Structure
Orders return detailed fill information:
interface OrderFillResponse {
orderId: string;
totalStake: number;
fills: {
venue: 'bbook' | 'betfair' | 'pinnacle';
amount: number;
odds: number;
fillId: string;
}[];
status: 'filled' | 'partial' | 'rejected';
limitingFactor?: string; // e.g., 'per_user_limit', 'pool_capacity'
}
4.4 Partial Fill Handling
Orders may be partially filled across venues:
| Scenario | B-Book | Exchange | User Sees |
|---|---|---|---|
| Full B-Book capacity | 100% | 0% | Order filled |
| B-Book at limit | 0% | 100% | Order filled |
| Split by limits | 70% | 30% | Order filled (details in response) |
| Exchange unavailable, B-Book has capacity | 100% | 0% | Order filled |
| Exchange unavailable, B-Book at limit | 0% | 0% | Order rejected |
5. The Filter Engine
5.1 Purpose
The Filter Engine is the decision-making component that determines how each order should be routed. It evaluates:
- Is B-Book enabled?
- Is the user on the sharp list?
- Does B-Book have capacity?
- How should the order be split?
5.2 Decision Flow
5.3 Capacity Calculation Algorithm
The Filter Engine calculates available B-Book capacity by finding the minimum across all limits:
function calculateBBookCapacity(order: Order): number {
const config = getBBookConfig();
// Get current exposures
const globalExposure = getGlobalExposure();
const marketExposure = getMarketExposure(order.marketId);
const selectionExposure = getSelectionExposure(order.selectionId);
const userExposure = getUserBBookExposure(order.userId);
// Calculate remaining capacity at each level
const globalRemaining = config.global_pool_limit - globalExposure;
const marketRemaining = config.per_market_limit - marketExposure;
const selectionRemaining = config.per_selection_limit - selectionExposure;
const userRemaining = config.per_user_limit - userExposure;
// Apply percentage cap
const percentageCap = order.stake * config.max_bbook_percentage;
// B-Book capacity is the minimum of all limits
const capacity = Math.min(
globalRemaining,
marketRemaining,
selectionRemaining,
userRemaining,
percentageCap
);
return Math.max(0, capacity);
}
5.4 Balancing Logic
Orders that reduce existing exposure are treated favorably:
function calculateExposureImpact(order: Order): number {
const selection = getSelectionState(order.selectionId);
const currentBack = selection.backLiability;
const currentLay = selection.layLiability;
const currentNet = Math.abs(currentBack - currentLay);
const orderLiability = calculateLiability(order);
let newNet: number;
if (order.betType === 'back') {
newNet = Math.abs((currentBack + orderLiability) - currentLay);
} else {
newNet = Math.abs(currentBack - (currentLay + orderLiability));
}
const exposureChange = newNet - currentNet;
// Negative = reduces exposure (good!)
// Positive = increases exposure (apply limits)
// Zero = perfectly balancing
return exposureChange;
}
Balancing Orders Get Priority:
| Exposure Change | Treatment |
|---|---|
| Negative (reduces exposure) | Accept freely, no capacity limits |
| Zero (perfectly balances) | Accept freely |
| Positive (increases exposure) | Apply all limits |
5.5 Side Imbalance Restriction
When one side is significantly heavier, restrict new orders on the heavy side:
function checkSideImbalance(order: Order, selection: SelectionState): boolean {
const config = getBBookConfig();
const ratio = selection.backLiability / selection.layLiability;
if (ratio > config.side_imbalance_threshold) {
// BACK heavy - restrict BACK orders, accept LAY
return order.betType === 'lay';
}
if (ratio < (1 / config.side_imbalance_threshold)) {
// LAY heavy - restrict LAY orders, accept BACK
return order.betType === 'back';
}
return true; // Balanced, accept both
}
6. Liability Tracking
6.1 Understanding Liability
Liability is what the platform stands to lose if an outcome occurs.
BACK Order Liability:
When a user places a BACK bet, the platform (as B-Book) takes the LAY side.
User BACK: stake × (odds - 1) = Platform liability if selection WINS
Example: 1000 pts BACK @ 2.50 = 1000 × (2.50 - 1) = 1,500 pts liability
LAY Order Liability:
When a user places a LAY bet, the platform takes the BACK side.
User LAY: stake = Platform liability if selection LOSES
Example: 1000 pts LAY @ 2.50 = 1,000 pts liability
6.2 Net Exposure Calculation
The actual risk is the net exposure, not gross:
Net Exposure = |BACK liability - LAY liability|
Example:
| Selection: Mumbai Win | BACK Liability | LAY Liability | Net Exposure | Direction |
|---|---|---|---|---|
| Position 1 | 10,000 | 0 | 10,000 | Short Mumbai |
| After LAY order | 10,000 | 6,000 | 4,000 | Short Mumbai (reduced!) |
| More LAY orders | 10,000 | 10,000 | 0 | Balanced (no risk!) |
| Even more LAY | 10,000 | 15,000 | 5,000 | Long Mumbai |
Key Insight: LAY orders offset BACK orders. A perfectly balanced selection has zero net exposure.
6.3 Position State Tracking
The B-Book maintains real-time position state at multiple levels:
Per-Selection State:
interface SelectionState {
selectionId: string;
marketId: string;
fixtureId: string;
// Liability tracking
backLiability: number; // What we pay if selection wins
layLiability: number; // What we pay if selection loses
netExposure: number; // abs(back - lay)
direction: 'short' | 'long' | 'balanced';
// Capacity
capacityRemaining: number;
// Status
status: 'open' | 'suspended' | 'settled';
updatedAt: timestamp;
}
Per-Market State:
interface MarketState {
marketId: string;
fixtureId: string;
// Aggregate of all selections in market
totalNetExposure: number;
selectionCount: number;
status: 'open' | 'suspended' | 'settled';
}
Per-User State:
interface UserBBookState {
userId: string;
// User's total B-Book exposure
totalBackLiability: number;
totalLayLiability: number;
totalExposure: number;
// Limits
remainingCapacity: number;
}
Global Pool State:
interface PoolState {
// Aggregate exposure
totalNetExposure: number;
// Pool health
utilizationPercent: number; // exposure / limit
capacityRemaining: number;
// Side balance
totalBackLiability: number;
totalLayLiability: number;
imbalanceRatio: number;
// P&L tracking
realizedPnL: number; // Settled positions
unrealizedPnL: number; // Open positions (mark-to-market)
// Status
status: 'active' | 'restricted' | 'suspended';
lastUpdated: timestamp;
}
6.4 Real-Time State Updates
Position state is updated atomically with each B-Book fill:
async function updatePositionState(order: Order, fill: BBookFill): Promise<void> {
const liability = calculateLiability(order, fill.amount);
await transaction(async (tx) => {
// 1. Update selection state
const selection = await tx.getSelectionState(order.selectionId);
if (order.betType === 'back') {
selection.backLiability += liability;
} else {
selection.layLiability += liability;
}
selection.netExposure = Math.abs(selection.backLiability - selection.layLiability);
await tx.saveSelectionState(selection);
// 2. Update market state
await tx.updateMarketExposure(order.marketId);
// 3. Update user state
await tx.updateUserExposure(order.userId, liability, order.betType);
// 4. Update global pool state
await tx.updatePoolState();
// 5. Create position record
await tx.createBBookPosition({
orderId: order.id,
selectionId: order.selectionId,
userId: order.userId,
side: order.betType,
stake: fill.amount,
liability: liability,
odds: order.odds,
status: 'open'
});
});
}
6.5 State Storage Strategy
| Data | Primary Storage | Cache | Rationale |
|---|---|---|---|
| Selection State | PostgreSQL | Redis | Fast reads for capacity checks |
| Market State | PostgreSQL | Redis | Aggregated from selections |
| User State | PostgreSQL | Redis | Per-user limit checks |
| Pool State | PostgreSQL | Redis (singleton) | Most frequently accessed |
| Position Records | PostgreSQL | None | Audit trail, less frequent access |
Redis Keys:
bbook:pool → Global pool state (hash)
bbook:selection:{selectionId} → Selection state (hash)
bbook:market:{marketId} → Market state (hash)
bbook:user:{userId} → User B-Book exposure (hash)
Consistency:
- PostgreSQL is the source of truth
- Redis is updated synchronously with database writes
- On cache miss, rebuild from PostgreSQL
- On startup, rebuild all Redis state from PostgreSQL
7. Sharp User Management
7.1 The Sharp List
Sharp users are professional bettors with positive expected value. They consistently beat the closing line and would drain the B-Book pool over time. These users must be excluded from B-Book.
Sharp List Storage:
interface SharpUser {
userId: string;
addedAt: timestamp;
addedBy: string; // Admin who added
reason: string; // Why classified as sharp
source: 'manual' | 'auto'; // How identified (MVP: always 'manual')
isActive: boolean;
}
7.2 Sharp Detection Criteria (Manual for MVP)
For MVP, ops team manually identifies sharp users based on:
| Indicator | Threshold | Weight |
|---|---|---|
| Win rate over 200+ bets | > 55% | High |
| Closing Line Value (CLV) | Consistently positive | High |
| Stake patterns | Precise amounts (not round numbers) | Medium |
| Timing | Bets placed close to event start | Medium |
| Market selection | Niche/low-liquidity markets | Medium |
7.3 Sharp Check in Order Flow
async function isSharpUser(userId: string): Promise<boolean> {
// First check Redis cache
const cached = await redis.sismember('bbook:sharp_list', userId);
if (cached) return true;
// Fallback to database
const sharpRecord = await db.sharpUsers.findUnique({
where: { userId, isActive: true }
});
if (sharpRecord) {
// Add to cache
await redis.sadd('bbook:sharp_list', userId);
return true;
}
return false;
}
7.4 Sharp List Management API
GET /admin/bbook/sharp-list - List all sharp users
POST /admin/bbook/sharp-list - Add user to sharp list
DELETE /admin/bbook/sharp-list/:userId - Remove user from sharp list
GET /admin/bbook/sharp-candidates - Users flagged for review (future)
7.5 Impact of Sharp Classification
| Scenario | Before Classification | After Classification |
|---|---|---|
| New order | Evaluated for B-Book | Routes 100% to exchange |
| Existing B-Book positions | Remain until settled | Remain until settled |
| Future orders | May go to B-Book | Never go to B-Book |
Sharp classification is immediate: The moment a user is added to the sharp list, their next order routes entirely to the exchange.
8. Settlement and P&L
8.1 B-Book Settlement Process
When an event concludes, B-Book positions are settled:
8.2 Settlement Calculation
If Selection WINS:
| Position Type | User Outcome | Platform Outcome |
|---|---|---|
| BACK | User wins: stake × (odds - 1) | Platform pays liability |
| LAY | User loses: loses stake | Platform keeps stake |
If Selection LOSES:
| Position Type | User Outcome | Platform Outcome |
|---|---|---|
| BACK | User loses: loses stake | Platform keeps stake |
| LAY | User wins: stake × (odds - 1) | Platform pays liability |
8.3 Settlement Example
Selection: Mumbai Win @ 2.50
Positions:
- User A: BACK 10,000 pts @ 2.50 (liability: 15,000 pts)
- User B: BACK 5,000 pts @ 2.60 (liability: 8,000 pts)
- User C: LAY 8,000 pts @ 2.50 (liability: 8,000 pts)
If Mumbai WINS:
| User | Position | Stake | Odds | User P&L | Platform P&L |
|---|---|---|---|---|---|
| A | BACK | 10,000 | 2.50 | +15,000 | -15,000 |
| B | BACK | 5,000 | 2.60 | +8,000 | -8,000 |
| C | LAY | 8,000 | 2.50 | -8,000 | +8,000 |
| Total | +15,000 | -15,000 |
If Mumbai LOSES:
| User | Position | Stake | Odds | User P&L | Platform P&L |
|---|---|---|---|---|---|
| A | BACK | 10,000 | 2.50 | -10,000 | +10,000 |
| B | BACK | 5,000 | 2.60 | -5,000 | +5,000 |
| C | LAY | 8,000 | 2.50 | +12,000 | -12,000 |
| Total | -3,000 | +3,000 |
8.4 Settlement Code Flow
async function settleBBookPositions(
selectionId: string,
won: boolean
): Promise<SettlementResult> {
const positions = await db.bbookPositions.findMany({
where: { selectionId, status: 'open' }
});
let totalPlatformPnL = 0;
await transaction(async (tx) => {
for (const position of positions) {
let userPnL: number;
let platformPnL: number;
if (position.side === 'back') {
if (won) {
// User wins: pay them liability
userPnL = position.liability;
platformPnL = -position.liability;
} else {
// User loses: keep their stake
userPnL = -position.stake;
platformPnL = position.stake;
}
} else { // LAY
if (won) {
// User loses: keep their stake
userPnL = -position.stake;
platformPnL = position.stake;
} else {
// User wins: pay them liability
userPnL = position.liability;
platformPnL = -position.liability;
}
}
// Update user balance
await tx.updateUserBalance(position.userId, userPnL);
// Mark position as settled
await tx.updateBBookPosition(position.id, {
status: 'settled',
settledAt: new Date(),
settlementPnL: userPnL
});
// Record in B-Book P&L
await tx.recordBBookPnL({
positionId: position.id,
selectionId,
platformPnL,
settledAt: new Date()
});
totalPlatformPnL += platformPnL;
}
// Update pool state (release exposure, record P&L)
await tx.updatePoolAfterSettlement(selectionId, totalPlatformPnL);
});
return { positionsSettled: positions.length, platformPnL: totalPlatformPnL };
}
8.5 Void/Cancelled Events
When an event is voided or cancelled:
| Action | All Positions |
|---|---|
| Stakes | Returned to users |
| Liabilities | Released |
| P&L | Zero (no win/loss) |
| Pool State | Exposure removed |
9. Accounting Integration
9.1 Separation of B-Book and Exchange Accounting
Critical Principle: Agent settlements only apply to exchange orders. B-Book is a direct platform-user transaction.
9.2 Why This Separation?
The agent model works as follows:
- Agents distribute points to punters
- When punters bet, agents bear the risk of their punters' P&L
- Weekly settlement reconciles agent ↔ platform
B-Book orders bypass this because:
- Platform is the counterparty, not the exchange
- There's no external exchange settlement to reconcile
- Platform P&L is direct, not through agent
Result: B-Book P&L belongs entirely to the platform, not shared with agents.
9.3 Accounting Treatment by Venue
| Venue | Who Profits/Loses | Agent Commission | Agent NGR Share |
|---|---|---|---|
| B-Book | Platform directly | No | No |
| Exchange | Agent (via settlement) | Yes | Yes (if NGR > 0) |
9.4 Order-Level Tracking
Each order tracks which venue processed it:
interface OrderAccountingData {
orderId: string;
// B-Book portion (platform P&L)
bbookStake: number;
bbookPnL: number; // Populated on settlement
bbookCommission: number; // Always 0 for B-Book
// Exchange portion (agent accounting)
exchangeStake: number;
exchangePnL: number; // Populated on settlement
exchangeCommission: number; // Standard commission on winnings
// Settlement tracking
bbookSettled: boolean;
exchangeSettled: boolean;
}
9.5 Commission Handling
For Exchange Orders:
- Commission charged on user winnings (existing logic)
- Commission flows through agent accounting system
- Agent receives commission share per their configuration
For B-Book Orders:
- No commission charged to user (platform keeps full margin)
- Commission field is not applicable
- Platform profit is the edge on recreational flow
9.6 Ledger Entries for B-Book
B-Book transactions create ledger entries with a distinct account type:
// On B-Book fill
await createLedgerEntry({
transactionRef: generateRef(),
accountType: 'bbook_pool',
accountId: null, // Platform-level
entryType: 'debit', // Exposure increases
amount: liability,
description: `B-Book fill: ${orderId}`,
category: 'bbook_fill',
orderId: order.id
});
// On B-Book settlement (platform wins)
await createLedgerEntry({
transactionRef: generateRef(),
accountType: 'bbook_pool',
accountId: null,
entryType: 'credit', // Pool increases
amount: pnl,
description: `B-Book settlement: ${positionId}`,
category: 'bbook_settlement',
orderId: order.id
});
9.7 B-Book P&L Reporting
The platform needs clear visibility into B-Book performance:
interface BBookReport {
period: {
start: Date;
end: Date;
};
// Volume metrics
totalBBookStakes: number;
totalBBookFills: number;
averageFillSize: number;
// P&L metrics
realizedPnL: number; // Settled positions
unrealizedPnL: number; // Open positions (mark-to-market)
totalPnL: number;
// Pool metrics
peakExposure: number;
averageUtilization: number;
currentUtilization: number;
// Risk metrics
maxDrawdown: number;
sharpeRatio: number;
winRate: number;
// User breakdown
userCount: number;
topWinners: { userId: string; pnl: number }[];
topLosers: { userId: string; pnl: number }[];
}
9.8 Impact on Agent Settlement
Agent settlement calculation explicitly excludes B-Book:
async function calculateAgentSettlement(
agentId: string,
period: SettlementPeriod
): Promise<AgentSettlement> {
// Get all settled orders for agent's punters in this period
const orders = await db.orders.findMany({
where: {
user: { agentId },
settledAt: { gte: period.start, lte: period.end },
status: 'settled',
// IMPORTANT: Only exchange orders
exchangeFill: { gt: 0 }
}
});
// Calculate totals from EXCHANGE portions only
let totalStakes = 0;
let totalPayouts = 0;
let commission = 0;
for (const order of orders) {
// Only count exchange portion, not B-Book
totalStakes += order.exchangeStake;
if (order.settlementOutcome === 'win') {
const payout = order.exchangeStake * order.placedOdds;
totalPayouts += payout;
commission += payout * COMMISSION_RATE;
}
}
const ngr = totalStakes - totalPayouts;
// Rest of settlement calculation...
}
10. Database Schema
10.1 New Tables for B-Book
The following Prisma models are added to support B-Book functionality:
// B-Book Configuration (singleton or per-period)
model BBookConfig {
id String @id @default(uuid())
// Master switch
bbookEnabled Boolean @default(false)
// Liability limits (in points)
globalPoolLimit Decimal @default(10000000)
perMarketLimit Decimal @default(2000000)
perSelectionLimit Decimal @default(500000)
perUserLimit Decimal @default(50000)
// Routing controls
maxBbookPercentage Decimal @default(0.70)
minExchangePercentage Decimal @default(0)
preferBbook Boolean @default(true)
// Risk controls
sideImbalanceThreshold Decimal @default(3.0)
autoHedgeThreshold Decimal @default(0.80)
emergencyShutdownThreshold Decimal @default(0.95)
dailyLossLimit Decimal @default(500000)
// Metadata
updatedAt DateTime @updatedAt
updatedBy String?
@@map("bbook_config")
}
// B-Book Position (individual fills)
model BBookPosition {
id String @id @default(uuid())
// References
orderId String
order Order @relation(fields: [orderId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
// Market identifiers
fixtureId String
marketId String
selectionId String
// Position details
side BetType // BACK or LAY
stake Decimal
odds Decimal
liability Decimal // Calculated based on side and odds
// Status
status BBookPositionStatus @default(OPEN)
// Settlement
settledAt DateTime?
settlementPnL Decimal?
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([selectionId, status])
@@index([userId])
@@index([status])
@@map("bbook_positions")
}
enum BBookPositionStatus {
OPEN
SETTLED
VOIDED
}
// Selection State (aggregate per selection)
model BBookSelectionState {
id String @id @default(uuid())
// Identifiers
fixtureId String
marketId String
selectionId String @unique
// Liability tracking
backLiability Decimal @default(0)
layLiability Decimal @default(0)
netExposure Decimal @default(0)
// Status
status SelectionStatus @default(OPEN)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([marketId])
@@index([fixtureId])
@@map("bbook_selection_state")
}
enum SelectionStatus {
OPEN
SUSPENDED
SETTLED
}
// Market State (aggregate per market)
model BBookMarketState {
id String @id @default(uuid())
// Identifiers
fixtureId String
marketId String @unique
// Aggregate exposure
totalNetExposure Decimal @default(0)
selectionCount Int @default(0)
// Status
status MarketStatus @default(OPEN)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([fixtureId])
@@map("bbook_market_state")
}
enum MarketStatus {
OPEN
SUSPENDED
SETTLED
}
// User B-Book State (per-user exposure tracking)
model BBookUserState {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
// Exposure tracking
totalBackLiability Decimal @default(0)
totalLayLiability Decimal @default(0)
totalExposure Decimal @default(0)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("bbook_user_state")
}
// Global Pool State (singleton)
model BBookPoolState {
id String @id @default(uuid())
// Exposure tracking
totalNetExposure Decimal @default(0)
totalBackLiability Decimal @default(0)
totalLayLiability Decimal @default(0)
// Utilization
utilizationPercent Decimal @default(0)
// P&L tracking
realizedPnL Decimal @default(0)
unrealizedPnL Decimal @default(0)
// Daily tracking
dailyPnL Decimal @default(0)
dailyResetAt DateTime @default(now())
// Status
status PoolStatus @default(ACTIVE)
// Timestamps
updatedAt DateTime @updatedAt
@@map("bbook_pool_state")
}
enum PoolStatus {
ACTIVE
RESTRICTED
SUSPENDED
}
// Sharp User List
model SharpUser {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
// Classification
reason String
source SharpSource @default(MANUAL)
isActive Boolean @default(true)
// Audit
addedBy String
addedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("sharp_users")
}
enum SharpSource {
MANUAL
AUTO
}
// B-Book P&L Records (audit trail)
model BBookPnLRecord {
id String @id @default(uuid())
positionId String
selectionId String
platformPnL Decimal
settledAt DateTime @default(now())
@@index([settledAt])
@@map("bbook_pnl_records")
}
10.2 Order Model Extensions
Add these fields to the existing Order model:
model Order {
// ... existing fields ...
// B-Book routing fields (new)
bbookFill Decimal?
exchangeFill Decimal?
routingVenue RoutingVenue?
isSharpAtOrderTime Boolean?
// B-Book position reference
bbookPosition BBookPosition?
// ... existing relations ...
}
enum RoutingVenue {
BBOOK
EXCHANGE
SPLIT
}
10.3 Index Strategy
Critical indexes for performance:
| Table | Index | Purpose |
|---|---|---|
bbook_positions | (selectionId, status) | Fast position lookup for settlement |
bbook_positions | (userId) | User position history |
bbook_selection_state | (marketId) | Market-level aggregation |
bbook_pnl_records | (settledAt) | P&L reporting queries |
sharp_users | (userId) | Fast sharp check |
11. API Endpoints
11.1 Admin Configuration APIs
Get B-Book Configuration
GET /admin/bbook/config
Response:
{
"bbookEnabled": true,
"globalPoolLimit": 10000000,
"perMarketLimit": 2000000,
"perSelectionLimit": 500000,
"perUserLimit": 50000,
"maxBbookPercentage": 0.70,
"sideImbalanceThreshold": 3.0,
"autoHedgeThreshold": 0.80,
"emergencyShutdownThreshold": 0.95,
"dailyLossLimit": 500000,
"updatedAt": "2026-01-22T10:00:00Z"
}
Update B-Book Configuration
PUT /admin/bbook/config
Request:
{
"globalPoolLimit": 15000000,
"perUserLimit": 100000
}
Response:
{
"success": true,
"config": { ... updated config ... }
}
Enable/Disable B-Book
POST /admin/bbook/enable
POST /admin/bbook/disable
Response:
{
"success": true,
"bbookEnabled": true/false,
"message": "B-Book enabled successfully"
}
11.2 Pool State APIs
Get Current Pool State
GET /admin/bbook/pool-state
Response:
{
"totalNetExposure": 4500000,
"utilizationPercent": 45.0,
"capacityRemaining": 5500000,
"totalBackLiability": 7000000,
"totalLayLiability": 2500000,
"imbalanceRatio": 2.8,
"realizedPnL": 150000,
"unrealizedPnL": -25000,
"dailyPnL": 15000,
"status": "ACTIVE"
}
Get Market Exposure
GET /admin/bbook/markets/:marketId/exposure
Response:
{
"marketId": "mkt_123",
"totalNetExposure": 800000,
"selections": [
{
"selectionId": "sel_456",
"backLiability": 500000,
"layLiability": 200000,
"netExposure": 300000,
"capacityRemaining": 200000
}
]
}
11.3 Sharp User APIs
List Sharp Users
GET /admin/bbook/sharp-list
Response:
{
"sharpUsers": [
{
"userId": "usr_123",
"username": "pro_bettor_1",
"reason": "Consistent CLV positive, 58% win rate over 500 bets",
"source": "MANUAL",
"addedBy": "admin_001",
"addedAt": "2026-01-15T10:00:00Z"
}
],
"totalCount": 15
}
Add Sharp User
POST /admin/bbook/sharp-list
Request:
{
"userId": "usr_789",
"reason": "Win rate 62% over 300 bets, strong CLV"
}
Response:
{
"success": true,
"sharpUser": { ... }
}
Remove Sharp User
DELETE /admin/bbook/sharp-list/:userId
Response:
{
"success": true,
"message": "User removed from sharp list"
}
11.4 Reporting APIs
B-Book P&L Report
GET /admin/bbook/reports/pnl?startDate=2026-01-01&endDate=2026-01-22
Response:
{
"period": {
"start": "2026-01-01",
"end": "2026-01-22"
},
"totalBBookStakes": 50000000,
"totalBBookFills": 8500,
"realizedPnL": 750000,
"unrealizedPnL": -50000,
"peakExposure": 8500000,
"averageUtilization": 42.5,
"winRate": 54.2,
"maxDrawdown": 200000
}
Position History
GET /admin/bbook/positions?selectionId=sel_123&status=SETTLED
Response:
{
"positions": [
{
"id": "pos_001",
"userId": "usr_456",
"side": "BACK",
"stake": 10000,
"odds": 2.50,
"liability": 15000,
"status": "SETTLED",
"settlementPnL": -15000,
"createdAt": "2026-01-20T14:30:00Z",
"settledAt": "2026-01-20T17:45:00Z"
}
],
"totalCount": 25
}
12. Risk Monitoring and Alerts
12.1 Real-Time Monitoring Dashboard
The admin dashboard displays live B-Book metrics:
| Metric | Update Frequency | Alert Threshold |
|---|---|---|
| Pool Utilization % | Real-time (WebSocket) | > 80% (warning), > 95% (critical) |
| Daily P&L | Real-time | < -500,000 (critical) |
| Side Imbalance Ratio | Every minute | > 3.0 (warning) |
| Per-Market Exposure | Real-time | > 80% of limit |
| Per-Selection Exposure | Real-time | > 80% of limit |
12.2 Alert System
Alerts are triggered automatically and sent via configured channels (email, Slack, etc.):
interface BBookAlert {
id: string;
type: AlertType;
severity: 'info' | 'warning' | 'critical';
message: string;
data: Record<string, any>;
triggeredAt: Date;
acknowledgedAt?: Date;
acknowledgedBy?: string;
}
enum AlertType {
POOL_UTILIZATION_HIGH = 'pool_utilization_high',
DAILY_LOSS_LIMIT_APPROACHING = 'daily_loss_limit_approaching',
DAILY_LOSS_LIMIT_BREACHED = 'daily_loss_limit_breached',
SIDE_IMBALANCE = 'side_imbalance',
MARKET_EXPOSURE_HIGH = 'market_exposure_high',
SELECTION_EXPOSURE_HIGH = 'selection_exposure_high',
AUTO_HEDGE_TRIGGERED = 'auto_hedge_triggered',
EMERGENCY_SHUTDOWN = 'emergency_shutdown'
}
12.3 Alert Triggers
| Alert | Trigger Condition | Action |
|---|---|---|
| Pool Utilization Warning | utilization > 80% | Notify ops team |
| Pool Utilization Critical | utilization > 95% | Suspend B-Book, notify |
| Daily Loss Approaching | dailyPnL < -80% of limit | Notify, restrict new positions |
| Daily Loss Breached | dailyPnL < -100% of limit | Suspend B-Book, notify |
| Side Imbalance | ratio > threshold | Restrict heavy side |
| Market Concentration | single market > 50% of pool | Warning to ops |
12.4 Auto-Hedge Logic (Future Enhancement)
When pool utilization exceeds the auto-hedge threshold:
async function autoHedge(): Promise<void> {
const poolState = await getPoolState();
const config = await getBBookConfig();
if (poolState.utilizationPercent > config.autoHedgeThreshold * 100) {
// Find largest unbalanced selections
const riskySelections = await findRiskySelections({
minNetExposure: config.perSelectionLimit * 0.5,
limit: 10
});
for (const selection of riskySelections) {
// Calculate hedge amount
const hedgeAmount = selection.netExposure * 0.3; // Hedge 30%
// Place opposing bet on exchange
await placeHedgeBet({
selectionId: selection.selectionId,
side: selection.direction === 'short' ? 'back' : 'lay',
amount: hedgeAmount,
venue: 'betfair'
});
// Record hedge action
await recordHedgeAction({
selectionId: selection.selectionId,
amount: hedgeAmount,
reason: 'auto_hedge_threshold'
});
}
}
}
12.5 Emergency Shutdown
When critical thresholds are breached:
async function triggerEmergencyShutdown(reason: string): Promise<void> {
// 1. Disable B-Book immediately
await updateBBookConfig({ bbookEnabled: false });
// 2. Update pool status
await updatePoolState({ status: 'SUSPENDED' });
// 3. Create alert
await createAlert({
type: AlertType.EMERGENCY_SHUTDOWN,
severity: 'critical',
message: `B-Book emergency shutdown: ${reason}`,
data: {
poolState: await getPoolState(),
timestamp: new Date()
}
});
// 4. Notify all configured channels
await notifyAllChannels({
title: 'B-BOOK EMERGENCY SHUTDOWN',
message: reason,
urgency: 'immediate'
});
// Note: Existing positions remain until settled
// New orders route 100% to exchange
}
13. Error Handling and Edge Cases
13.1 Exchange Unavailability
When Betfair or Pinnacle is unavailable:
| Scenario | B-Book Enabled | B-Book Disabled |
|---|---|---|
| Exchange down, B-Book has capacity | Fill via B-Book (up to limits) | Reject order |
| Exchange down, B-Book at limit | Reject order | Reject order |
| Exchange slow (timeout) | Fill B-Book portion, retry exchange | Retry or reject |
async function handleExchangeUnavailable(
order: Order,
error: ExchangeError
): Promise<OrderResult> {
const config = await getBBookConfig();
if (!config.bbookEnabled) {
return { status: 'rejected', reason: 'exchange_unavailable' };
}
// Check if user is sharp
if (await isSharpUser(order.userId)) {
return { status: 'rejected', reason: 'exchange_unavailable_sharp_user' };
}
// Try to fill entirely via B-Book
const capacity = await calculateBBookCapacity(order);
if (capacity >= order.stake) {
// Can fulfill entirely via B-Book
return await executeBBookFill(order, order.stake);
} else if (capacity > 0) {
// Partial fill via B-Book
return await executeBBookFill(order, capacity);
} else {
return { status: 'rejected', reason: 'no_capacity' };
}
}
13.2 Race Conditions
Problem: Multiple concurrent orders could exceed limits.
Solution: Optimistic locking with Redis:
async function reserveBBookCapacity(
order: Order,
amount: number
): Promise<boolean> {
const lockKey = `bbook:lock:${order.selectionId}`;
// Acquire lock
const lock = await redis.set(lockKey, order.id, 'NX', 'PX', 5000);
if (!lock) {
// Another order is processing - retry or queue
return false;
}
try {
// Double-check capacity under lock
const capacity = await calculateBBookCapacity(order);
if (capacity < amount) {
return false;
}
// Reserve capacity atomically
await redis.hincrby(`bbook:selection:${order.selectionId}`, 'reserved', amount);
return true;
} finally {
await redis.del(lockKey);
}
}
13.3 Partial Fill Edge Cases
| Scenario | Handling |
|---|---|
| B-Book fills 70%, exchange rejects 30% | Return partial fill (70%) |
| B-Book fails, exchange succeeds | Return exchange fill only |
| Both fail | Reject order, no state changes |
| B-Book fills, exchange timeout | Return B-Book fill, log exchange issue |
13.4 Settlement Edge Cases
Double Settlement Prevention:
async function settlePosition(positionId: string): Promise<void> {
const position = await db.bbookPosition.findUnique({
where: { id: positionId }
});
if (position.status !== 'OPEN') {
throw new Error(`Position ${positionId} already ${position.status}`);
}
// Use optimistic locking
const result = await db.bbookPosition.updateMany({
where: {
id: positionId,
status: 'OPEN' // Only if still OPEN
},
data: {
status: 'SETTLED',
settledAt: new Date()
}
});
if (result.count === 0) {
throw new Error('Position was modified by another process');
}
}
Missing Event Result:
If an event result is delayed or missing:
- Positions remain OPEN
- Exposure remains counted against limits
- Alert triggered after 24 hours
- Manual settlement option available to admins
13.5 Price Slippage
B-Book fills at the requested odds, not market odds. This differs from exchange behavior:
| Aspect | B-Book | Exchange |
|---|---|---|
| Price | Fixed at request time | May slip based on market |
| Fill guarantee | Guaranteed if capacity exists | Depends on liquidity |
| Better price | Never | Sometimes (exchange finds better price) |
13.6 User Balance Insufficient
Always check balance before routing decision:
async function processOrder(order: Order): Promise<OrderResult> {
// Step 1: Validate balance FIRST
const user = await getUser(order.userId);
const requiredBalance = order.stake; // For BACK
// For LAY: order.stake * (order.odds - 1)
if (user.balance < requiredBalance) {
return { status: 'rejected', reason: 'insufficient_balance' };
}
// Step 2: Reserve balance atomically
await reserveUserBalance(order.userId, requiredBalance);
try {
// Step 3: Route and fill
return await routeAndFill(order);
} catch (error) {
// Release reserved balance on failure
await releaseUserBalance(order.userId, requiredBalance);
throw error;
}
}
14. Currency Abstraction Layer
14.1 Design Philosophy
The B-Book is built on Hannibal's points system but designed to be currency-agnostic. This allows:
- Current operation on points (internal unit)
- Future support for real money (fiat currencies)
- Potential support for crypto currencies
- Easy conversion between units
14.2 Currency Provider Interface
interface CurrencyProvider {
// Currency identification
readonly code: string; // e.g., 'POINTS', 'INR', 'USDT'
readonly symbol: string; // e.g., '◉', '₹', '$'
readonly decimals: number; // e.g., 2 for fiat, 0 for points
// Balance operations
getBalance(userId: string): Promise<Decimal>;
reserveBalance(userId: string, amount: Decimal): Promise<boolean>;
releaseReserve(userId: string, amount: Decimal): Promise<void>;
debit(userId: string, amount: Decimal, reason: string): Promise<void>;
credit(userId: string, amount: Decimal, reason: string): Promise<void>;
// Pool operations
getPoolBalance(): Promise<Decimal>;
debitPool(amount: Decimal, reason: string): Promise<void>;
creditPool(amount: Decimal, reason: string): Promise<void>;
// Conversion (optional)
convertToBase?(amount: Decimal): Promise<Decimal>;
convertFromBase?(amount: Decimal): Promise<Decimal>;
}
14.3 Points Provider Implementation (Default)
class PointsCurrencyProvider implements CurrencyProvider {
readonly code = 'POINTS';
readonly symbol = '◉';
readonly decimals = 0;
async getBalance(userId: string): Promise<Decimal> {
const user = await db.user.findUnique({ where: { id: userId } });
return user.points;
}
async reserveBalance(userId: string, amount: Decimal): Promise<boolean> {
// Atomic decrement with check
const result = await db.user.updateMany({
where: {
id: userId,
points: { gte: amount }
},
data: {
points: { decrement: amount },
reservedPoints: { increment: amount }
}
});
return result.count > 0;
}
async debit(userId: string, amount: Decimal, reason: string): Promise<void> {
await db.$transaction([
db.user.update({
where: { id: userId },
data: { reservedPoints: { decrement: amount } }
}),
db.transaction.create({
data: {
userId,
type: 'DEBIT',
amount,
description: reason,
currency: 'POINTS'
}
})
]);
}
// ... other methods
}
14.4 Real Money Provider (Future)
class FiatCurrencyProvider implements CurrencyProvider {
constructor(private readonly currency: 'INR' | 'USD' | 'GBP') {}
readonly code = this.currency;
readonly symbol = { INR: '₹', USD: '$', GBP: '£' }[this.currency];
readonly decimals = 2;
async getBalance(userId: string): Promise<Decimal> {
const wallet = await db.userWallet.findUnique({
where: { userId_currency: { userId, currency: this.currency } }
});
return wallet?.balance ?? new Decimal(0);
}
// Similar implementations with appropriate decimal handling
// May integrate with payment gateway for real deposits/withdrawals
}
14.5 B-Book Currency Integration
The B-Book module accepts a currency provider at initialization:
class BBookModule {
constructor(
private readonly currencyProvider: CurrencyProvider,
private readonly config: BBookConfig
) {}
async executeFill(order: Order, amount: Decimal): Promise<FillResult> {
// Use provider for all balance operations
const reserved = await this.currencyProvider.reserveBalance(
order.userId,
amount
);
if (!reserved) {
throw new InsufficientBalanceError();
}
// ... rest of fill logic
}
async settlePosition(position: BBookPosition): Promise<void> {
if (position.settlementPnL > 0) {
await this.currencyProvider.credit(
position.userId,
position.settlementPnL,
`B-Book win: ${position.id}`
);
await this.currencyProvider.debitPool(
position.settlementPnL,
`B-Book payout: ${position.id}`
);
} else {
await this.currencyProvider.creditPool(
Math.abs(position.settlementPnL),
`B-Book collection: ${position.id}`
);
}
}
}
14.6 Multi-Currency Configuration
Future enhancement to support multiple currencies:
interface BBookMultiCurrencyConfig {
// Per-currency pool limits
poolLimits: {
POINTS: Decimal;
INR: Decimal;
USDT: Decimal;
};
// Base currency for aggregation/reporting
baseCurrency: string;
// Exchange rates (if needed for reporting)
exchangeRates: {
[from: string]: {
[to: string]: Decimal;
};
};
}
15. Performance Considerations
15.1 Latency Requirements
| Operation | Target Latency | Acceptable Max |
|---|---|---|
| Sharp check | < 5ms | 20ms |
| Capacity calculation | < 10ms | 50ms |
| B-Book fill | < 50ms | 200ms |
| Settlement (batch) | < 1s per 100 positions | 5s |
15.2 Throughput Requirements
| Metric | Expected | Design Target |
|---|---|---|
| Orders per second | 100 | 500 |
| Concurrent markets | 50 | 200 |
| Active positions | 10,000 | 100,000 |
| Position settlements per minute | 500 | 2,000 |
15.3 Caching Strategy
Redis as Primary Cache:
| Data | TTL | Invalidation |
|---|---|---|
| Sharp list | No expiry | On add/remove |
| Pool state | 1 second | On every update |
| Selection state | 1 second | On position change |
| User exposure | 5 seconds | On position change |
| Config | 60 seconds | On admin update |
Cache Warming:
async function warmBBookCache(): Promise<void> {
// Load sharp list
const sharpUsers = await db.sharpUser.findMany({ where: { isActive: true } });
await redis.sadd('bbook:sharp_list', ...sharpUsers.map(u => u.userId));
// Load pool state
const poolState = await db.bbookPoolState.findFirst();
await redis.hset('bbook:pool', flattenObject(poolState));
// Load active selection states
const selections = await db.bbookSelectionState.findMany({
where: { status: 'OPEN' }
});
for (const sel of selections) {
await redis.hset(`bbook:selection:${sel.selectionId}`, flattenObject(sel));
}
console.log(`B-Book cache warmed: ${sharpUsers.length} sharp users, ${selections.length} selections`);
}
15.4 Database Optimization
Partitioning Strategy:
| Table | Partition By | Rationale |
|---|---|---|
bbook_positions | createdAt (monthly) | High volume, mostly historical |
bbook_pnl_records | settledAt (monthly) | Reporting access pattern |
Connection Pooling:
// Recommended PgBouncer configuration
{
pool_mode: 'transaction',
max_client_conn: 1000,
default_pool_size: 25,
reserve_pool_size: 5
}
15.5 Batching and Bulk Operations
Bulk Settlement:
async function bulkSettleSelections(
selectionIds: string[],
results: Map<string, boolean>
): Promise<BulkSettlementResult> {
const BATCH_SIZE = 100;
const batches = chunk(selectionIds, BATCH_SIZE);
const allResults: SettlementResult[] = [];
for (const batch of batches) {
const batchResults = await Promise.all(
batch.map(selId => settleBBookPositions(selId, results.get(selId)!))
);
allResults.push(...batchResults);
}
return {
totalSelections: selectionIds.length,
totalPositions: allResults.reduce((sum, r) => sum + r.positionsSettled, 0),
totalPlatformPnL: allResults.reduce((sum, r) => sum + r.platformPnL, 0)
};
}
15.6 Horizontal Scaling
The B-Book module can be scaled horizontally with considerations:
| Component | Scaling Approach |
|---|---|
| Filter Engine | Stateless, scale with load balancer |
| Position State | Partitioned by selection/market |
| Settlement Workers | Queue-based, multiple consumers |
| Cache (Redis) | Redis Cluster for high availability |
Important: Use distributed locking (Redis/Redlock) for critical sections when scaled.
16. Testing Strategy
16.1 Unit Tests
Filter Engine Tests:
describe('FilterEngine', () => {
describe('calculateBBookCapacity', () => {
it('should return 0 when B-Book is disabled', async () => {
await setBBookEnabled(false);
const capacity = await calculateBBookCapacity(mockOrder);
expect(capacity).toBe(0);
});
it('should return 0 for sharp users', async () => {
await addToSharpList(mockUser.id);
const capacity = await calculateBBookCapacity(mockOrder);
expect(capacity).toBe(0);
});
it('should respect global pool limit', async () => {
await setGlobalExposure(9_000_000);
await setGlobalLimit(10_000_000);
const capacity = await calculateBBookCapacity({ stake: 2_000_000 });
expect(capacity).toBe(1_000_000);
});
it('should respect per-user limit', async () => {
await setUserExposure(mockUser.id, 40_000);
await setPerUserLimit(50_000);
const capacity = await calculateBBookCapacity(mockOrder);
expect(capacity).toBeLessThanOrEqual(10_000);
});
it('should allow balancing orders without limit', async () => {
await createImbalancedPosition('BACK', 100_000);
const layOrder = { ...mockOrder, betType: 'LAY' };
const capacity = await calculateBBookCapacity(layOrder);
expect(capacity).toBeGreaterThan(0); // Balancing is always allowed
});
});
});
Settlement Tests:
describe('BBookSettlement', () => {
it('should credit user on winning BACK bet', async () => {
const position = await createPosition({ side: 'BACK', stake: 1000, odds: 2.5 });
await settleBBookPositions(position.selectionId, true); // Selection won
const user = await getUser(position.userId);
expect(user.balance).toBe(initialBalance + 1500); // Won 1000 * (2.5-1)
});
it('should credit platform on losing BACK bet', async () => {
const position = await createPosition({ side: 'BACK', stake: 1000, odds: 2.5 });
const poolBefore = await getPoolState();
await settleBBookPositions(position.selectionId, false); // Selection lost
const poolAfter = await getPoolState();
expect(poolAfter.realizedPnL).toBe(poolBefore.realizedPnL + 1000);
});
it('should prevent double settlement', async () => {
const position = await createPosition({ side: 'BACK', stake: 1000 });
await settleBBookPositions(position.selectionId, true);
await expect(settleBBookPositions(position.selectionId, true))
.rejects.toThrow('already settled');
});
});
16.2 Integration Tests
describe('BBook Integration', () => {
describe('Full Order Flow', () => {
it('should split order between B-Book and exchange', async () => {
await setBBookEnabled(true);
await setMaxBBookPercentage(0.7);
const result = await placeOrder({
userId: recreationalUser.id,
stake: 10_000,
selectionId: 'sel_123',
betType: 'BACK',
odds: 2.0
});
expect(result.bbookFill).toBeLessThanOrEqual(7_000);
expect(result.exchangeFill).toBeGreaterThanOrEqual(3_000);
expect(result.bbookFill + result.exchangeFill).toBe(10_000);
});
it('should route sharp user entirely to exchange', async () => {
await addToSharpList(sharpUser.id);
const result = await placeOrder({
userId: sharpUser.id,
stake: 10_000,
selectionId: 'sel_123',
betType: 'BACK',
odds: 2.0
});
expect(result.bbookFill).toBe(0);
expect(result.exchangeFill).toBe(10_000);
});
});
describe('Exchange Failover', () => {
it('should fill via B-Book when exchange is unavailable', async () => {
mockExchange.setUnavailable(true);
const result = await placeOrder({
userId: recreationalUser.id,
stake: 5_000,
selectionId: 'sel_123',
betType: 'BACK',
odds: 2.0
});
expect(result.bbookFill).toBe(5_000);
expect(result.status).toBe('filled');
});
});
});
16.3 Load Tests
describe('BBook Load Tests', () => {
it('should handle 500 concurrent orders', async () => {
const orders = Array.from({ length: 500 }, (_, i) => ({
userId: `user_${i % 100}`,
stake: 1000,
selectionId: `sel_${i % 10}`,
betType: i % 2 === 0 ? 'BACK' : 'LAY',
odds: 2.0
}));
const startTime = Date.now();
const results = await Promise.all(orders.map(o => placeOrder(o)));
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(10_000); // < 10s for 500 orders
expect(results.filter(r => r.status === 'filled').length).toBe(500);
});
it('should maintain consistency under concurrent settlement', async () => {
// Create 1000 positions across 10 selections
await createManyPositions(1000, 10);
const poolBefore = await getPoolState();
// Settle all 10 selections concurrently
await Promise.all(
selections.map(sel => settleBBookPositions(sel.id, Math.random() > 0.5))
);
const poolAfter = await getPoolState();
// Verify pool state is consistent
expect(poolAfter.totalNetExposure).toBe(0); // All settled
// Verify no positions left open
const openPositions = await db.bbookPosition.count({ where: { status: 'OPEN' } });
expect(openPositions).toBe(0);
});
});
16.4 Critical Test Scenarios
| Scenario | Test Approach |
|---|---|
| Race condition: two orders exceed limit | Concurrent test with assertions on final state |
| Partial fill: B-Book capacity mid-order | Mock capacity depletion during order |
| Settlement during order placement | Concurrent settlement + order tests |
| Redis cache miss | Clear cache, verify DB fallback |
| Emergency shutdown during settlement | Trigger shutdown, verify positions remain OPEN |
17. Deployment and Operations
17.1 Deployment Modes
Option A: Monolith Integration
The B-Book module runs within the main Hannibal backend process.
┌─────────────────────────────────────────┐
│ Hannibal Backend │
│ ┌─────────┐ ┌─────────┐ ┌───────────┐ │
│ │ Order │ │ B-Book │ │ Exchange │ │
│ │ Service │ │ Module │ │ Connector │ │
│ └─────────┘ └─────────┘ └───────────┘ │
└─────────────────────────────────────────┘
Pros: Simple deployment, no network overhead Cons: B-Book failure can affect entire backend
Option B: Microservice (Recommended for Scale)
The B-Book runs as a separate service.
┌─────────────────┐ ┌─────────────────┐
│ Hannibal Backend│ │ B-Book Service │
│ │◄───►│ │
│ Order Service │gRPC │ Filter Engine │
│ │ │ Position Mgr │
└─────────────────┘ └─────────────────┘
│ │
└──────────┬───────────┘
│
┌────────▼────────┐
│ PostgreSQL │
│ Redis │
└─────────────────┘
Pros: Failure isolation, independent scaling Cons: Network latency, operational complexity
17.2 Feature Flags
Use feature flags for gradual rollout:
const BBOOK_FEATURE_FLAGS = {
// Master enable
'bbook.enabled': false,
// Gradual rollout
'bbook.enabled_for_users': [], // Specific user IDs
'bbook.enabled_percentage': 0, // % of users
// Feature-specific flags
'bbook.auto_hedge.enabled': false,
'bbook.side_restriction.enabled': true,
// Emergency
'bbook.emergency_disable': false
};
17.3 Rollback Procedure
If B-Book issues detected:
# 1. Disable B-Book (immediate, no new B-Book fills)
curl -X POST https://api.hannibal.com/admin/bbook/disable
# 2. Verify new orders route to exchange
# Monitor logs for routing decisions
# 3. Existing positions remain until settled
# No manual intervention needed
# 4. Investigate and fix
# Once fixed, re-enable via admin panel
17.4 Monitoring Dashboards
Key Metrics to Display:
| Panel | Metrics |
|---|---|
| Pool Health | Utilization %, Capacity remaining, Status |
| P&L | Daily P&L, Realized P&L, Unrealized P&L |
| Volume | Orders routed to B-Book, Fill rate, Rejection rate |
| Risk | Largest exposures, Side imbalance, Near-limit selections |
| Performance | Fill latency (p50, p95, p99), Settlement latency |
17.5 Alerting Configuration
# Example AlertManager configuration
groups:
- name: bbook_alerts
rules:
- alert: BBookPoolUtilizationHigh
expr: bbook_pool_utilization > 0.80
for: 5m
labels:
severity: warning
annotations:
summary: "B-Book pool utilization above 80%"
- alert: BBookPoolCritical
expr: bbook_pool_utilization > 0.95
for: 1m
labels:
severity: critical
annotations:
summary: "B-Book pool near capacity - auto-suspend may trigger"
- alert: BBookDailyLossHigh
expr: bbook_daily_pnl < -400000
for: 1m
labels:
severity: warning
annotations:
summary: "B-Book daily loss approaching limit"
18. Migration Path
18.1 Prerequisites
Before enabling B-Book on an existing Hannibal installation:
- ✅ Database migrations applied (new tables created)
- ✅ Redis deployed and accessible
- ✅ Admin UI updated with B-Book controls
- ✅ Monitoring dashboards configured
- ✅ Alerting configured
- ✅ Staff trained on B-Book operations
18.2 Database Migration
-- Migration: Add B-Book tables
-- Run in a maintenance window
-- 1. Create new tables
CREATE TABLE bbook_config (...);
CREATE TABLE bbook_positions (...);
CREATE TABLE bbook_selection_state (...);
CREATE TABLE bbook_market_state (...);
CREATE TABLE bbook_user_state (...);
CREATE TABLE bbook_pool_state (...);
CREATE TABLE sharp_users (...);
CREATE TABLE bbook_pnl_records (...);
-- 2. Add columns to orders table
ALTER TABLE orders ADD COLUMN bbook_fill DECIMAL;
ALTER TABLE orders ADD COLUMN exchange_fill DECIMAL;
ALTER TABLE orders ADD COLUMN routing_venue VARCHAR(20);
ALTER TABLE orders ADD COLUMN is_sharp_at_order_time BOOLEAN;
-- 3. Create indexes
CREATE INDEX idx_bbook_positions_selection ON bbook_positions(selection_id, status);
-- ... other indexes
-- 4. Initialize singleton records
INSERT INTO bbook_config (id, bbook_enabled, ...) VALUES (uuid_generate_v4(), false, ...);
INSERT INTO bbook_pool_state (id, ...) VALUES (uuid_generate_v4(), ...);
18.3 Gradual Enablement
Phase 1: Shadow Mode (1 week)
- B-Book logic runs but doesn't affect routing
- Log what would have happened
- Verify capacity calculations are accurate
Phase 2: Limited Rollout (2 weeks)
- Enable for 5% of recreational users
- Low pool limits (1,000,000 points)
- High monitoring, quick disable if issues
Phase 3: Expanded Rollout (2 weeks)
- Enable for 25% of recreational users
- Increase pool limits (5,000,000 points)
- Fine-tune sharp detection
Phase 4: Full Enablement
- Enable for all recreational users
- Full pool limits (10,000,000 points)
- Ongoing monitoring and optimization
18.4 Rollback Plan
If issues arise during migration:
| Issue | Action |
|---|---|
| Database migration fails | Rollback migration, fix, retry |
| B-Book fills incorrectly | Disable B-Book, manual review |
| Performance degradation | Reduce B-Book percentage, investigate |
| Exposure calculation wrong | Disable B-Book, rebuild state from positions |
19. Security Considerations
19.1 Access Control
Admin APIs require elevated permissions:
| API Category | Required Role |
|---|---|
| View B-Book config | admin, ops |
| Update B-Book config | admin |
| Enable/Disable B-Book | admin |
| View pool state | admin, ops |
| Manage sharp list | admin, risk |
| View positions | admin, ops, support |
| Manual settlement | admin |
// Example middleware
function requireBBookAdminAccess(req, res, next) {
const user = req.user;
const action = req.path;
const requiredRoles = getBBookPermissions(action);
if (!user.roles.some(r => requiredRoles.includes(r))) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
}
19.2 Audit Logging
All B-Book administrative actions are logged:
interface BBookAuditLog {
id: string;
action: BBookAuditAction;
performedBy: string;
timestamp: Date;
details: Record<string, any>;
ipAddress: string;
userAgent: string;
}
enum BBookAuditAction {
CONFIG_UPDATED = 'config_updated',
BBOOK_ENABLED = 'bbook_enabled',
BBOOK_DISABLED = 'bbook_disabled',
SHARP_USER_ADDED = 'sharp_user_added',
SHARP_USER_REMOVED = 'sharp_user_removed',
MANUAL_SETTLEMENT = 'manual_settlement',
EMERGENCY_SHUTDOWN = 'emergency_shutdown',
LIMIT_OVERRIDDEN = 'limit_overridden'
}
19.3 Fraud Prevention
Detecting Manipulation Attempts:
| Risk | Detection | Mitigation |
|---|---|---|
| Coordinated betting | Multiple users, same selection, short time | Aggregate limit per selection |
| Account abuse | User creates multiple accounts | KYC verification, device fingerprinting |
| Insider information | Unusual bet patterns before events | Real-time monitoring, sharp classification |
| Arbitrage | Simultaneous opposite bets | Per-user netting (already handled) |
19.4 Data Protection
Sensitive Data Handling:
| Data | Classification | Protection |
|---|---|---|
| User balances | Confidential | Encrypted at rest, access logged |
| Betting history | Confidential | Access restricted, audit logged |
| Sharp list | Internal | Access restricted to risk team |
| Pool state | Internal | No PII, aggregate data only |
| P&L records | Confidential | Access restricted to finance team |
20. Glossary
| Term | Definition |
|---|---|
| A-Book | Orders passed through to external exchanges (Betfair, Pinnacle) |
| B-Book | Platform takes the opposite side of user bets using own capital |
| BACK bet | Betting that an outcome will happen |
| LAY bet | Betting against an outcome (acting as bookmaker) |
| Liability | Maximum amount platform could lose on a position |
| Net Exposure | Absolute difference between BACK and LAY liability |
| Sharp user | Professional bettor with positive expected value |
| Recreational user | Casual bettor, negative expected value overall |
| Filter Engine | Component that decides order routing |
| Netting | Offsetting opposite positions to reduce exposure |
| Hedge | Placing opposite bet on exchange to reduce B-Book exposure |
| CLV | Closing Line Value - how bet price compares to final price |
| NGR | Net Gaming Revenue - stakes minus payouts |
| Pool | Capital reserved for B-Book operations |
| Utilization | Current exposure as percentage of pool limit |
21. Open Questions
The following decisions require stakeholder input before final implementation:
21.1 Business Questions
| # | Question | Options | Recommendation |
|---|---|---|---|
| 1 | Should agents receive any share of B-Book profits? | a) No share b) Small share c) Same as exchange | No share - B-Book is platform risk |
| 2 | What should the initial pool limit be? | a) 5M b) 10M c) 20M points | Start conservative at 5M |
| 3 | Should users be informed their bet is B-Booked? | a) Yes, transparent b) No, seamless | No - seamless UX, internal routing |
| 4 | How aggressive should sharp detection be? | a) Conservative (few false positives) b) Aggressive | Conservative initially |
| 5 | Should B-Book be market-type specific? | a) All markets b) Only pre-match c) Exclude in-play | Start with pre-match only |
21.2 Technical Questions
| # | Question | Options | Recommendation |
|---|---|---|---|
| 1 | Deployment model? | a) Monolith b) Microservice | Monolith for V1, extract later |
| 2 | State rebuild frequency? | a) On-demand b) Scheduled c) Never | Scheduled nightly + on-demand |
| 3 | Redis failure handling? | a) Degrade to DB b) Reject orders | Degrade to DB (slower but safe) |
| 4 | Position archival policy? | a) 90 days b) 1 year c) Forever | 90 days hot, archive to cold storage |
| 5 | Auto-hedge in V1? | a) Yes b) No (manual only) | No - manual hedging for V1 |
21.3 Operational Questions
| # | Question | Options | Recommendation |
|---|---|---|---|
| 1 | Who can add/remove sharp users? | a) Admin only b) Risk team c) Automated | Risk team with admin override |
| 2 | B-Book on/off approval process? | a) Single admin b) Two-person rule | Two-person rule for safety |
| 3 | Alert escalation path? | Define on-call rotation | Define before launch |
| 4 | Reconciliation frequency? | a) Daily b) Weekly c) Monthly | Daily automated, weekly manual review |
22. Appendix
A. Sample Configuration
{
"bbookEnabled": true,
"globalPoolLimit": 10000000,
"perMarketLimit": 2000000,
"perSelectionLimit": 500000,
"perUserLimit": 50000,
"maxBbookPercentage": 0.70,
"minExchangePercentage": 0.00,
"preferBbook": true,
"sideImbalanceThreshold": 3.0,
"autoHedgeThreshold": 0.80,
"emergencyShutdownThreshold": 0.95,
"dailyLossLimit": 500000
}
B. Example Order Flow
Scenario: Recreational user places 100,000 pts BACK on Mumbai @ 2.50
1. Order received: { stake: 100000, odds: 2.50, betType: 'BACK', selectionId: 'mumbai_win' }
2. B-Book check:
- B-Book enabled: true
- User sharp: false
3. Capacity calculation:
- Global remaining: 8,000,000 pts
- Market remaining: 1,500,000 pts
- Selection remaining: 400,000 pts
- User remaining: 30,000 pts ← Most restrictive!
- Percentage cap: 70,000 pts
4. Routing decision:
- B-Book fill: 30,000 pts (user limit)
- Exchange fill: 70,000 pts
5. B-Book execution:
- Liability: 30,000 × (2.50 - 1) = 45,000 pts
- Update selection state: backLiability += 45,000
- Update user state: totalExposure += 45,000
- Create position record
6. Exchange execution:
- Place 70,000 BACK @ 2.50 on Betfair
- Record in agent accounting
7. Response to user:
{
orderId: 'ord_123',
status: 'filled',
totalStake: 100000,
fills: [
{ venue: 'bbook', amount: 30000, odds: 2.50 },
{ venue: 'betfair', amount: 70000, odds: 2.50 }
]
}
C. Settlement Example
Scenario: Mumbai wins
1. Event result received: { selectionId: 'mumbai_win', won: true }
2. Get B-Book positions:
- Position 1: BACK 30,000 @ 2.50 (User A) → Liability: 45,000
- Position 2: BACK 20,000 @ 2.40 (User B) → Liability: 28,000
- Position 3: LAY 25,000 @ 2.50 (User C) → Liability: 25,000
3. Settlement calculations:
| Position | Side | Stake | Outcome | User P&L | Platform P&L |
|----------|------|-------|---------|----------|--------------|
| 1 | BACK | 30,000 | WIN | +45,000 | -45,000 |
| 2 | BACK | 20,000 | WIN | +28,000 | -28,000 |
| 3 | LAY | 25,000 | LOSE | -25,000 | +25,000 |
| **Total** | | | | **+48,000** | **-48,000** |
4. Execute settlements:
- Credit User A: 45,000 pts
- Credit User B: 28,000 pts
- User C already paid stake (reserved), no action
- Pool P&L: -48,000 pts
5. Update state:
- Selection state: SETTLED
- All positions: SETTLED
- Pool realizedPnL += -48,000
- Pool totalNetExposure -= settled exposure
Document Version: 1.0 Last Updated: January 2026 Author: Hannibal Engineering Team