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).
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) -> floatReturns: 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) -> floatReturns: 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.0max_drawdown_pct#
Max drawdown expressed as a fraction of the running peak, rather than in raw units.
max_drawdown_pct(equity) -> floatReturns: 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| Param | Type | Default | Meaning |
|---|---|---|---|
returns | array-like | — | Per-period return series (non-finite values dropped). |
periods_per_year | float | 252.0 | Annualisation 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) -> floatReturns: 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| Param | Type | Default | Meaning |
|---|---|---|---|
r | array-like | — | Per-trade net R-multiples. |
dates | array-like | None | None | Exit dates — enables annualised R, max DD, Sharpe, frequency. |
hold | array-like | None | None | Holding 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: onlyn,total_r,ev_r,win_rate,pf. - With
dates: addsyears,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: addsavg_hold,median_hold.
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 Requity_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| Param | Type | Default | Meaning |
|---|---|---|---|
daily_pnl | array-like | — | Daily P&L (dollars if account given) or fractional returns. |
account | float | None | None | Account size; when set, daily_pnl is dollars and normalised by it. |
periods_per_year | float | 252.0 | Annualisation factor for CAGR / Sharpe / Sortino. |
Returns (all keys): total_return, cagr, sharpe, sortino, max_dd_pct, mar, worst_day, best_day.
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| Param | Type | Default | Meaning |
|---|---|---|---|
daily_r | array-like | — | Daily R-stream (per-day sum of trade R-multiples). |
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): 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).
dd95 estimate.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-DDTrade 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| Param | Type | Default | Meaning |
|---|---|---|---|
trades | pd.DataFrame | — | The canonical trade frame from a backtest (entry/exit/stop columns). |
bars | pd.DataFrame | — | The 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 evidenceedge_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| Param | Type | Default | Meaning |
|---|---|---|---|
r | array-like | — | Per-trade net R-multiples, in order. |
dates | array-like | None | None | Trade dates for the index (integer index if None). |
window | int | 50 | Rolling 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 doesprobabilistic_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| Param | Type | Default | Meaning |
|---|---|---|---|
r | array-like | — | Per-period return series. |
sr_benchmark | float | 0.0 | The 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 upomega#
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) -> floatr— 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) -> floatweights— 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 bookcapacity_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| Param | Type | Default | Meaning |
|---|---|---|---|
edge_daily | float | — | Expected daily edge as a return fraction. |
sigma_daily | float | — | Daily volatility of the asset. |
adv | float | — | Average daily volume (same units the answer comes back in). |
c | float | 1.0 | Impact 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 edgeSee also#
- edgekit.sizing —
size_to_ddwrapsdd_matched_sizeand returns the sized dollar series. - edgekit.costs — feed
trade_statsintocost_stress. - The validation gauntlet — where these metrics get stress-tested.