diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 18:23:39 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 18:23:39 +0900 |
| commit | cd3a06c7788ad8a747b1b4579fb6c45b6c43008e (patch) | |
| tree | 1e8fb14802f2a607d821b83d372f82ce5eb63f83 | |
| parent | 66d0b86af0630e5ae9cf9071420db214410e1780 (diff) | |
feat(backtester): improve metrics with daily Sharpe, recovery factor, consecutive stats
| -rw-r--r-- | services/backtester/src/backtester/metrics.py | 79 | ||||
| -rw-r--r-- | services/backtester/tests/test_metrics.py | 68 |
2 files changed, 131 insertions, 16 deletions
diff --git a/services/backtester/src/backtester/metrics.py b/services/backtester/src/backtester/metrics.py index caf8477..5b43afd 100644 --- a/services/backtester/src/backtester/metrics.py +++ b/services/backtester/src/backtester/metrics.py @@ -16,6 +16,7 @@ class TradeRecord: side: str # "BUY" or "SELL" price: Decimal quantity: Decimal + fee: Decimal = Decimal("0") @dataclass @@ -38,6 +39,11 @@ class DetailedMetrics: largest_loss: float = 0.0 avg_holding_period: timedelta = field(default_factory=timedelta) trade_pairs: list[dict[str, Any]] = field(default_factory=list) + risk_free_rate: float = 0.0 + recovery_factor: float = 0.0 + max_consecutive_losses: int = 0 + max_consecutive_wins: int = 0 + daily_returns: list[float] = field(default_factory=list) def _pair_trades(trades: list[TradeRecord]) -> list[dict[str, Any]]: @@ -50,7 +56,8 @@ def _pair_trades(trades: list[TradeRecord]) -> list[dict[str, Any]]: buys.append(trade) elif trade.side == "SELL" and buys: buy = buys.pop(0) - pnl = float((trade.price - buy.price) * trade.quantity) + total_fees = float(buy.fee + trade.fee) + pnl = float((trade.price - buy.price) * trade.quantity) - total_fees pairs.append( { "entry_time": buy.time, @@ -60,6 +67,7 @@ def _pair_trades(trades: list[TradeRecord]) -> list[dict[str, Any]]: "exit_price": float(trade.price), "quantity": float(trade.quantity), "pnl": pnl, + "fees": total_fees, "holding_period": trade.time - buy.time, } ) @@ -70,6 +78,7 @@ def compute_detailed_metrics( trades: list[TradeRecord], initial_balance: Decimal, final_balance: Decimal, + risk_free_rate: float = 0.05, ) -> DetailedMetrics: """Compute detailed backtest metrics from a list of trade records.""" if not trades: @@ -170,26 +179,59 @@ def compute_detailed_metrics( end_time = pairs[i - 1]["exit_time"] max_dd_duration = end_time - start_time if end_time > start_time else timedelta(0) - # Sharpe ratio (annualized, assume 365 trading days) - if len(returns) > 1: - mean_r = sum(returns) / len(returns) - std_r = math.sqrt(sum((r - mean_r) ** 2 for r in returns) / (len(returns) - 1)) - sharpe = (mean_r / std_r * math.sqrt(365)) if std_r > 0 else 0.0 - elif len(returns) == 1: - sharpe = 0.0 - else: - sharpe = 0.0 + # Build daily equity curve for Sharpe/Sortino + from collections import defaultdict + + daily_equity: dict[str, float] = defaultdict(float) + for p in pairs: + day_key = p["exit_time"].strftime("%Y-%m-%d") + daily_equity[day_key] = daily_equity.get(day_key, 0.0) + p["pnl"] + + sorted_days = sorted(daily_equity.keys()) + daily_returns: list[float] = [] + balance = float(initial_balance) + for day in sorted_days: + pnl = daily_equity[day] + if balance > 0: + daily_returns.append(pnl / balance) + balance += pnl - # Sortino ratio (annualized) - if len(returns) > 1: - mean_r = sum(returns) / len(returns) - downside = [min(r, 0.0) for r in returns] - downside_var = sum(d**2 for d in downside) / (len(downside) - 1) + # Sharpe = (mean_daily - daily_rf) / std_daily * sqrt(365) + daily_rf = risk_free_rate / 365 + if len(daily_returns) > 1: + mean_r = sum(daily_returns) / len(daily_returns) + excess_returns = [r - daily_rf for r in daily_returns] + mean_excess = sum(excess_returns) / len(excess_returns) + std_r = math.sqrt(sum((r - mean_r) ** 2 for r in daily_returns) / (len(daily_returns) - 1)) + sharpe = (mean_excess / std_r * math.sqrt(365)) if std_r > 0 else 0.0 + + # Sortino (downside deviation of excess returns) + downside = [min(r - daily_rf, 0.0) for r in daily_returns] + downside_var = sum(d ** 2 for d in downside) / len(downside) downside_std = math.sqrt(downside_var) - sortino = (mean_r / downside_std * math.sqrt(365)) if downside_std > 0 else 0.0 + sortino = (mean_excess / downside_std * math.sqrt(365)) if downside_std > 0 else 0.0 else: + sharpe = 0.0 sortino = 0.0 + # Recovery factor + recovery_factor = abs(total_return / max_dd) if max_dd > 0 else 0.0 + + # Consecutive wins/losses + max_consec_wins = 0 + max_consec_losses = 0 + current_wins = 0 + current_losses = 0 + for p in pairs: + if p["pnl"] > 0: + current_wins += 1 + current_losses = 0 + max_consec_wins = max(max_consec_wins, current_wins) + else: + current_losses += 1 + current_wins = 0 + max_consec_losses = max(max_consec_losses, current_losses) + # Calmar ratio = annualized return / max drawdown # Estimate annualized return from total return and time span if pairs and max_dd > 0: @@ -225,4 +267,9 @@ def compute_detailed_metrics( largest_loss=largest_loss, avg_holding_period=avg_holding, trade_pairs=[p for p in pairs], + risk_free_rate=risk_free_rate, + recovery_factor=recovery_factor, + max_consecutive_losses=max_consec_losses, + max_consecutive_wins=max_consec_wins, + daily_returns=daily_returns, ) diff --git a/services/backtester/tests/test_metrics.py b/services/backtester/tests/test_metrics.py index 68bc0b5..34314b3 100644 --- a/services/backtester/tests/test_metrics.py +++ b/services/backtester/tests/test_metrics.py @@ -93,3 +93,71 @@ def test_compute_metrics_empty_trades(): assert metrics.calmar_ratio == 0.0 assert metrics.max_drawdown == 0.0 assert metrics.monthly_returns == {} + + +def test_recovery_factor(): + """Recovery factor should be positive when there is a drawdown.""" + trades = [ + _make_trade("BUY", "100", 0), + _make_trade("SELL", "150", 10), # win + _make_trade("BUY", "150", 20), + _make_trade("SELL", "120", 30), # loss: creates drawdown + ] + metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10020")) + assert metrics.recovery_factor > 0 + + +def test_consecutive_losses(): + """Consecutive loss tracking should count streaks correctly.""" + trades = [ + _make_trade("BUY", "100", 0), + _make_trade("SELL", "110", 10), # win + _make_trade("BUY", "110", 20), + _make_trade("SELL", "105", 30), # loss + _make_trade("BUY", "105", 40), + _make_trade("SELL", "100", 50), # loss + ] + metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10005")) + assert metrics.max_consecutive_losses >= 1 + assert metrics.max_consecutive_wins >= 1 + + +def test_risk_free_rate_affects_sharpe(): + """Higher risk-free rate should lower Sharpe ratio.""" + base = datetime(2025, 1, 1, tzinfo=timezone.utc) + trades = [ + TradeRecord(time=base, symbol="BTCUSDT", side="BUY", price=Decimal("100"), quantity=Decimal("1")), + TradeRecord(time=base + timedelta(days=1), symbol="BTCUSDT", side="SELL", price=Decimal("110"), quantity=Decimal("1")), + TradeRecord(time=base + timedelta(days=2), symbol="BTCUSDT", side="BUY", price=Decimal("105"), quantity=Decimal("1")), + TradeRecord(time=base + timedelta(days=3), symbol="BTCUSDT", side="SELL", price=Decimal("115"), quantity=Decimal("1")), + TradeRecord(time=base + timedelta(days=4), symbol="BTCUSDT", side="BUY", price=Decimal("110"), quantity=Decimal("1")), + TradeRecord(time=base + timedelta(days=5), symbol="BTCUSDT", side="SELL", price=Decimal("108"), quantity=Decimal("1")), + ] + m1 = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10018"), risk_free_rate=0.0) + m2 = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10018"), risk_free_rate=0.10) + assert m2.sharpe_ratio <= m1.sharpe_ratio + + +def test_daily_returns_populated(): + """Daily returns list should be populated when there are trades.""" + trades = [ + _make_trade("BUY", "100", 0), + _make_trade("SELL", "110", 60), + _make_trade("BUY", "105", 120), + _make_trade("SELL", "115", 180), + ] + metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10020")) + assert len(metrics.daily_returns) > 0 + + +def test_fee_subtracted_from_pnl(): + """Fees should be subtracted from trade PnL.""" + base = datetime(2025, 1, 1, tzinfo=timezone.utc) + trades_with_fees = [ + TradeRecord(time=base, symbol="BTC", side="BUY", price=Decimal("100"), quantity=Decimal("1"), fee=Decimal("1")), + TradeRecord(time=base + timedelta(minutes=10), symbol="BTC", side="SELL", price=Decimal("110"), quantity=Decimal("1"), fee=Decimal("1")), + ] + # PnL should be 10 - 1 - 1 = 8 + metrics = compute_detailed_metrics(trades_with_fees, Decimal("10000"), Decimal("10008")) + assert metrics.winning_trades == 1 + assert metrics.trade_pairs[0]["pnl"] == pytest.approx(8.0) |
