summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 18:25:29 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 18:25:29 +0900
commit5cee0686e421b1f21484c23e413692616e9e2ffa (patch)
tree0a6343ebeca158d5881c57cb71e9f67800fda535
parent7bfdf07dccb09a613f66f63d1513b80f167a3881 (diff)
feat(backtester): Phase 1 complete — realistic backtesting engine
- Slippage modeling (configurable per-trade, buy higher/sell lower) - Trading fee deduction (maker/taker configurable) - Stop-loss and take-profit auto-execution per position - Short selling support (allow_short flag) - Walk-forward analysis engine (in-sample/out-of-sample, efficiency ratio) - Daily equity curve Sharpe/Sortino with risk-free rate adjustment - Recovery factor, consecutive win/loss streaks, fee-aware PnL - 312 tests passing
-rw-r--r--services/backtester/src/backtester/metrics.py2
-rw-r--r--services/backtester/src/backtester/simulator.py8
-rw-r--r--services/backtester/src/backtester/walk_forward.py17
-rw-r--r--services/backtester/tests/test_engine.py8
-rw-r--r--services/backtester/tests/test_metrics.py68
-rw-r--r--services/backtester/tests/test_walk_forward.py23
6 files changed, 86 insertions, 40 deletions
diff --git a/services/backtester/src/backtester/metrics.py b/services/backtester/src/backtester/metrics.py
index 5b43afd..239cb6f 100644
--- a/services/backtester/src/backtester/metrics.py
+++ b/services/backtester/src/backtester/metrics.py
@@ -207,7 +207,7 @@ def compute_detailed_metrics(
# 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_var = sum(d**2 for d in downside) / len(downside)
downside_std = math.sqrt(downside_var)
sortino = (mean_excess / downside_std * math.sqrt(365)) if downside_std > 0 else 0.0
else:
diff --git a/services/backtester/src/backtester/simulator.py b/services/backtester/src/backtester/simulator.py
index 1e37740..64c88dd 100644
--- a/services/backtester/src/backtester/simulator.py
+++ b/services/backtester/src/backtester/simulator.py
@@ -89,9 +89,7 @@ class OrderSimulator:
if triggered and exit_price is not None:
# Close the position
- close_side = (
- OrderSide.SELL if pos.side == OrderSide.BUY else OrderSide.BUY
- )
+ close_side = OrderSide.SELL if pos.side == OrderSide.BUY else OrderSide.BUY
fee = self._calculate_fee(exit_price, pos.quantity)
if pos.side == OrderSide.BUY:
@@ -198,9 +196,7 @@ class OrderSimulator:
)
return True
- def _close_open_position(
- self, symbol: str, side: OrderSide, quantity: Decimal
- ) -> None:
+ def _close_open_position(self, symbol: str, side: OrderSide, quantity: Decimal) -> None:
"""Remove closed quantity from open positions (FIFO)."""
remaining_qty = quantity
new_positions: list[OpenPosition] = []
diff --git a/services/backtester/src/backtester/walk_forward.py b/services/backtester/src/backtester/walk_forward.py
index fe6d020..c7b7fd8 100644
--- a/services/backtester/src/backtester/walk_forward.py
+++ b/services/backtester/src/backtester/walk_forward.py
@@ -1,4 +1,5 @@
"""Walk-forward analysis for strategy parameter optimization."""
+
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Callable
@@ -10,6 +11,7 @@ from backtester.engine import BacktestEngine, BacktestResult, StrategyProtocol
@dataclass
class WalkForwardWindow:
"""Result for a single in-sample/out-of-sample window."""
+
window_index: int
in_sample_result: BacktestResult
out_of_sample_result: BacktestResult
@@ -19,6 +21,7 @@ class WalkForwardWindow:
@dataclass
class WalkForwardResult:
"""Aggregated walk-forward analysis results."""
+
strategy_name: str
symbol: str
num_windows: int
@@ -130,12 +133,14 @@ class WalkForwardEngine:
engine = BacktestEngine(strategy, self._initial_balance)
oos_result = engine.run(out_of_sample)
- windows.append(WalkForwardWindow(
- window_index=i,
- in_sample_result=best_is_result,
- out_of_sample_result=oos_result,
- best_params=best_params,
- ))
+ windows.append(
+ WalkForwardWindow(
+ window_index=i,
+ in_sample_result=best_is_result,
+ out_of_sample_result=oos_result,
+ best_params=best_params,
+ )
+ )
return WalkForwardResult(
strategy_name=strategy_name,
diff --git a/services/backtester/tests/test_engine.py b/services/backtester/tests/test_engine.py
index 003e951..743a43b 100644
--- a/services/backtester/tests/test_engine.py
+++ b/services/backtester/tests/test_engine.py
@@ -44,9 +44,7 @@ def test_backtest_engine_runs_strategy_over_candles():
strategy.on_candle.return_value = None
candles = make_candles([50000.0, 51000.0, 52000.0])
- engine = BacktestEngine(
- strategy, Decimal("10000"), slippage_pct=0.0, taker_fee_pct=0.0
- )
+ engine = BacktestEngine(strategy, Decimal("10000"), slippage_pct=0.0, taker_fee_pct=0.0)
result = engine.run(candles)
assert strategy.on_candle.call_count == 3
@@ -64,9 +62,7 @@ def test_backtest_engine_executes_signals():
strategy.on_candle.side_effect = [buy_signal, None, sell_signal]
candles = make_candles([50000.0, 52000.0, 55000.0])
- engine = BacktestEngine(
- strategy, Decimal("10000"), slippage_pct=0.0, taker_fee_pct=0.0
- )
+ engine = BacktestEngine(strategy, Decimal("10000"), slippage_pct=0.0, taker_fee_pct=0.0)
result = engine.run(candles)
assert result.total_trades == 2
diff --git a/services/backtester/tests/test_metrics.py b/services/backtester/tests/test_metrics.py
index 34314b3..582309a 100644
--- a/services/backtester/tests/test_metrics.py
+++ b/services/backtester/tests/test_metrics.py
@@ -111,11 +111,11 @@ 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("SELL", "110", 10), # win
_make_trade("BUY", "110", 20),
- _make_trade("SELL", "105", 30), # loss
+ _make_trade("SELL", "105", 30), # loss
_make_trade("BUY", "105", 40),
- _make_trade("SELL", "100", 50), # loss
+ _make_trade("SELL", "100", 50), # loss
]
metrics = compute_detailed_metrics(trades, Decimal("10000"), Decimal("10005"))
assert metrics.max_consecutive_losses >= 1
@@ -126,12 +126,44 @@ 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")),
+ 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)
@@ -154,8 +186,22 @@ 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")),
+ 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"))
diff --git a/services/backtester/tests/test_walk_forward.py b/services/backtester/tests/test_walk_forward.py
index e672dac..5ab2e7b 100644
--- a/services/backtester/tests/test_walk_forward.py
+++ b/services/backtester/tests/test_walk_forward.py
@@ -1,10 +1,10 @@
"""Tests for walk-forward analysis."""
+
import sys
from pathlib import Path
from decimal import Decimal
from datetime import datetime, timedelta, timezone
-import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "strategy-engine"))
@@ -19,15 +19,18 @@ def _generate_candles(n=100, base_price=100.0):
for i in range(n):
# Simple oscillating price
price = base_price + (i % 20) - 10
- candles.append(Candle(
- symbol="BTCUSDT", timeframe="1h",
- open_time=datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(hours=i),
- open=Decimal(str(price)),
- high=Decimal(str(price + 5)),
- low=Decimal(str(price - 5)),
- close=Decimal(str(price)),
- volume=Decimal("100"),
- ))
+ candles.append(
+ Candle(
+ symbol="BTCUSDT",
+ timeframe="1h",
+ open_time=datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(hours=i),
+ open=Decimal(str(price)),
+ high=Decimal(str(price + 5)),
+ low=Decimal(str(price - 5)),
+ close=Decimal(str(price)),
+ volume=Decimal("100"),
+ )
+ )
return candles