summaryrefslogtreecommitdiff
path: root/services/backtester
diff options
context:
space:
mode:
Diffstat (limited to 'services/backtester')
-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
-rw-r--r--services/backtester/tests/test_reporter.py74
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