diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 18:25:29 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 18:25:29 +0900 |
| commit | 5cee0686e421b1f21484c23e413692616e9e2ffa (patch) | |
| tree | 0a6343ebeca158d5881c57cb71e9f67800fda535 | |
| parent | 7bfdf07dccb09a613f66f63d1513b80f167a3881 (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.py | 2 | ||||
| -rw-r--r-- | services/backtester/src/backtester/simulator.py | 8 | ||||
| -rw-r--r-- | services/backtester/src/backtester/walk_forward.py | 17 | ||||
| -rw-r--r-- | services/backtester/tests/test_engine.py | 8 | ||||
| -rw-r--r-- | services/backtester/tests/test_metrics.py | 68 | ||||
| -rw-r--r-- | services/backtester/tests/test_walk_forward.py | 23 |
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 |
