From 6e52e4190d62d0609d7391a03d18d6b8b6cf01a0 Mon Sep 17 00:00:00 2001 From: Andrew Kaszubski Date: Fri, 26 Dec 2025 22:55:18 +1100 Subject: [PATCH] feat(backtest): add BacktestEngine with slippage and commission models - Issue #42 (57 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive backtesting framework: - OrderSide, OrderType, FillStatus enums - OHLCV bar data and Signal dataclasses - BacktestConfig, BacktestPosition, BacktestTrade, BacktestResult - BacktestSnapshot for portfolio tracking over time - BacktestEngine main class Slippage Models: - NoSlippage: Zero slippage (ideal execution) - FixedSlippage: Fixed amount per share - PercentageSlippage: Percentage of price - VolumeSlippage: Volume-impact model with participation ratio Commission Models: - NoCommission: Zero commission - FixedCommission: Fixed per trade - PerShareCommission: Per share with min/max - PercentageCommission: Percentage of trade value - TieredCommission: Tiered by trade value thresholds Features: - Historical price data replay with date filtering - Multi-symbol portfolio support - Automatic position sizing (equal weight) - Buy/sell signal execution with slippage - Cash and position tracking - Shorting support (configurable) - Max position percentage limits - Minimum trade value enforcement - Strategy callback for dynamic signals - Portfolio snapshots at each timestamp - Drawdown tracking with peak detection - Sharpe and Sortino ratio calculation - Win rate, profit factor, avg trade P&L - Total commission and slippage tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/batch_state.json | 6 +- .claude/cache/commit_msg.txt | 40 +- logs/security_audit.log | 6 + tests/unit/backtest/test_backtest_engine.py | 964 ++++++++++++++ tradingagents/backtest/__init__.py | 141 ++ tradingagents/backtest/backtest_engine.py | 1270 +++++++++++++++++++ 6 files changed, 2405 insertions(+), 22 deletions(-) create mode 100644 tests/unit/backtest/test_backtest_engine.py create mode 100644 tradingagents/backtest/__init__.py create mode 100644 tradingagents/backtest/backtest_engine.py diff --git a/.claude/batch_state.json b/.claude/batch_state.json index 94f0e2fc..068e9240 100644 --- a/.claude/batch_state.json +++ b/.claude/batch_state.json @@ -49,8 +49,8 @@ "Issue #48: [DOCS-47] Documentation - user guide, developer docs" ], "total_features": 45, - "current_index": 37, - "completed_features": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36], + "current_index": 38, + "completed_features": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37], "failed_features": [], "context_token_estimate": 0, "auto_clear_count": 0, @@ -60,5 +60,5 @@ "source_type": "issues", "feature_order": [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44], "started_at": "2025-12-26T12:35:00Z", - "notes": "Issue #2 already implemented. Issue #3: 84 tests (d3892b0). Issue #4: 51 tests (0d09f15). Issue #5: 43 tests (1c6c2fa). Issue #6: 87 tests (1ea006e). Issue #7: migrations fixed + README (68be12c). Issue #8: 108 tests FRED API (4d693fb). Issue #9: 42 tests multi-timeframe (19171a4). Issue #10: 35 tests benchmark (bbd85c9). Issue #11: 84 tests vendor routing (2c80264). Issue #12: 41 tests data cache (ae7899a). Issue #13: 47 tests momentum analyst (8522b4b). Issue #14: 57 tests macro analyst (bdff87a). Issue #15: 59 tests correlation analyst (b0140a8). Issue #16: 52 tests position sizing (a17fc1f). Issue #17: 35 tests analyst integration (5a0606b). Issue #18: 71 tests layered memory (d72c214). Issue #19: 51 tests trade history (dbfcea3). Issue #20: 59 tests risk profiles (25c31d5). Issue #21: 26 tests memory integration (4f6f7c1). Issue #22: 71 tests broker base (e4ef947). Issue #23: 57 tests broker router (850346a). Issue #24: 37 tests alpaca broker (593d599). Issue #25: 38 tests ibkr broker (1e32c0e). Issue #26: 63 tests paper broker (834d18f). Issue #27: 47 tests order manager (6863e3e). Issue #28: 45 tests risk controls (9aee433). Issue #29: 68 tests portfolio state (6642047). Issue #31: 63 tests performance metrics (bedb59b). Issue #32: 66 tests CGT calculator (13f2bba). Issue #33: 45 tests scenario runner (e7bff2c). Issue #34: 43 tests strategy comparator (76eac65). Issue #35: 53 tests economic conditions (b54d6ba). Issue #36: 56 tests signal to order (c423c6b). Issue #37: 37 tests strategy executor (ddb12c1). Issue #38: 55 tests alert manager (7ab60eb). Issue #40: 44 tests slack channel (795f970)." + "notes": "Issue #2 already implemented. Issue #3: 84 tests (d3892b0). Issue #4: 51 tests (0d09f15). Issue #5: 43 tests (1c6c2fa). Issue #6: 87 tests (1ea006e). Issue #7: migrations fixed + README (68be12c). Issue #8: 108 tests FRED API (4d693fb). Issue #9: 42 tests multi-timeframe (19171a4). Issue #10: 35 tests benchmark (bbd85c9). Issue #11: 84 tests vendor routing (2c80264). Issue #12: 41 tests data cache (ae7899a). Issue #13: 47 tests momentum analyst (8522b4b). Issue #14: 57 tests macro analyst (bdff87a). Issue #15: 59 tests correlation analyst (b0140a8). Issue #16: 52 tests position sizing (a17fc1f). Issue #17: 35 tests analyst integration (5a0606b). Issue #18: 71 tests layered memory (d72c214). Issue #19: 51 tests trade history (dbfcea3). Issue #20: 59 tests risk profiles (25c31d5). Issue #21: 26 tests memory integration (4f6f7c1). Issue #22: 71 tests broker base (e4ef947). Issue #23: 57 tests broker router (850346a). Issue #24: 37 tests alpaca broker (593d599). Issue #25: 38 tests ibkr broker (1e32c0e). Issue #26: 63 tests paper broker (834d18f). Issue #27: 47 tests order manager (6863e3e). Issue #28: 45 tests risk controls (9aee433). Issue #29: 68 tests portfolio state (6642047). Issue #31: 63 tests performance metrics (bedb59b). Issue #32: 66 tests CGT calculator (13f2bba). Issue #33: 45 tests scenario runner (e7bff2c). Issue #34: 43 tests strategy comparator (76eac65). Issue #35: 53 tests economic conditions (b54d6ba). Issue #36: 56 tests signal to order (c423c6b). Issue #37: 37 tests strategy executor (ddb12c1). Issue #38: 55 tests alert manager (7ab60eb). Issue #40: 44 tests slack channel (795f970). Issue #41: 59 tests SMS channel (b6eca9e)." } diff --git a/.claude/cache/commit_msg.txt b/.claude/cache/commit_msg.txt index 93c7b8e9..595b8e01 100644 --- a/.claude/cache/commit_msg.txt +++ b/.claude/cache/commit_msg.txt @@ -1,27 +1,29 @@ -feat(alerts): add Slack channel with webhooks and Block Kit - Issue #40 (44 tests) +feat(alerts): add SMS channel with Twilio integration - Issue #41 (59 tests) -Implements Slack integration for alert delivery: -- SlackMessageStyle enum (simple, blocks, attachment) -- SlackConfig for webhook URL, channel, username, icon -- SlackMessageResult for delivery tracking -- SlackMessageFormatter with Block Kit support -- SlackChannel implementing AlertChannel protocol -- create_slack_channel factory function +Implements SMS alert delivery using Twilio API: +- SMSFormat enum (plain, compact, detailed) +- SMSStatus enum (queued, sending, sent, delivered, failed, etc.) +- SMSConfig for Twilio credentials and settings +- SMSMessageResult for delivery tracking +- SMSBatchResult for multi-recipient sends +- SMSMessageFormatter with priority indicators +- SMSChannel implementing AlertChannel protocol +- create_sms_channel factory function Features: -- Three formatting styles (simple text, blocks, attachments) -- Priority-based colors and emojis -- Category emojis for visual identification -- @mention support for critical alerts -- Timestamp and source inclusion options -- Data field display in messages -- Webhook URL validation (hooks.slack.com) +- E.164 phone number validation +- Three formatting styles (plain, compact, detailed) +- Priority indicators ([!], [!!!]) for high/critical alerts +- Category prefixes (TRD, RSK, SYS, MKT, etc.) +- SMS segment counting (GSM-7 vs Unicode) +- Messaging service SID support +- Priority filtering (send only HIGH+ alerts) +- Batch sending to multiple recipients - Retry logic with exponential backoff -- Rate limit handling (429 status with Retry-After) -- Server error retry (500+) vs client error fail-fast (400) +- Server error retry vs client error fail-fast - Latency tracking for performance monitoring -- Sync and async delivery methods -- Test webhook functionality +- Status callback URL support +- Connection test functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/logs/security_audit.log b/logs/security_audit.log index 2796caaa..0268c7a4 100644 --- a/logs/security_audit.log +++ b/logs/security_audit.log @@ -2511,3 +2511,9 @@ {"timestamp": "2025-12-26T11:46:58.778745Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts", "test_mode": false}} {"timestamp": "2025-12-26T11:47:09.309952Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/sms_channel.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/sms_channel.py", "test_mode": false}} {"timestamp": "2025-12-26T11:47:28.653880Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tests/unit/alerts/test_sms_channel.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tests/unit/alerts/test_sms_channel.py", "test_mode": false}} +{"timestamp": "2025-12-26T11:47:55.583233Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/cache/commit_msg.txt", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/cache/commit_msg.txt", "test_mode": false}} +{"timestamp": "2025-12-26T11:48:31.321467Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/batch_state.json", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/batch_state.json", "test_mode": false}} +{"timestamp": "2025-12-26T11:49:04.090842Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/batch_state.json", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/batch_state.json", "test_mode": false}} +{"timestamp": "2025-12-26T11:52:02.146237Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/backtest/backtest_engine.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/backtest/backtest_engine.py", "test_mode": false}} +{"timestamp": "2025-12-26T11:52:24.761044Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/backtest/__init__.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/backtest/__init__.py", "test_mode": false}} +{"timestamp": "2025-12-26T11:54:45.126921Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tests/unit/backtest/test_backtest_engine.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tests/unit/backtest/test_backtest_engine.py", "test_mode": false}} diff --git a/tests/unit/backtest/test_backtest_engine.py b/tests/unit/backtest/test_backtest_engine.py new file mode 100644 index 00000000..afa0a0c7 --- /dev/null +++ b/tests/unit/backtest/test_backtest_engine.py @@ -0,0 +1,964 @@ +"""Tests for Backtest Engine. + +Issue #42: [BT-41] Backtest engine - historical replay, slippage +""" + +from datetime import datetime, timedelta +from decimal import Decimal +import pytest + +from tradingagents.backtest import ( + # Enums + OrderSide, + OrderType, + FillStatus, + # Data Classes + OHLCV, + Signal, + BacktestConfig, + BacktestPosition, + BacktestTrade, + BacktestSnapshot, + BacktestResult, + # Slippage Models + SlippageModel, + NoSlippage, + FixedSlippage, + PercentageSlippage, + VolumeSlippage, + # Commission Models + CommissionModel, + NoCommission, + FixedCommission, + PerShareCommission, + PercentageCommission, + TieredCommission, + # Main Classes + BacktestEngine, + # Factory Functions + create_backtest_engine, +) + + +ZERO = Decimal("0") + + +# ============================================================================ +# Enum Tests +# ============================================================================ + +class TestOrderSide: + """Tests for OrderSide enum.""" + + def test_values(self): + """Test enum values.""" + assert OrderSide.BUY.value == "buy" + assert OrderSide.SELL.value == "sell" + + +class TestOrderType: + """Tests for OrderType enum.""" + + def test_values(self): + """Test enum values.""" + assert OrderType.MARKET.value == "market" + assert OrderType.LIMIT.value == "limit" + assert OrderType.STOP.value == "stop" + assert OrderType.STOP_LIMIT.value == "stop_limit" + + +class TestFillStatus: + """Tests for FillStatus enum.""" + + def test_values(self): + """Test enum values.""" + assert FillStatus.UNFILLED.value == "unfilled" + assert FillStatus.FILLED.value == "filled" + assert FillStatus.PARTIAL.value == "partial" + + +# ============================================================================ +# Data Class Tests +# ============================================================================ + +class TestOHLCV: + """Tests for OHLCV dataclass.""" + + def test_creation(self): + """Test OHLCV creation.""" + bar = OHLCV( + timestamp=datetime(2023, 1, 3), + open=Decimal("100"), + high=Decimal("105"), + low=Decimal("99"), + close=Decimal("103"), + volume=Decimal("1000000"), + symbol="AAPL", + ) + assert bar.open == Decimal("100") + assert bar.close == Decimal("103") + assert bar.symbol == "AAPL" + + def test_numeric_conversion(self): + """Test numeric types are converted to Decimal.""" + bar = OHLCV( + timestamp=datetime(2023, 1, 3), + open=100, + high=105, + low=99, + close=103, + volume=1000000, + ) + assert isinstance(bar.open, Decimal) + assert bar.open == Decimal("100") + + +class TestSignal: + """Tests for Signal dataclass.""" + + def test_creation(self): + """Test signal creation.""" + signal = Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + quantity=Decimal("100"), + ) + assert signal.symbol == "AAPL" + assert signal.side == OrderSide.BUY + assert signal.quantity == Decimal("100") + + def test_defaults(self): + """Test signal defaults.""" + signal = Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + ) + assert signal.quantity == ZERO + assert signal.order_type == OrderType.MARKET + assert signal.confidence == Decimal("1") + + +class TestBacktestConfig: + """Tests for BacktestConfig dataclass.""" + + def test_defaults(self): + """Test default configuration.""" + config = BacktestConfig() + assert config.initial_capital == Decimal("100000") + assert config.allow_shorting is False + assert config.max_position_pct == Decimal("20") + + def test_custom_config(self): + """Test custom configuration.""" + config = BacktestConfig( + initial_capital=Decimal("50000"), + allow_shorting=True, + max_position_pct=Decimal("10"), + ) + assert config.initial_capital == Decimal("50000") + assert config.allow_shorting is True + + +class TestBacktestPosition: + """Tests for BacktestPosition dataclass.""" + + def test_creation(self): + """Test position creation.""" + position = BacktestPosition( + symbol="AAPL", + quantity=Decimal("100"), + average_cost=Decimal("150"), + current_price=Decimal("155"), + ) + assert position.symbol == "AAPL" + assert position.quantity == Decimal("100") + + def test_market_value(self): + """Test market value calculation.""" + position = BacktestPosition( + symbol="AAPL", + quantity=Decimal("100"), + average_cost=Decimal("150"), + current_price=Decimal("160"), + ) + assert position.market_value == Decimal("16000") + + def test_cost_basis(self): + """Test cost basis calculation.""" + position = BacktestPosition( + symbol="AAPL", + quantity=Decimal("100"), + average_cost=Decimal("150"), + ) + assert position.cost_basis == Decimal("15000") + + def test_is_long(self): + """Test is_long property.""" + position = BacktestPosition(symbol="AAPL", quantity=Decimal("100")) + assert position.is_long is True + assert position.is_short is False + + def test_is_short(self): + """Test is_short property.""" + position = BacktestPosition(symbol="AAPL", quantity=Decimal("-100")) + assert position.is_short is True + assert position.is_long is False + + def test_update_price(self): + """Test price update.""" + position = BacktestPosition( + symbol="AAPL", + quantity=Decimal("100"), + average_cost=Decimal("150"), + current_price=Decimal("150"), + ) + position.update_price(Decimal("160"), datetime(2023, 1, 4)) + assert position.current_price == Decimal("160") + assert position.unrealized_pnl == Decimal("1000") # (160-150)*100 + + +# ============================================================================ +# Slippage Model Tests +# ============================================================================ + +class TestNoSlippage: + """Tests for NoSlippage model.""" + + def test_calculate(self): + """Test no slippage.""" + model = NoSlippage() + slippage = model.calculate( + price=Decimal("100"), + quantity=Decimal("100"), + side=OrderSide.BUY, + volume=Decimal("1000000"), + ) + assert slippage == ZERO + + +class TestFixedSlippage: + """Tests for FixedSlippage model.""" + + def test_calculate(self): + """Test fixed slippage.""" + model = FixedSlippage(Decimal("0.01")) + slippage = model.calculate( + price=Decimal("100"), + quantity=Decimal("100"), + side=OrderSide.BUY, + volume=Decimal("1000000"), + ) + assert slippage == Decimal("0.01") + + +class TestPercentageSlippage: + """Tests for PercentageSlippage model.""" + + def test_calculate(self): + """Test percentage slippage.""" + model = PercentageSlippage(Decimal("0.1")) # 0.1% + slippage = model.calculate( + price=Decimal("100"), + quantity=Decimal("100"), + side=OrderSide.BUY, + volume=Decimal("1000000"), + ) + assert slippage == Decimal("0.1") # 0.1% of 100 + + +class TestVolumeSlippage: + """Tests for VolumeSlippage model.""" + + def test_calculate_low_volume(self): + """Test low volume participation.""" + model = VolumeSlippage( + base_percentage=Decimal("0.05"), + volume_impact=Decimal("0.1"), + ) + slippage = model.calculate( + price=Decimal("100"), + quantity=Decimal("100"), + side=OrderSide.BUY, + volume=Decimal("10000"), + ) + # 100/10000 = 1% participation + # slippage = 0.05% + (0.01 * 0.1 * 100) = 0.05% + 0.1% = 0.15% + assert slippage > ZERO + + def test_calculate_high_volume(self): + """Test high volume participation.""" + model = VolumeSlippage( + base_percentage=Decimal("0.05"), + volume_impact=Decimal("0.1"), + max_percentage=Decimal("1.0"), + ) + slippage = model.calculate( + price=Decimal("100"), + quantity=Decimal("5000"), + side=OrderSide.BUY, + volume=Decimal("10000"), + ) + # 50% participation - should hit max + assert slippage <= Decimal("1.0") # Max 1% + + def test_calculate_no_volume(self): + """Test with no volume data.""" + model = VolumeSlippage(base_percentage=Decimal("0.05")) + slippage = model.calculate( + price=Decimal("100"), + quantity=Decimal("100"), + side=OrderSide.BUY, + volume=ZERO, + ) + # Falls back to base percentage + assert slippage == Decimal("0.05") + + +# ============================================================================ +# Commission Model Tests +# ============================================================================ + +class TestNoCommission: + """Tests for NoCommission model.""" + + def test_calculate(self): + """Test no commission.""" + model = NoCommission() + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("100"), + trade_value=Decimal("10000"), + ) + assert commission == ZERO + + +class TestFixedCommission: + """Tests for FixedCommission model.""" + + def test_calculate(self): + """Test fixed commission.""" + model = FixedCommission(Decimal("9.99")) + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("100"), + trade_value=Decimal("10000"), + ) + assert commission == Decimal("9.99") + + def test_minimum(self): + """Test minimum commission.""" + model = FixedCommission(Decimal("5"), minimum=Decimal("10")) + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("100"), + trade_value=Decimal("10000"), + ) + assert commission == Decimal("10") + + +class TestPerShareCommission: + """Tests for PerShareCommission model.""" + + def test_calculate(self): + """Test per-share commission.""" + model = PerShareCommission(Decimal("0.005")) # $0.005/share + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("100"), + trade_value=Decimal("10000"), + ) + assert commission == Decimal("0.5") # 100 * 0.005 + + def test_minimum(self): + """Test minimum commission.""" + model = PerShareCommission(Decimal("0.005"), minimum=Decimal("1.0")) + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("10"), # Only 10 shares + trade_value=Decimal("1000"), + ) + assert commission == Decimal("1.0") # Minimum + + def test_maximum(self): + """Test maximum commission.""" + model = PerShareCommission( + Decimal("0.005"), + minimum=ZERO, + maximum=Decimal("10"), + ) + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("10000"), # Many shares + trade_value=Decimal("1000000"), + ) + assert commission == Decimal("10") # Maximum + + +class TestPercentageCommission: + """Tests for PercentageCommission model.""" + + def test_calculate(self): + """Test percentage commission.""" + model = PercentageCommission(Decimal("0.1")) # 0.1% + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("100"), + trade_value=Decimal("10000"), + ) + assert commission == Decimal("10") # 0.1% of 10000 + + def test_minimum(self): + """Test minimum commission.""" + model = PercentageCommission(Decimal("0.1"), minimum=Decimal("5")) + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("1"), + trade_value=Decimal("100"), + ) + assert commission == Decimal("5") # Minimum + + +class TestTieredCommission: + """Tests for TieredCommission model.""" + + def test_calculate_low_tier(self): + """Test low tier commission.""" + model = TieredCommission([ + (Decimal("0"), Decimal("0.2")), + (Decimal("10000"), Decimal("0.15")), + (Decimal("50000"), Decimal("0.1")), + ]) + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("50"), + trade_value=Decimal("5000"), + ) + assert commission == Decimal("10") # 0.2% of 5000 + + def test_calculate_mid_tier(self): + """Test middle tier commission.""" + model = TieredCommission([ + (Decimal("0"), Decimal("0.2")), + (Decimal("10000"), Decimal("0.15")), + (Decimal("50000"), Decimal("0.1")), + ]) + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("200"), + trade_value=Decimal("20000"), + ) + assert commission == Decimal("30") # 0.15% of 20000 + + def test_calculate_high_tier(self): + """Test high tier commission.""" + model = TieredCommission([ + (Decimal("0"), Decimal("0.2")), + (Decimal("10000"), Decimal("0.15")), + (Decimal("50000"), Decimal("0.1")), + ]) + commission = model.calculate( + price=Decimal("100"), + quantity=Decimal("1000"), + trade_value=Decimal("100000"), + ) + assert commission == Decimal("100") # 0.1% of 100000 + + +# ============================================================================ +# BacktestEngine Tests +# ============================================================================ + +class TestBacktestEngine: + """Tests for BacktestEngine class.""" + + @pytest.fixture + def config(self): + """Create test config.""" + return BacktestConfig( + initial_capital=Decimal("100000"), + ) + + @pytest.fixture + def engine(self, config): + """Create test engine.""" + return BacktestEngine(config) + + @pytest.fixture + def price_data(self): + """Create test price data.""" + return { + "AAPL": [ + OHLCV(datetime(2023, 1, 3), 130, 132, 129, 131, 1000000, "AAPL"), + OHLCV(datetime(2023, 1, 4), 131, 135, 130, 134, 1200000, "AAPL"), + OHLCV(datetime(2023, 1, 5), 134, 136, 133, 135, 1100000, "AAPL"), + OHLCV(datetime(2023, 1, 6), 135, 138, 134, 137, 1300000, "AAPL"), + OHLCV(datetime(2023, 1, 9), 137, 140, 136, 139, 1400000, "AAPL"), + ], + } + + def test_initialization(self, engine): + """Test engine initialization.""" + assert engine.cash == Decimal("100000") + assert len(engine.positions) == 0 + assert len(engine.trades) == 0 + + def test_reset(self, engine): + """Test engine reset.""" + engine.cash = Decimal("50000") + engine.positions["AAPL"] = BacktestPosition(symbol="AAPL") + engine.reset() + assert engine.cash == Decimal("100000") + assert len(engine.positions) == 0 + + def test_run_empty(self, engine): + """Test run with no data.""" + result = engine.run({}, []) + assert result.total_trades == 0 + assert result.final_value == Decimal("100000") + + def test_run_no_signals(self, engine, price_data): + """Test run with no signals.""" + result = engine.run(price_data, []) + assert result.total_trades == 0 + assert result.final_value == Decimal("100000") + assert len(result.snapshots) == 5 + + def test_run_buy_signal(self, engine, price_data): + """Test run with buy signal.""" + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + quantity=Decimal("100"), + ), + ] + result = engine.run(price_data, signals) + + assert result.total_trades == 1 + assert len(engine.positions) == 1 + assert "AAPL" in engine.positions + + def test_run_buy_and_sell(self, engine, price_data): + """Test run with buy and sell signals.""" + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + quantity=Decimal("100"), + ), + Signal( + timestamp=datetime(2023, 1, 6), + symbol="AAPL", + side=OrderSide.SELL, + quantity=Decimal("100"), + ), + ] + result = engine.run(price_data, signals) + + assert result.total_trades == 2 + assert len(engine.positions) == 0 # Position closed + # Should have profit: bought at ~131, sold at ~137 + assert result.final_value > Decimal("100000") + + def test_run_with_slippage(self, price_data): + """Test run with slippage model.""" + config = BacktestConfig( + initial_capital=Decimal("100000"), + slippage_model=FixedSlippage(Decimal("0.10")), + ) + engine = BacktestEngine(config) + + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + quantity=Decimal("100"), + ), + ] + result = engine.run(price_data, signals) + + # Check slippage was applied + trade = result.trades[0] + assert trade.slippage > ZERO + assert trade.price > trade.base_price # Buy price increased by slippage + + def test_run_with_commission(self, price_data): + """Test run with commission model.""" + config = BacktestConfig( + initial_capital=Decimal("100000"), + commission_model=FixedCommission(Decimal("10")), + ) + engine = BacktestEngine(config) + + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + quantity=Decimal("100"), + ), + ] + result = engine.run(price_data, signals) + + assert result.total_commission == Decimal("10") + trade = result.trades[0] + assert trade.commission == Decimal("10") + + def test_run_insufficient_cash(self, price_data): + """Test run with insufficient cash.""" + config = BacktestConfig( + initial_capital=Decimal("1000"), # Not enough for 100 shares at $131 + ) + engine = BacktestEngine(config) + + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + quantity=Decimal("100"), + ), + ] + result = engine.run(price_data, signals) + + # Should have bought fewer shares + if result.total_trades > 0: + assert engine.positions["AAPL"].quantity < Decimal("100") + # If no trades, that's also acceptable (couldn't afford any) + + def test_run_no_shorting(self, engine, price_data): + """Test no shorting when disabled.""" + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.SELL, + quantity=Decimal("100"), + ), + ] + result = engine.run(price_data, signals) + + # Sell should be rejected (no position and shorting disabled) + assert result.total_trades == 0 + + def test_run_with_shorting(self, price_data): + """Test shorting when enabled.""" + config = BacktestConfig( + initial_capital=Decimal("100000"), + allow_shorting=True, + ) + engine = BacktestEngine(config) + + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.SELL, + quantity=Decimal("100"), + ), + ] + result = engine.run(price_data, signals) + + # Should have short position + assert result.total_trades == 1 + assert engine.positions["AAPL"].quantity == Decimal("-100") + + def test_run_position_sizing(self, price_data): + """Test automatic position sizing.""" + engine = BacktestEngine(BacktestConfig( + initial_capital=Decimal("100000"), + position_sizing="equal", + )) + + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + quantity=ZERO, # Auto-size + ), + ] + result = engine.run(price_data, signals) + + # Should have calculated quantity + if result.total_trades > 0: + assert result.trades[0].quantity > ZERO + + def test_get_position(self, engine, price_data): + """Test getting position.""" + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + quantity=Decimal("100"), + ), + ] + engine.run(price_data, signals) + + position = engine.get_position("AAPL") + assert position is not None + assert position.quantity == Decimal("100") + + no_position = engine.get_position("GOOG") + assert no_position is None + + def test_get_cash(self, engine, price_data): + """Test getting cash balance.""" + initial_cash = engine.get_cash() + assert initial_cash == Decimal("100000") + + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + quantity=Decimal("100"), + ), + ] + engine.run(price_data, signals) + + assert engine.get_cash() < initial_cash + + def test_get_portfolio_value(self, engine, price_data): + """Test getting portfolio value.""" + signals = [ + Signal( + timestamp=datetime(2023, 1, 3), + symbol="AAPL", + side=OrderSide.BUY, + quantity=Decimal("100"), + ), + ] + engine.run(price_data, signals) + + value = engine.get_portfolio_value() + # Should be approximately initial capital (cash + position value) + assert value > Decimal("99000") + assert value < Decimal("101000") + + +class TestBacktestResult: + """Tests for BacktestResult metrics.""" + + @pytest.fixture + def price_data(self): + """Create test price data with clear trend.""" + return { + "AAPL": [ + OHLCV(datetime(2023, 1, 3), 100, 102, 99, 100, 1000000, "AAPL"), + OHLCV(datetime(2023, 1, 4), 100, 105, 99, 105, 1200000, "AAPL"), + OHLCV(datetime(2023, 1, 5), 105, 110, 104, 110, 1100000, "AAPL"), + OHLCV(datetime(2023, 1, 6), 110, 115, 109, 115, 1300000, "AAPL"), + OHLCV(datetime(2023, 1, 9), 115, 120, 114, 120, 1400000, "AAPL"), + ], + } + + def test_winning_trade(self, price_data): + """Test metrics for winning trade.""" + engine = BacktestEngine(BacktestConfig(initial_capital=Decimal("100000"))) + + signals = [ + Signal(datetime(2023, 1, 3), "AAPL", OrderSide.BUY, Decimal("100")), + Signal(datetime(2023, 1, 9), "AAPL", OrderSide.SELL, Decimal("100")), + ] + result = engine.run(price_data, signals) + + assert result.total_trades == 2 + assert result.winning_trades >= 1 + assert result.total_return > ZERO + assert result.final_value > result.initial_capital + + def test_max_drawdown(self, price_data): + """Test max drawdown calculation.""" + # Add some volatility + price_data["AAPL"].insert(2, OHLCV( + datetime(2023, 1, 4, 12), 105, 106, 95, 95, 1000000, "AAPL" + )) + + engine = BacktestEngine(BacktestConfig(initial_capital=Decimal("100000"))) + + signals = [ + Signal(datetime(2023, 1, 3), "AAPL", OrderSide.BUY, Decimal("100")), + ] + result = engine.run(price_data, signals) + + # Should have some drawdown recorded + assert result.max_drawdown >= ZERO + + def test_snapshots(self, price_data): + """Test snapshot creation.""" + engine = BacktestEngine(BacktestConfig(initial_capital=Decimal("100000"))) + + signals = [ + Signal(datetime(2023, 1, 3), "AAPL", OrderSide.BUY, Decimal("100")), + ] + result = engine.run(price_data, signals) + + assert len(result.snapshots) == 5 + for snapshot in result.snapshots: + assert snapshot.total_value > ZERO + assert snapshot.cash >= ZERO + + def test_daily_returns(self, price_data): + """Test daily returns calculation.""" + engine = BacktestEngine(BacktestConfig(initial_capital=Decimal("100000"))) + + signals = [ + Signal(datetime(2023, 1, 3), "AAPL", OrderSide.BUY, Decimal("100")), + ] + result = engine.run(price_data, signals) + + assert len(result.daily_returns) == 4 # 5 snapshots = 4 returns + + def test_trade_stats(self, price_data): + """Test trade statistics.""" + engine = BacktestEngine(BacktestConfig(initial_capital=Decimal("100000"))) + + signals = [ + Signal(datetime(2023, 1, 3), "AAPL", OrderSide.BUY, Decimal("50")), + Signal(datetime(2023, 1, 5), "AAPL", OrderSide.SELL, Decimal("50")), + Signal(datetime(2023, 1, 5), "AAPL", OrderSide.BUY, Decimal("50")), + Signal(datetime(2023, 1, 9), "AAPL", OrderSide.SELL, Decimal("50")), + ] + result = engine.run(price_data, signals) + + assert result.total_trades == 4 + assert result.winning_trades + result.losing_trades + (result.total_trades - result.winning_trades - result.losing_trades) == result.total_trades + + +class TestBacktestEngineIntegration: + """Integration tests for backtest engine.""" + + def test_module_imports(self): + """Test that all classes are exported from module.""" + from tradingagents.backtest import ( + OrderSide, + OrderType, + FillStatus, + OHLCV, + Signal, + BacktestConfig, + BacktestPosition, + BacktestTrade, + BacktestSnapshot, + BacktestResult, + SlippageModel, + NoSlippage, + FixedSlippage, + PercentageSlippage, + VolumeSlippage, + CommissionModel, + NoCommission, + FixedCommission, + PerShareCommission, + PercentageCommission, + TieredCommission, + BacktestEngine, + create_backtest_engine, + ) + + # All imports successful + assert BacktestEngine is not None + assert OrderSide.BUY is not None + + def test_create_backtest_engine_factory(self): + """Test factory function.""" + engine = create_backtest_engine( + initial_capital=Decimal("50000"), + slippage=PercentageSlippage(Decimal("0.1")), + commission=FixedCommission(Decimal("10")), + ) + + assert engine.config.initial_capital == Decimal("50000") + assert isinstance(engine.config.slippage_model, PercentageSlippage) + assert isinstance(engine.config.commission_model, FixedCommission) + + def test_strategy_callback(self): + """Test dynamic signal generation via callback.""" + engine = BacktestEngine(BacktestConfig(initial_capital=Decimal("100000"))) + + price_data = { + "AAPL": [ + OHLCV(datetime(2023, 1, 3), 100, 102, 99, 101, 1000000, "AAPL"), + OHLCV(datetime(2023, 1, 4), 101, 105, 100, 104, 1200000, "AAPL"), + OHLCV(datetime(2023, 1, 5), 104, 108, 103, 107, 1100000, "AAPL"), + ], + } + + def strategy(timestamp, bars): + """Simple momentum strategy.""" + if "AAPL" in bars and bars["AAPL"].close > Decimal("102"): + return [Signal( + timestamp=timestamp, + symbol="AAPL", + side=OrderSide.BUY, + quantity=Decimal("10"), + )] + return [] + + result = engine.run(price_data, [], strategy_callback=strategy) + + # Strategy should have generated signals on days 2 and 3 + assert result.total_trades >= 1 + + def test_multi_symbol(self): + """Test with multiple symbols.""" + engine = BacktestEngine(BacktestConfig(initial_capital=Decimal("100000"))) + + price_data = { + "AAPL": [ + OHLCV(datetime(2023, 1, 3), 100, 102, 99, 101, 1000000, "AAPL"), + OHLCV(datetime(2023, 1, 4), 101, 105, 100, 104, 1200000, "AAPL"), + ], + "GOOG": [ + OHLCV(datetime(2023, 1, 3), 90, 92, 89, 91, 500000, "GOOG"), + OHLCV(datetime(2023, 1, 4), 91, 94, 90, 93, 600000, "GOOG"), + ], + } + + signals = [ + Signal(datetime(2023, 1, 3), "AAPL", OrderSide.BUY, Decimal("50")), + Signal(datetime(2023, 1, 3), "GOOG", OrderSide.BUY, Decimal("50")), + ] + + result = engine.run(price_data, signals) + + assert result.total_trades == 2 + assert "AAPL" in engine.positions + assert "GOOG" in engine.positions + + def test_date_range_filter(self): + """Test date range filtering.""" + config = BacktestConfig( + initial_capital=Decimal("100000"), + start_date=datetime(2023, 1, 4), + end_date=datetime(2023, 1, 5), + ) + engine = BacktestEngine(config) + + price_data = { + "AAPL": [ + OHLCV(datetime(2023, 1, 3), 100, 102, 99, 101, 1000000, "AAPL"), + OHLCV(datetime(2023, 1, 4), 101, 105, 100, 104, 1200000, "AAPL"), + OHLCV(datetime(2023, 1, 5), 104, 108, 103, 107, 1100000, "AAPL"), + OHLCV(datetime(2023, 1, 6), 107, 110, 106, 109, 1300000, "AAPL"), + ], + } + + signals = [ + Signal(datetime(2023, 1, 3), "AAPL", OrderSide.BUY, Decimal("50")), # Before range + Signal(datetime(2023, 1, 4), "AAPL", OrderSide.BUY, Decimal("50")), # In range + Signal(datetime(2023, 1, 6), "AAPL", OrderSide.SELL, Decimal("50")), # After range + ] + + result = engine.run(price_data, signals) + + # Only Jan 4 signal should execute + assert result.total_trades == 1 + assert len(result.snapshots) == 2 # Only Jan 4-5 diff --git a/tradingagents/backtest/__init__.py b/tradingagents/backtest/__init__.py new file mode 100644 index 00000000..fec31ce7 --- /dev/null +++ b/tradingagents/backtest/__init__.py @@ -0,0 +1,141 @@ +"""Backtest module for historical strategy replay. + +Issue #42: [BT-41] Backtest engine - historical replay, slippage + +This module provides backtesting capabilities: +- Historical price data replay +- Realistic slippage modeling +- Commission/fee handling +- Position and portfolio tracking +- Trade execution simulation +- Performance metrics calculation + +Classes: + Enums: + - OrderSide: Buy or sell + - OrderType: Market, limit, stop, stop_limit + - FillStatus: Order fill status + + Data Classes: + - OHLCV: Price bar data + - Signal: Trading signal + - BacktestConfig: Backtest configuration + - BacktestPosition: Position tracking + - BacktestTrade: Trade record + - BacktestSnapshot: Portfolio snapshot + - BacktestResult: Complete result with metrics + + Slippage Models: + - SlippageModel: Base class + - NoSlippage: No slippage + - FixedSlippage: Fixed amount per share + - PercentageSlippage: Percentage of price + - VolumeSlippage: Volume-impact model + + Commission Models: + - CommissionModel: Base class + - NoCommission: No commission + - FixedCommission: Fixed per trade + - PerShareCommission: Per share commission + - PercentageCommission: Percentage of value + - TieredCommission: Tiered by trade value + + Main Classes: + - BacktestEngine: Main backtest engine + +Example: + >>> from tradingagents.backtest import ( + ... BacktestEngine, + ... BacktestConfig, + ... OHLCV, + ... Signal, + ... OrderSide, + ... PercentageSlippage, + ... PercentageCommission, + ... ) + >>> from decimal import Decimal + >>> from datetime import datetime + >>> + >>> config = BacktestConfig( + ... initial_capital=Decimal("100000"), + ... slippage_model=PercentageSlippage(Decimal("0.1")), + ... commission_model=PercentageCommission(Decimal("0.1")), + ... ) + >>> engine = BacktestEngine(config) + >>> + >>> price_data = { + ... "AAPL": [ + ... OHLCV(datetime(2023, 1, 3), 130, 132, 129, 131, 1000000), + ... OHLCV(datetime(2023, 1, 4), 131, 135, 130, 134, 1200000), + ... ], + ... } + >>> signals = [ + ... Signal(datetime(2023, 1, 3), "AAPL", OrderSide.BUY, Decimal("100")), + ... ] + >>> result = engine.run(price_data, signals) + >>> print(f"Return: {result.total_return}%") +""" + +from .backtest_engine import ( + # Enums + OrderSide, + OrderType, + FillStatus, + # Data Classes + OHLCV, + Signal, + BacktestConfig, + BacktestPosition, + BacktestTrade, + BacktestSnapshot, + BacktestResult, + # Slippage Models + SlippageModel, + NoSlippage, + FixedSlippage, + PercentageSlippage, + VolumeSlippage, + # Commission Models + CommissionModel, + NoCommission, + FixedCommission, + PerShareCommission, + PercentageCommission, + TieredCommission, + # Main Classes + BacktestEngine, + # Factory Functions + create_backtest_engine, +) + +__all__ = [ + # Enums + "OrderSide", + "OrderType", + "FillStatus", + # Data Classes + "OHLCV", + "Signal", + "BacktestConfig", + "BacktestPosition", + "BacktestTrade", + "BacktestSnapshot", + "BacktestResult", + # Slippage Models + "SlippageModel", + "NoSlippage", + "FixedSlippage", + "PercentageSlippage", + "VolumeSlippage", + # Commission Models + "CommissionModel", + "NoCommission", + "FixedCommission", + "PerShareCommission", + "PercentageCommission", + "TieredCommission", + # Main Classes + "BacktestEngine", + # Factory Functions + "create_backtest_engine", +] diff --git a/tradingagents/backtest/backtest_engine.py b/tradingagents/backtest/backtest_engine.py new file mode 100644 index 00000000..e9aca319 --- /dev/null +++ b/tradingagents/backtest/backtest_engine.py @@ -0,0 +1,1270 @@ +"""Backtest Engine for historical strategy replay. + +Issue #42: [BT-41] Backtest engine - historical replay, slippage + +This module provides backtesting capabilities for trading strategies: +- Historical price data replay +- Realistic slippage modeling +- Commission/fee handling +- Position and portfolio tracking +- Trade execution simulation + +Classes: + SlippageModel: Base class for slippage calculation + FixedSlippage: Fixed amount slippage + PercentageSlippage: Percentage-based slippage + VolumeSlippage: Volume-impact slippage + CommissionModel: Base class for commission calculation + FixedCommission: Fixed per-trade commission + PercentageCommission: Percentage-based commission + TieredCommission: Tiered commission based on trade value + BacktestConfig: Configuration for backtest + BacktestPosition: Position tracking during backtest + BacktestTrade: Individual trade record + BacktestResult: Complete backtest result + BacktestEngine: Main backtest engine + +Example: + >>> from tradingagents.backtest import BacktestEngine, BacktestConfig + >>> from decimal import Decimal + >>> + >>> config = BacktestConfig( + ... initial_capital=Decimal("100000"), + ... start_date=datetime(2023, 1, 1), + ... end_date=datetime(2023, 12, 31), + ... ) + >>> engine = BacktestEngine(config) + >>> result = engine.run(price_data, signals) +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from decimal import Decimal +from enum import Enum +from typing import Any, Callable, Optional, Protocol +import logging + + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Constants +# ============================================================================ + +ZERO = Decimal("0") +ONE = Decimal("1") +HUNDRED = Decimal("100") + + +# ============================================================================ +# Enums +# ============================================================================ + +class OrderSide(Enum): + """Order side.""" + BUY = "buy" + SELL = "sell" + + +class OrderType(Enum): + """Order type for backtest.""" + MARKET = "market" + LIMIT = "limit" + STOP = "stop" + STOP_LIMIT = "stop_limit" + + +class FillStatus(Enum): + """Fill status for orders.""" + UNFILLED = "unfilled" + PARTIAL = "partial" + FILLED = "filled" + CANCELLED = "cancelled" + REJECTED = "rejected" + + +# ============================================================================ +# Price Data Types +# ============================================================================ + +@dataclass +class OHLCV: + """OHLCV bar data. + + Attributes: + timestamp: Bar timestamp + open: Open price + high: High price + low: Low price + close: Close price + volume: Volume + symbol: Optional symbol identifier + """ + timestamp: datetime + open: Decimal + high: Decimal + low: Decimal + close: Decimal + volume: Decimal = ZERO + symbol: str = "" + + def __post_init__(self): + """Convert numeric types to Decimal.""" + for field_name in ["open", "high", "low", "close", "volume"]: + value = getattr(self, field_name) + if not isinstance(value, Decimal): + setattr(self, field_name, Decimal(str(value))) + + +@dataclass +class Signal: + """Trading signal. + + Attributes: + timestamp: Signal timestamp + symbol: Symbol to trade + side: Buy or sell + quantity: Quantity to trade (0 for position sizing by engine) + price: Target price (for limit orders) + order_type: Order type + confidence: Signal confidence (0-1) + metadata: Additional signal data + """ + timestamp: datetime + symbol: str + side: OrderSide + quantity: Decimal = ZERO + price: Decimal = ZERO + order_type: OrderType = OrderType.MARKET + confidence: Decimal = ONE + metadata: dict[str, Any] = field(default_factory=dict) + + +# ============================================================================ +# Slippage Models +# ============================================================================ + +class SlippageModel(ABC): + """Base class for slippage calculation.""" + + @abstractmethod + def calculate( + self, + price: Decimal, + quantity: Decimal, + side: OrderSide, + volume: Decimal, + ) -> Decimal: + """Calculate slippage amount. + + Args: + price: Order price + quantity: Order quantity + side: Order side + volume: Bar volume + + Returns: + Slippage amount (added to buy, subtracted from sell) + """ + pass + + +class NoSlippage(SlippageModel): + """No slippage model.""" + + def calculate( + self, + price: Decimal, + quantity: Decimal, + side: OrderSide, + volume: Decimal, + ) -> Decimal: + """No slippage.""" + return ZERO + + +class FixedSlippage(SlippageModel): + """Fixed amount slippage per share. + + Attributes: + amount: Fixed slippage amount per share + """ + + def __init__(self, amount: Decimal): + """Initialize with fixed amount. + + Args: + amount: Slippage per share + """ + self.amount = Decimal(str(amount)) + + def calculate( + self, + price: Decimal, + quantity: Decimal, + side: OrderSide, + volume: Decimal, + ) -> Decimal: + """Calculate fixed slippage.""" + return self.amount + + +class PercentageSlippage(SlippageModel): + """Percentage-based slippage. + + Attributes: + percentage: Slippage as percentage of price (e.g., 0.1 = 0.1%) + """ + + def __init__(self, percentage: Decimal): + """Initialize with percentage. + + Args: + percentage: Slippage percentage (0.1 = 0.1%) + """ + self.percentage = Decimal(str(percentage)) + + def calculate( + self, + price: Decimal, + quantity: Decimal, + side: OrderSide, + volume: Decimal, + ) -> Decimal: + """Calculate percentage slippage.""" + return price * self.percentage / HUNDRED + + +class VolumeSlippage(SlippageModel): + """Volume-impact slippage model. + + Slippage increases with order size relative to volume. + + Attributes: + base_percentage: Base slippage percentage + volume_impact: Impact factor for volume (higher = more slippage) + max_percentage: Maximum slippage percentage + """ + + def __init__( + self, + base_percentage: Decimal = Decimal("0.05"), + volume_impact: Decimal = Decimal("0.1"), + max_percentage: Decimal = Decimal("1.0"), + ): + """Initialize volume slippage model. + + Args: + base_percentage: Base slippage (%) + volume_impact: Volume impact factor + max_percentage: Maximum slippage cap (%) + """ + self.base_percentage = Decimal(str(base_percentage)) + self.volume_impact = Decimal(str(volume_impact)) + self.max_percentage = Decimal(str(max_percentage)) + + def calculate( + self, + price: Decimal, + quantity: Decimal, + side: OrderSide, + volume: Decimal, + ) -> Decimal: + """Calculate volume-based slippage.""" + if volume <= ZERO: + # No volume data, use base slippage + return price * self.base_percentage / HUNDRED + + # Calculate volume participation + participation = quantity / volume + + # Calculate slippage percentage + slippage_pct = self.base_percentage + (participation * self.volume_impact * HUNDRED) + + # Cap at maximum + slippage_pct = min(slippage_pct, self.max_percentage) + + return price * slippage_pct / HUNDRED + + +# ============================================================================ +# Commission Models +# ============================================================================ + +class CommissionModel(ABC): + """Base class for commission calculation.""" + + @abstractmethod + def calculate( + self, + price: Decimal, + quantity: Decimal, + trade_value: Decimal, + ) -> Decimal: + """Calculate commission. + + Args: + price: Trade price + quantity: Trade quantity + trade_value: Total trade value + + Returns: + Commission amount + """ + pass + + +class NoCommission(CommissionModel): + """No commission model.""" + + def calculate( + self, + price: Decimal, + quantity: Decimal, + trade_value: Decimal, + ) -> Decimal: + """No commission.""" + return ZERO + + +class FixedCommission(CommissionModel): + """Fixed per-trade commission. + + Attributes: + amount: Fixed commission per trade + minimum: Minimum commission + """ + + def __init__( + self, + amount: Decimal, + minimum: Decimal = ZERO, + ): + """Initialize with fixed amount. + + Args: + amount: Commission per trade + minimum: Minimum commission + """ + self.amount = Decimal(str(amount)) + self.minimum = Decimal(str(minimum)) + + def calculate( + self, + price: Decimal, + quantity: Decimal, + trade_value: Decimal, + ) -> Decimal: + """Calculate fixed commission.""" + return max(self.amount, self.minimum) + + +class PerShareCommission(CommissionModel): + """Per-share commission. + + Attributes: + per_share: Commission per share + minimum: Minimum commission per trade + maximum: Maximum commission per trade + """ + + def __init__( + self, + per_share: Decimal, + minimum: Decimal = ZERO, + maximum: Decimal = Decimal("Infinity"), + ): + """Initialize per-share commission. + + Args: + per_share: Commission per share + minimum: Minimum per trade + maximum: Maximum per trade + """ + self.per_share = Decimal(str(per_share)) + self.minimum = Decimal(str(minimum)) + self.maximum = Decimal(str(maximum)) if maximum != Decimal("Infinity") else None + + def calculate( + self, + price: Decimal, + quantity: Decimal, + trade_value: Decimal, + ) -> Decimal: + """Calculate per-share commission.""" + commission = self.per_share * abs(quantity) + commission = max(commission, self.minimum) + if self.maximum is not None: + commission = min(commission, self.maximum) + return commission + + +class PercentageCommission(CommissionModel): + """Percentage-based commission. + + Attributes: + percentage: Commission as percentage of trade value + minimum: Minimum commission + """ + + def __init__( + self, + percentage: Decimal, + minimum: Decimal = ZERO, + ): + """Initialize percentage commission. + + Args: + percentage: Commission percentage (e.g., 0.1 = 0.1%) + minimum: Minimum commission + """ + self.percentage = Decimal(str(percentage)) + self.minimum = Decimal(str(minimum)) + + def calculate( + self, + price: Decimal, + quantity: Decimal, + trade_value: Decimal, + ) -> Decimal: + """Calculate percentage commission.""" + commission = abs(trade_value) * self.percentage / HUNDRED + return max(commission, self.minimum) + + +class TieredCommission(CommissionModel): + """Tiered commission based on trade value. + + Attributes: + tiers: List of (threshold, percentage) tuples + minimum: Minimum commission + """ + + def __init__( + self, + tiers: list[tuple[Decimal, Decimal]], + minimum: Decimal = ZERO, + ): + """Initialize tiered commission. + + Args: + tiers: List of (threshold, percentage) - sorted ascending + minimum: Minimum commission + """ + self.tiers = sorted( + [(Decimal(str(t)), Decimal(str(p))) for t, p in tiers], + key=lambda x: x[0], + ) + self.minimum = Decimal(str(minimum)) + + def calculate( + self, + price: Decimal, + quantity: Decimal, + trade_value: Decimal, + ) -> Decimal: + """Calculate tiered commission.""" + abs_value = abs(trade_value) + + # Find applicable tier + percentage = self.tiers[0][1] if self.tiers else ZERO + for threshold, pct in self.tiers: + if abs_value >= threshold: + percentage = pct + else: + break + + commission = abs_value * percentage / HUNDRED + return max(commission, self.minimum) + + +# ============================================================================ +# Backtest Data Classes +# ============================================================================ + +@dataclass +class BacktestConfig: + """Configuration for backtest. + + Attributes: + initial_capital: Starting capital + start_date: Backtest start date + end_date: Backtest end date + slippage_model: Slippage model to use + commission_model: Commission model to use + position_sizing: Position sizing mode + max_position_pct: Maximum position as % of portfolio + min_trade_value: Minimum trade value + allow_shorting: Whether to allow short positions + margin_rate: Margin rate for leveraged trades + risk_free_rate: Risk-free rate for Sharpe calculation + benchmark_symbol: Benchmark symbol for comparison + rebalance_frequency: Rebalance frequency in days (0 = no rebalance) + """ + initial_capital: Decimal = Decimal("100000") + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + slippage_model: SlippageModel = field(default_factory=NoSlippage) + commission_model: CommissionModel = field(default_factory=NoCommission) + position_sizing: str = "equal" # equal, risk_parity, kelly + max_position_pct: Decimal = Decimal("20") # 20% max per position + min_trade_value: Decimal = Decimal("100") + allow_shorting: bool = False + margin_rate: Decimal = Decimal("50") # 50% margin + risk_free_rate: Decimal = Decimal("0.05") # 5% annual + benchmark_symbol: str = "SPY" + rebalance_frequency: int = 0 # 0 = no automatic rebalance + + +@dataclass +class BacktestPosition: + """Position during backtest. + + Attributes: + symbol: Position symbol + quantity: Current quantity (negative for short) + average_cost: Average cost basis + current_price: Current market price + unrealized_pnl: Unrealized P&L + realized_pnl: Realized P&L from closed trades + opened_at: Position open timestamp + last_updated: Last update timestamp + """ + symbol: str + quantity: Decimal = ZERO + average_cost: Decimal = ZERO + current_price: Decimal = ZERO + unrealized_pnl: Decimal = ZERO + realized_pnl: Decimal = ZERO + opened_at: Optional[datetime] = None + last_updated: Optional[datetime] = None + + @property + def market_value(self) -> Decimal: + """Get current market value.""" + return self.quantity * self.current_price + + @property + def cost_basis(self) -> Decimal: + """Get total cost basis.""" + return self.quantity * self.average_cost + + @property + def is_long(self) -> bool: + """Check if long position.""" + return self.quantity > ZERO + + @property + def is_short(self) -> bool: + """Check if short position.""" + return self.quantity < ZERO + + def update_price(self, price: Decimal, timestamp: datetime) -> None: + """Update current price and unrealized P&L. + + Args: + price: New price + timestamp: Update timestamp + """ + self.current_price = price + self.unrealized_pnl = (price - self.average_cost) * self.quantity + self.last_updated = timestamp + + +@dataclass +class BacktestTrade: + """Individual trade record. + + Attributes: + trade_id: Unique trade ID + timestamp: Trade timestamp + symbol: Symbol traded + side: Buy or sell + quantity: Quantity traded + price: Execution price (after slippage) + base_price: Price before slippage + slippage: Slippage amount + commission: Commission paid + trade_value: Total trade value + signal_confidence: Original signal confidence + position_after: Position quantity after trade + cash_after: Cash balance after trade + pnl: Realized P&L (for closing trades) + """ + trade_id: str = "" + timestamp: datetime = field(default_factory=datetime.now) + symbol: str = "" + side: OrderSide = OrderSide.BUY + quantity: Decimal = ZERO + price: Decimal = ZERO + base_price: Decimal = ZERO + slippage: Decimal = ZERO + commission: Decimal = ZERO + trade_value: Decimal = ZERO + signal_confidence: Decimal = ONE + position_after: Decimal = ZERO + cash_after: Decimal = ZERO + pnl: Decimal = ZERO + + +@dataclass +class BacktestSnapshot: + """Portfolio snapshot at a point in time. + + Attributes: + timestamp: Snapshot timestamp + cash: Cash balance + positions_value: Total value of positions + total_value: Total portfolio value + positions: Current positions + drawdown: Current drawdown from peak + peak_value: Peak portfolio value + """ + timestamp: datetime + cash: Decimal + positions_value: Decimal + total_value: Decimal + positions: dict[str, BacktestPosition] = field(default_factory=dict) + drawdown: Decimal = ZERO + peak_value: Decimal = ZERO + + +@dataclass +class BacktestResult: + """Complete backtest result. + + Attributes: + config: Backtest configuration + start_date: Actual start date + end_date: Actual end date + initial_capital: Starting capital + final_value: Ending portfolio value + total_return: Total return percentage + annualized_return: Annualized return + sharpe_ratio: Sharpe ratio + sortino_ratio: Sortino ratio + max_drawdown: Maximum drawdown + win_rate: Win rate + profit_factor: Profit factor + total_trades: Number of trades + winning_trades: Number of winning trades + losing_trades: Number of losing trades + avg_trade_pnl: Average P&L per trade + avg_win: Average winning trade + avg_loss: Average losing trade + max_win: Largest winning trade + max_loss: Largest losing trade + total_commission: Total commission paid + total_slippage: Total slippage cost + trades: List of all trades + snapshots: Portfolio snapshots over time + daily_returns: Daily return series + benchmark_return: Benchmark return (if available) + alpha: Alpha vs benchmark + beta: Beta vs benchmark + errors: Any errors during backtest + """ + config: BacktestConfig = field(default_factory=BacktestConfig) + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + initial_capital: Decimal = ZERO + final_value: Decimal = ZERO + total_return: Decimal = ZERO + annualized_return: Decimal = ZERO + sharpe_ratio: Decimal = ZERO + sortino_ratio: Decimal = ZERO + max_drawdown: Decimal = ZERO + win_rate: Decimal = ZERO + profit_factor: Decimal = ZERO + total_trades: int = 0 + winning_trades: int = 0 + losing_trades: int = 0 + avg_trade_pnl: Decimal = ZERO + avg_win: Decimal = ZERO + avg_loss: Decimal = ZERO + max_win: Decimal = ZERO + max_loss: Decimal = ZERO + total_commission: Decimal = ZERO + total_slippage: Decimal = ZERO + trades: list[BacktestTrade] = field(default_factory=list) + snapshots: list[BacktestSnapshot] = field(default_factory=list) + daily_returns: list[Decimal] = field(default_factory=list) + benchmark_return: Decimal = ZERO + alpha: Decimal = ZERO + beta: Decimal = ZERO + errors: list[str] = field(default_factory=list) + + +# ============================================================================ +# Backtest Engine +# ============================================================================ + +class BacktestEngine: + """Main backtest engine for historical strategy replay. + + Attributes: + config: Backtest configuration + cash: Current cash balance + positions: Current positions + trades: Trade history + snapshots: Portfolio snapshots + """ + + def __init__(self, config: Optional[BacktestConfig] = None): + """Initialize backtest engine. + + Args: + config: Backtest configuration + """ + self.config = config or BacktestConfig() + self.reset() + + def reset(self) -> None: + """Reset engine state.""" + self.cash = self.config.initial_capital + self.positions: dict[str, BacktestPosition] = {} + self.trades: list[BacktestTrade] = [] + self.snapshots: list[BacktestSnapshot] = [] + self._trade_counter = 0 + self._peak_value = self.config.initial_capital + self._current_timestamp: Optional[datetime] = None + self._price_data: dict[str, list[OHLCV]] = {} + self._current_prices: dict[str, Decimal] = {} + + def run( + self, + price_data: dict[str, list[OHLCV]], + signals: list[Signal], + strategy_callback: Optional[Callable[[datetime, dict[str, OHLCV]], list[Signal]]] = None, + ) -> BacktestResult: + """Run backtest. + + Args: + price_data: Dict of symbol -> list of OHLCV bars + signals: List of trading signals (pre-generated) + strategy_callback: Optional callback for dynamic signal generation + + Returns: + BacktestResult with all metrics + """ + self.reset() + self._price_data = price_data + + # Determine date range + all_timestamps = set() + for bars in price_data.values(): + for bar in bars: + all_timestamps.add(bar.timestamp) + + if not all_timestamps: + return self._create_result([]) + + sorted_timestamps = sorted(all_timestamps) + start_date = self.config.start_date or sorted_timestamps[0] + end_date = self.config.end_date or sorted_timestamps[-1] + + # Filter timestamps to date range + timestamps = [t for t in sorted_timestamps if start_date <= t <= end_date] + + if not timestamps: + return self._create_result([]) + + # Index signals by timestamp + signal_index: dict[datetime, list[Signal]] = {} + for signal in signals: + if start_date <= signal.timestamp <= end_date: + if signal.timestamp not in signal_index: + signal_index[signal.timestamp] = [] + signal_index[signal.timestamp].append(signal) + + # Main replay loop + errors = [] + for timestamp in timestamps: + self._current_timestamp = timestamp + + # Get current prices + current_bars = self._get_bars_at(timestamp) + self._update_prices(current_bars) + + # Process signals for this timestamp + timestamp_signals = signal_index.get(timestamp, []) + + # Also get signals from callback if provided + if strategy_callback: + try: + callback_signals = strategy_callback(timestamp, current_bars) + timestamp_signals.extend(callback_signals) + except Exception as e: + errors.append(f"Strategy callback error at {timestamp}: {e}") + + # Execute signals + for signal in timestamp_signals: + try: + self._execute_signal(signal, current_bars) + except Exception as e: + errors.append(f"Signal execution error at {timestamp}: {e}") + + # Take snapshot + self._take_snapshot(timestamp) + + result = self._create_result(errors) + return result + + def _get_bars_at(self, timestamp: datetime) -> dict[str, OHLCV]: + """Get OHLCV bars at timestamp. + + Args: + timestamp: Target timestamp + + Returns: + Dict of symbol -> OHLCV + """ + bars = {} + for symbol, bar_list in self._price_data.items(): + for bar in bar_list: + if bar.timestamp == timestamp: + bars[symbol] = bar + break + return bars + + def _update_prices(self, bars: dict[str, OHLCV]) -> None: + """Update current prices and position values. + + Args: + bars: Current price bars + """ + for symbol, bar in bars.items(): + self._current_prices[symbol] = bar.close + + if symbol in self.positions: + self.positions[symbol].update_price(bar.close, bar.timestamp) + + def _execute_signal(self, signal: Signal, bars: dict[str, OHLCV]) -> Optional[BacktestTrade]: + """Execute a trading signal. + + Args: + signal: Signal to execute + bars: Current price bars + + Returns: + BacktestTrade if executed, None if rejected + """ + symbol = signal.symbol + + # Check if we have price data + if symbol not in bars: + logger.warning(f"No price data for {symbol} at {signal.timestamp}") + return None + + bar = bars[symbol] + + # Determine quantity + quantity = self._calculate_quantity(signal, bar) + if quantity == ZERO: + return None + + # Get execution price with slippage + base_price = bar.close + if signal.order_type == OrderType.LIMIT: + base_price = signal.price + + slippage = self.config.slippage_model.calculate( + base_price, quantity, signal.side, bar.volume + ) + + if signal.side == OrderSide.BUY: + exec_price = base_price + slippage + else: + exec_price = base_price - slippage + + # Calculate trade value and commission + trade_value = exec_price * quantity + commission = self.config.commission_model.calculate( + exec_price, quantity, trade_value + ) + + # Check if we can afford the trade + if signal.side == OrderSide.BUY: + total_cost = trade_value + commission + if total_cost > self.cash: + # Reduce quantity to what we can afford + available = self.cash - commission + if available <= ZERO: + return None + quantity = (available / exec_price).quantize(Decimal("1")) + if quantity <= ZERO: + return None + trade_value = exec_price * quantity + commission = self.config.commission_model.calculate( + exec_price, quantity, trade_value + ) + total_cost = trade_value + commission + + self.cash -= total_cost + else: + # Sell - check position + current_position = self.positions.get(symbol) + if current_position is None or current_position.quantity <= ZERO: + if not self.config.allow_shorting: + return None + elif quantity > current_position.quantity: + # Can only sell what we have + quantity = current_position.quantity + trade_value = exec_price * quantity + commission = self.config.commission_model.calculate( + exec_price, quantity, trade_value + ) + + self.cash += trade_value - commission + + # Update position + pnl = self._update_position(signal, quantity, exec_price) + + # Create trade record + self._trade_counter += 1 + trade = BacktestTrade( + trade_id=f"BT-{self._trade_counter:06d}", + timestamp=signal.timestamp, + symbol=symbol, + side=signal.side, + quantity=quantity, + price=exec_price, + base_price=base_price, + slippage=slippage * quantity, + commission=commission, + trade_value=trade_value, + signal_confidence=signal.confidence, + position_after=self.positions.get(symbol, BacktestPosition(symbol)).quantity, + cash_after=self.cash, + pnl=pnl, + ) + + self.trades.append(trade) + return trade + + def _calculate_quantity(self, signal: Signal, bar: OHLCV) -> Decimal: + """Calculate trade quantity based on position sizing. + + Args: + signal: Trading signal + bar: Current price bar + + Returns: + Quantity to trade + """ + if signal.quantity > ZERO: + return signal.quantity + + # Position sizing based on config + portfolio_value = self._get_portfolio_value() + max_position_value = portfolio_value * self.config.max_position_pct / HUNDRED + + if self.config.position_sizing == "equal": + # Equal weight for each position + num_positions = max(len(self.positions), 5) # Assume at least 5 positions + target_value = portfolio_value / Decimal(num_positions) + target_value = min(target_value, max_position_value) + else: + target_value = max_position_value + + # Check minimum trade value + if target_value < self.config.min_trade_value: + return ZERO + + quantity = (target_value / bar.close).quantize(Decimal("1")) + return max(quantity, ZERO) + + def _update_position( + self, + signal: Signal, + quantity: Decimal, + price: Decimal, + ) -> Decimal: + """Update position after trade. + + Args: + signal: Trading signal + quantity: Trade quantity + price: Execution price + + Returns: + Realized P&L + """ + symbol = signal.symbol + pnl = ZERO + + if symbol not in self.positions: + self.positions[symbol] = BacktestPosition( + symbol=symbol, + opened_at=signal.timestamp, + ) + + position = self.positions[symbol] + + if signal.side == OrderSide.BUY: + # Buying + if position.quantity >= ZERO: + # Adding to long or opening new long + total_cost = position.quantity * position.average_cost + quantity * price + new_quantity = position.quantity + quantity + position.average_cost = total_cost / new_quantity if new_quantity > ZERO else ZERO + position.quantity = new_quantity + else: + # Covering short + pnl = (position.average_cost - price) * min(quantity, abs(position.quantity)) + position.realized_pnl += pnl + position.quantity += quantity + else: + # Selling + if position.quantity > ZERO: + # Closing long + pnl = (price - position.average_cost) * min(quantity, position.quantity) + position.realized_pnl += pnl + position.quantity -= quantity + else: + # Adding to short or opening new short + total_cost = abs(position.quantity) * position.average_cost + quantity * price + new_quantity = position.quantity - quantity + position.average_cost = total_cost / abs(new_quantity) if new_quantity != ZERO else price + position.quantity = new_quantity + + position.last_updated = signal.timestamp + + # Clean up closed positions + if position.quantity == ZERO: + del self.positions[symbol] + + return pnl + + def _get_portfolio_value(self) -> Decimal: + """Get total portfolio value. + + Returns: + Total value (cash + positions) + """ + positions_value = sum(p.market_value for p in self.positions.values()) + return self.cash + positions_value + + def _take_snapshot(self, timestamp: datetime) -> None: + """Take portfolio snapshot. + + Args: + timestamp: Snapshot timestamp + """ + positions_value = sum(p.market_value for p in self.positions.values()) + total_value = self.cash + positions_value + + # Update peak and drawdown + if total_value > self._peak_value: + self._peak_value = total_value + + drawdown = (self._peak_value - total_value) / self._peak_value * HUNDRED if self._peak_value > ZERO else ZERO + + snapshot = BacktestSnapshot( + timestamp=timestamp, + cash=self.cash, + positions_value=positions_value, + total_value=total_value, + positions={k: BacktestPosition( + symbol=v.symbol, + quantity=v.quantity, + average_cost=v.average_cost, + current_price=v.current_price, + unrealized_pnl=v.unrealized_pnl, + realized_pnl=v.realized_pnl, + ) for k, v in self.positions.items()}, + drawdown=drawdown, + peak_value=self._peak_value, + ) + + self.snapshots.append(snapshot) + + def _create_result(self, errors: list[str]) -> BacktestResult: + """Create backtest result with calculated metrics. + + Args: + errors: List of errors during backtest + + Returns: + Complete BacktestResult + """ + if not self.snapshots: + return BacktestResult( + config=self.config, + initial_capital=self.config.initial_capital, + final_value=self.config.initial_capital, + errors=errors, + ) + + # Basic metrics + start_date = self.snapshots[0].timestamp + end_date = self.snapshots[-1].timestamp + final_value = self.snapshots[-1].total_value + total_return = (final_value - self.config.initial_capital) / self.config.initial_capital * HUNDRED + + # Calculate trading days and annualized return + trading_days = len(self.snapshots) + years = Decimal(str((end_date - start_date).days)) / Decimal("365") + if years > ZERO: + annualized_return = ((final_value / self.config.initial_capital) ** (ONE / years) - ONE) * HUNDRED + else: + annualized_return = ZERO + + # Calculate daily returns + daily_returns = [] + for i in range(1, len(self.snapshots)): + prev_value = self.snapshots[i - 1].total_value + curr_value = self.snapshots[i].total_value + if prev_value > ZERO: + daily_returns.append((curr_value - prev_value) / prev_value) + else: + daily_returns.append(ZERO) + + # Sharpe ratio + if daily_returns: + avg_return = sum(daily_returns) / len(daily_returns) + variance = sum((r - avg_return) ** 2 for r in daily_returns) / len(daily_returns) + std_dev = variance ** Decimal("0.5") + daily_rf = self.config.risk_free_rate / Decimal("252") + if std_dev > ZERO: + sharpe_ratio = (avg_return - daily_rf) / std_dev * Decimal("252").sqrt() + else: + sharpe_ratio = ZERO + else: + sharpe_ratio = ZERO + + # Sortino ratio (downside deviation) + negative_returns = [r for r in daily_returns if r < ZERO] + if negative_returns: + downside_variance = sum(r ** 2 for r in negative_returns) / len(negative_returns) + downside_dev = downside_variance ** Decimal("0.5") + daily_rf = self.config.risk_free_rate / Decimal("252") + if downside_dev > ZERO: + avg_return = sum(daily_returns) / len(daily_returns) if daily_returns else ZERO + sortino_ratio = (avg_return - daily_rf) / downside_dev * Decimal("252").sqrt() + else: + sortino_ratio = ZERO + else: + sortino_ratio = ZERO + + # Maximum drawdown + max_drawdown = max((s.drawdown for s in self.snapshots), default=ZERO) + + # Trade statistics + total_trades = len(self.trades) + winning_trades = sum(1 for t in self.trades if t.pnl > ZERO) + losing_trades = sum(1 for t in self.trades if t.pnl < ZERO) + win_rate = Decimal(str(winning_trades)) / Decimal(str(total_trades)) * HUNDRED if total_trades > 0 else ZERO + + wins = [t.pnl for t in self.trades if t.pnl > ZERO] + losses = [t.pnl for t in self.trades if t.pnl < ZERO] + + avg_win = sum(wins) / len(wins) if wins else ZERO + avg_loss = sum(losses) / len(losses) if losses else ZERO + max_win = max(wins) if wins else ZERO + max_loss = min(losses) if losses else ZERO # Most negative + + total_wins = sum(wins) + total_losses = abs(sum(losses)) + profit_factor = total_wins / total_losses if total_losses > ZERO else ZERO + + avg_trade_pnl = sum(t.pnl for t in self.trades) / total_trades if total_trades > 0 else ZERO + + # Total costs + total_commission = sum(t.commission for t in self.trades) + total_slippage = sum(t.slippage for t in self.trades) + + return BacktestResult( + config=self.config, + start_date=start_date, + end_date=end_date, + initial_capital=self.config.initial_capital, + final_value=final_value, + total_return=total_return, + annualized_return=annualized_return, + sharpe_ratio=sharpe_ratio, + sortino_ratio=sortino_ratio, + max_drawdown=max_drawdown, + win_rate=win_rate, + profit_factor=profit_factor, + total_trades=total_trades, + winning_trades=winning_trades, + losing_trades=losing_trades, + avg_trade_pnl=avg_trade_pnl, + avg_win=avg_win, + avg_loss=avg_loss, + max_win=max_win, + max_loss=max_loss, + total_commission=total_commission, + total_slippage=total_slippage, + trades=self.trades, + snapshots=self.snapshots, + daily_returns=daily_returns, + errors=errors, + ) + + def get_position(self, symbol: str) -> Optional[BacktestPosition]: + """Get current position for symbol. + + Args: + symbol: Symbol to look up + + Returns: + Position if exists + """ + return self.positions.get(symbol) + + def get_cash(self) -> Decimal: + """Get current cash balance. + + Returns: + Cash balance + """ + return self.cash + + def get_portfolio_value(self) -> Decimal: + """Get current portfolio value. + + Returns: + Total portfolio value + """ + return self._get_portfolio_value() + + +# ============================================================================ +# Factory Functions +# ============================================================================ + +def create_backtest_engine( + initial_capital: Decimal = Decimal("100000"), + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + slippage: Optional[SlippageModel] = None, + commission: Optional[CommissionModel] = None, + **kwargs, +) -> BacktestEngine: + """Create a configured backtest engine. + + Args: + initial_capital: Starting capital + start_date: Backtest start date + end_date: Backtest end date + slippage: Slippage model + commission: Commission model + **kwargs: Additional config options + + Returns: + Configured BacktestEngine + """ + config = BacktestConfig( + initial_capital=initial_capital, + start_date=start_date, + end_date=end_date, + slippage_model=slippage or NoSlippage(), + commission_model=commission or NoCommission(), + **{k: v for k, v in kwargs.items() if hasattr(BacktestConfig, k)}, + ) + + return BacktestEngine(config)