Skip to main content

position sizing with kelly criterion - python implementation

rebuilt my position sizing engine last weekend.

kelly criterion with practical modifications.

the problem
#

old approach:

fixed 2% risk per trade.

same size regardless of edge quality.

leaving money on table on high-confidence setups.

new approach:

kelly-based sizing adjusted by confidence.

scale up on high-edge setups.

scale down on marginal setups.

kelly criterion basics
#

kelly % = (win_rate * avg_win - (1 - win_rate) * avg_loss) / avg_win

example:

win rate: 65%

avg win: $500

avg loss: $300

kelly = (0.65 * 500 - 0.35 * 300) / 500 = 0.44 = 44%

problem: 44% is way too aggressive.

practical kelly uses fractional approach.

my implementation
#

import numpy as np
from dataclasses import dataclass
from typing import Optional

@dataclass
class TradeSetup:
    symbol: str
    direction: str  # 'long' or 'short'
    entry_price: float
    stop_loss: float
    take_profit: float
    confidence: float  # 0.0 to 1.0
    strategy_win_rate: float
    strategy_avg_win: float
    strategy_avg_loss: float

class KellyPositionSizer:
    def __init__(
        self,
        account_size: float,
        max_position_pct: float = 0.05,  # 5% max
        kelly_fraction: float = 0.25,     # quarter kelly
        min_position_pct: float = 0.005,  # 0.5% min
        max_daily_risk: float = 0.02      # 2% daily max
    ):
        self.account_size = account_size
        self.max_position_pct = max_position_pct
        self.kelly_fraction = kelly_fraction
        self.min_position_pct = min_position_pct
        self.max_daily_risk = max_daily_risk
        self.daily_risk_used = 0.0

    def calculate_kelly(self, setup: TradeSetup) -> float:
        """Calculate raw kelly percentage"""
        p = setup.strategy_win_rate
        w = setup.strategy_avg_win
        l = setup.strategy_avg_loss

        if w <= 0 or l <= 0:
            return 0.0

        kelly = (p * w - (1 - p) * l) / w

        # kelly can be negative (don't trade)
        return max(0.0, kelly)

    def calculate_position_size(
        self,
        setup: TradeSetup,
        current_positions: int = 0
    ) -> dict:
        """Calculate position size with all adjustments"""

        # 1. raw kelly
        raw_kelly = self.calculate_kelly(setup)

        if raw_kelly <= 0:
            return {
                'shares': 0,
                'dollars': 0,
                'risk_pct': 0,
                'reason': 'negative_edge'
            }

        # 2. apply fractional kelly
        fractional_kelly = raw_kelly * self.kelly_fraction

        # 3. confidence adjustment
        confidence_adjusted = fractional_kelly * setup.confidence

        # 4. position count adjustment (reduce size with more positions)
        position_factor = 1.0 / (1 + current_positions * 0.2)
        adjusted_pct = confidence_adjusted * position_factor

        # 5. apply bounds
        bounded_pct = np.clip(
            adjusted_pct,
            self.min_position_pct,
            self.max_position_pct
        )

        # 6. daily risk check
        risk_per_trade = self._calculate_risk(setup, bounded_pct)
        remaining_daily_risk = self.max_daily_risk - self.daily_risk_used

        if risk_per_trade > remaining_daily_risk:
            # scale down to fit daily budget
            scale_factor = remaining_daily_risk / risk_per_trade
            bounded_pct *= scale_factor
            risk_per_trade = remaining_daily_risk

        # 7. calculate final values
        position_dollars = self.account_size * bounded_pct
        shares = int(position_dollars / setup.entry_price)

        return {
            'shares': shares,
            'dollars': shares * setup.entry_price,
            'risk_pct': risk_per_trade,
            'position_pct': bounded_pct,
            'raw_kelly': raw_kelly,
            'confidence_factor': setup.confidence,
            'reason': 'calculated'
        }

    def _calculate_risk(self, setup: TradeSetup, position_pct: float) -> float:
        """Calculate risk as percentage of account"""
        position_value = self.account_size * position_pct
        risk_per_share = abs(setup.entry_price - setup.stop_loss)
        shares = position_value / setup.entry_price
        total_risk = shares * risk_per_share
        return total_risk / self.account_size

    def record_trade(self, risk_pct: float):
        """Record trade risk for daily tracking"""
        self.daily_risk_used += risk_pct

    def reset_daily_risk(self):
        """Reset daily risk counter (call at market open)"""
        self.daily_risk_used = 0.0


