edgekit.sizing
The layer that turns a book of per-asset R-streams into a single dollar-risked equity path, plus the risk governors that ride on top of it. Four composable jobs, kept deliberately separate: combine → vol-target → size-to-DD → govern.
What’s inside. Two weight builders combine correlated R-streams without letting the loudest asset dominate — risk_parity (inverse-vol) and hrp (Hierarchical Risk Parity). vol_targetstabilises the combined book’s volatility. size_to_dd sizes the stream to a hard drawdown budget in dollars. And two live-path governors, cppi and dd_throttle, throttle risk as the book bleeds.
i is .shift(1). A weight on day i may only see returns through i-1, never its own bar. risk_parity lags its trailing std; vol_target lags both the vol and the median target; hrp builds day-t weights only from the window strictly before t. Skip that lag and the backtest quietly reads the future.Combine — weight builders#
risk_parity#
Inverse-volatility (naive risk-parity) weights for a book of R-streams. Each asset is weighted by the reciprocal of its trailing win-day standard deviation, then the row is renormalised to sum to n_assets — so the mean weight is 1.0 and (M * weights).sum(axis=1) reproduces an equal-risk book. The quiet asset gets the larger weight; the loud one is muted.
risk_parity(M: pd.DataFrame, win: int = 90) -> pd.DataFrame| Param | Type | Default | Meaning |
|---|---|---|---|
M | pd.DataFrame | — | Columns are per-asset daily-R series. |
win | int | 90 | Trailing window (days) for the volatility estimate. |
Returns: a DataFrame of weights aligned to M, each row summing to n_assets. The trailing std is .shift(1) (causal).
from edgekit import sizing
w = sizing.risk_parity(M) # sums to n_assets per row
book = (M * w).sum(axis=1) # the equal-risk combined R-stream
# the quiet asset gets the larger mean weight; the loud one is mutedhrp#
Rolling Hierarchical Risk Parity weights (Lopez de Prado). Clusters the assets by correlation, then allocates risk top-down through the tree by inverse cluster-variance — sidestepping the unstable covariance-matrix inversion that wrecks plain mean-variance on correlated books. Pure numpy (no scipy dependency).
hrp(M: pd.DataFrame, lookback: int = 252, step: int = 21) -> pd.DataFrame| Param | Type | Default | Meaning |
|---|---|---|---|
M | pd.DataFrame | — | Columns are per-asset daily-R series. |
lookback | int | 252 | Trailing window (days) the clustering is fit on. |
step | int | 21 | Recompute cadence — weights are held flat then forward-filled between recomputes. |
Returns: a DataFrame of weights, renormalised to sum to n_assets per row. Weights applied from day t are built only from returns strictly before t (window [t-lookback:t]), never peeking.
from edgekit import sizing
w = sizing.hrp(M, lookback=252, step=21) # correlation-clustered risk weights
book = (M * w).sum(axis=1)Vol-target the book#
vol_target#
Scale a portfolio-R series toward a constant volatility, leverage-capped. The scale on day i is (trailing-median vol) / (current win-day vol), clipped to [0.5, cap] — it presses risk up when the book has gone quiet and trims it when vol spikes, but never levers past cap nor cuts below half. The target level is the expanding medianof realised vol, so it’s set by the book’s own history rather than a hand-picked number.
vol_target(port: pd.Series, cap: float = 1.5, win: int = 60, med_min: int = 60) -> pd.Series| Param | Type | Default | Meaning |
|---|---|---|---|
port | pd.Series | — | The combined portfolio-R series to stabilise. |
cap | float | 1.5 | Maximum leverage the scale may reach (upper clip). |
win | int | 60 | Trailing window (days) for the current-vol estimate. |
med_min | int | 60 | Min periods before the expanding-median target activates. |
Returns: the scaled portfolio-R series (port * scale). Both the current vol and the median target are .shift(1).
from edgekit import sizing
stable = sizing.vol_target(book, cap=1.5) # scale clipped to [0.5, 1.5]Size to a drawdown budget#
size_to_dd#
Size a daily-R stream to a hard drawdown budget, in dollars. It delegates the dollar-per-R scalar to metrics.dd_matched_size (the single home for that math — no re-deriving max-drawdown here), applies it, and returns both the scaled dollar series and the sizing decision. When daily_cap is given, whichever of the two constraints (max-DD vs worst-day) binds tighter wins.
size_to_dd(daily_r: pd.Series, dd_budget: float, account: float,
daily_cap: float | None = None) -> dict| Param | Type | Default | Meaning |
|---|---|---|---|
daily_r | pd.Series | — | Daily-R stream to size. |
dd_budget | float | — | Max-drawdown budget as a fraction of account (e.g. 0.095). |
account | float | — | Account size in dollars. |
daily_cap | float | None | None | Optional worst-day limit as a fraction of account (e.g. 0.045). |
Returns (all keys): sized(the scaled dollar P&L series), plus dollar_per_r, binding and max_dd_r passed through from dd_matched_size. Applied to the history, sized has a max drawdown of dd_budget * account when max-DD is the binding constraint.
size_to_dd reproduces the historical worst drawdown exactly — live drawdown runs deeper. The sized series is an optimistic ceiling, not a forecast; plan around a haircut and, ideally, size against a bootstrapped dd95 rather than the lucky realised drawdown.from edgekit import sizing
res = sizing.size_to_dd(daily_r, dd_budget=0.095, account=100_000)
res["sized"] # dollar P&L series; max DD == 9.5% of account (when max-dd binds)
res["dollar_per_r"] # the applied scalar
res["binding"] # "max-dd" or "daily"
res["max_dd_r"] # historical max drawdown, in RGovern the live path#
cppi#
Constant-Proportion Portfolio Insurance on a live P&L stream. Runs a trailing floor at mstop below the high-water mark and risks in proportion to the cushion — how far equity sits from that floor up to the high-water mark. The risk multiplier is slope * cushion, floored at 0 (fully de-risked near the floor) and capped at cap. It presses risk when winning and throttles toward zero as it bleeds into the floor — the mechanism behind the prop-challenge survival curves.
cppi(returns, account: float, mstop: float, slope: float = 2.0, cap: float | None = None) -> pd.Series| Param | Type | Default | Meaning |
|---|---|---|---|
returns | array-like | — | Dollar P&L per period (same units as account). |
account | float | — | Starting equity in dollars. |
mstop | float | — | Trailing floor distance below the high-water mark (e.g. 0.10). |
slope | float | 2.0 | Risk multiplier per unit of cushion. |
cap | float | None | None | Ceiling on the risk multiplier; None = no ceiling. |
Returns: the throttled dollar P&L series, indexed to returns.
from edgekit import sizing
throttled = sizing.cppi(daily_pnl, account=100_000, mstop=0.10, slope=2.0, cap=1.5)
# full cushion (eq == hwm) => multiplier == slope; near the floor => ~0dd_throttle#
Halve risk while the equity curve is more than thresh below its peak. A blunt, path-dependent governor: track the equity peak-to-date, and any period whose drawdown-from-peak exceeds thresh is sized at factor of full risk until the book recovers. Because the throttle changes the equity it then measures, it is applied sequentially.
dd_throttle(returns, thresh: float = 0.04, factor: float = 0.5) -> pd.Series| Param | Type | Default | Meaning |
|---|---|---|---|
returns | array-like | — | Per-period returns/P&L on a unit equity base. |
thresh | float | 0.04 | Drawdown-from-peak that triggers the throttle (4%). |
factor | float | 0.5 | Risk multiplier applied while underwater (0.5 = halve). |
Returns: the throttled series, indexed to returns.
from edgekit import sizing
out = sizing.dd_throttle([-0.05, 0.10, 0.10], thresh=0.04, factor=0.5)
out.iloc[0] # -0.05 first bar full risk (no drawdown yet)
out.iloc[1] # 0.05 halved: the 5% drawdown exceeds the 4% thresholdSee also#
- edgekit.metrics —
size_to_dddelegates todd_matched_size. - edgekit.costs — sizing operates on net-of-cost R.
- API reference — every module, symbol by symbol.