Overview
The risk management system combines three layers:
Fractional Kelly Criterion - Optimal bet sizing based on edge and accuracy
Brier-Tiered Alpha - Dynamic Kelly fraction tied to model calibration quality
4-Level Drawdown Tracking - Progressive risk reduction as bankroll declines
All three layers work together: even with a strong edge, poor calibration (high Brier Score) or drawdown forces smaller bets or complete suspension.
Kelly Criterion
Mathematical Foundation
The Kelly Criterion maximizes long-term log growth of bankroll:
f* = (p × b - q) / b
For binary outcomes with 1:1 payoff:
f* = (p - q) / (1 - q)
where:
f* = fraction of bankroll to wager
p = probability of winning (model prediction)
q = breakeven probability (market price)
b = odds received (for binary markets, b = 1/q - 1)
Why Fractional Kelly?
Full Kelly maximizes growth but has extreme volatility:
Single bad prediction can lose 30%+ of bankroll
Assumes perfect model calibration (unrealistic)
Requires infinite trials to converge (impractical for 5-min markets)
Fractional Kelly (f_real = α × f*) reduces variance:
α = 0.25 (quarter-Kelly) reduces volatility by 50%
α = 0.10 (tenth-Kelly) ultra-conservative for uncertain models
α = 0.40 aggressive for well-calibrated models (Brier < 0.18)
Never use full Kelly (α = 1.0) unless Brier Score is consistently below 0.10 over 500+ predictions. Even small calibration errors compound into catastrophic drawdowns.
Brier Score Mapping
What is Brier Score?
Brier Score measures the accuracy of probabilistic predictions:
BS = (1/N) × Σ(pᵢ - oᵢ)²
where:
pᵢ = predicted probability
oᵢ = actual outcome (0 or 1)
N = number of predictions
Interpretation :
BS = 0.00 → Perfect calibration (impossible in practice)
BS = 0.25 → Random guessing (coin flip)
BS < 0.20 → Good calibration
BS < 0.10 → Excellent calibration
Brier Tiers
The engine maps Brier Score to Kelly fraction alpha:
Tier Brier Range Min Predictions Alpha Interpretation 0 Any 0 - 99 0.00 Insufficient data (no trading) 1 > 0.26 100+ 0.10 Poor calibration (tenth-Kelly) 2 0.22 - 0.26 100+ 0.20 Mediocre calibration (fifth-Kelly) 3 0.18 - 0.22 100+ 0.25 Good calibration (quarter-Kelly) 4 < 0.18 100+ 0.40 Excellent calibration (aggressive)
From config/default.json:59-65:
{
"brierTiers" : [
{ "maxBrier" : null , "minPredictions" : 0 , "maxPredictions" : 100 , "alpha" : 0 },
{ "maxBrier" : 1.0 , "minPredictions" : 100 , "alpha" : 0.10 },
{ "maxBrier" : 0.26 , "minPredictions" : 100 , "alpha" : 0.20 },
{ "maxBrier" : 0.22 , "minPredictions" : 100 , "alpha" : 0.25 },
{ "maxBrier" : 0.18 , "minPredictions" : 100 , "alpha" : 0.40 }
]
}
Implementation
From src/risk/position-sizer.js:28-45:
getAlpha ( brierScore , predictionCount ) {
// Tier 0: insufficient data
if ( predictionCount < 100 ) return 0
// Walk tiers from most aggressive (lowest Brier) to least
const tiers = this . _brierTiers
for ( let i = tiers . length - 1 ; i >= 1 ; i -- ) {
const tier = tiers [ i ]
if ( tier . minPredictions !== undefined && predictionCount < tier . minPredictions ) continue
if ( brierScore < tier . maxBrier ) return tier . alpha
}
// Fallback: worst qualifying tier (Brier > 0.26)
return tiers [ 1 ]. alpha
}
Why 100 predictions minimum?
Brier Score is a sample statistic with high variance for small N:
At N=10, a single wrong confident prediction (p=0.9, o=0) contributes 0.81/10 = 0.081 to Brier
At N=100, the same prediction contributes only 0.0081
Requiring 100+ predictions ensures Brier Score is stable and representative.
Full Calculation
From src/risk/position-sizer.js:65-106:
calculateBet ({ p , q , bankroll , brierScore , predictionCount , adjustments }) {
// Determine side: if p > 0.5 we bet YES, otherwise NO
const side = p >= 0.5 ? 'YES' : 'NO'
const pEff = side === 'YES' ? p : 1 - p
const qEff = side === 'YES' ? q : 1 - q
// Full Kelly: f* = (p - q) / (1 - q)
const denom = 1 - qEff
const fullKelly = denom > 0 ? ( pEff - qEff ) / denom : 0
// No edge -> no bet
if ( fullKelly <= 0 ) {
return { bet: 0 , fullKelly , alpha: 0 , fractionalKelly: 0 , capped: false , side }
}
// Fractional alpha from Brier tier
let alpha = this . getAlpha ( brierScore , predictionCount )
// Apply drawdown adjustments if present
if ( adjustments && typeof adjustments . alphaMultiplier === 'number' ) {
alpha *= adjustments . alphaMultiplier
}
const fractionalKelly = alpha * fullKelly
let bet = fractionalKelly * bankroll
let capped = false
// Cap at maxBetPct of bankroll
const maxBet = this . _maxBetPct * bankroll // default: 0.05 (5%)
if ( bet > maxBet ) {
bet = maxBet
capped = true
}
// Floor: below minimum bet size -> no trade
if ( bet < this . _minBetUsd ) { // default: $1.00
return { bet: 0 , fullKelly , alpha , fractionalKelly , capped: false , side }
}
return { bet , fullKelly , alpha , fractionalKelly , capped , side }
}
Example 1: Green Zone, Good Calibration
const result = positionSizer . calculateBet ({
p: 0.75 , // Model predicts 75% UP
q: 0.50 , // Market prices UP at 50%
bankroll: 100 , // $100 bankroll
brierScore: 0.19 , // Good calibration
predictionCount: 150 ,
adjustments: { alphaMultiplier: 1.0 } // Green zone, no penalty
})
/*
Step-by-step:
1. Side = 'YES' (p > 0.5)
pEff = 0.75, qEff = 0.50
2. Full Kelly
f* = (0.75 - 0.50) / (1 - 0.50) = 0.25 / 0.50 = 0.50
3. Alpha from Brier tier
Brier = 0.19 → Tier 3 (0.18 < 0.19 < 0.22) → α = 0.25
4. Fractional Kelly
f_real = 0.25 × 0.50 = 0.125
5. Bet size
bet = 0.125 × $100 = $12.50
6. Cap check
maxBet = 0.05 × $100 = $5.00
$12.50 > $5.00 → capped to $5.00
7. Final result
{ bet: 5.00, fullKelly: 0.50, alpha: 0.25, fractionalKelly: 0.125, capped: true, side: 'YES' }
*/
Example 2: Yellow Zone, Poor Calibration
const result = positionSizer . calculateBet ({
p: 0.85 ,
q: 0.10 ,
bankroll: 80 , // Drawn down from $100
brierScore: 0.27 , // Poor calibration
predictionCount: 120 ,
adjustments: { alphaMultiplier: 0.5 } // Yellow zone, half alpha
})
/*
1. Side = 'YES', pEff = 0.85, qEff = 0.10
2. Full Kelly
f* = (0.85 - 0.10) / (1 - 0.10) = 0.75 / 0.90 = 0.833
3. Alpha from Brier tier
Brier = 0.27 → Tier 1 (> 0.26) → α = 0.10
4. Drawdown adjustment
α_adjusted = 0.10 × 0.5 = 0.05
5. Fractional Kelly
f_real = 0.05 × 0.833 = 0.042
6. Bet size
bet = 0.042 × $80 = $3.36
7. Cap check
maxBet = 0.05 × $80 = $4.00
$3.36 < $4.00 → not capped
8. Min bet check
$3.36 > $1.00 → passes
9. Final result
{ bet: 3.36, fullKelly: 0.833, alpha: 0.05, fractionalKelly: 0.042, capped: false, side: 'YES' }
*/
Drawdown Tracking
4-Level System
Drawdown is measured from the high-water mark (peak bankroll):
Drawdown% = (HighWaterMark - CurrentBankroll) / HighWaterMark
Level Drawdown Range Alpha Multiplier Min EV Override Suspend Trading Green 0 - 10% 1.0 None No Yellow 10 - 20% 0.5 10% (vs 5%) No Red 20 - 30% 0.0 N/A Yes Critical 30%+ 0.0 N/A Yes
From config/default.json:50-54:
{
"drawdown" : {
"yellowPct" : 0.10 ,
"redPct" : 0.20 ,
"criticalPct" : 0.30
}
}
Implementation
From src/risk/drawdown-tracker.js:80-119:
getLevel () {
const drawdown = this . _highWaterMark > 0
? ( this . _highWaterMark - this . _bankroll ) / this . _highWaterMark
: 0
if ( drawdown >= this . _criticalPct ) return 'critical'
if ( drawdown >= this . _redPct ) return 'red'
if ( drawdown >= this . _yellowPct ) return 'yellow'
// Green zone, but cold streak may force yellow
if ( this . _forcedYellow ) return 'yellow'
return 'green'
}
getAdjustments () {
const level = this . getLevel ()
switch ( level ) {
case 'green' :
return { alphaMultiplier: 1.0 , minEVOverride: null , suspend: false }
case 'yellow' :
return { alphaMultiplier: 0.5 , minEVOverride: 0.10 , suspend: false }
case 'red' :
return { alphaMultiplier: 0 , minEVOverride: null , suspend: true }
case 'critical' :
return { alphaMultiplier: 0 , minEVOverride: null , suspend: true }
}
}
Bankroll Updates
From src/risk/drawdown-tracker.js:36-49:
recordTrade ( betAmount , won ) {
this . _tradeCount ++
if ( won ) {
this . _winCount ++
this . _bankroll += betAmount * 0.97 // 3% Polymarket fee
} else {
this . _bankroll -= betAmount
}
if ( this . _bankroll > this . _highWaterMark ) {
this . _highWaterMark = this . _bankroll
}
}
Polymarket Fee : 3% is deducted from winnings. A 10 b e t t h a t w i n s r e t u r n s 10 bet that wins returns 10 b e tt ha tw in sre t u r n s 10 profit × 0.97 = $9.70 net. Expected Value calculations must account for this fee.
Drawdown Lifecycle
Cold-Streak Detection
Circuit Breaker Logic
When the model makes 5 consecutive high-confidence misses (confidence ≥ 70%), the system forces yellow mode even if bankroll is in green zone.
From src/risk/drawdown-tracker.js:60-73:
recordOutcome ( correct , confidence ) {
if ( correct ) {
this . _coldStreak = 0
this . _forcedYellow = false
return
}
if ( confidence >= this . _minConfidenceForStreak ) { // 0.70
this . _coldStreak ++
if ( this . _coldStreak >= this . _consecutiveMissThreshold ) { // 5
this . _forcedYellow = true
}
}
}
Rationale
A cold streak indicates:
Model miscalibration (predicting 80% but losing)
Regime change (strategy no longer works)
Data quality issues (oracle delays, stale feeds)
Forcing yellow mode (half alpha + higher EV threshold) provides breathing room to recover without catastrophic losses.
Low-confidence misses (< 70%) do not count toward the streak. A prediction of 55% that loses is expected 45% of the time and doesn’t signal model failure.
Risk Limits
From config/default.json:46-49:
{
"risk" : {
"bankroll" : 100 ,
"maxBetPct" : 0.05 ,
"minBetUsd" : 1 ,
"kellyFraction" : 0.25
}
}
Max Bet Cap (5%)
Even with massive edge (e.g., p=0.99, q=0.01), bet size is capped at 5% of bankroll:
const maxBet = this . _maxBetPct * bankroll // 0.05 × $100 = $5
if ( bet > maxBet ) {
bet = maxBet
capped = true
}
Prevents:
Single-trade wipeout (max loss = 5% per interval)
Liquidity issues (can’t fill $50 order on thin market)
Emotional tilt (seeing large losses)
Min Bet Floor ($1)
Bets below $1 are rejected:
if ( bet < this . _minBetUsd ) {
return { bet: 0 , ... }
}
Prevents:
Dust trades (gas fees > profit)
Psychological noise (tracking $0.25 bets)
Order rejection (Polymarket min order size)
Complete Example: Risk Cascade
// ── Scenario ──
// Bankroll started at $100, now at $78 (22% drawdown = RED zone)
// Model: p = 0.90 (very confident UP)
// Market: q = 0.20 (market disagrees)
// Brier Score: 0.21 (good calibration, tier 3)
// Prediction count: 180
const drawdown = drawdownTracker . getState ()
console . log ( drawdown )
/*
{
bankroll: 78,
highWaterMark: 100,
drawdownPct: 0.22,
level: 'red',
tradeCount: 45,
winCount: 24
}
*/
const adjustments = drawdownTracker . getAdjustments ()
console . log ( adjustments )
/*
{
alphaMultiplier: 0,
minEVOverride: null,
suspend: true // ← RED ZONE: NO TRADING
}
*/
// Attempt to calculate bet
const betResult = positionSizer . calculateBet ({
p: 0.90 ,
q: 0.20 ,
bankroll: 78 ,
brierScore: 0.21 ,
predictionCount: 180 ,
adjustments
})
console . log ( betResult )
/*
{
bet: 0, // ← SUSPENDED
fullKelly: 0.875,
alpha: 0, // ← alphaMultiplier = 0 from red zone
fractionalKelly: 0,
capped: false,
side: 'YES'
}
*/
// Main loop detects suspension and abstains
if ( adjustments . suspend ) {
abstention = { abstained: true , reason: 'drawdown_suspended' , ... }
prediction = null
}
Recovery Path
To exit red/critical zone:
Wait for green intervals - Let winning intervals (if any) accumulate
Bankroll must recover - Cross back above 20% drawdown threshold
High-water mark resets - New peak bankroll unlocks full sizing
Example recovery:
Interval 1: Bankroll = $78, Drawdown = 22%, Level = RED (suspended)
→ Winning interval (no bet, but model was correct)
Interval 2: Bankroll = $78, Drawdown = 22%, Level = RED (suspended)
→ Another winning interval
Interval 3: External deposit of $5 → Bankroll = $83
→ Drawdown = (100 - 83) / 100 = 17%, Level = YELLOW (half alpha)
Interval 4: $2 winning trade → Bankroll = $83 + $2×0.97 = $84.94
→ Drawdown = 15%, Level = YELLOW
Interval 5: $3 winning trade → Bankroll = $87.85
→ Drawdown = 12%, Level = YELLOW
Interval 6: $4 winning trade → Bankroll = $91.73
→ Drawdown = 8%, Level = GREEN (full alpha restored)
Interval 7: New high-water mark → Bankroll = $105.20
→ High-water mark = $105.20, Drawdown = 0%
Configuration Tuning
Conservative (Risk-Averse)
{
"risk" : {
"maxBetPct" : 0.02 ,
"kellyFraction" : 0.10 ,
"drawdown" : {
"yellowPct" : 0.05 ,
"redPct" : 0.10 ,
"criticalPct" : 0.15
},
"brierTiers" : [
{ "maxBrier" : 1.0 , "minPredictions" : 200 , "alpha" : 0.05 },
{ "maxBrier" : 0.22 , "minPredictions" : 200 , "alpha" : 0.10 },
{ "maxBrier" : 0.18 , "minPredictions" : 200 , "alpha" : 0.15 },
{ "maxBrier" : 0.15 , "minPredictions" : 200 , "alpha" : 0.20 }
]
}
}
Max bet: 2% (vs 5% default)
Yellow at 5% drawdown (vs 10%)
Requires 200 predictions before trading (vs 100)
Max alpha = 0.20 (vs 0.40)
Aggressive (Risk-Seeking)
{
"risk" : {
"maxBetPct" : 0.10 ,
"kellyFraction" : 0.50 ,
"drawdown" : {
"yellowPct" : 0.20 ,
"redPct" : 0.35 ,
"criticalPct" : 0.50
},
"brierTiers" : [
{ "maxBrier" : 1.0 , "minPredictions" : 50 , "alpha" : 0.20 },
{ "maxBrier" : 0.26 , "minPredictions" : 50 , "alpha" : 0.30 },
{ "maxBrier" : 0.22 , "minPredictions" : 50 , "alpha" : 0.40 },
{ "maxBrier" : 0.18 , "minPredictions" : 50 , "alpha" : 0.60 }
]
}
}
Max bet: 10% (vs 5% default)
Yellow at 20% drawdown (vs 10%)
Requires only 50 predictions (vs 100)
Max alpha = 0.60 (vs 0.40)
Aggressive settings can lead to 50%+ drawdowns in unfavorable streaks.
Next Steps
Abstention System Learn when the system refuses to trade despite having an edge
How Predictions Work Understand the probability models that feed into risk calculations