edgekit

edgekit.metrics

Performance metrics — one honest home for the stats/mets/st builder that appeared ~47 times across the research scripts, each subtly different. Everything speaks R-multiples first; dollars arrive only when you pass an account.

What’s inside. Two entry points do the heavy lifting: trade_stats (from an array of per-trade R-multiples) and equity_stats (from a daily P&L / return series). The scalar metrics that were re-derived inline everywhere — profit_factor, max_drawdown, max_drawdown_pct, sharpe, sortino — are exposed standalone. And dd_matched_size is the one place that turns an R-stream into a dollar-per-R scalar against a drawdown budget (the sizing layer delegates to it).

!Sharpe understates trend-following
Trend-following returns are skewed and autocorrelated, so their annualised Sharpe reads low. Never judge a trend edge on Sharpe alone — always report pf (profit factor) and mar (return / max-drawdown) alongside it. sharpe and sortino both return 0.0 on degenerate or too-short input rather than blowing up.

Scalar metrics#

profit_factor#

Gross win divided by gross loss. The single most robust summary of an edge — it survives skew, doesn’t assume normality, and is the number you cost-stress against. Returns inf when there are no losing trades.

profit_factor(r) -> float

Returns: the ratio as a float; inf if gross loss is 0.

from edgekit import profit_factor
profit_factor([2.0, -1.0, -1.0])   # -> 1.0  (2 won / 2 lost)
profit_factor([1.0, 1.0])          # -> inf  (no losers)

max_drawdown#

Max peak-to-trough drawdown of an equity curve, as a positive number in the curve’s own units. Pass a cumulative-sum curve for R, or a dollar equity curve for dollars.

max_drawdown(equity) -> float

Returns: the largest peak-minus-trough gap (a positive number in the input units).

from edgekit import max_drawdown
import numpy as np
eq = np.array([0, 1, 2, 1, 0, 3])   # cumulative R; peak 2 -> trough 0
max_drawdown(eq)                    # -> 2.0

max_drawdown_pct#

Max drawdown expressed as a fraction of the running peak, rather than in raw units.

max_drawdown_pct(equity) -> float

Returns: the worst drawdown as a fraction of its preceding peak.

sharpe#

Annualised Sharpe of a per-period return series. Read the caveat above — this understates trend-following.

sharpe(returns, periods_per_year: float = 252.0) -> float
ParamTypeDefaultMeaning
returnsarray-likePer-period return series (non-finite values dropped).
periods_per_yearfloat252.0Annualisation factor (252 daily, 365 for calendar-daily crypto).

Returns: annualised Sharpe; 0.0 if std is 0 or fewer than 3 points.

sortino#

Annualised Sortino — Sharpe using downside deviation only (penalises loss volatility, not gains).

sortino(returns, periods_per_year: float = 252.0) -> float

Returns: annualised Sortino; 0.0 when there is no downside deviation.

Summary builders#

trade_stats#

The universal per-trade summary in R-space — the consolidated replacement for the ~47 copy-pasted stats builders. Feed it per-trade net R; pass dates to unlock annualised metrics and hold to unlock holding-period metrics.

trade_stats(r, dates=None, hold=None) -> dict
ParamTypeDefaultMeaning
rarray-likePer-trade net R-multiples.
datesarray-like | NoneNoneExit dates — enables annualised R, max DD, Sharpe, frequency.
holdarray-like | NoneNoneHolding period per trade (bars or days) — enables hold stats.

Returns (dict keys). The set grows with what you pass:

  • Always: n, total_r, ev_r, win_rate, pf, avg_win, avg_loss, best, worst, win_streak, loss_streak.
  • When n == 0: only n, total_r, ev_r, win_rate, pf.
  • With dates: adds years, ann_r, max_dd_r, mar, sharpe, worst_day_r, trades_per_year. (The Sharpe here is annualised at 365 — daily R buckets.)
  • With hold: adds avg_hold, median_hold.
trade_stats.py
from edgekit import trade_stats

st = trade_stats(trades.r, dates=trades.date)
print(st["pf"], st["mar"], st["ann_r"])   # judge the edge on PF and MAR, not Sharpe
print(st["win_rate"], st["ev_r"])          # ev_r is expectancy per trade, in R

equity_stats#

Summary of a daily P&L (or return) series — the equity-curve counterpart to trade_stats. If account is given, daily_pnl is treated as dollars and normalised by it; otherwise it is treated as fractional returns.

equity_stats(daily_pnl, account: float | None = None, periods_per_year: float = 252.0) -> dict
ParamTypeDefaultMeaning
daily_pnlarray-likeDaily P&L (dollars if account given) or fractional returns.
accountfloat | NoneNoneAccount size; when set, daily_pnl is dollars and normalised by it.
periods_per_yearfloat252.0Annualisation factor for CAGR / Sharpe / Sortino.

Returns (all keys): total_return, cagr, sharpe, sortino, max_dd_pct, mar, worst_day, best_day.

equity_stats.py
from edgekit.metrics import equity_stats

es = equity_stats(daily_pnl_dollars, account=100_000)
print(es["cagr"], es["max_dd_pct"], es["mar"])

dd_matched_size#

The dollar-per-R scalar that sizes an R-stream to a drawdown budget — the honest bridge from an R backtest to a dollar-risked plan. It sizes so the historical max drawdown equals dd_budget * account (e.g. 0.095 for a 10% prop limit with buffer). If daily_cap is given (e.g. 0.045 for a 5% daily limit), whichever of the two constraints binds tighter wins — the dual-constraint sizing.

