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.
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.
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
| Method | Risk Per Trade | Pros | Cons | Best For |
|---|---|---|---|---|
| Fixed Fractional | 1-2% of capital | Simple, consistent | Ignores volatility | Beginners |
| Volatility-Adjusted | Varies with ATR | Adapts to market | More complex | Swing traders |
| Kelly Criterion | Optimal math | Maximizes growth | Aggressive, needs accurate estimates | Advanced quants |
| Equal Risk | Equal vol contribution | Diversified | Ignores conviction | Portfolios |
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.
Where W = win rate, A = avg loss, B = avg win. Use half-Kelly (f*/2) in practice.
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%
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 limit your downside. The right stop depends on the strategy, volatility, and timeframe.
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 Type | Method | Pros | Cons |
|---|---|---|---|
| Fixed % | Entry - X% | Simple, predictable | Ignores volatility |
| ATR-based | Entry - N*ATR | Volatility-adaptive | Wide in volatile markets |
| Trailing | HH - N*ATR | Locks in profits | Can be whipsawed |
| Time-based | Close after N days | Frees capital | May exit too early |
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?"
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}")
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.
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
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.
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
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.