Risk Management Rules for Automated Prediction Market Bots

April 21, 2026 · 14 min read

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

  1. Hard cap on position size (per trade)
  2. Hard cap on total simultaneous exposure
  3. Per-game exposure cap (correlation control)
  4. Daily loss limit (halt on drawdown)
  5. Rolling win-rate circuit breaker (halt on model drift)
  6. Maximum concurrent open positions
  7. 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
Real-world example: an MLB game where our model evaluated 50 signals on the same matchup over the course of 3 hours. Without a per-game cap, we might have entered 10 of them with equal confidence and taken a coordinated loss when the model was wrong about the starting pitcher's stuff. With the cap, maximum damage from that one game is 3% of bankroll.

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

RuleTypical valuePrevents
Per-trade cap5% of bankrollBug in sizing function
Total exposure cap15-20%Portfolio correlation risk
Per-game cap3%Single-game model error
Daily loss limit4%Single-day disaster
Rolling WR breakerWR < 45% over 20 tradesSlow model drift
Max positions20 concurrentState management chaos
No-trade windowsPregame, late close, injuriesOut-of-distribution inputs
The real test: run your bot in paper mode for a month and check how often each rule fires. If a rule never fires, it's not doing anything. If one fires every day, it's too tight. Calibrate.

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 ZenHodl

Further reading: Kelly Criterion for Sizing · Reading Order Books · Finding Edge in Prediction Markets