A 100 → 0 → 50 equity path yields non-finite pct_change values that poisoned Sharpe/Sortino/IR with NaN/inf. Skip those ratios (report 0) when bar returns are non-finite, matching the options metrics path. Signed-off-by: santhreal <64453045+santhreal@users.noreply.github.com>
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""Shared backtest metrics, extracted from daily_portfolio.py for reuse.
|
|
|
|
Provides annualisation helpers, trade statistics, and full metric calculation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from backtest.models import TradeRecord
|
|
|
|
# ─── Annualisation factor mapping ───
|
|
|
|
# mootdx (A-share) and futu (HK + A-share) are equity sources, so they mirror
|
|
# the tushare/akshare column: 252 trading days and a 240-minute session. HK
|
|
# sessions are marginally longer (~330 min) — an approximation in line with the
|
|
# rest of this annualisation table; the key fix is that intraday mootdx/futu no
|
|
# longer fall back to the bars_per_day=1 default, which mis-annualised vol/Sharpe.
|
|
_TRADING_DAYS = {"tushare": 252, "yfinance": 252, "okx": 365, "akshare": 252, "ccxt": 365, "mootdx": 252, "futu": 252, "mt5": 260}
|
|
# mt5 is a forex/CFD feed: 24x5 sessions → 260 trading days, 24h intraday bars.
|
|
_BARS_PER_DAY = {
|
|
"1m": {"tushare": 240, "okx": 1440, "yfinance": 390, "akshare": 240, "ccxt": 1440, "mootdx": 240, "futu": 240, "mt5": 1440},
|
|
"5m": {"tushare": 48, "okx": 288, "yfinance": 78, "akshare": 48, "ccxt": 288, "mootdx": 48, "futu": 48, "mt5": 288},
|
|
"15m": {"tushare": 16, "okx": 96, "yfinance": 26, "akshare": 16, "ccxt": 96, "mootdx": 16, "futu": 16, "mt5": 96},
|
|
"30m": {"tushare": 8, "okx": 48, "yfinance": 13, "akshare": 8, "ccxt": 48, "mootdx": 8, "futu": 8, "mt5": 48},
|
|
"1H": {"tushare": 4, "okx": 24, "yfinance": 7, "akshare": 4, "ccxt": 24, "mootdx": 4, "futu": 4, "mt5": 24},
|
|
"4H": {"tushare": 1, "okx": 6, "yfinance": 2, "akshare": 1, "ccxt": 6, "mootdx": 1, "futu": 1, "mt5": 6},
|
|
"1D": {"tushare": 1, "okx": 1, "yfinance": 1, "akshare": 1, "ccxt": 1, "mootdx": 1, "futu": 1, "mt5": 1},
|
|
}
|
|
|
|
|
|
def calc_bars_per_year(interval: str = "1D", source: str = "tushare") -> int:
|
|
"""Number of bars per year for annualisation.
|
|
|
|
Args:
|
|
interval: Bar size (1m / 5m / 15m / 30m / 1H / 4H / 1D).
|
|
source: Data source (tushare / yfinance / okx).
|
|
|
|
Returns:
|
|
Bars per year.
|
|
"""
|
|
trading_days = _TRADING_DAYS.get(source, 252)
|
|
bars_per_day = _BARS_PER_DAY.get(interval, {}).get(source, 1)
|
|
return trading_days * bars_per_day
|
|
|
|
|
|
def win_rate_and_stats(trades: List[TradeRecord]) -> Dict[str, float]:
|
|
"""Win rate and P&L statistics from completed trades.
|
|
|
|
Args:
|
|
trades: Completed round-trip trades.
|
|
|
|
Returns:
|
|
Dict with win_rate, profit_loss_ratio, max_consecutive_loss,
|
|
avg_holding_bars, profit_factor.
|
|
"""
|
|
if not trades:
|
|
return {
|
|
"win_rate": 0.0,
|
|
"profit_loss_ratio": 0.0,
|
|
"max_consecutive_loss": 0,
|
|
"avg_holding_bars": 0.0,
|
|
"profit_factor": 0.0,
|
|
}
|
|
|
|
wins = [t.pnl for t in trades if t.pnl > 0]
|
|
losses = [t.pnl for t in trades if t.pnl < 0]
|
|
|
|
win_rate = len(wins) / len(trades)
|
|
|
|
avg_win = float(np.mean(wins)) if wins else 0.0
|
|
avg_loss = abs(float(np.mean(losses))) if losses else 1e-10
|
|
profit_loss_ratio = avg_win / avg_loss if avg_loss > 1e-10 else 0.0
|
|
|
|
gross_profit = sum(wins) if wins else 0.0
|
|
gross_loss = abs(sum(losses)) if losses else 1e-10
|
|
profit_factor = gross_profit / gross_loss if gross_loss > 1e-10 else 0.0
|
|
|
|
max_consec = 0
|
|
cur_consec = 0
|
|
for t in trades:
|
|
if t.pnl < 0:
|
|
cur_consec += 1
|
|
max_consec = max(max_consec, cur_consec)
|
|
else:
|
|
cur_consec = 0
|
|
|
|
hold_bars = [t.holding_bars for t in trades if t.holding_bars > 0]
|
|
avg_holding = float(np.mean(hold_bars)) if hold_bars else 0.0
|
|
|
|
return {
|
|
"win_rate": win_rate,
|
|
"profit_loss_ratio": round(profit_loss_ratio, 4),
|
|
"max_consecutive_loss": max_consec,
|
|
"avg_holding_bars": round(avg_holding, 1),
|
|
"profit_factor": round(profit_factor, 4),
|
|
}
|
|
|
|
|
|
def by_symbol_stats(trades: List[TradeRecord]) -> Dict[str, Dict[str, Any]]:
|
|
"""Per-symbol trade statistics.
|
|
|
|
Args:
|
|
trades: Completed round-trip trades.
|
|
|
|
Returns:
|
|
{symbol: {count, win_rate, total_pnl, avg_pnl}}.
|
|
"""
|
|
groups: Dict[str, list] = {}
|
|
for t in trades:
|
|
groups.setdefault(t.symbol, []).append(t)
|
|
|
|
result = {}
|
|
for sym, sym_trades in groups.items():
|
|
pnls = [t.pnl for t in sym_trades]
|
|
wins = [p for p in pnls if p > 0]
|
|
result[sym] = {
|
|
"count": len(sym_trades),
|
|
"win_rate": round(len(wins) / len(sym_trades), 4) if sym_trades else 0.0,
|
|
"total_pnl": round(sum(pnls), 2),
|
|
"avg_pnl": round(float(np.mean(pnls)), 2) if pnls else 0.0,
|
|
}
|
|
return result
|
|
|
|
|
|
def by_exit_reason_stats(trades: List[TradeRecord]) -> Dict[str, Dict[str, Any]]:
|
|
"""Per-exit-reason trade statistics.
|
|
|
|
Args:
|
|
trades: Completed round-trip trades.
|
|
|
|
Returns:
|
|
{reason: {count, total_pnl}}.
|
|
"""
|
|
groups: Dict[str, list] = {}
|
|
for t in trades:
|
|
groups.setdefault(t.exit_reason, []).append(t)
|
|
|
|
result = {}
|
|
for reason, reason_trades in groups.items():
|
|
pnls = [t.pnl for t in reason_trades]
|
|
result[reason] = {
|
|
"count": len(reason_trades),
|
|
"total_pnl": round(sum(pnls), 2),
|
|
}
|
|
return result
|
|
|
|
|
|
def calc_turnover_series(positions: pd.DataFrame) -> pd.Series:
|
|
"""Per-bar weight-implied portfolio turnover from a position frame.
|
|
|
|
Turnover for a bar is ``0.5 * sum_i |w_{t,i} - w_{t-1,i}|``, so a full
|
|
rotation from one asset to another counts as 1.0 (matching the
|
|
``turnover_aware`` optimizer's convention). The first bar's turnover is
|
|
``0.5 * sum_i |w_{0,i}|``, treating the initial allocation as entry from
|
|
cash. Turnover is measured on the weight frame the caller supplies. It
|
|
does not know whether the execution engine filled, rounded, or rejected
|
|
those target positions.
|
|
|
|
Args:
|
|
positions: Position-weight matrix (index=timestamp, columns=codes).
|
|
|
|
Returns:
|
|
Per-bar turnover series indexed like ``positions``; empty when the
|
|
input is empty.
|
|
"""
|
|
if positions is None or positions.empty:
|
|
return pd.Series(dtype=float)
|
|
filled = positions.fillna(0.0)
|
|
prev = filled.shift(1).fillna(0.0)
|
|
return 0.5 * (filled - prev).abs().sum(axis=1)
|
|
|
|
|
|
def calc_trade_turnover_series(
|
|
trades: List[TradeRecord],
|
|
equity_curve: pd.Series,
|
|
) -> pd.Series:
|
|
"""Per-bar turnover from actual entry and exit allocations.
|
|
|
|
Each filled leg contributes its margin-equivalent traded value. Dividing
|
|
gross traded value by twice the portfolio equity preserves the existing
|
|
convention: entering a 100% allocation counts as 0.5 and rotating a 100%
|
|
allocation between two assets counts as 1.0.
|
|
|
|
Args:
|
|
trades: Completed trades carrying actual entry/exit margin values.
|
|
equity_curve: Portfolio equity used to normalize traded values.
|
|
|
|
Returns:
|
|
Per-bar realized turnover aligned to ``equity_curve``. Bars without
|
|
fills are zero and remain part of the average-turnover denominator.
|
|
"""
|
|
if equity_curve is None or equity_curve.empty:
|
|
return pd.Series(dtype=float)
|
|
|
|
traded_margin = pd.Series(0.0, index=equity_curve.index, dtype=float)
|
|
for trade in trades:
|
|
for timestamp, margin in (
|
|
(trade.entry_time, trade.entry_margin),
|
|
(trade.exit_time, trade.exit_margin),
|
|
):
|
|
try:
|
|
margin_value = float(margin)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if (
|
|
timestamp in traded_margin.index
|
|
and np.isfinite(margin_value)
|
|
and margin_value > 0
|
|
):
|
|
traded_margin.loc[timestamp] += margin_value
|
|
|
|
denominator = 2.0 * equity_curve.abs().replace(0.0, np.nan)
|
|
return (traded_margin / denominator).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
|
|
|
|
|
def calc_metrics(
|
|
equity_curve: pd.Series,
|
|
trades: List[TradeRecord],
|
|
initial_cash: float,
|
|
bars_per_year: Optional[int] = 252,
|
|
bench_ret: Optional[pd.Series] = None,
|
|
positions: Optional[pd.DataFrame] = None,
|
|
turnover_series: Optional[pd.Series] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Full set of performance metrics.
|
|
|
|
Args:
|
|
equity_curve: Equity time series (index=timestamp, values=equity).
|
|
trades: Completed round-trip trades.
|
|
initial_cash: Starting capital.
|
|
bars_per_year: Bars per year for annualisation. None = auto-detect
|
|
from equity curve dates (calendar-day method, for cross-market).
|
|
bench_ret: Benchmark per-bar return series (optional).
|
|
positions: Position-weight frame used as a backward-compatible
|
|
turnover fallback when ``turnover_series`` is not supplied.
|
|
turnover_series: Actual per-bar execution turnover (optional). When
|
|
supplied, it takes precedence over position-implied turnover.
|
|
|
|
Returns:
|
|
Metrics dictionary (compatible with daily_portfolio format).
|
|
"""
|
|
if len(equity_curve) == 0:
|
|
return _empty_metrics(initial_cash)
|
|
|
|
n = len(equity_curve)
|
|
|
|
# Calendar-day annualization for cross-market (bars_per_year=None)
|
|
if bars_per_year is None:
|
|
first, last = equity_curve.index[0], equity_curve.index[-1]
|
|
calendar_days = (last - first).days
|
|
years = calendar_days / 365.25 if calendar_days > 0 else 1.0
|
|
bpy = int(n / years) if years > 0 else 252
|
|
else:
|
|
bpy = bars_per_year
|
|
|
|
port_ret = equity_curve.pct_change().fillna(0.0)
|
|
# Equity that touches zero then recovers (100 → 0 → 50) yields non-finite
|
|
# pct_change values; options metrics already skip risk ratios in that case.
|
|
returns_finite = bool(np.isfinite(port_ret.to_numpy(dtype=float, copy=False)).all())
|
|
|
|
total_ret = float(equity_curve.iloc[-1] / initial_cash - 1)
|
|
# A leveraged/short book can end at or below zero equity (``total_ret <= -1``).
|
|
# ``(1 + total_ret) ** fractional`` would then raise a negative base to a
|
|
# fractional power, which Python evaluates to a ``complex`` and crashes the
|
|
# subsequent ``float(...)``. A total wipeout annualises to -100%.
|
|
growth = 1 + total_ret
|
|
if growth <= 0:
|
|
ann_ret = -1.0
|
|
else:
|
|
# Explosive equity paths (e.g. 1 → 1e6 in a few bars) overflow
|
|
# ``float(growth ** …)`` on CPython; treat as non-finite annualisation.
|
|
try:
|
|
ann_ret = float(growth ** (bpy / max(n, 1)) - 1)
|
|
except OverflowError:
|
|
ann_ret = float("inf")
|
|
if not np.isfinite(ann_ret):
|
|
ann_ret = float("inf")
|
|
# ``Series.std()`` uses ddof=1, so a single-observation return series
|
|
# (e.g. a one-bar backtest) yields NaN and poisons the Sharpe ratio.
|
|
# Guard the small sample the same way ``downside_std`` is guarded below.
|
|
vol = float(port_ret.std()) if len(port_ret) > 1 and returns_finite else 0.0
|
|
sharpe = (
|
|
float(port_ret.mean() / (vol + 1e-10) * np.sqrt(bpy))
|
|
if returns_finite
|
|
else 0.0
|
|
)
|
|
if not np.isfinite(sharpe):
|
|
sharpe = 0.0
|
|
|
|
# Drawdown
|
|
peak = equity_curve.cummax()
|
|
dd = (equity_curve - peak) / peak.replace(0, 1)
|
|
max_dd = float(dd.min())
|
|
|
|
calmar = ann_ret / abs(max_dd) if abs(max_dd) > 1e-10 else 0.0
|
|
|
|
# Sortino
|
|
if returns_finite:
|
|
downside = port_ret[port_ret < 0]
|
|
downside_std = float(downside.std()) if len(downside) > 1 else 1e-10
|
|
sortino = float(port_ret.mean() / (downside_std + 1e-10) * np.sqrt(bpy))
|
|
else:
|
|
sortino = 0.0
|
|
if not np.isfinite(sortino):
|
|
sortino = 0.0
|
|
|
|
trade_stats = win_rate_and_stats(trades)
|
|
|
|
# Prefer execution-derived turnover; retain the position-frame fallback
|
|
# for external callers of calc_metrics that do not have fill records.
|
|
turnover_values = (
|
|
turnover_series.reindex(equity_curve.index).fillna(0.0).clip(lower=0.0)
|
|
if turnover_series is not None
|
|
else calc_turnover_series(positions)
|
|
if positions is not None
|
|
else pd.Series(dtype=float)
|
|
)
|
|
avg_turnover = float(turnover_values.mean()) if len(turnover_values) > 0 else 0.0
|
|
total_turnover = float(turnover_values.sum()) if len(turnover_values) > 0 else 0.0
|
|
|
|
# Benchmark comparison
|
|
bench_return = 0.0
|
|
excess = 0.0
|
|
ir = 0.0
|
|
if bench_ret is not None and len(bench_ret) > 0:
|
|
bench_return = float((1 + bench_ret).prod() - 1)
|
|
excess = total_ret - bench_return
|
|
active_ret = port_ret - bench_ret.reindex(port_ret.index).fillna(0.0)
|
|
# Same ddof=1 small-sample guard as ``vol`` / ``downside_std`` so the
|
|
# information ratio stays finite for a single-observation series.
|
|
active_std = float(active_ret.std()) if len(active_ret) > 1 and returns_finite else 0.0
|
|
ir = (
|
|
float(active_ret.mean() / (active_std + 1e-10) * np.sqrt(bpy))
|
|
if returns_finite
|
|
else 0.0
|
|
)
|
|
if not np.isfinite(ir):
|
|
ir = 0.0
|
|
|
|
return {
|
|
"final_value": float(equity_curve.iloc[-1]),
|
|
"total_return": total_ret,
|
|
"annual_return": ann_ret,
|
|
"max_drawdown": max_dd,
|
|
"sharpe": sharpe,
|
|
"calmar": round(calmar, 4),
|
|
"sortino": round(sortino, 4),
|
|
"win_rate": trade_stats["win_rate"],
|
|
"profit_loss_ratio": trade_stats["profit_loss_ratio"],
|
|
"profit_factor": trade_stats["profit_factor"],
|
|
"max_consecutive_loss": trade_stats["max_consecutive_loss"],
|
|
"avg_holding_days": trade_stats["avg_holding_bars"],
|
|
"trade_count": len(trades),
|
|
"benchmark_return": round(bench_return, 6),
|
|
"excess_return": round(excess, 6),
|
|
"information_ratio": round(ir, 4),
|
|
"avg_turnover": round(avg_turnover, 6),
|
|
"total_turnover": round(total_turnover, 6),
|
|
}
|
|
|
|
|
|
def _empty_metrics(initial_cash: float) -> Dict[str, Any]:
|
|
"""Return zero-valued metrics when no data is available."""
|
|
return {
|
|
"final_value": initial_cash,
|
|
"total_return": 0, "annual_return": 0, "max_drawdown": 0,
|
|
"sharpe": 0, "calmar": 0, "sortino": 0,
|
|
"win_rate": 0, "profit_loss_ratio": 0, "profit_factor": 0,
|
|
"max_consecutive_loss": 0, "avg_holding_days": 0, "trade_count": 0,
|
|
"benchmark_return": 0, "excess_return": 0, "information_ratio": 0,
|
|
"avg_turnover": 0.0, "total_turnover": 0.0,
|
|
}
|