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:
parent
b6eca9ea07
commit
6e52e4190d
|
|
@ -49,8 +49,8 @@
|
||||||
"Issue #48: [DOCS-47] Documentation - user guide, developer docs"
|
"Issue #48: [DOCS-47] Documentation - user guide, developer docs"
|
||||||
],
|
],
|
||||||
"total_features": 45,
|
"total_features": 45,
|
||||||
"current_index": 37,
|
"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],
|
"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": [],
|
"failed_features": [],
|
||||||
"context_token_estimate": 0,
|
"context_token_estimate": 0,
|
||||||
"auto_clear_count": 0,
|
"auto_clear_count": 0,
|
||||||
|
|
@ -60,5 +60,5 @@
|
||||||
"source_type": "issues",
|
"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],
|
"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",
|
"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)."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
Implements SMS alert delivery using Twilio API:
|
||||||
- SlackMessageStyle enum (simple, blocks, attachment)
|
- SMSFormat enum (plain, compact, detailed)
|
||||||
- SlackConfig for webhook URL, channel, username, icon
|
- SMSStatus enum (queued, sending, sent, delivered, failed, etc.)
|
||||||
- SlackMessageResult for delivery tracking
|
- SMSConfig for Twilio credentials and settings
|
||||||
- SlackMessageFormatter with Block Kit support
|
- SMSMessageResult for delivery tracking
|
||||||
- SlackChannel implementing AlertChannel protocol
|
- SMSBatchResult for multi-recipient sends
|
||||||
- create_slack_channel factory function
|
- SMSMessageFormatter with priority indicators
|
||||||
|
- SMSChannel implementing AlertChannel protocol
|
||||||
|
- create_sms_channel factory function
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Three formatting styles (simple text, blocks, attachments)
|
- E.164 phone number validation
|
||||||
- Priority-based colors and emojis
|
- Three formatting styles (plain, compact, detailed)
|
||||||
- Category emojis for visual identification
|
- Priority indicators ([!], [!!!]) for high/critical alerts
|
||||||
- @mention support for critical alerts
|
- Category prefixes (TRD, RSK, SYS, MKT, etc.)
|
||||||
- Timestamp and source inclusion options
|
- SMS segment counting (GSM-7 vs Unicode)
|
||||||
- Data field display in messages
|
- Messaging service SID support
|
||||||
- Webhook URL validation (hooks.slack.com)
|
- Priority filtering (send only HIGH+ alerts)
|
||||||
|
- Batch sending to multiple recipients
|
||||||
- Retry logic with exponential backoff
|
- Retry logic with exponential backoff
|
||||||
- Rate limit handling (429 status with Retry-After)
|
- Server error retry vs client error fail-fast
|
||||||
- Server error retry (500+) vs client error fail-fast (400)
|
|
||||||
- Latency tracking for performance monitoring
|
- Latency tracking for performance monitoring
|
||||||
- Sync and async delivery methods
|
- Status callback URL support
|
||||||
- Test webhook functionality
|
- Connection test functionality
|
||||||
|
|
||||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: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: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: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}}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
Loading…
Reference in New Issue