edgekit

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.

!Every weighting stat is .shift(1)
The one non-negotiable across this whole module: every rolling statistic used to weight or scale bar 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
ParamTypeDefaultMeaning
Mpd.DataFrameColumns are per-asset daily-R series.
winint90Trailing 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).

risk_parity.py
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 muted

hrp#

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
ParamTypeDefaultMeaning
Mpd.DataFrameColumns are per-asset daily-R series.
lookbackint252Trailing window (days) the clustering is fit on.
stepint21Recompute 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.

hrp.py
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
ParamTypeDefaultMeaning
portpd.SeriesThe combined portfolio-R series to stabilise.
capfloat1.5Maximum leverage the scale may reach (upper clip).
winint60Trailing window (days) for the current-vol estimate.
med_minint60Min 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).

vol_target.py
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
ParamTypeDefaultMeaning
daily_rpd.SeriesDaily-R stream to size.
dd_budgetfloatMax-drawdown budget as a fraction of account (e.g. 0.095).
accountfloatAccount size in dollars.
daily_capfloat | NoneNoneOptional 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.

!The sized curve is a historical ceiling
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.
size_to_dd.py
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 R

Govern 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
ParamTypeDefaultMeaning
returnsarray-likeDollar P&L per period (same units as account).
accountfloatStarting equity in dollars.
mstopfloatTrailing floor distance below the high-water mark (e.g. 0.10).
slopefloat2.0Risk multiplier per unit of cushion.
capfloat | NoneNoneCeiling on the risk multiplier; None = no ceiling.

Returns: the throttled dollar P&L series, indexed to returns.

cppi.py
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 => ~0

dd_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
ParamTypeDefaultMeaning
returnsarray-likePer-period returns/P&L on a unit equity base.
threshfloat0.04Drawdown-from-peak that triggers the throttle (4%).
factorfloat0.5Risk multiplier applied while underwater (0.5 = halve).

Returns: the throttled series, indexed to returns.

dd_throttle.py
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% threshold

See also#