class VolatilityAdjustedSizer(KellyPositionSizer):
    """Kelly sizer with volatility adjustment"""

    def __init__(self, *args, target_vol: float = 0.02, **kwargs):
        super().__init__(*args, **kwargs)
        self.target_vol = target_vol

    def calculate_position_size(
        self,
        setup: TradeSetup,
        current_vol: float,  # current realized volatility
        current_positions: int = 0
    ) -> dict:

        # get base kelly size
        base_result = super().calculate_position_size(setup, current_positions)

        if base_result['shares'] == 0:
            return base_result

        # volatility adjustment
        vol_ratio = self.target_vol / current_vol if current_vol > 0 else 1.0
        vol_adjusted_pct = base_result['position_pct'] * vol_ratio

        # re-apply bounds after vol adjustment
        vol_adjusted_pct = np.clip(
            vol_adjusted_pct,
            self.min_position_pct,
            self.max_position_pct
        )

        position_dollars = self.account_size * vol_adjusted_pct
        shares = int(position_dollars / setup.entry_price)

        return {
            **base_result,
            'shares': shares,
            'dollars': shares * setup.entry_price,
            'position_pct': vol_adjusted_pct,
            'vol_adjustment': vol_ratio
        }

usage example
#

# initialize sizer
sizer = VolatilityAdjustedSizer(
    account_size=460000,
    max_position_pct=0.05,
    kelly_fraction=0.25,
    target_vol=0.015
)

# define setup
setup = TradeSetup(
    symbol='SPX',
    direction='short',
    entry_price=5850,
    stop_loss=5900,
    take_profit=5750,
    confidence=0.75,
    strategy_win_rate=0.68,
    strategy_avg_win=450,
    strategy_avg_loss=280
)

# calculate size
result = sizer.calculate_position_size(
    setup,
    current_vol=0.012,
    current_positions=2
)

print(f"Position: ${result['dollars']:,.0f}")
print(f"Risk: {result['risk_pct']:.2%}")
print(f"Kelly raw: {result['raw_kelly']:.2%}")

backtest results
#

before (fixed 2%):

sharpe: 1.42

max drawdown: -8.2%

annual return: +16.4%

after (kelly-based):

sharpe: 1.58

max drawdown: -7.1%

annual return: +18.9%

improvement:

+11% sharpe improvement.

-13% drawdown reduction.

+15% return improvement.

key insights
#

1. quarter kelly is enough

full kelly too volatile.

quarter kelly captures 75% of growth with much less variance.

2. confidence matters

not all setups equal.

high-confidence = larger size.

marginal setups = minimum size.

3. volatility adjustment essential

high vol = smaller positions.

low vol = larger positions.

maintains consistent risk.

4. daily risk budget prevents disaster

2% daily max.

can’t blow up in one day regardless of kelly calculation.

tonight (june 10, 2:34am)
#

rebuilt position sizing with kelly criterion. quarter kelly + confidence adjustment + volatility scaling + daily risk budget. backtest improvement: sharpe 1.42→1.58 (+11%), max DD -8.2%→-7.1% (-13%), annual return +16.4%→+18.9% (+15%). key: quarter kelly captures 75% of growth with much less variance. confidence scaling on high-edge setups. vol adjustment maintains consistent risk.


2:34am tuesday. kelly position sizing implementation. raw kelly too aggressive, using quarter kelly. confidence adjustment scales size by setup quality. vol adjustment maintains risk consistency. daily 2% budget prevents disasters. backtest: sharpe +11%, drawdown -13%, return +15%. deployed to live trading this week.

-AK

Related

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:
thetadata review - options greeks data for algo trading
been using thetadata for 8 months now. here’s the real review for options algo traders. what is thetadata # options-focused market data provider.
vectorbt vs backtrader - python backtesting framework comparison 2025
been using both for 2+ years. here’s when to use each. quick verdict # vectorbt: fast parameter sweeps, simple strategies
redis caching optimization - 40% latency reduction for market data
optimized redis caching during honeymoon downtime review. 40% latency improvement. the problem # before optimization: market data fetch: 180ms avg
coinbase advanced vs kraken - python API comparison for algo trading
been using both coinbase and kraken for 2+ years. here’s the real comparison for algo traders. quick verdict # coinbase advanced: better for fiat on/off ramp, simpler API
pre-wedding pause - algos offline until may 20, capital protected
algos offline. wedding 8 days out. capital protected. the decision # paused all strategies may 1. manual override on everything.