summaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 16:22:07 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 16:22:07 +0900
commitc89701668527ab94a124ac5ceb7a8e1045da1d72 (patch)
treee0ef1d021668eac9dc5f274f89d2bd7c7295cb8e /services
parente0320a4d4b7d22d7d663ef474c7d5e081f4e83a1 (diff)
feat(backtester): add detailed metrics (Sharpe, Sortino, drawdown)
Add metrics.py with TradeRecord/DetailedMetrics dataclasses and compute_detailed_metrics() that pairs BUY/SELL trades FIFO, computes win/loss stats, profit factor, equity curve, max drawdown, Sharpe, Sortino, Calmar ratios, and monthly returns.
Diffstat (limited to 'services')
-rw-r--r--services/backtester/src/backtester/metrics.py215
-rw-r--r--services/backtester/tests/test_metrics.py96
2 files changed, 311 insertions, 0 deletions
diff --git a/services/backtester/src/backtester/metrics.py b/services/backtester/src/backtester/metrics.py
new file mode 100644
index 0000000..15be0e6
--- /dev/null
+++ b/services/backtester/src/backtester/metrics.py
@@ -0,0 +1,215 @@
+"""Detailed backtest metrics: Sharpe, Sortino, drawdown, and more."""
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+from decimal import Decimal
+from typing import Any
+
+
+@dataclass
+class TradeRecord:
+ time: datetime
+ symbol: str
+ side: str # "BUY" or "SELL"
+ price: Decimal
+ quantity: Decimal
+
+
+@dataclass
+class DetailedMetrics:
+ total_return: float
+ total_trades: int
+ winning_trades: int
+ losing_trades: int
+ win_rate: float
+ profit_factor: float
+ sharpe_ratio: float
+ sortino_ratio: float
+ calmar_ratio: float
+ max_drawdown: float
+ max_drawdown_duration: timedelta
+ monthly_returns: dict[str, float] = field(default_factory=dict)
+ avg_win: float = 0.0
+ avg_loss: float = 0.0
+ largest_win: float = 0.0
+ largest_loss: float = 0.0
+ avg_holding_period: timedelta = field(default_factory=timedelta)
+ trade_pairs: list[dict[str, Any]] = field(default_factory=list)
+
+
+def _pair_trades(trades: list[TradeRecord]) -> list[dict[str, Any]]:
+ """Pair BUY/SELL trades using FIFO matching."""
+ buys: list[TradeRecord] = []
+ pairs: list[dict[str, Any]] = []
+
+ for trade in trades:
+ if trade.side == "BUY":
+ buys.append(trade)
+ elif trade.side == "SELL" and buys:
+ buy = buys.pop(0)
+ pnl = float((trade.price - buy.price) * trade.quantity)
+ pairs.append(
+ {
+ "entry_time": buy.time,
+ "exit_time": trade.time,
+ "symbol": trade.symbol,
+ "entry_price": float(buy.price),
+ "exit_price": float(trade.price),
+ "quantity": float(trade.quantity),
+ "pnl": pnl,
+ "holding_period": trade.time - buy.time,
+ }
+ )
+ return pairs
+
+
+def compute_detailed_metrics(
+ trades: list[TradeRecord],
+ initial_balance: Decimal,
+ final_balance: Decimal,
+) -> DetailedMetrics:
+ """Compute detailed backtest metrics from a list of trade records."""
+ if not trades:
+ return DetailedMetrics(
+ total_return=0.0,
+ total_trades=0,
+ winning_trades=0,
+ losing_trades=0,
+ win_rate=0.0,
+ profit_factor=0.0,
+ sharpe_ratio=0.0,
+ sortino_ratio=0.0,
+ calmar_ratio=0.0,
+ max_drawdown=0.0,
+ max_drawdown_duration=timedelta(0),
+ )
+
+ pairs = _pair_trades(trades)
+ total_return = float(final_balance - initial_balance) / float(initial_balance) if initial_balance else 0.0
+
+ if not pairs:
+ return DetailedMetrics(
+ total_return=total_return,
+ total_trades=len(trades),
+ winning_trades=0,
+ losing_trades=0,
+ win_rate=0.0,
+ profit_factor=0.0,
+ sharpe_ratio=0.0,
+ sortino_ratio=0.0,
+ calmar_ratio=0.0,
+ max_drawdown=0.0,
+ max_drawdown_duration=timedelta(0),
+ )
+
+ # Win/loss stats
+ wins = [p for p in pairs if p["pnl"] > 0]
+ losses = [p for p in pairs if p["pnl"] <= 0]
+ winning_trades = len(wins)
+ losing_trades = len(losses)
+ total_pairs = len(pairs)
+ win_rate = winning_trades / total_pairs * 100 if total_pairs else 0.0
+
+ gross_profit = sum(p["pnl"] for p in wins)
+ gross_loss = abs(sum(p["pnl"] for p in losses))
+ profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf") if gross_profit > 0 else 0.0
+
+ avg_win = gross_profit / winning_trades if winning_trades else 0.0
+ avg_loss = gross_loss / losing_trades if losing_trades else 0.0
+ largest_win = max((p["pnl"] for p in wins), default=0.0)
+ largest_loss = min((p["pnl"] for p in losses), default=0.0)
+
+ # Holding periods
+ holding_periods = [p["holding_period"] for p in pairs]
+ avg_holding = sum(holding_periods, timedelta(0)) / len(holding_periods) if holding_periods else timedelta(0)
+
+ # Build equity curve from pairs
+ init_bal = float(initial_balance)
+ equity = [init_bal]
+ for p in pairs:
+ equity.append(equity[-1] + p["pnl"])
+
+ # Returns per pair
+ returns = []
+ for i in range(1, len(equity)):
+ if equity[i - 1] != 0:
+ returns.append((equity[i] - equity[i - 1]) / equity[i - 1])
+ else:
+ returns.append(0.0)
+
+ # Max drawdown
+ peak = equity[0]
+ max_dd = 0.0
+ max_dd_duration = timedelta(0)
+ dd_start_idx = 0
+ for i in range(1, len(equity)):
+ if equity[i] > peak:
+ peak = equity[i]
+ dd_start_idx = i
+ dd = (peak - equity[i]) / peak if peak > 0 else 0.0
+ if dd > max_dd:
+ max_dd = dd
+ # Duration: use pair exit times
+ if i <= len(pairs) and dd_start_idx < len(pairs):
+ start_time = pairs[dd_start_idx]["exit_time"] if dd_start_idx < len(pairs) else pairs[0]["entry_time"]
+ 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
+
+ # 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)
+ downside_std = math.sqrt(downside_var)
+ sortino = (mean_r / downside_std * math.sqrt(365)) if downside_std > 0 else 0.0
+ else:
+ sortino = 0.0
+
+ # Calmar ratio = annualized return / max drawdown
+ # Estimate annualized return from total return and time span
+ if pairs and max_dd > 0:
+ time_span = (pairs[-1]["exit_time"] - pairs[0]["entry_time"]).total_seconds()
+ years = time_span / (365.25 * 86400) if time_span > 0 else 1.0
+ annualized_return = (1 + total_return) ** (1 / years) - 1 if years > 0 else total_return
+ calmar = annualized_return / max_dd
+ else:
+ calmar = 0.0
+
+ # Monthly returns grouped by exit_time month
+ monthly: dict[str, float] = {}
+ for p in pairs:
+ key = p["exit_time"].strftime("%Y-%m")
+ monthly[key] = monthly.get(key, 0.0) + p["pnl"]
+
+ return DetailedMetrics(
+ total_return=total_return,
+ total_trades=len(trades),
+ winning_trades=winning_trades,
+ losing_trades=losing_trades,
+ win_rate=win_rate,
+ profit_factor=profit_factor,
+ sharpe_ratio=sharpe,
+ sortino_ratio=sortino,
+ calmar_ratio=calmar,
+ max_drawdown=max_dd,
+ max_drawdown_duration=max_dd_duration,
+ monthly_returns=monthly,
+ avg_win=avg_win,
+ avg_loss=avg_loss,
+ largest_win=largest_win,
+ largest_loss=largest_loss,
+ avg_holding_period=avg_holding,
+ trade_pairs=[p for p in pairs],
+ )
diff --git a/services/backtester/tests/test_metrics.py b/services/backtester/tests/test_metrics.py
new file mode 100644
index 0000000..b222b8a
--- /dev/null
+++ b/services/backtester/tests/test_metrics.py
@@ -0,0 +1,96 @@
+"""Tests for detailed backtest metrics."""
+from datetime import datetime, timedelta, timezone
+from decimal import Decimal
+
+import pytest
+
+from backtester.metrics import TradeRecord, compute_detailed_metrics
+
+
+def _make_trade(side: str, price: str, minutes_offset: int = 0) -> TradeRecord:
+ return TradeRecord(
+ time=datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(minutes=minutes_offset),
+ symbol="BTCUSDT",
+ side=side,
+ price=Decimal(price),
+ quantity=Decimal("1"),
+ )
+
+
+def test_compute_metrics_basic():
+ """Two round-trip trades: 1 win, 1 loss. Verify counts and win_rate."""
+ trades = [
+ _make_trade("BUY", "100", 0),
+ _make_trade("SELL", "120", 10), # win: +20
+ _make_trade("BUY", "130", 20),
+ _make_trade("SELL", "110", 30), # loss: -20
+ ]
+ metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10000"))
+
+ assert metrics.total_trades == 4
+ assert metrics.winning_trades == 1
+ assert metrics.losing_trades == 1
+ assert metrics.win_rate == pytest.approx(50.0)
+
+
+def test_compute_metrics_profit_factor():
+ """Verify profit_factor = gross_profit / gross_loss."""
+ trades = [
+ _make_trade("BUY", "100", 0),
+ _make_trade("SELL", "150", 10), # win: +50
+ _make_trade("BUY", "150", 20),
+ _make_trade("SELL", "130", 30), # loss: -20
+ ]
+ metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10030"))
+
+ # gross_profit=50, gross_loss=20 → profit_factor=2.5
+ assert metrics.profit_factor == pytest.approx(2.5)
+
+
+def test_compute_metrics_max_drawdown():
+ """Max drawdown should be > 0 when there is a losing trade after a peak."""
+ trades = [
+ _make_trade("BUY", "100", 0),
+ _make_trade("SELL", "150", 10), # win: equity goes up
+ _make_trade("BUY", "150", 20),
+ _make_trade("SELL", "120", 30), # loss: equity drops
+ ]
+ metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10020"))
+
+ assert metrics.max_drawdown > 0
+
+
+def test_compute_metrics_sharpe_ratio():
+ """Sharpe ratio should be a finite number with multiple trades."""
+ trades = [
+ _make_trade("BUY", "100", 0),
+ _make_trade("SELL", "110", 60),
+ _make_trade("BUY", "105", 120),
+ _make_trade("SELL", "115", 180),
+ _make_trade("BUY", "110", 240),
+ _make_trade("SELL", "108", 300),
+ ]
+ metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10018"))
+
+ assert math.isfinite(metrics.sharpe_ratio)
+ assert math.isfinite(metrics.sortino_ratio)
+
+
+def test_compute_metrics_empty_trades():
+ """Empty trades should return all zeros."""
+ metrics = compute_detailed_metrics([], Decimal("10000"), Decimal("10000"))
+
+ assert metrics.total_return == 0.0
+ assert metrics.total_trades == 0
+ assert metrics.winning_trades == 0
+ assert metrics.losing_trades == 0
+ assert metrics.win_rate == 0.0
+ assert metrics.profit_factor == 0.0
+ assert metrics.sharpe_ratio == 0.0
+ assert metrics.sortino_ratio == 0.0
+ assert metrics.calmar_ratio == 0.0
+ assert metrics.max_drawdown == 0.0
+ assert metrics.monthly_returns == {}
+
+
+import math