Skip to main content

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:

$1,500 every trade.

ignores win rate and profit factor.

suboptimal capital allocation.

my 2023 mistake:

lost $180k using fixed sizing on losing strategies.

never adjusted for performance.

kelly criterion formula
#

full kelly:

f* = (bp - q) / b

where:

  • f* = fraction of capital to risk
  • b = odds received (avg win / avg loss)
  • p = win probability
  • q = loss probability (1 - p)

fractional kelly (what i use):

position_size = kelly_fraction * 0.25

(quarter kelly to reduce variance)

learned this conservative approach from NexusFi risk management discussions where experienced traders emphasized fractional kelly for sustainable algo trading.

python implementation
#

basic kelly calculator:

import pandas as pd
import numpy as np
from typing import Dict, List

class KellyCriterion:
    def __init__(self, trades_df: pd.DataFrame, fraction: float = 0.25):
        """
        Initialize Kelly Criterion calculator

        Args:
            trades_df: DataFrame with columns ['pnl', 'win']
            fraction: Kelly fraction (0.25 = quarter kelly)
        """
        self.trades = trades_df
        self.fraction = fraction
        self.metrics = self._calculate_metrics()

    def _calculate_metrics(self) -> Dict:
        """Calculate win rate and profit metrics"""
        wins = self.trades[self.trades['win'] == True]
        losses = self.trades[self.trades['win'] == False]

        total_trades = len(self.trades)
        win_count = len(wins)
        loss_count = len(losses)

        if total_trades == 0:
            return {'error': 'No trades provided'}

        win_rate = win_count / total_trades if total_trades > 0 else 0
        loss_rate = 1 - win_rate

        avg_win = wins['pnl'].mean() if len(wins) > 0 else 0
        avg_loss = abs(losses['pnl'].mean()) if len(losses) > 0 else 1

        # Profit factor
        total_wins = wins['pnl'].sum() if len(wins) > 0 else 0
        total_losses = abs(losses['pnl'].sum()) if len(losses) > 0 else 1
        profit_factor = total_wins / total_losses if total_losses > 0 else 0

        return {
            'win_rate': win_rate,
            'loss_rate': loss_rate,
            'avg_win': avg_win,
            'avg_loss': avg_loss,
            'profit_factor': profit_factor,
            'total_trades': total_trades
        }

    def calculate_kelly(self) -> float:
        """
        Calculate full Kelly percentage

        Returns:
            Kelly fraction as decimal (0.25 = 25% of capital)
        """
        if 'error' in self.metrics:
            return 0.0

        p = self.metrics['win_rate']
        q = self.metrics['loss_rate']

        # Avoid division by zero
        if self.metrics['avg_loss'] == 0:
            return 0.0

        b = self.metrics['avg_win'] / self.metrics['avg_loss']

        # Kelly formula: (bp - q) / b
        kelly_full = (b * p - q) / b

        # Ensure kelly is positive (negative kelly = don't trade)
        kelly_full = max(0, kelly_full)

        # Apply fraction (quarter kelly)
        kelly_fractional = kelly_full * self.fraction

        # Cap at 25% max (safety)
        kelly_fractional = min(kelly_fractional, 0.25)

        return kelly_fractional

    def get_position_size(self, account_balance: float) -> float:
        """
        Calculate position size based on Kelly

        Args:
            account_balance: Current account value

        Returns:
            Dollar amount to risk per trade
        """
        kelly_pct = self.calculate_kelly()
        position_size = account_balance * kelly_pct

        return position_size

    def get_report(self) -> Dict:
        """Generate detailed Kelly report"""
        kelly_full = self.calculate_kelly() / self.fraction
        kelly_fractional = self.calculate_kelly()

        return {
            'metrics': self.metrics,
            'kelly_full': kelly_full,
            'kelly_fractional': kelly_fractional,
            'kelly_fraction_used': self.fraction,
            'recommended_risk_pct': kelly_fractional * 100
        }

# Example usage
if __name__ == "__main__":
    # Create sample trade data
    trades_data = {
        'pnl': [520, -340, 680, 460, -280, 740, 380, -220],
        'win': [True, False, True, True, False, True, True, False]
    }
    trades_df = pd.DataFrame(trades_data)

    # Calculate Kelly
    kelly = KellyCriterion(trades_df, fraction=0.25)
    report = kelly.get_report()

    print(f"Win Rate: {report['metrics']['win_rate']:.1%}")
    print(f"Profit Factor: {report['metrics']['profit_factor']:.2f}")
    print(f"Full Kelly: {report['kelly_full']:.2%}")
    print(f"Quarter Kelly: {report['kelly_fractional']:.2%}")
    print(f"Recommended Risk: {report['recommended_risk_pct']:.2f}%")

    # Get position size for $450,000 account
    position_size = kelly.get_position_size(450000)
    print(f"Position Size: ${position_size:,.0f}")

