Quant Trading in Python — Part 4 of 6

Risk Management — The Only Edge That Lasts

Amateurs focus on entries. Professionals focus on risk. We'll build a complete risk management framework: position sizing, portfolio optimization, VaR, drawdown control, and the Kelly criterion.

Position Sizing Kelly Criterion VaR Efficient Frontier
Quant Trading in Python4/6
Position SizingKelly CriterionStop LossesVaR & ESPortfolio OptimizationDrawdown Control
Position Sizing

Position Sizing: How Much to Bet

The single most important decision in trading isn't what to buy — it's how much. A great strategy with bad sizing will blow up. A mediocre strategy with good sizing will survive long enough to improve.

risk/position_sizer.py
import numpy as np
import pandas as pd

class PositionSizer:
    """Position sizing methods for quant trading."""

    @staticmethod
    def fixed_fractional(capital: float,
                          risk_per_trade: float = 0.02,
                          entry_price: float = 100,
                          stop_price: float = 95) -> int:
        """
        Fixed fractional: risk X% of capital per trade.

        Standard rule: never risk more than 1-2% per trade.
        """
        risk_amount = capital * risk_per_trade  # $2,000 on $100k
        risk_per_share = abs(entry_price - stop_price)
        shares = int(risk_amount / risk_per_share)
        return shares

    @staticmethod
    def volatility_adjusted(capital: float,
                             risk_per_trade: float = 0.02,
                             atr: float = 3.5,
                             atr_multiplier: float = 2.0,
                             price: float = 100) -> int:
        """
        Size based on ATR (volatility-adjusted).

        Stop = entry - ATR * multiplier.
        More volatile stocks = smaller position.
        """
        risk_amount = capital * risk_per_trade
        risk_per_share = atr * atr_multiplier
        shares = int(risk_amount / risk_per_share)

        # Cap at 10% of capital
        max_shares = int(capital * 0.10 / price)
        return min(shares, max_shares)

    @staticmethod
    def equal_risk(capital: float,
                    volatilities: pd.Series,
                    target_vol: float = 0.15) -> pd.Series:
        """
        Equal risk contribution: each position contributes
        equal volatility to the portfolio.

        Inverse-volatility weighting.
        """
        inv_vol = 1 / volatilities
        weights = inv_vol / inv_vol.sum()

        # Scale to target portfolio volatility
        port_vol = (weights * volatilities).sum()
        scale = target_vol / port_vol
        return weights * scale * capital
MethodRisk Per TradeProsConsBest For
Fixed Fractional1-2% of capitalSimple, consistentIgnores volatilityBeginners
Volatility-AdjustedVaries with ATRAdapts to marketMore complexSwing traders
Kelly CriterionOptimal mathMaximizes growthAggressive, needs accurate estimatesAdvanced quants
Equal RiskEqual vol contributionDiversifiedIgnores convictionPortfolios
Kelly Criterion

The Kelly Criterion

The Kelly criterion tells you the mathematically optimal fraction of your capital to bet, given your edge. It maximizes the long-run geometric growth rate of your portfolio. But full Kelly is too aggressive for trading — most practitioners use half-Kelly or quarter-Kelly.

Kelly Fraction
f* = W/A - (1-W)/B = (W * B - (1-W) * A) / (A * B)

Where W = win rate, A = avg loss, B = avg win. Use half-Kelly (f*/2) in practice.

risk/kelly.py
def kelly_fraction(win_rate: float,
                    avg_win: float,
                    avg_loss: float) -> float:
    """
    Kelly criterion for optimal position sizing.

    Args:
        win_rate: probability of winning (0 to 1)
        avg_win:  average winning trade return (positive)
        avg_loss: average losing trade return (positive, absolute)

    Returns:
        Optimal fraction of capital to risk.
        Use f/2 (half-Kelly) for real trading.
    """
    if avg_loss == 0:
        return 0

    # Kelly formula
    b = avg_win / avg_loss  # Win/loss ratio
    f = win_rate - (1 - win_rate) / b

    # Clip to [0, 0.25] — never risk more than 25%
    return max(0, min(f, 0.25))

# Example
wr = 0.55     # 55% win rate
aw = 0.035    # Average win: +3.5%
al = 0.025    # Average loss: -2.5%

full_kelly = kelly_fraction(wr, aw, al)
print(f"Full Kelly:  {full_kelly:.1%}")   # ~23%
print(f"Half Kelly:  {full_kelly/2:.1%}")  # ~11.5%
print(f"Qtr Kelly:   {full_kelly/4:.1%}")  # ~5.8%

Why Half-Kelly?

Full Kelly maximizes growth but with wild swings — it can produce 50%+ drawdowns. Half-Kelly achieves ~75% of the growth rate with less than half the volatility. The key insight: the Kelly curve is flat near the top. Going from half-Kelly to full-Kelly barely increases growth but dramatically increases risk. Quarter-Kelly is even more conservative and appropriate for strategies with uncertain edge estimates.

