summaryrefslogtreecommitdiff
path: root/services/backtester/src
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 16:22:12 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 16:22:12 +0900
commit73eaf704584e5bf3c4499ccdd574af87304e1e5f (patch)
tree2697356db1c17f8dac7d1b33813aeec8c4b4c736 /services/backtester/src
parentc89701668527ab94a124ac5ceb7a8e1045da1d72 (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
Diffstat (limited to 'services/backtester/src')
-rw-r--r--services/backtester/src/backtester/engine.py22
-rw-r--r--services/backtester/src/backtester/reporter.py149
-rw-r--r--services/backtester/src/backtester/simulator.py22
3 files changed, 160 insertions, 33 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