advanced: rolling kelly calculation
#

track kelly over time as performance changes:

class RollingKelly:
    def __init__(self, trades_df: pd.DataFrame, window: int = 30, fraction: float = 0.25):
        """
        Calculate rolling Kelly criterion

        Args:
            trades_df: DataFrame with datetime index, columns ['pnl', 'win']
            window: Rolling window size (number of trades)
            fraction: Kelly fraction
        """
        self.trades = trades_df.sort_index()
        self.window = window
        self.fraction = fraction

    def calculate_rolling(self) -> pd.DataFrame:
        """Calculate Kelly for each rolling window"""
        results = []

        for i in range(self.window, len(self.trades) + 1):
            window_trades = self.trades.iloc[i - self.window:i]

            kelly_calc = KellyCriterion(window_trades, self.fraction)
            kelly_pct = kelly_calc.calculate_kelly()

            results.append({
                'date': window_trades.index[-1],
                'kelly_pct': kelly_pct,
                'win_rate': kelly_calc.metrics['win_rate'],
                'profit_factor': kelly_calc.metrics['profit_factor']
            })

        return pd.DataFrame(results)

    def plot_rolling_kelly(self, save_path: str = None):
        """Plot rolling Kelly over time"""
        import matplotlib.pyplot as plt

        rolling_df = self.calculate_rolling()

        fig, axes = plt.subplots(3, 1, figsize=(12, 10))

        # Plot Kelly percentage
        axes[0].plot(rolling_df['date'], rolling_df['kelly_pct'] * 100)
        axes[0].axhline(y=5, color='r', linestyle='--', label='5% threshold')
        axes[0].set_ylabel('Kelly %')
        axes[0].set_title(f'Rolling Kelly Criterion ({self.window}-trade window)')
        axes[0].legend()
        axes[0].grid(True, alpha=0.3)

        # Plot win rate
        axes[1].plot(rolling_df['date'], rolling_df['win_rate'] * 100, color='green')
        axes[1].axhline(y=50, color='gray', linestyle='--')
        axes[1].set_ylabel('Win Rate %')
        axes[1].grid(True, alpha=0.3)

        # Plot profit factor
        axes[2].plot(rolling_df['date'], rolling_df['profit_factor'], color='orange')
        axes[2].axhline(y=1.0, color='r', linestyle='--', label='Breakeven')
        axes[2].set_ylabel('Profit Factor')
        axes[2].set_xlabel('Date')
        axes[2].legend()
        axes[2].grid(True, alpha=0.3)

        plt.tight_layout()

        if save_path:
            plt.savefig(save_path, dpi=150)
        else:
            plt.show()

# Example usage
if __name__ == "__main__":
    # Load historical trades
    trades_df = pd.read_csv('my_trades_2024.csv', index_col='date', parse_dates=True)

    # Calculate rolling Kelly
    rolling = RollingKelly(trades_df, window=30, fraction=0.25)
    rolling_results = rolling.calculate_rolling()

    # Plot
    rolling.plot_rolling_kelly(save_path='rolling_kelly_2024.png')

my current implementation
#

monthly review process:

def monthly_kelly_review(account_balance: float, trades_csv: str):
    """
    Monthly Kelly review for position sizing adjustment

    Args:
        account_balance: Current account value
        trades_csv: Path to trades CSV file
    """
    # Load last 90 days of trades
    trades_df = pd.read_csv(trades_csv, parse_dates=['date'])
    trades_df = trades_df[trades_df['date'] > pd.Timestamp.now() - pd.Timedelta(days=90)]

    # Calculate Kelly
    kelly = KellyCriterion(trades_df, fraction=0.25)
    report = kelly.get_report()

    # Get recommended position size
    recommended_size = kelly.get_position_size(account_balance)

    # Current fixed size
    current_size = 1500

    # Analysis
    print("=== Monthly Kelly Review ===")
    print(f"Account Balance: ${account_balance:,.0f}")
    print(f"Current Position Size: ${current_size:,.0f}")
    print(f"")
    print(f"Last 90 Days Performance:")
    print(f"  Trades: {report['metrics']['total_trades']}")
    print(f"  Win Rate: {report['metrics']['win_rate']:.1%}")
    print(f"  Profit Factor: {report['metrics']['profit_factor']:.2f}")
    print(f"")
    print(f"Kelly Analysis:")
    print(f"  Full Kelly: {report['kelly_full']:.2%}")
    print(f"  Quarter Kelly: {report['kelly_fractional']:.2%}")
    print(f"  Recommended Size: ${recommended_size:,.0f}")
    print(f"")

    # Decision
    if recommended_size > current_size * 1.2:
        print("⚠️ RECOMMENDED: Increase position size")
        print(f"   New size: ${recommended_size:,.0f}")
    elif recommended_size < current_size * 0.8:
        print("⚠️ RECOMMENDED: Decrease position size")
        print(f"   New size: ${recommended_size:,.0f}")
    else:
        print("✓ Current position size within optimal range")

    return report

# Run monthly review
if __name__ == "__main__":
    monthly_kelly_review(
        account_balance=449490,
        trades_csv='trades_2025.csv'
    )

my actual numbers february 2025
#

account: $449,490

last 90 days (nov-feb):

  • trades: 87
  • win rate: 72%
  • avg win: $580
  • avg loss: $260
  • profit factor: 2.23

kelly calculation:

b = 580 / 260 = 2.23
p = 0.72
q = 0.28

kelly_full = (2.23 * 0.72 - 0.28) / 2.23 = 0.595 (59.5%!)

quarter_kelly = 0.595 * 0.25 = 0.149 (14.9%)

position_size = 449490 * 0.149 = $66,974

but:

full kelly 59.5% = insane risk.

quarter kelly $66,974 = way too aggressive.

my actual position size: $1,500

kelly says: could risk $66k per trade.

reality: would blow up account in 3 losses.

why i use conservative sizing
#

quarter kelly too aggressive:

assumes win rate/profit factor constant.

reality: market conditions change.

my approach:

fixed $1,500 per trade.

~0.33% of account.

even if kelly says 15%.

capital preservation > kelly optimization.

when kelly is useful
#

strategy comparison:

strategy A: 65% wr, 1.8 pf → kelly 8%

strategy B: 75% wr, 2.5 pf → kelly 18%

allocate more capital to strategy B.

not for absolute position sizing.

for relative allocation between strategies.

resources
#

books:

“Fortune’s Formula” by William Poundstone

explains kelly criterion history.

NexusFi discussions:

risk management threads.

learned fractional kelly from experienced traders.

tonight (february 19, 3:12am)
#

kelly criterion = mathematically optimal position sizing.

my numbers: 72% wr, 2.23 pf → quarter kelly 14.9% ($66k).

my actual: $1,500 (0.33%).

why conservative:

kelly assumes constant performance.

reality: market conditions change.

capital preservation > optimization.

use kelly for strategy allocation, not absolute sizing.


3:12am wednesday. kelly criterion position sizing python implementation. formula: (bp - q) / b where b=avg_win/avg_loss, p=win_rate. my numbers feb 2025: 72% wr, 2.23 pf → quarter kelly suggests $66k position (14.9% account). actual position: $1,500 (0.33%). kelly too aggressive - assumes constant performance. use for relative strategy allocation, not absolute sizing. learned fractional kelly from NexusFi risk discussions. capital preservation > mathematical optimization.

-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:
backtesting framework - vectorbt for fast parameter testing at scale
vectorbt = game changer for parameter testing. 10x faster than backtrader. vectorized operations instead of event-driven. the speed problem # traditional backtesting:
parameter tuning - avoiding overfitting with walk-forward validation
parameter optimization = dangerous. easy to overfit historical data. walk-forward validation = solution. the overfitting problem # traditional optimization:
year-end portfolio rebalance - reducing crypto exposure
rebalancing for 2025. reducing crypto allocation. more conservative positioning. current allocation (november) # options: 60% ($267,660) SPX puts, QQQ calls, sector ETFs.
thanksgiving week - pause, family time approaching
thanksgiving week here. pausing wed-fri. not interested in holiday volume lottery. week 4 pre-thanksgiving # mon nov 18: +$160, 1 trade, 100% wr
slippage correlation volume deep dive - data analysis
slippage been great november. wanted to understand why. data analysis time. november slippage performance # november avg (through nov 18): 1.9 ticks