Stop Losses

Stop Loss Strategies

Stop losses limit your downside. The right stop depends on the strategy, volatility, and timeframe.

risk/stops.py
class StopLossManager:
    """Stop loss strategies."""

    @staticmethod
    def fixed_stop(entry: float, pct: float = 0.05) -> float:
        """Fixed percentage stop. Simple but ignores volatility."""
        return entry * (1 - pct)

    @staticmethod
    def atr_stop(entry: float, atr: float,
                  multiplier: float = 2.0) -> float:
        """ATR-based stop. Adapts to current volatility.
        Wider in volatile markets, tighter in quiet markets."""
        return entry - atr * multiplier

    @staticmethod
    def trailing_stop(prices: pd.Series,
                       multiplier: float = 2.0,
                       atr: pd.Series = None) -> pd.Series:
        """Chandelier trailing stop.
        Follows the highest high minus ATR * multiplier.
        Never moves down — only up."""
        if atr is None:
            # Use rolling range as proxy
            atr = prices.rolling(14).apply(
                lambda x: x.max() - x.min()
            )

        highest = prices.cummax()
        stop = highest - atr * multiplier
        # Trailing: stop never decreases
        return stop.cummax()

    @staticmethod
    def time_stop(entry_date, max_days: int = 20,
                   current_date = None) -> bool:
        """Time-based stop. Close if trade hasn't moved
        after max_days. Prevents capital from being tied up."""
        if current_date is None:
            return False
        return (current_date - entry_date).days >= max_days
Stop TypeMethodProsCons
Fixed %Entry - X%Simple, predictableIgnores volatility
ATR-basedEntry - N*ATRVolatility-adaptiveWide in volatile markets
TrailingHH - N*ATRLocks in profitsCan be whipsawed
Time-basedClose after N daysFrees capitalMay exit too early
VaR & Expected Shortfall

Value at Risk & Expected Shortfall

VaR answers: "What's the worst I can lose with 95% confidence over the next day?" Expected Shortfall (CVaR) answers the harder question: "When things go bad, HOW bad?"

risk/var.py
import numpy as np
from scipy import stats

class RiskMetrics:
    """VaR and Expected Shortfall calculators."""

    @staticmethod
    def parametric_var(returns: pd.Series,
                        confidence: float = 0.95,
                        horizon: int = 1) -> float:
        """Parametric VaR assuming normal distribution.
        Fast but underestimates tail risk."""
        mu = returns.mean() * horizon
        sigma = returns.std() * np.sqrt(horizon)
        return -(mu + sigma * stats.norm.ppf(1 - confidence))

    @staticmethod
    def historical_var(returns: pd.Series,
                        confidence: float = 0.95) -> float:
        """Historical VaR — no distribution assumption.
        Uses actual return distribution."""
        return -np.percentile(returns, (1 - confidence) * 100)

    @staticmethod
    def expected_shortfall(returns: pd.Series,
                            confidence: float = 0.95) -> float:
        """Expected Shortfall (CVaR) — average loss beyond VaR.
        More conservative and coherent risk measure."""
        var = -np.percentile(returns, (1 - confidence) * 100)
        tail = returns[returns <= -var]
        return -tail.mean() if len(tail) > 0 else var

    @staticmethod
    def monte_carlo_var(returns: pd.Series,
                          confidence: float = 0.95,
                          horizon: int = 10,
                          simulations: int = 10000) -> float:
        """Monte Carlo VaR — simulate many possible paths."""
        mu = returns.mean()
        sigma = returns.std()

        # Simulate random paths
        sims = np.random.normal(mu, sigma, (simulations, horizon))
        cum_returns = sims.sum(axis=1)

        return -np.percentile(cum_returns, (1 - confidence) * 100)

# Example: $100k portfolio
rm = RiskMetrics()
capital = 100_000

p_var = rm.parametric_var(returns)
h_var = rm.historical_var(returns)
es = rm.expected_shortfall(returns)
mc_var = rm.monte_carlo_var(returns, horizon=10)

print(f"1-Day 95% VaR (Parametric):    ${p_var * capital:,.0f}")
print(f"1-Day 95% VaR (Historical):    ${h_var * capital:,.0f}")
print(f"1-Day 95% Expected Shortfall:  ${es * capital:,.0f}")
print(f"10-Day 95% VaR (Monte Carlo):  ${mc_var * capital:,.0f}")
Portfolio Optimization

Portfolio Optimization

Harry Markowitz won the Nobel Prize for proving that diversification is the only free lunch in finance. The efficient frontier shows the optimal risk/return tradeoff for a given set of assets.

risk/portfolio.py — Mean-Variance Optimizer
from scipy.optimize import minimize

