Skip to main content

Overview

The risk management system combines three layers:
  1. Fractional Kelly Criterion - Optimal bet sizing based on edge and accuracy
  2. Brier-Tiered Alpha - Dynamic Kelly fraction tied to model calibration quality
  3. 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:
TierBrier RangeMin PredictionsAlphaInterpretation
0Any0 - 990.00Insufficient data (no trading)
1> 0.26100+0.10Poor calibration (tenth-Kelly)
20.22 - 0.26100+0.20Mediocre calibration (fifth-Kelly)
30.18 - 0.22100+0.25Good calibration (quarter-Kelly)
4< 0.18100+0.40Excellent 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
}
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.

Position Sizing Formula

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
LevelDrawdown RangeAlpha MultiplierMin EV OverrideSuspend Trading
Green0 - 10%1.0NoneNo
Yellow10 - 20%0.510% (vs 5%)No
Red20 - 30%0.0N/AYes
Critical30%+0.0N/AYes
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 10betthatwinsreturns10 bet that wins returns 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:
  1. Wait for green intervals - Let winning intervals (if any) accumulate
  2. Bankroll must recover - Cross back above 20% drawdown threshold
  3. 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

{
  "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)
{
  "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

Build docs developers (and LLMs) love