feat(backtest): add BacktestEngine with slippage and commission models - Issue #42 (57 tests)

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 <noreply@anthropic.com>
This commit is contained in:
Andrew Kaszubski 2025-12-26 22:55:18 +11:00
parent b6eca9ea07
commit 6e52e4190d
6 changed files with 2405 additions and 22 deletions

View File

@ -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)."
}

View File

@ -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)

View File

@ -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}}

View File

@ -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

View File

@ -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",
]

File diff suppressed because it is too large Load Diff