class PortfolioOptimizer:
    """Mean-variance portfolio optimization."""

    def __init__(self, returns: pd.DataFrame):
        self.returns = returns
        self.mean_returns = returns.mean() * 252
        self.cov_matrix = returns.cov() * 252
        self.n_assets = len(returns.columns)

    def portfolio_stats(self, weights):
        """Compute portfolio return and volatility."""
        ret = np.dot(weights, self.mean_returns)
        vol = np.sqrt(np.dot(weights.T,
                     np.dot(self.cov_matrix, weights)))
        return ret, vol

    def max_sharpe(self, risk_free: float = 0.05):
        """Find the maximum Sharpe ratio portfolio."""
        def neg_sharpe(w):
            r, v = self.portfolio_stats(w)
            return -(r - risk_free) / v

        constraints = [{'type': 'eq', 'fun': lambda w: np.sum(w) - 1}]
        bounds = [(0, 0.30)] * self.n_assets  # Max 30% per asset
        w0 = np.ones(self.n_assets) / self.n_assets

        result = minimize(neg_sharpe, w0,
                          method='SLSQP',
                          bounds=bounds,
                          constraints=constraints)
        return result.x

    def min_volatility(self):
        """Find the minimum volatility portfolio."""
        def portfolio_vol(w):
            return np.sqrt(np.dot(w.T, np.dot(self.cov_matrix, w)))

        constraints = [{'type': 'eq', 'fun': lambda w: np.sum(w) - 1}]
        bounds = [(0, 0.30)] * self.n_assets
        w0 = np.ones(self.n_assets) / self.n_assets

        result = minimize(portfolio_vol, w0,
                          method='SLSQP',
                          bounds=bounds,
                          constraints=constraints)
        return result.x

    def efficient_frontier(self, n_points: int = 100):
        """Generate the efficient frontier."""
        results = []
        target_returns = np.linspace(
            self.mean_returns.min(),
            self.mean_returns.max(),
            n_points
        )

        for target in target_returns:
            def vol(w):
                return np.sqrt(np.dot(w.T, np.dot(self.cov_matrix, w)))

            constraints = [
                {'type': 'eq', 'fun': lambda w: np.sum(w) - 1},
                {'type': 'eq', 'fun': lambda w: np.dot(w, self.mean_returns) - target}
            ]
            bounds = [(0, 0.30)] * self.n_assets
            w0 = np.ones(self.n_assets) / self.n_assets

            res = minimize(vol, w0, method='SLSQP',
                           bounds=bounds, constraints=constraints)
            if res.success:
                results.append((res.fun, target))

        return results
Drawdown Control

Drawdown Control & Circuit Breakers

A 50% drawdown requires a 100% return to recover. A 10% drawdown only needs 11%. Controlling drawdown is the difference between surviving and blowing up.

risk/drawdown.py — Circuit Breaker
class DrawdownController:
    """Drawdown monitoring and circuit breakers."""

    def __init__(self,
                 max_drawdown: float = 0.15,   # Kill switch at 15%
                 reduce_at: float = 0.08,      # Reduce size at 8%
                 reduction: float = 0.50):     # Cut to 50% size
        self.max_drawdown = max_drawdown
        self.reduce_at = reduce_at
        self.reduction = reduction
        self.peak = 0
        self.halted = False

    def update(self, equity: float) -> dict:
        """Update drawdown state. Call after each trade/bar."""
        self.peak = max(self.peak, equity)
        dd = (self.peak - equity) / self.peak

        if dd >= self.max_drawdown:
            self.halted = True
            return {
                'action': 'HALT',
                'drawdown': dd,
                'message': f'Max drawdown {dd:.1%} hit. Liquidating.',
                'size_multiplier': 0
            }
        elif dd >= self.reduce_at:
            return {
                'action': 'REDUCE',
                'drawdown': dd,
                'message': f'Drawdown {dd:.1%}. Reducing to {self.reduction:.0%}.',
                'size_multiplier': self.reduction
            }
        else:
            return {
                'action': 'OK',
                'drawdown': dd,
                'size_multiplier': 1.0
            }

    def equity_curve_trading(self,
                              equity_curve: pd.Series,
                              sma_period: int = 50) -> pd.Series:
        """Equity curve trading: only trade when your
        equity curve is above its own moving average.
        Meta-strategy that reduces exposure during drawdowns."""
        equity_sma = equity_curve.rolling(sma_period).mean()
        active = (equity_curve > equity_sma).astype(float)
        return active

The Recovery Table

Drawdowns are asymmetric. A 10% loss needs 11% to recover. A 25% loss needs 33%. A 50% loss needs 100%. A 75% loss needs 300%. This is why prevention is better than cure. Set your max drawdown tolerance before you start trading and stick to it. Most institutional quants use 15-20% as their hard stop.

Part 4 Checklist

Next: Part 5 of 6
Going Live — Paper Trading to Real Money
Quant Trading in Python4/6