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

See also#