one of the dumbest things i did in 2023 was running fixed position sizes. every trade was the same size regardless of conviction, volatility, or recent performance. looking back it’s obvious why i hemorrhaged $180k - i was sizing up the same during high-vol crashes as during calm trending markets.
rebuilt my position sizing from scratch using a modified Kelly criterion that adapts to the current market regime. been running it live for 6 weeks and the risk-adjusted returns are noticeably better.
why fixed sizing is broken #
fixed sizing (e.g., “always risk 1% per trade”) sounds disciplined but it ignores crucial information:
- signal strength varies - a screaming signal should get more capital than a marginal one
- volatility changes - 1% risk in VIX 12 is wildly different from 1% risk in VIX 35
- recent performance matters - after a drawdown, you should size down (both mathematically and psychologically)
- correlation between positions - 5 positions at 1% each isn’t 5% risk if they’re all correlated
Kelly criterion addresses the first two. combining it with regime detection handles the rest.
kelly criterion basics #
for anyone who hasn’t seen it - Kelly gives you the optimal bet size to maximize long-term growth rate:
f* = (p * b - q) / b
where:
f*= fraction of bankroll to betp= probability of winningb= ratio of win size to loss sizeq= probability of losing (1 - p)
problem: full Kelly is AGGRESSIVE. like, terrifyingly aggressive. most practitioners use fractional Kelly (usually 0.25 to 0.5 of full Kelly) to reduce variance.
here’s my implementation:
import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
class MarketRegime(Enum):
LOW_VOL_TRENDING = "low_vol_trending"
LOW_VOL_RANGING = "low_vol_ranging"
HIGH_VOL_TRENDING = "high_vol_trending"
HIGH_VOL_RANGING = "high_vol_ranging"
CRISIS = "crisis"
@dataclass
class RegimeState:
regime: MarketRegime
confidence: float # 0 to 1
vol_percentile: float
trend_strength: float
detected_at: datetime
@property
def kelly_multiplier(self) -> float:
"""adjust kelly fraction based on regime"""
multipliers = {
MarketRegime.LOW_VOL_TRENDING: 0.50, # full fractional kelly
MarketRegime.LOW_VOL_RANGING: 0.35, # reduce - low edge in chop
MarketRegime.HIGH_VOL_TRENDING: 0.30, # careful - bigger moves both ways
MarketRegime.HIGH_VOL_RANGING: 0.20, # very conservative
MarketRegime.CRISIS: 0.10, # survival mode
}
base = multipliers.get(self.regime, 0.25)
# scale by confidence
return base * (0.5 + 0.5 * self.confidence)
class RegimeDetector:
"""
classifies current market regime using
volatility percentile + trend strength
"""
def __init__(self, lookback_days: int = 252):
self.lookback = lookback_days
self._history: list[RegimeState] = []
def detect(self, prices: pd.Series) -> RegimeState:
if len(prices) < self.lookback:
return RegimeState(
regime=MarketRegime.LOW_VOL_RANGING,
confidence=0.3,
vol_percentile=0.5,
trend_strength=0.0,
detected_at=datetime.now(),
)
log_returns = np.log(prices / prices.shift(1)).dropna()
# volatility assessment
current_vol = log_returns.iloc[-21:].std() * np.sqrt(252)
hist_vols = log_returns.rolling(21).std().dropna() * np.sqrt(252)
vol_pct = (hist_vols < current_vol).mean()
# trend assessment using ADX-like measure
# positive returns momentum
up_days = (log_returns.iloc[-21:] > 0).sum()
trend_bias = (up_days / 21 - 0.5) * 2 # -1 to 1
# directional movement
abs_return_21d = abs(prices.iloc[-1] / prices.iloc[-22] - 1)
avg_abs_return = abs(prices / prices.shift(21) - 1).dropna().mean()
trend_strength = abs_return_21d / (avg_abs_return + 1e-8)
trend_strength = min(trend_strength, 2.0) / 2.0 # normalize 0-1
# classify
is_high_vol = vol_pct > 0.7
is_trending = trend_strength > 0.5
# crisis detection
if vol_pct > 0.95 and current_vol > 0.30:
regime = MarketRegime.CRISIS
confidence = min(vol_pct, 0.95)
elif is_high_vol and is_trending:
regime = MarketRegime.HIGH_VOL_TRENDING
confidence = (vol_pct + trend_strength) / 2
elif is_high_vol:
regime = MarketRegime.HIGH_VOL_RANGING
confidence = vol_pct
elif is_trending:
regime = MarketRegime.LOW_VOL_TRENDING
confidence = trend_strength
else:
regime = MarketRegime.LOW_VOL_RANGING
confidence = 1 - trend_strength
state = RegimeState(
regime=regime,
confidence=confidence,
vol_percentile=vol_pct,
trend_strength=trend_strength,
detected_at=datetime.now(),
)
self._history.append(state)
return state
def regime_stability(self, window: int = 10) -> float:
"""how stable has the regime been recently (0-1)"""
if len(self._history) < window:
return 0.5
recent = self._history[-window:]
current = recent[-1].regime
agreement = sum(1 for s in recent if s.regime == current) / window
return agreement
@dataclass
class PositionSizeResult:
raw_kelly: float
fractional_kelly: float
regime_adjusted: float
drawdown_adjusted: float
final_size: float # this is what we actually use
max_position_pct: float
signal_strength: float
regime: MarketRegime
reasoning: dict = field(default_factory=dict)
class DynamicPositionSizer:
"""
kelly criterion with regime detection and drawdown scaling
"""
def __init__(
self,
account_size: float,
max_position_pct: float = 0.05, # never more than 5% on one trade
max_portfolio_heat: float = 0.15, # never more than 15% total exposure
drawdown_scale_start: float = 0.05, # start scaling at 5% DD
drawdown_scale_max: float = 0.15, # fully scaled at 15% DD
):
self.account_size = account_size
self.max_position_pct = max_position_pct
self.max_portfolio_heat = max_portfolio_heat
self.dd_scale_start = drawdown_scale_start
self.dd_scale_max = drawdown_scale_max
self.regime_detector = RegimeDetector()
self._trade_history: list[dict] = []
self._peak_equity = account_size
self._current_equity = account_size
def update_equity(self, current_equity: float):
self._current_equity = current_equity
self._peak_equity = max(self._peak_equity, current_equity)
@property
def current_drawdown(self) -> float:
if self._peak_equity == 0:
return 0
return (self._peak_equity - self._current_equity) / self._peak_equity
def _drawdown_multiplier(self) -> float:
"""scale position size down during drawdowns"""
dd = self.current_drawdown
if dd <= self.dd_scale_start:
return 1.0 # no scaling needed
elif dd >= self.dd_scale_max:
return 0.25 # minimum 25% of normal size
else:
# linear scale between start and max
progress = (dd - self.dd_scale_start) / (self.dd_scale_max - self.dd_scale_start)
return 1.0 - (progress * 0.75)
def calculate(
self,
win_rate: float,
avg_win: float,
avg_loss: float,
signal_strength: float,
prices: pd.Series,
current_exposure_pct: float = 0.0,
) -> PositionSizeResult:
"""
calculate position size incorporating:
- kelly criterion
- regime detection
- drawdown scaling
- signal strength
- portfolio heat
"""
# step 1: raw kelly
if avg_loss == 0:
raw_kelly = 0.0
else:
b = avg_win / avg_loss
p = win_rate
q = 1 - p
raw_kelly = max(0, (p * b - q) / b)
# step 2: regime detection
regime_state = self.regime_detector.detect(prices)
fractional_kelly = raw_kelly * regime_state.kelly_multiplier
# step 3: signal strength scaling
# stronger signals get closer to full fractional kelly
# weak signals get reduced further
signal_mult = 0.3 + 0.7 * signal_strength # 0.3 to 1.0
regime_adjusted = fractional_kelly * signal_mult
# step 4: drawdown scaling
dd_mult = self._drawdown_multiplier()
drawdown_adjusted = regime_adjusted * dd_mult
# step 5: apply caps
available_heat = max(0, self.max_portfolio_heat - current_exposure_pct)
final_pct = min(drawdown_adjusted, self.max_position_pct, available_heat)
final_size = self._current_equity * final_pct
result = PositionSizeResult(
raw_kelly=raw_kelly,
fractional_kelly=fractional_kelly,
regime_adjusted=regime_adjusted,
drawdown_adjusted=drawdown_adjusted,
final_size=final_size,
max_position_pct=self.max_position_pct,
signal_strength=signal_strength,
regime=regime_state.regime,
reasoning={
"account_equity": self._current_equity,
"current_drawdown_pct": round(self.current_drawdown * 100, 2),
"dd_multiplier": round(dd_mult, 3),
"regime_kelly_mult": round(regime_state.kelly_multiplier, 3),
"signal_multiplier": round(signal_mult, 3),
"vol_percentile": round(regime_state.vol_percentile, 3),
"trend_strength": round(regime_state.trend_strength, 3),
"portfolio_heat": round(current_exposure_pct * 100, 2),
"available_heat": round(available_heat * 100, 2),
},
)
logger.info(
f"POSITION SIZE: raw_kelly={raw_kelly:.3f} "
f"-> regime_adj={regime_adjusted:.3f} "
f"-> dd_adj={drawdown_adjusted:.3f} "
f"-> final=${final_size:,.0f} "
f"({regime_state.regime.value}, "
f"DD={self.current_drawdown*100:.1f}%)"
)
return result
def add_trade_result(self, pnl: float, strategy: str):
"""track trade results for performance statistics"""
self._trade_history.append({
"pnl": pnl,
"strategy": strategy,
"timestamp": datetime.now(),
})
self.update_equity(self._current_equity + pnl)
def get_strategy_stats(
self, strategy: str, lookback_trades: int = 50
) -> dict:
"""rolling statistics for kelly inputs"""
trades = [
t for t in self._trade_history
if t["strategy"] == strategy
][-lookback_trades:]
if len(trades) < 10:
return {"win_rate": 0.5, "avg_win": 0, "avg_loss": 0, "n_trades": len(trades)}
wins = [t["pnl"] for t in trades if t["pnl"] > 0]
losses = [t["pnl"] for t in trades if t["pnl"] <= 0]
return {
"win_rate": len(wins) / len(trades),
"avg_win": np.mean(wins) if wins else 0,
"avg_loss": abs(np.mean(losses)) if losses else 0,
"n_trades": len(trades),
"max_win": max(wins) if wins else 0,
"max_loss": min(losses) if losses else 0,
"profit_factor": (
(sum(wins) / abs(sum(losses)))
if losses and sum(losses) != 0
else float("inf")
),
}
regime impact visualization #
this is from my live data - shows how the position sizer adapts to different market conditions:
look at the crisis period (around day 50-60). raw kelly says “bet 12-18%!” because win rate and payoff ratio look fine historically. but the regime-adjusted sizing drops to 1-2%. that’s the whole point - historical kelly estimates are garbage during regime changes because the distribution of outcomes shifts.
drawdown scaling in action #
the drawdown scaling is simple but effective. at 0-5% drawdown, no adjustment. from 5-15%, linear reduction down to 25% of normal size. beyond 15%, stay at minimum 25%.
this prevents the death spiral where you lose money, keep same position size, lose more money, bigger drawdown, now you need a 30% return just to get back to even.
real results comparison #
ran a comparison: same strategies over the same 6-month backtest period (July-Dec 2025), one with fixed 2% sizing and one with dynamic kelly sizing:
| metric | fixed 2% | dynamic kelly |
|---|---|---|
| total return | +14.2% | +16.8% |
| max drawdown | -11.4% | -7.2% |
| sharpe ratio | 1.31 | 1.89 |
| sortino ratio | 1.72 | 2.54 |
| avg trade size | $24,000 | $8k-$36k |
| win rate | 54% | 54% |
same win rate (obviously - sizing doesn’t affect signal quality) but way better risk-adjusted returns. the max drawdown improvement is the real win. 7.2% vs 11.4% means i sleep better and have more capital to deploy when the opportunity is best.
been talking about kelly criterion adaptations on NexusFi’s risk management threads and some of the institutional guys there use even more sophisticated approaches. but for a retail algo trader, this implementation captures 80% of the benefit.
the key takeaway: position sizing is at least as important as signal generation. a mediocre signal with great sizing beats a great signal with bad sizing every time. took me $180k and most of 2023 to learn that.
-AK