Add drawdown control - CPPI, Kelly criterion, dynamic position sizing
Browse files- drawdown_control.py +118 -0
drawdown_control.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""drawdown_control.py — Dynamic Drawdown Control & Position Sizing
|
| 2 |
+
|
| 3 |
+
Implements CPPI (Constant Proportion Portfolio Insurance), fractional Kelly
|
| 4 |
+
criterion, dynamic leverage based on current drawdown, and volatility targeting.
|
| 5 |
+
Essential for survival-first quant strategies.
|
| 6 |
+
|
| 7 |
+
References:
|
| 8 |
+
- Perold & Sharpe 1988: "Dynamic Strategies for Asset Allocation" (CPPI)
|
| 9 |
+
- Thorp 2006: "The Kelly Criterion in Blackjack, Sports Betting, and the Stock Market"
|
| 10 |
+
- Grossman & Zhou 1993: "Optimal Investment Strategies for Controlling Drawdowns"
|
| 11 |
+
"""
|
| 12 |
+
import numpy as np, pandas as pd
|
| 13 |
+
|
| 14 |
+
class DrawdownController:
|
| 15 |
+
"""Controls drawdown via CPPI, Kelly, and dynamic leverage."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, max_dd=0.15, target_vol=0.10, kelly_fraction=0.5):
|
| 18 |
+
self.max_dd = max_dd
|
| 19 |
+
self.target_vol = target_vol
|
| 20 |
+
self.kelly_frac = kelly_fraction
|
| 21 |
+
|
| 22 |
+
def cppi_weights(self, prices, floor_ratio=0.8, multiplier=3.0):
|
| 23 |
+
"""CPPI: allocate to risky asset based on cushion above floor.
|
| 24 |
+
|
| 25 |
+
Returns equity exposure fraction [0, 1].
|
| 26 |
+
"""
|
| 27 |
+
nav = prices / prices.iloc[0]
|
| 28 |
+
peak = nav.expanding().max()
|
| 29 |
+
floor = peak * floor_ratio
|
| 30 |
+
cushion = nav - floor
|
| 31 |
+
exposure = multiplier * cushion / nav
|
| 32 |
+
return np.clip(exposure, 0, 1.0)
|
| 33 |
+
|
| 34 |
+
def kelly_leverage(self, returns, window=252):
|
| 35 |
+
"""Fractional Kelly optimal leverage.
|
| 36 |
+
|
| 37 |
+
f* = mu / sigma^2 (full Kelly)
|
| 38 |
+
f = fraction * f* (half Kelly = safer)
|
| 39 |
+
"""
|
| 40 |
+
r = returns.dropna()
|
| 41 |
+
if len(r) < 30: return 0.5
|
| 42 |
+
mu = r.tail(window).mean() * 252
|
| 43 |
+
sigma2 = (r.tail(window).std() * np.sqrt(252)) ** 2
|
| 44 |
+
if sigma2 < 1e-10: return 0.5
|
| 45 |
+
f_star = mu / sigma2
|
| 46 |
+
return np.clip(self.kelly_frac * f_star, 0.0, 2.0)
|
| 47 |
+
|
| 48 |
+
def dynamic_leverage(self, prices, returns, current_dd=None):
|
| 49 |
+
"""Dynamic leverage: reduce when in drawdown, increase when at peak."""
|
| 50 |
+
nav = prices / prices.iloc[0]
|
| 51 |
+
peak = nav.expanding().max()
|
| 52 |
+
dd = (nav - peak) / peak
|
| 53 |
+
if current_dd is None:
|
| 54 |
+
current_dd = dd.iloc[-1]
|
| 55 |
+
# Vol targeting
|
| 56 |
+
vol = returns.tail(63).std() * np.sqrt(252)
|
| 57 |
+
vol_scalar = self.target_vol / (vol + 1e-10)
|
| 58 |
+
# Drawdown guard
|
| 59 |
+
dd_scalar = max(0, 1 - abs(current_dd) / self.max_dd)
|
| 60 |
+
return np.clip(vol_scalar * dd_scalar, 0.0, 2.0)
|
| 61 |
+
|
| 62 |
+
def position_size(self, capital, atr, risk_per_trade=0.01):
|
| 63 |
+
"""ATR-based position sizing (Turtle-style).
|
| 64 |
+
|
| 65 |
+
Position = (Capital * Risk%) / ATR
|
| 66 |
+
"""
|
| 67 |
+
if atr <= 0: return 0
|
| 68 |
+
return capital * risk_per_trade / atr
|
| 69 |
+
|
| 70 |
+
def optimal_f(self, returns, window=100):
|
| 71 |
+
"""Optimal-F (Ralph Vince): geometric growth maximizing fraction.
|
| 72 |
+
|
| 73 |
+
f = argmax G(f) where G(f) = product(1 + f * (-W/R))
|
| 74 |
+
"""
|
| 75 |
+
r = returns.tail(window).dropna()
|
| 76 |
+
if len(r) < 20: return 0.25
|
| 77 |
+
# Simplified: use Kelly as proxy
|
| 78 |
+
mu = r.mean(); sigma = r.std()
|
| 79 |
+
if sigma < 1e-10: return 0.25
|
| 80 |
+
return np.clip(mu / (sigma ** 2 + 1e-10), 0.0, 1.0)
|
| 81 |
+
|
| 82 |
+
def report(self, prices, returns):
|
| 83 |
+
"""Generate position sizing recommendations."""
|
| 84 |
+
nav = prices / prices.iloc[0]
|
| 85 |
+
current_dd = (nav.iloc[-1] - nav.expanding().max().iloc[-1]) / nav.expanding().max().iloc[-1]
|
| 86 |
+
cppi = self.cppi_weights(prices)
|
| 87 |
+
kelly = self.kelly_leverage(returns)
|
| 88 |
+
dyn = self.dynamic_leverage(prices, returns, current_dd)
|
| 89 |
+
optf = self.optimal_f(returns)
|
| 90 |
+
vol = returns.tail(63).std() * np.sqrt(252)
|
| 91 |
+
return f"""## Position Sizing & Drawdown Control
|
| 92 |
+
|
| 93 |
+
| Metric | Value |
|
| 94 |
+
|--------|-------|
|
| 95 |
+
| Current Drawdown | {current_dd*100:.1f}% |
|
| 96 |
+
| Target Max Drawdown | {self.max_dd*100:.1f}% |
|
| 97 |
+
| Current Volatility (3M ann) | {vol*100:.1f}% |
|
| 98 |
+
| Target Volatility | {self.target_vol*100:.1f}% |
|
| 99 |
+
|
| 100 |
+
**Recommended Leverage / Exposure:**
|
| 101 |
+
|
| 102 |
+
| Strategy | Exposure | Rationale |
|
| 103 |
+
|----------|----------|-----------|
|
| 104 |
+
| CPPI (multiplier=3) | {cppi.iloc[-1]*100:.0f}% | Cushion-based insurance |
|
| 105 |
+
| Half-Kelly | {kelly*100:.0f}% | Growth-optimal, halved for safety |
|
| 106 |
+
| Vol-Target + DD-Guard | {dyn*100:.0f}% | Scales with vol and drawdown |
|
| 107 |
+
| Optimal-F | {optf*100:.0f}% | Geometric growth maximizing |
|
| 108 |
+
|
| 109 |
+
**Composite Recommendation:** {(cppi.iloc[-1] * 0.3 + kelly * 0.3 + dyn * 0.4)*100:.0f}% equity exposure
|
| 110 |
+
"""
|
| 111 |
+
|
| 112 |
+
if __name__ == '__main__':
|
| 113 |
+
np.random.seed(42)
|
| 114 |
+
returns = pd.Series(np.random.normal(0.0005, 0.015, 500),
|
| 115 |
+
index=pd.date_range('2022-01-01', periods=500, freq='B'))
|
| 116 |
+
prices = (1 + returns).cumprod()
|
| 117 |
+
ctrl = DrawdownController(max_dd=0.15, target_vol=0.10, kelly_fraction=0.5)
|
| 118 |
+
print(ctrl.report(prices, returns))
|