diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 16:22:12 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 16:22:12 +0900 |
| commit | 73eaf704584e5bf3c4499ccdd574af87304e1e5f (patch) | |
| tree | 2697356db1c17f8dac7d1b33813aeec8c4b4c736 | |
| parent | c89701668527ab94a124ac5ceb7a8e1045da1d72 (diff) | |
feat(backtester): integrate detailed metrics and rich reporter
- Add timestamp field to SimulatedTrade, pass candle.open_time from engine
- Engine now builds TradeRecord list and computes DetailedMetrics
- Reporter uses rich tables for summary and monthly returns display
- Add export_csv() and export_json() functions
- Update reporter tests for rich output and export functions
| -rw-r--r-- | services/backtester/src/backtester/engine.py | 22 | ||||
| -rw-r--r-- | services/backtester/src/backtester/reporter.py | 149 | ||||
| -rw-r--r-- | services/backtester/src/backtester/simulator.py | 22 | ||||
| -rw-r--r-- | services/backtester/tests/test_reporter.py | 74 |
4 files changed, 231 insertions, 36 deletions
diff --git a/services/backtester/src/backtester/engine.py b/services/backtester/src/backtester/engine.py index b89d422..386309b 100644 --- a/services/backtester/src/backtester/engine.py +++ b/services/backtester/src/backtester/engine.py @@ -1,10 +1,13 @@ """Backtesting engine that runs strategies against historical candle data.""" +from __future__ import annotations + from dataclasses import dataclass, field from decimal import Decimal from typing import Protocol from shared.models import Candle, Signal +from backtester.metrics import DetailedMetrics, TradeRecord, compute_detailed_metrics from backtester.simulator import OrderSimulator, SimulatedTrade @@ -30,6 +33,7 @@ class BacktestResult: profit: Decimal profit_pct: Decimal trades: list[SimulatedTrade] = field(default_factory=list) + detailed: DetailedMetrics | None = None @property def win_rate(self) -> float: @@ -67,7 +71,7 @@ class BacktestEngine: for candle in candles: signal = self._strategy.on_candle(candle) if signal is not None: - simulator.execute(signal) + simulator.execute(signal, timestamp=candle.open_time) # Calculate final balance including open positions valued at last candle close final_balance = simulator.balance @@ -83,6 +87,21 @@ class BacktestEngine: else: profit_pct = Decimal("0") + # Build TradeRecord list and compute detailed metrics + trade_records = [ + TradeRecord( + time=t.timestamp, + symbol=t.symbol, + side=t.side.value, + price=t.price, + quantity=t.quantity, + ) + for t in simulator.trades + ] + detailed = compute_detailed_metrics( + trade_records, self._initial_balance, final_balance + ) + return BacktestResult( strategy_name=self._strategy.name, symbol=candles[0].symbol if candles else "", @@ -92,4 +111,5 @@ class BacktestEngine: profit=profit, profit_pct=profit_pct, trades=simulator.trades, + detailed=detailed, ) diff --git a/services/backtester/src/backtester/reporter.py b/services/backtester/src/backtester/reporter.py index 916d5d4..e9e9936 100644 --- a/services/backtester/src/backtester/reporter.py +++ b/services/backtester/src/backtester/reporter.py @@ -1,28 +1,131 @@ """Report formatting for backtest results.""" +from __future__ import annotations + +import csv +import io +import json +from decimal import Decimal + +from rich.console import Console +from rich.table import Table + from backtester.engine import BacktestResult +class _DecimalEncoder(json.JSONEncoder): + def default(self, o: object) -> object: + if isinstance(o, Decimal): + return float(o) + return super().default(o) + + def format_report(result: BacktestResult) -> str: - """Format a backtest result into a human-readable text report.""" - separator = "=" * 50 - lines = [ - separator, - "BACKTEST REPORT", - separator, - f"Strategy: {result.strategy_name}", - f"Symbol: {result.symbol}", - separator, - "PERFORMANCE SUMMARY", - separator, - f"Initial Balance: {result.initial_balance:.2f}", - f"Final Balance: {result.final_balance:.2f}", - f"Profit/Loss: {result.profit:.2f}", - f"Profit %: {result.profit_pct:.2f}%", - separator, - "TRADE STATISTICS", - separator, - f"Total Trades: {result.total_trades}", - f"Win Rate: {result.win_rate:.2f}%", - separator, - ] - return "\n".join(lines) + """Format a backtest result using rich tables, returned as a string.""" + console = Console(file=io.StringIO(), force_terminal=True, width=80) + + # Summary table + summary = Table(title="Backtest Report", show_header=True, header_style="bold cyan") + summary.add_column("Metric", style="bold") + summary.add_column("Value", justify="right") + + summary.add_row("Strategy", result.strategy_name) + summary.add_row("Symbol", result.symbol) + summary.add_row("Initial Balance", f"{result.initial_balance:.2f}") + summary.add_row("Final Balance", f"{result.final_balance:.2f}") + summary.add_row("Profit/Loss", f"{result.profit:.2f}") + summary.add_row("Profit %", f"{result.profit_pct:.2f}%") + summary.add_row("Total Trades", str(result.total_trades)) + summary.add_row("Win Rate", f"{result.win_rate:.2f}%") + + if result.detailed: + d = result.detailed + summary.add_row("Winning Trades", str(d.winning_trades)) + summary.add_row("Losing Trades", str(d.losing_trades)) + summary.add_row("Profit Factor", f"{d.profit_factor:.2f}") + summary.add_row("Sharpe Ratio", f"{d.sharpe_ratio:.4f}") + summary.add_row("Sortino Ratio", f"{d.sortino_ratio:.4f}") + summary.add_row("Calmar Ratio", f"{d.calmar_ratio:.4f}") + summary.add_row("Max Drawdown", f"{d.max_drawdown:.4f}") + summary.add_row("Avg Win", f"{d.avg_win:.2f}") + summary.add_row("Avg Loss", f"{d.avg_loss:.2f}") + summary.add_row("Largest Win", f"{d.largest_win:.2f}") + summary.add_row("Largest Loss", f"{d.largest_loss:.2f}") + + console.print(summary) + + # Monthly returns table + if result.detailed and result.detailed.monthly_returns: + monthly_table = Table(title="Monthly Returns", show_header=True, header_style="bold green") + monthly_table.add_column("Month", style="bold") + monthly_table.add_column("Return", justify="right") + + for month, ret in sorted(result.detailed.monthly_returns.items()): + monthly_table.add_row(month, f"{ret:.2f}") + + console.print(monthly_table) + + output = console.file.getvalue() # type: ignore[attr-defined] + return output + + +def export_csv(result: BacktestResult) -> str: + """Export backtest result as CSV string.""" + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow(["Metric", "Value"]) + writer.writerow(["Strategy", result.strategy_name]) + writer.writerow(["Symbol", result.symbol]) + writer.writerow(["Initial Balance", str(result.initial_balance)]) + writer.writerow(["Final Balance", str(result.final_balance)]) + writer.writerow(["Profit", str(result.profit)]) + writer.writerow(["Profit %", str(result.profit_pct)]) + writer.writerow(["Total Trades", str(result.total_trades)]) + writer.writerow(["Win Rate", f"{result.win_rate:.2f}"]) + + if result.detailed: + d = result.detailed + writer.writerow(["Winning Trades", str(d.winning_trades)]) + writer.writerow(["Losing Trades", str(d.losing_trades)]) + writer.writerow(["Profit Factor", f"{d.profit_factor:.2f}"]) + writer.writerow(["Sharpe Ratio", f"{d.sharpe_ratio:.4f}"]) + writer.writerow(["Sortino Ratio", f"{d.sortino_ratio:.4f}"]) + writer.writerow(["Calmar Ratio", f"{d.calmar_ratio:.4f}"]) + writer.writerow(["Max Drawdown", f"{d.max_drawdown:.4f}"]) + + return output.getvalue() + + +def export_json(result: BacktestResult) -> str: + """Export backtest result as JSON string.""" + data: dict = { + "strategy_name": result.strategy_name, + "symbol": result.symbol, + "initial_balance": float(result.initial_balance), + "final_balance": float(result.final_balance), + "profit": float(result.profit), + "profit_pct": float(result.profit_pct), + "total_trades": result.total_trades, + "win_rate": result.win_rate, + } + + if result.detailed: + d = result.detailed + data["detailed"] = { + "total_return": d.total_return, + "winning_trades": d.winning_trades, + "losing_trades": d.losing_trades, + "win_rate": d.win_rate, + "profit_factor": d.profit_factor, + "sharpe_ratio": d.sharpe_ratio, + "sortino_ratio": d.sortino_ratio, + "calmar_ratio": d.calmar_ratio, + "max_drawdown": d.max_drawdown, + "avg_win": d.avg_win, + "avg_loss": d.avg_loss, + "largest_win": d.largest_win, + "largest_loss": d.largest_loss, + "monthly_returns": d.monthly_returns, + } + + return json.dumps(data, indent=2, cls=_DecimalEncoder) diff --git a/services/backtester/src/backtester/simulator.py b/services/backtester/src/backtester/simulator.py index 081ea3b..b897c5a 100644 --- a/services/backtester/src/backtester/simulator.py +++ b/services/backtester/src/backtester/simulator.py @@ -1,6 +1,8 @@ """Simulated order executor for backtesting.""" from dataclasses import dataclass, field +from datetime import datetime, timezone from decimal import Decimal +from typing import Optional from shared.models import OrderSide, Signal @@ -12,6 +14,7 @@ class SimulatedTrade: price: Decimal quantity: Decimal balance_after: Decimal + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) class OrderSimulator: @@ -22,7 +25,7 @@ class OrderSimulator: self.positions: dict[str, Decimal] = {} self.trades: list[SimulatedTrade] = [] - def execute(self, signal: Signal) -> bool: + def execute(self, signal: Signal, timestamp: Optional[datetime] = None) -> bool: """Execute a signal. Returns True if the trade was accepted, False otherwise.""" if signal.side == OrderSide.BUY: cost = signal.price * signal.quantity @@ -42,13 +45,14 @@ class OrderSimulator: self.balance += proceeds self.positions[signal.symbol] = current_position - trade_quantity - self.trades.append( - SimulatedTrade( - symbol=signal.symbol, - side=signal.side, - price=signal.price, - quantity=trade_quantity, - balance_after=self.balance, - ) + trade_kwargs: dict = dict( + symbol=signal.symbol, + side=signal.side, + price=signal.price, + quantity=trade_quantity, + balance_after=self.balance, ) + if timestamp is not None: + trade_kwargs["timestamp"] = timestamp + self.trades.append(SimulatedTrade(**trade_kwargs)) return True diff --git a/services/backtester/tests/test_reporter.py b/services/backtester/tests/test_reporter.py index f5c694c..aef3fc6 100644 --- a/services/backtester/tests/test_reporter.py +++ b/services/backtester/tests/test_reporter.py @@ -1,12 +1,35 @@ """Tests for the report formatter.""" +import json +from datetime import timedelta from decimal import Decimal from backtester.engine import BacktestResult -from backtester.reporter import format_report +from backtester.metrics import DetailedMetrics +from backtester.reporter import export_csv, export_json, format_report -def test_format_report_contains_key_metrics(): - result = BacktestResult( +def _make_result(with_detailed: bool = False) -> BacktestResult: + detailed = None + if with_detailed: + detailed = DetailedMetrics( + total_return=0.15, + total_trades=10, + winning_trades=6, + losing_trades=4, + win_rate=60.0, + profit_factor=2.5, + sharpe_ratio=1.5, + sortino_ratio=2.0, + calmar_ratio=3.0, + max_drawdown=0.05, + max_drawdown_duration=timedelta(hours=5), + monthly_returns={"2025-01": 500.0, "2025-02": 1000.0}, + avg_win=250.0, + avg_loss=125.0, + largest_win=600.0, + largest_loss=-300.0, + ) + return BacktestResult( strategy_name="sma_crossover", symbol="BTCUSDT", total_trades=10, @@ -15,7 +38,12 @@ def test_format_report_contains_key_metrics(): profit=Decimal("1500"), profit_pct=Decimal("15"), trades=[], + detailed=detailed, ) + + +def test_format_report_contains_key_metrics(): + result = _make_result(with_detailed=False) report = format_report(result) assert "sma_crossover" in report @@ -24,3 +52,43 @@ def test_format_report_contains_key_metrics(): assert "11500" in report assert "1500" in report assert "15" in report + + +def test_format_report_with_detailed_metrics(): + result = _make_result(with_detailed=True) + report = format_report(result) + + assert "Sharpe Ratio" in report + assert "Sortino Ratio" in report + assert "Max Drawdown" in report + assert "Profit Factor" in report + + +def test_format_report_monthly_returns(): + result = _make_result(with_detailed=True) + report = format_report(result) + + assert "Monthly Returns" in report + assert "2025-01" in report + assert "2025-02" in report + + +def test_export_csv(): + result = _make_result(with_detailed=True) + csv_output = export_csv(result) + + assert "Metric,Value" in csv_output + assert "sma_crossover" in csv_output + assert "Sharpe Ratio" in csv_output + + +def test_export_json(): + result = _make_result(with_detailed=True) + json_output = export_json(result) + + data = json.loads(json_output) + assert data["strategy_name"] == "sma_crossover" + assert data["symbol"] == "BTCUSDT" + assert "detailed" in data + assert data["detailed"]["sharpe_ratio"] == 1.5 + assert data["detailed"]["monthly_returns"]["2025-01"] == 500.0 |
