feat(backtest): add Sortino, Gain-to-Pain and dollar profit factor
Three portfolio metrics computed where their inputs already live in _simulate_portfolio: Sortino off the existing daily return series, Gain-to-Pain off a monthly aggregation of the equity curve, profit factor off closed-trade dollar P&L. Gain-to-Pain follows Schwager — sum of ALL monthly returns over the absolute sum of the negative ones. The profit-factor-shaped variant, sum(positive)/|sum(negative)|, sits exactly 1.0 higher for every input since sum(all) = sum(pos) - |sum(neg)|; the test asserts against both so the wrong one cannot pass. Sortino divides by len(rets), the full-sample lower partial moment, not by the count of down days, which would shrink the denominator and inflate the ratio. No MAR field: calmar is already CAGR / max drawdown, the same number under the other name (docs/research/effective-risk-floor-ab.md). All three keys are emitted unconditionally even when None — the UI reads an absent key as "report predates these metrics", so presence is a contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2625,6 +2625,19 @@ def _simulate_portfolio(
|
||||
diag = sharpe_diagnostics(rets)
|
||||
sharpe = diag["sharpe"]
|
||||
|
||||
# Sortino: the same numerator as Sharpe over downside deviation about a zero
|
||||
# target. The denominator divides by len(rets) — the full-sample lower partial
|
||||
# moment — NOT by the count of down days, which would shrink the denominator
|
||||
# and inflate the ratio. n >= 3 matches sharpe_diagnostics so the two appear
|
||||
# together or not at all. No down days is +inf, reported as None.
|
||||
sortino = None
|
||||
downside = [r for r in rets if r < 0.0]
|
||||
if len(rets) >= 3 and downside:
|
||||
mean_ret = sum(rets) / len(rets)
|
||||
dd = math.sqrt(sum(r * r for r in downside) / len(rets))
|
||||
if dd > 0:
|
||||
sortino = round(mean_ret / dd * math.sqrt(252.0), 2)
|
||||
|
||||
# Per-calendar-year returns off the equity curve — shows whether every year
|
||||
# contributed or one exceptional stretch carried the result.
|
||||
yearly: list[dict] = []
|
||||
@@ -2650,8 +2663,40 @@ def _simulate_portfolio(
|
||||
),
|
||||
})
|
||||
|
||||
# Gain-to-Pain off the same curve, on MONTHLY returns: Schwager's ratio is
|
||||
# defined monthly and the daily variant is not comparable to published
|
||||
# figures. Distinct loop variables from the yearly pass above — that one exits
|
||||
# with last_eq at final equity, so reusing its names silently corrupts the
|
||||
# first month. The monthly series itself is not emitted: 36-120 floats per
|
||||
# strategy per lookback would bloat the single stored report blob.
|
||||
monthly: list[float] = []
|
||||
month_start_eq = curve[0][1]
|
||||
month_last_eq = curve[0][1]
|
||||
cur_month = date.fromordinal(curve[0][0]).replace(day=1)
|
||||
for o, eq in curve:
|
||||
m = date.fromordinal(o).replace(day=1)
|
||||
if m != cur_month:
|
||||
if month_start_eq > 0:
|
||||
monthly.append(month_last_eq / month_start_eq - 1.0)
|
||||
cur_month = m
|
||||
month_start_eq = month_last_eq
|
||||
month_last_eq = eq
|
||||
if month_start_eq > 0:
|
||||
monthly.append(month_last_eq / month_start_eq - 1.0)
|
||||
|
||||
# Schwager: SUM OF ALL monthly returns over the absolute sum of the negative
|
||||
# ones. Not sum(positive)/|sum(negative)| — that is profit-factor-shaped and
|
||||
# sits exactly 1.0 higher for every input, since sum(all) = sum(pos) - |sum(neg)|.
|
||||
monthly_pain = -sum(r for r in monthly if r < 0.0)
|
||||
gain_to_pain = round(sum(monthly) / monthly_pain, 2) if monthly_pain > 0 else None
|
||||
|
||||
pnls = [t["pnl"] for t in trades]
|
||||
wins = sum(1 for p in pnls if p > 0)
|
||||
# Dollar-based, over closed-trade P&L. Distinct from the R-based profit_factor
|
||||
# in _robustness_stats; the two never share an object.
|
||||
gross_win = sum(p for p in pnls if p > 0)
|
||||
gross_loss = -sum(p for p in pnls if p < 0)
|
||||
profit_factor = round(gross_win / gross_loss, 2) if gross_loss > 0 else None
|
||||
reason_counts = {
|
||||
reason: sum(1 for t in trades if t["reason"] == reason)
|
||||
for reason in sorted({t["reason"] for t in trades})
|
||||
@@ -2706,7 +2751,13 @@ def _simulate_portfolio(
|
||||
"total_return_pct": round(total_return_pct, 1),
|
||||
"cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None,
|
||||
"max_drawdown_pct": round(max_dd_pct, 1),
|
||||
# calmar IS MAR here (CAGR / max drawdown) — one field, two names.
|
||||
"calmar": round(calmar, 2) if calmar is not None else None,
|
||||
# Emitted unconditionally even when None: the UI treats an ABSENT key as
|
||||
# "report predates these metrics", so presence is a contract.
|
||||
"sortino": sortino,
|
||||
"gain_to_pain": gain_to_pain,
|
||||
"profit_factor": profit_factor,
|
||||
"sharpe": sharpe,
|
||||
"sharpe_se": diag["sharpe_se"],
|
||||
"psr": diag["psr"],
|
||||
|
||||
Reference in New Issue
Block a user