Files
quant-trading/metrics.py
Gahow Wang 1697bc1641 fix(report): correct periodic returns, injection metrics and live view
- Monthly/yearly returns chain from the previous period's close (the first
  trading day of every period was dropped; up to ~9pp/yr error).
- With cash injections, metrics/drawdowns/periodic returns are time-weighted
  on the no-injection curve; capitalFlows reports invested/profit separately.
- Benchmarks cover exactly the strategies' dates.
- CAGR uses calendar time, so gaps in live records do not inflate it.
- Backtests skip the per-trade fee for rebalances below the live minimum
  order and keep a ruined portfolio at zero.
- live: exact strategy match, ineligible strategies hidden, currency,
  startDate, same-window benchmark, stale-price fallback, equity anchored at
  the initial capital.
- PIT membership falls back to the cached history when the Wikipedia change
  log cannot be parsed (the table is no longer on the page).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:16:33 +08:00

96 lines
3.3 KiB
Python

import numpy as np
import pandas as pd
def total_return(equity: pd.Series) -> float:
return equity.iloc[-1] / equity.iloc[0] - 1
def annualized_return(equity: pd.Series, periods: int = 252) -> float:
"""CAGR. Uses elapsed calendar time when the index is dated, so gaps in a
live record (missed days) do not inflate the annualized figure."""
growth = 1 + total_return(equity)
if growth <= 0:
return -1.0
if isinstance(equity.index, pd.DatetimeIndex) and len(equity) > 1:
years = (equity.index[-1] - equity.index[0]).days / 365.25
if years > 0:
return growth ** (1 / years) - 1
return growth ** (periods / len(equity)) - 1
def annualized_volatility(returns: pd.Series, periods: int = 252) -> float:
return returns.std() * np.sqrt(periods)
def sharpe_ratio(returns: pd.Series, risk_free_rate: float = 0.0, periods: int = 252) -> float:
excess = returns - risk_free_rate / periods
std = excess.std()
if std == 0:
return 0.0
return (excess.mean() / std) * np.sqrt(periods)
def sortino_ratio(returns: pd.Series, risk_free_rate: float = 0.0, periods: int = 252) -> float:
excess = returns - risk_free_rate / periods
downside_std = excess[excess < 0].std()
if downside_std == 0:
return 0.0
return (excess.mean() / downside_std) * np.sqrt(periods)
def max_drawdown(equity: pd.Series) -> float:
rolling_peak = equity.cummax()
drawdown = (equity - rolling_peak) / rolling_peak
return drawdown.min()
def calmar_ratio(equity: pd.Series, periods: int = 252) -> float:
ann_ret = annualized_return(equity, periods)
mdd = abs(max_drawdown(equity))
if mdd == 0:
return np.inf
return ann_ret / mdd
def win_rate(returns: pd.Series) -> float:
active = returns[returns != 0]
if len(active) == 0:
return 0.0
return (active > 0).sum() / len(active)
def raw_summary(equity: pd.Series) -> dict:
"""Return numeric metrics suitable for JSON serialization."""
returns = equity.pct_change().dropna()
return {
"totalReturn": float(total_return(equity)),
"annualizedReturn": float(annualized_return(equity)),
"annualizedVolatility": float(annualized_volatility(returns)),
"sharpeRatio": float(sharpe_ratio(returns)),
"sortinoRatio": float(sortino_ratio(returns)),
"maxDrawdown": float(max_drawdown(equity)),
"calmarRatio": float(calmar_ratio(equity)),
"winRate": float(win_rate(returns)),
}
def summary(equity: pd.Series, name: str = "Strategy") -> dict:
returns = equity.pct_change().dropna()
metrics = {
"Total Return": f"{total_return(equity):.2%}",
"Annualized Return": f"{annualized_return(equity):.2%}",
"Annualized Volatility": f"{annualized_volatility(returns):.2%}",
"Sharpe Ratio": f"{sharpe_ratio(returns):.2f}",
"Sortino Ratio": f"{sortino_ratio(returns):.2f}",
"Max Drawdown": f"{max_drawdown(equity):.2%}",
"Calmar Ratio": f"{calmar_ratio(equity):.2f}",
"Win Rate": f"{win_rate(returns):.2%}",
}
print(f"\n{'='*45}")
print(f" {name}")
print(f"{'='*45}")
for k, v in metrics.items():
print(f" {k:<26} {v:>10}")
return metrics