dd_matched_size(daily_r, dd_budget: float, account: float, daily_cap: float | None = None) -> dict
ParamTypeDefaultMeaning
daily_rarray-likeDaily R-stream (per-day sum of trade R-multiples).
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): dollar_per_r (the sizing scalar), binding ("max-dd" or "daily" — which constraint set the size), and max_dd_r (the historical max drawdown in R).

!A drawdown-matched size is a ceiling, not a forecast
This scalar reproduces the historical worst case exactly — live drawdown will be deeper. Treat the sized return as an optimistic ceiling and plan around a haircut. To size against a bootstrapped worst case instead of the lucky historical one, feed a dd95 estimate.
dd_matched_size.py
from edgekit import dd_matched_size

info = dd_matched_size(daily_r, dd_budget=0.10, account=100_000, daily_cap=0.03)
info["dollar_per_r"]   # dollars to risk per 1R so historical DD == budget
info["binding"]        # "daily" if the worst-day cap bound tighter than max-DD

Trade forensics & capacity#

Beyond the headline summary: mae_mfe dissects each trade’s excursion path, edge_decay watches the edge fade in time, probabilistic_sharpe and omega are the distribution-aware performance numbers, and turnover / capacity_estimate ask whether the edge survives being traded at size.

mae_mfe#

Maximum adverse / favourable excursion per trade, in R — scan the bars each trade was open and record how far it went against you (MAE) and for you (MFE) before it closed. The forensic pair behind stop and target placement: an edge whose winners see little MAE supports a tighter stop; big MFE given back argues for a nearer target.

mae_mfe(trades, bars) -> pd.DataFrame
ParamTypeDefaultMeaning
tradespd.DataFrameThe canonical trade frame from a backtest (entry/exit/stop columns).
barspd.DataFrameThe OHLC frame the trades were generated on.

Returns: a DataFrame aligned to trades with columns mae_r and mfe_r — worst and best excursion per trade, in R.

from edgekit.metrics import mae_mfe
ex = mae_mfe(trades, bars)
ex["mae_r"].quantile(0.9)   # 90% of trades never went worse than this — stop-tightening evidence

edge_decay#

Rolling expectancy with a confidence band — the mean R over a trailing window of trades, plus its CI, so you can see an edge fading while it fadesinstead of in the post-mortem. The frame’s attrs carry a slope_t — the t-stat of the time trend in expectancy (significantly negative = the edge is decaying, not just noisy).

edge_decay(r, dates=None, window=50) -> pd.DataFrame
ParamTypeDefaultMeaning
rarray-likePer-trade net R-multiples, in order.
datesarray-like | NoneNoneTrade dates for the index (integer index if None).
windowint50Rolling trade-count window.

Returns: a DataFrame with columns mean_r, ci_lo, ci_hi, and attrs["slope_t"] — the trend t-stat.

from edgekit.metrics import edge_decay
dec = edge_decay(trades.r, dates=trades.date, window=50)
dec.attrs["slope_t"]   # < -2: the edge is measurably decaying — retire it before the market does

probabilistic_sharpe#

The probabilistic Sharpe ratio (Bailey & Lopez de Prado) — the probability that the true Sharpe exceeds sr_benchmark, given the observed Sharpe, the sample size, and the skew/kurtosis of the returns. It answers what a point-estimate Sharpe cannot: fat tails and short samples widen the estimate’s own distribution, and PSR prices that in.

probabilistic_sharpe(r, sr_benchmark=0.0) -> float
ParamTypeDefaultMeaning
rarray-likePer-period return series.
sr_benchmarkfloat0.0The per-period Sharpe to beat (0 = “any edge at all”).

Returns: a float probability in [0, 1] — want > 0.95 before you believe the Sharpe.

from edgekit.metrics import probabilistic_sharpe
psr = probabilistic_sharpe(daily_r, sr_benchmark=0.0)
psr   # P(true Sharpe > 0); 0.97 is evidence, 0.60 is a coin flip dressed up

omega#

The Omega ratio — probability-weighted gains above threshold divided by probability-weighted losses below it. Uses the entire return distribution (every moment, not just mean and variance), so it ranks skewed strategies fairly where Sharpe penalises upside volatility.

omega(r, threshold=0.0) -> float
  • r — per-period return series.
  • threshold — the per-period return that counts as “good enough” (default 0).

Returns: a float> 1 means gains above the threshold outweigh losses below it.

turnover#

Average one-period turnover of a weights panel — the mean of sum |Δw| across rebalances. The number you multiply by your cost-per-unit-traded to see how much of the gross edge the rebalancing itself burns.

turnover(weights) -> float
  • weights — a (T, n) DataFrame of portfolio weights over time.

Returns: a float — mean total absolute weight change per period.

from edgekit.metrics import turnover
to = turnover(weights_panel)
annual_cost = to * periods_per_year * cost_per_unit   # what rebalancing costs the book

capacity_estimate#

The order size at which square-root market impact eats the entire daily edge — adv · (edge / (c·sigma))². A deliberately blunt ceiling: above this quantity the strategy pays its own alpha away in impact. Small edges on volatile assets have brutally small capacity, and it pays to know that before scaling.

capacity_estimate(edge_daily, sigma_daily, adv, c=1.0) -> float
ParamTypeDefaultMeaning
edge_dailyfloatExpected daily edge as a return fraction.
sigma_dailyfloatDaily volatility of the asset.
advfloatAverage daily volume (same units the answer comes back in).
cfloat1.0Impact coefficient of the sqrt-impact model.

Returns: a float — the break-even quantity, in adv’s units.

from edgekit.metrics import capacity_estimate
q_max = capacity_estimate(edge_daily=0.001, sigma_daily=0.02, adv=5_000_000)
q_max / 5_000_000   # the fraction of ADV you can trade before impact eats the edge

See also#