edgekit

edgekit.portfolio

Combine independently-validated strategy books into one daily-R stream, then decide how much risk each carries. The unit everywhere is the daily-R series— one strategy's per-day sum of trade R-multiples, indexed by date.

What's inside. Three functions: combine (merge books into one daily-R series), correlation (the pairwise daily-R correlation matrix), and allocation_sweep (sweep the risk-share between two books and pick the return-maximising split under a drawdown budget). Missing days are filled with 0.0— a strategy that didn't trade contributes no R, it is not missing data. Causality lives in sizing.risk_parity (lagged std).

combine#

Combine per-strategy daily-R series into a single book daily-R series, aligned on the union date grid and NaN-free.

combine(books: dict[str, pd.Series], method: str = "risk_parity",
        weights: dict[str, float] | None = None) -> pd.Series
ParamTypeDefaultMeaning
booksdict[str, pd.Series]Name → daily-R series for each validated strategy.
methodstr"risk_parity"One of "equal", "fixed", "risk_parity".
weightsdict[str, float] | NoneNonePer-strategy weights (required when method="fixed"; missing → 0).
  • method="equal" — unit-weight each (M.sum(axis=1)).
  • method="fixed" — apply the weights dict; raises ValueError if no dict is given.
  • method="risk_parity" — inverse-vol weights, renormalised to sum to n.

Returns a single pd.Series (the book's daily R), indexed by the union of all input dates, with no NaNs.

combine.py
from edgekit import portfolio
book = portfolio.combine({"trend": r_trend, "breakout": r_breakout}, method="risk_parity")

# fixed weights (e.g. 2x the trend book, mute the breakout sleeve)
book = portfolio.combine({"trend": r_trend, "breakout": r_breakout},
                         method="fixed", weights={"trend": 2.0, "breakout": 0.0})

correlation#

Pairwise daily-R correlation. Symmetric with a 1.0 diagonal.

correlation(books: dict[str, pd.Series]) -> pd.DataFrame
!Idle days are excluded on purpose
Only rows where at least one strategy was active (non-zero) enter the estimate — shared idle days (both books flat, contributing 0.0) would pull correlations spuriously toward zero and make two books look more diversifying than they are.
corr = portfolio.correlation({"trend": r_trend, "breakout": r_breakout})
print(corr.loc["trend", "breakout"])   # near 0 = genuine diversification

allocation_sweep#

Sweep the risk-share between two books, size each blend to the same drawdown budget, and report the return-maximising split plus the robust risk-parity default. For every share of A in the grid the books are inverse-vol weighted, blended, and sized to dd_budget (with daily_cap binding when it is tighter).

allocation_sweep(book_a: pd.Series, book_b: pd.Series, dd_budget: float,
                 daily_cap: float | None, account: float, grid=None, cut=None) -> dict
ParamTypeDefaultMeaning
book_a, book_bpd.SeriesThe two books' daily-R series.
dd_budgetfloatDrawdown budget as a fraction of account (e.g. 0.10 for 10%).
daily_capfloat | NoneDaily-loss fraction cap; binds when tighter than dd_budget.
accountfloatAccount size in dollars.
griditerable | NoneNoneShares of A to test (default 0→1 by 0.1).
cutdate | NoneNoneIf given, fixes 50/50 weights + sizing on the IS window and applies them unchanged OOS.

Returns a dict:

  • table — per-share DataFrame with columns share, w_a, w_b, dollar_per_r, binding, annual, max_dd_pct, worst_day_pct.
  • best — the return-maximising row.
  • best_share — the share of A at that maximum.
  • risk_parity — the 0.5-share allocation (the robust default).
  • correlation — the A/B correlation.
  • oos (only when cut is passed) — dollar_per_r, is_annual, oos_annual, is_sharpe, oos_sharpe, oos_max_dd_pct.
allocation_sweep.py
res = portfolio.allocation_sweep(r_trend, r_breakout, dd_budget=0.10, daily_cap=0.05,
                                 account=100_000, cut="2023-01-01")
print(res["best_share"])          # return-maximising share of the trend book
print(res["risk_parity"])         # robust 50/50 default
print(res["oos"]["oos_annual"])   # what the IS-fixed weights actually did out-of-sample
Trust the OOS panel, not the best row
best_share is chosen in-sample and is therefore optimistic. Pass a cut and read the oos panel: it fixes 50/50 weights and sizing on the in-sample window and reports what they did on unseen data. If oos_annual collapses versus is_annual, the blend does not generalise.

See also#