Skip to main content

dynamic position sizing - kelly criterion meets regime detection

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 bet
  • p = probability of winning
  • b = ratio of win size to loss size
  • q = 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

Related

risk management - position sizing with kelly criterion in python
position sizing = most important part of algo trading. kelly criterion = mathematically optimal. python implementation. the problem # fixed position sizing:
adaptive position sizing - regime-based approach
position sizing makes or breaks algo trading. been refining adaptive approach last 6 months. finally working consistently. the problem with static sizing # most algo traders:
adaptive stop losses - why fixed stops are leaving money on the table
2:30am wednesday. been refactoring my exit logic all week. fixed stop losses are lazy. there I said it. the problem with fixed stops # “just use a 2% stop loss.”
monte carlo backtesting - why single backtest runs lie to you
2:30am wednesday. ran a single backtest last week. looked incredible. sharpe of 2.4. max drawdown 8%. then I ran 10,000 of them. reality check. the problem with one backtest # you run a backtest. it returns +22% over 2 years.
cross-asset correlation tracking - why diversification is a lie
1:30am and i’m staring at correlation matrices again. everyone talks about diversification like it’s free lunch. it’s not. the diversification myth # portfolios are “diversified” until they’re not.
mean reversion implementation - statistical edge in practice
finally deploying the mean reversion algo I’ve been backtesting since june. 6 months of development. time to go live. the edge # simple concept: prices that deviate from their mean tend to revert.