Strategy · 7 min read

Sizing positions with drawdown caps

A drawdown-aware Kelly variant that halves position size after a losing streak, with the exact pattern used in poly15m-v6.

The problem with static position sizing

Kelly criterion gives you an optimal fraction of your bankroll per trade assuming your edge estimate is correct. But signal quality varies over time. During choppy markets or regimes your model was not trained on, your actual win rate drops below the estimate, and full Kelly can quickly wipe out 30-50% of your bankroll before you notice the regime change.

Drawdown caps are a mechanical safeguard: reduce size when recent results suggest the edge has degraded, restore it when results normalize.

The trailing loss counter

The simplest version: count the last N trade outcomes. If more than K of them are losses, apply a multiplier less than 1 to your normal Kelly fraction.

interface TradeResult {
  won: boolean
  timestamp: number
}

function drawdownMultiplier(
  recentTrades: TradeResult[],
  windowSize = 10,
  lossThreshold = 6,
): number {
  if (recentTrades.length < windowSize) return 1.0  // not enough data, full size
  const window = recentTrades.slice(-windowSize)
  const losses = window.filter((t) => !t.won).length
  if (losses >= lossThreshold) return 0.5   // halve the size
  return 1.0
}

This is the pattern used in poly15m-v6: after 6 or more losses in the last 10 trades, every new position is half the normal Kelly size.

The recovery condition

Once the drawdown multiplier kicks in, when do you restore full size? Two common approaches:

  1. Time-based: after a fixed cooldown period (e.g. 30 minutes), regardless of subsequent results.
  2. Result-based: once the trailing window returns below the loss threshold.

The result-based approach is more adaptive. Here is both options:

function shouldRestoreFullSize(
  recentTrades: TradeResult[],
  windowSize = 10,
  lossThreshold = 6,
  cooldownMs = 30 * 60 * 1000,
  lastReductionAt?: number,
): boolean {
  // Time-based
  if (lastReductionAt && Date.now() - lastReductionAt > cooldownMs) return true
  // Result-based
  const window = recentTrades.slice(-windowSize)
  const losses = window.filter((t) => !t.won).length
  return losses < lossThreshold
}

Full sizing pipeline with drawdown

Combining Kelly, quarter-Kelly, and the drawdown multiplier:

const KELLY_FRACTION = 0.25
const MIN_BET_USD = 2

function computeSize(
  bankroll: number,
  estimatedProb: number,
  tokenPrice: number,
  recentTrades: TradeResult[],
): number {
  const kellyFull = kellyWithFee(estimatedProb, tokenPrice)
  const ddMultiplier = drawdownMultiplier(recentTrades)
  const dollars = bankroll * kellyFull * KELLY_FRACTION * ddMultiplier
  return Math.max(dollars, MIN_BET_USD)
}

Per-bot balance caps

When running multiple bots on one wallet, you also need a hard per-bot balance cap independent of Kelly. This prevents one bot from consuming the entire wallet in a good streak, leaving nothing for the others.

const BOT_MAX_POSITION_USD = 20  // absolute ceiling

function cappedSize(
  kellyDollars: number,
  currentExposure: number,
  maxExposure = BOT_MAX_POSITION_USD,
): number {
  const available = Math.max(0, maxExposure - currentExposure)
  return Math.min(kellyDollars, available)
}

Logging the multiplier

Always log the drawdown multiplier alongside each trade. When reviewing performance, you want to distinguish "the bot had a signal but reduced size due to drawdown" from "the bot had no signal". Otherwise, the P&L during drawdown looks worse per-trade than it actually is relative to the risk taken.

function logTrade(shares: number, price: number, ddMultiplier: number) {
  const tag = ddMultiplier < 1 ? ' [DD-REDUCED]' : ''
  console.log(
    `[${new Date().toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' })}] ` +
    `BUY ${shares} shares @ $${price.toFixed(2)}${tag}`
  )
}

Regime detection vs drawdown caps

Drawdown caps are reactive: they respond to losses after the fact. A more proactive approach is to add a chop filter (volume imbalance or ATR threshold) that skips trades when the market regime does not suit the strategy. Drawdown caps and regime filters are complementary -- use both.

Summary

  • Count losses in a trailing window of 10 trades; halve size if 6 or more are losses.
  • Restore full size either after a 30-minute cooldown or when the window improves.
  • Layer the drawdown multiplier on top of fee-adjusted quarter-Kelly.
  • Add a per-bot hard cap to protect wallet balance when running multiple strategies.

Related bots