Edge finds the trade. Risk management keeps you in the game long enough for edge to matter. Most retail traders obsess over strategy and ignore risk — then discover that one day of bad trades or one software bug vaporizes six months of profit.
This post catalogs seven risk management rules every automated prediction market bot must enforce before a live deploy. Each rule is presented with the failure it prevents and concrete Python code to implement it.
The Seven Rules
- Hard cap on position size (per trade)
- Hard cap on total simultaneous exposure
- Per-game exposure cap (correlation control)
- Daily loss limit (halt on drawdown)
- Rolling win-rate circuit breaker (halt on model drift)
- Maximum concurrent open positions
- Stop-trading window during known regime shifts
Rule 1 Hard Cap on Position Size
Prevents: one bug in your sizing function draining your bankroll on a single trade.
Even if you use Kelly sizing with all the right formulas, a numerical bug or an extreme input (fair_prob = 0.99, market = 0.01) can produce a Kelly suggestion of "stake everything." A hard cap in code stops this from mattering.
MAX_STAKE_PCT = 0.05 # 5% hard cap per trade, regardless of Kelly
MIN_STAKE_USD = 1.00 # skip trades below $1 — not worth the fees
def safe_stake(bankroll: float, kelly_suggested_pct: float) -> float:
"""Apply hard cap and minimum after Kelly computation."""
pct = min(kelly_suggested_pct, MAX_STAKE_PCT)
stake = bankroll * pct
if stake < MIN_STAKE_USD:
return 0.0
return stake
Rule 2 Total Simultaneous Exposure Cap
Prevents: 10 simultaneous positions each "within limits" combining into a portfolio-level disaster.
Rule 1 controls individual positions. Rule 2 controls the portfolio. Even if every single position is within your per-trade cap, having 20 of them open at once exposes you to massively correlated downside. Cap total open exposure at 15-20% of bankroll.
MAX_CONCURRENT_EXPOSURE_PCT = 0.15 # 15% of bankroll across all open positions
def can_open_new_position(bankroll: float, open_positions: list, new_stake: float) -> bool:
current_exposure = sum(p.stake_usd for p in open_positions if not p.settled)
new_total = current_exposure + new_stake
return (new_total / bankroll) <= MAX_CONCURRENT_EXPOSURE_PCT
Rule 3 Per-Game Exposure Cap
Prevents: correlated loss on a single game nuking your session.
If your model is wrong about a specific game — roster change, injury you didn't know about, weather impact — every position on that game loses together. Cap exposure per game to ~3% of bankroll regardless of how many signals fire.
MAX_EXPOSURE_PER_GAME_PCT = 0.03 # 3% per game
def can_enter_game(bankroll: float, open_positions: list,
game_id: str, new_stake: float) -> bool:
game_exposure = sum(
p.stake_usd for p in open_positions
if p.game_id == game_id and not p.settled
)
return ((game_exposure + new_stake) / bankroll) <= MAX_EXPOSURE_PER_GAME_PCT
Rule 4 Daily Loss Limit (Halt on Drawdown)
Prevents: a bad day compounding into revenge-trading or model-death-spiral.
When you're down 4-5% in a day, your natural emotional response — even via code, weirdly — is to take more risk to "make it back." Don't. Set a hard daily loss limit, and have the bot halt automatically when it's hit.
DAILY_LOSS_LIMIT_PCT = 0.04 # 4% daily drawdown triggers halt
class DailyLossCircuitBreaker:
def __init__(self, starting_bankroll: float):
self.session_start_bankroll = starting_bankroll
self.session_pnl = 0.0
self.tripped = False
def record_settlement(self, pnl_usd: float):
self.session_pnl += pnl_usd
if self.session_pnl < -DAILY_LOSS_LIMIT_PCT * self.session_start_bankroll:
self.tripped = True
logger.critical(f"CIRCUIT BREAKER: daily loss {self.session_pnl:+.2f}")
def can_trade(self) -> bool:
return not self.tripped
When the circuit breaker trips, the bot stops entering new positions but lets existing ones settle naturally. This prevents you from locking in losses by force-closing winners.
Rule 5 Rolling Win-Rate Circuit Breaker
Prevents: silent model drift quietly accumulating losses for days before you notice.
Daily loss limits catch one-day disasters. But a miscalibrated model can bleed slowly — 2% per day for 10 days. Each individual day stays under your limit; the total is a 20% drawdown.
Solution: track rolling win rate over the last N trades. If it drops below a threshold that suggests model drift, halt.
from collections import deque
class WinRateCircuitBreaker:
def __init__(self, window_n: int = 20, min_wr: float = 0.45):
self.outcomes = deque(maxlen=window_n)
self.min_wr = min_wr
def record(self, won: bool):
self.outcomes.append(1 if won else 0)
def check(self) -> tuple[bool, float]:
if len(self.outcomes) < self.outcomes.maxlen:
return True, 0.5 # not enough data
wr = sum(self.outcomes) / len(self.outcomes)
return wr >= self.min_wr, wr
def can_trade(self) -> bool:
ok, wr = self.check()
if not ok:
logger.warning(f"Rolling WR circuit breaker: wr={wr:.1%}")
return ok
Typical thresholds for a calibrated bot targeting 60-65% WR: halt if rolling WR over last 20 trades falls below 45%. That's a clear signal that something has shifted from training distribution.
Rule 6 Max Concurrent Positions
Prevents: runaway signal loops and "death by a thousand cuts" from micro-positions.
Hard ceiling on number of simultaneously open positions. Even if individual positions are small, 100 positions is a lot of state to manage and a lot of correlated paths to manage.
MAX_CONCURRENT_POSITIONS = 20
def can_open(open_positions: list) -> bool:
active = sum(1 for p in open_positions if not p.settled)
return active < MAX_CONCURRENT_POSITIONS
Rule 7 Stop-Trading Windows
Prevents: trading into known regime shifts your model wasn't trained on.
Some market conditions you know are outside your model's training distribution. Examples from prediction markets:
- Pregame minutes. First 2 minutes of a game have few informative game-state features; model accuracy is near pregame-prior levels. Don't trade.
- Last 30 seconds of close games. Volatility explodes, spreads widen 5x, model isn't calibrated for end-game buzzer-beaters. Don't trade.
- Major injury or ejection news arriving mid-game. Market adjusts before your score-based model notices. Trade half-size for 2 minutes after any score pause > 90 seconds.
- First week of a new season. Rosters and team chemistry aren't established. Reduce size until 2 weeks of games are in the books.
def in_no_trade_window(game_state: GameState) -> bool:
# Pregame first 2 minutes
if game_state.seconds_elapsed < 120:
return True
# Last 30 seconds of close games (margin ≤ 5 pts)
if game_state.seconds_remaining < 30 and abs(game_state.score_diff) <= 5:
return True
# Long score pause (injury, review)
if (time.time() - game_state.last_score_change_ts) > 90:
return True
return False
Putting It All Together
A single entry-check function combining all rules:
class RiskGate:
def __init__(self, bankroll: float):
self.bankroll = bankroll
self.daily_breaker = DailyLossCircuitBreaker(bankroll)
self.wr_breaker = WinRateCircuitBreaker(window_n=20, min_wr=0.45)
def should_enter(self, stake_usd: float, game_id: str,
game_state: GameState,
open_positions: list) -> tuple[bool, str]:
if not self.daily_breaker.can_trade():
return False, "daily_loss_limit_hit"
if not self.wr_breaker.can_trade():
return False, "rolling_wr_circuit_breaker"
if not can_open(open_positions):
return False, "max_concurrent_positions"
if not can_open_new_position(self.bankroll, open_positions, stake_usd):
return False, "total_exposure_cap"
if not can_enter_game(self.bankroll, open_positions, game_id, stake_usd):
return False, "per_game_exposure_cap"
if in_no_trade_window(game_state):
return False, "no_trade_window"
if stake_usd > self.bankroll * MAX_STAKE_PCT:
return False, "per_trade_hard_cap"
return True, "ok"
Summary Table
| Rule | Typical value | Prevents |
|---|---|---|
| Per-trade cap | 5% of bankroll | Bug in sizing function |
| Total exposure cap | 15-20% | Portfolio correlation risk |
| Per-game cap | 3% | Single-game model error |
| Daily loss limit | 4% | Single-day disaster |
| Rolling WR breaker | WR < 45% over 20 trades | Slow model drift |
| Max positions | 20 concurrent | State management chaos |
| No-trade windows | Pregame, late close, injuries | Out-of-distribution inputs |
Start with a system that has risk management built-in. ZenHodl's API ships with documented per-trade, per-game, and per-session risk controls — no need to rediscover these rules through painful drawdowns.
See ZenHodlFurther reading: Kelly Criterion for Sizing · Reading Order Books · Finding Edge in Prediction Markets