py-backtesting-lib 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- Backtesting/__init__.py +0 -0
- Backtesting/analysis/__init__.py +11 -0
- Backtesting/analysis/persistence/__init__.py +5 -0
- Backtesting/analysis/persistence/event_sink.py +133 -0
- Backtesting/analysis/reports/__init__.py +5 -0
- Backtesting/analysis/reports/console_report.py +73 -0
- Backtesting/analysis/result.py +42 -0
- Backtesting/definition/__init__.py +7 -0
- Backtesting/definition/configs/__init__.py +8 -0
- Backtesting/definition/configs/base.py +31 -0
- Backtesting/definition/core/__init__.py +22 -0
- Backtesting/definition/core/domain_models.py +165 -0
- Backtesting/definition/experiment.py +142 -0
- Backtesting/definition/plugins/__init__.py +3 -0
- Backtesting/definition/plugins/base_interfaces.py +126 -0
- Backtesting/definition/plugins/plugin_registry.py +26 -0
- Backtesting/execution/__init__.py +22 -0
- Backtesting/execution/books/__init__.py +11 -0
- Backtesting/execution/books/order_book.py +130 -0
- Backtesting/execution/books/position_book.py +136 -0
- Backtesting/execution/books/trade_book.py +88 -0
- Backtesting/execution/engine.py +57 -0
- Backtesting/execution/event.py +44 -0
- Backtesting/execution/market_data.py +108 -0
- Backtesting/execution/pipeline.py +105 -0
- Backtesting/execution/portfolio.py +72 -0
- Backtesting/experiments/base.yaml +32 -0
- Backtesting/main.py +148 -0
- Backtesting/plugins/__init__.py +13 -0
- Backtesting/plugins/brokers/__init__.py +3 -0
- Backtesting/plugins/brokers/instant_market_fill.py +26 -0
- Backtesting/plugins/market_data/__init__.py +3 -0
- Backtesting/plugins/market_data/dummy_data_provider.py +32 -0
- Backtesting/plugins/money_managers/__init__.py +3 -0
- Backtesting/plugins/money_managers/fixed_quality.py +36 -0
- Backtesting/plugins/strategies/SMA_crossovr.py +52 -0
- Backtesting/plugins/strategies/__init__.py +3 -0
- py_backtesting_lib-0.1.0.dist-info/METADATA +281 -0
- py_backtesting_lib-0.1.0.dist-info/RECORD +42 -0
- py_backtesting_lib-0.1.0.dist-info/WHEEL +4 -0
- py_backtesting_lib-0.1.0.dist-info/entry_points.txt +2 -0
- py_backtesting_lib-0.1.0.dist-info/licenses/LICENSE +21 -0
Backtesting/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Backtesting/analysis/__init__.py
|
|
2
|
+
|
|
3
|
+
from .result import ExperimentResult
|
|
4
|
+
from .reports.console_report import ConsoleTradeReport
|
|
5
|
+
from .persistence.event_sink import DuckDBEventSink
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ExperimentResult",
|
|
9
|
+
"ConsoleTradeReport",
|
|
10
|
+
"DuckDBEventSink",
|
|
11
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Backtesting/analysis/persistence/event_sink.py
|
|
2
|
+
# Persists events to a database
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
|
|
5
|
+
import duckdb
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
import json
|
|
9
|
+
from ...definition import EventType, MarketBarReceived, SignalGenerated, OrderSubmitted, OrderFilled, FillExecuted, TradeArchived, MarketBar, Signal, Order, Fill, Trade
|
|
10
|
+
|
|
11
|
+
class DuckDBEventSink:
|
|
12
|
+
"""
|
|
13
|
+
Subscribes to the EventBus and persists events to a DuckDB database.
|
|
14
|
+
"""
|
|
15
|
+
def __init__(self, db_path: str = "backtest_events.duckdb"):
|
|
16
|
+
self.con = duckdb.connect(db_path)
|
|
17
|
+
self.experiment_id = None
|
|
18
|
+
self._create_table()
|
|
19
|
+
|
|
20
|
+
def _create_table(self):
|
|
21
|
+
create_sql = """
|
|
22
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
23
|
+
experiment_id VARCHAR NOT NULL,
|
|
24
|
+
event_timestamp TIMESTAMP NOT NULL,
|
|
25
|
+
event_type VARCHAR NOT NULL,
|
|
26
|
+
event_data JSON
|
|
27
|
+
);
|
|
28
|
+
"""
|
|
29
|
+
self.con.execute(create_sql)
|
|
30
|
+
|
|
31
|
+
def subscribe_to_engine(self, event_bus, experiment_id: str):
|
|
32
|
+
"""
|
|
33
|
+
Subscribes to the given EventBus for all event types and tags them with the experiment_id.
|
|
34
|
+
"""
|
|
35
|
+
self.experiment_id = experiment_id
|
|
36
|
+
# Subscribe to the base event type to catch all events
|
|
37
|
+
# This assumes all events have a common base class or share a common attribute like 'event_type'
|
|
38
|
+
# A more robust way might be to subscribe to each specific event type or use a wildcard if supported.
|
|
39
|
+
# For now, let's subscribe to a common parent type or a generic object type.
|
|
40
|
+
# We'll use the most general type possible in Python, `object`, but this is inefficient.
|
|
41
|
+
# A better approach is to subscribe to each specific event type dynamically if possible.
|
|
42
|
+
# Let's assume we know the types we care about for now.
|
|
43
|
+
# A more generic solution would be to have a base event class that all events inherit from.
|
|
44
|
+
# For this implementation, let's create a subscriber that catches any object and filters.
|
|
45
|
+
def event_handler(event):
|
|
46
|
+
self.persist_event(event)
|
|
47
|
+
|
|
48
|
+
# Subscribe to common event types
|
|
49
|
+
event_bus.subscribe(MarketBarReceived, event_handler)
|
|
50
|
+
event_bus.subscribe(SignalGenerated, event_handler)
|
|
51
|
+
event_bus.subscribe(OrderSubmitted, event_handler)
|
|
52
|
+
event_bus.subscribe(OrderFilled, event_handler)
|
|
53
|
+
event_bus.subscribe(FillExecuted, event_handler)
|
|
54
|
+
event_bus.subscribe(TradeArchived, event_handler)
|
|
55
|
+
# Add subscriptions for other known event types as needed
|
|
56
|
+
|
|
57
|
+
def persist_event(self, event: Any):
|
|
58
|
+
"""
|
|
59
|
+
Inserts an event into the database.
|
|
60
|
+
"""
|
|
61
|
+
if not self.experiment_id:
|
|
62
|
+
# Should not happen if subscribed correctly
|
|
63
|
+
print("Warning: Attempting to persist event without experiment_id set.")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
event_type_name = type(event).__name__
|
|
67
|
+
event_data_serialized = json.dumps(self._serialize_event_data(event))
|
|
68
|
+
|
|
69
|
+
insert_sql = """
|
|
70
|
+
INSERT INTO events (experiment_id, event_timestamp, event_type, event_data)
|
|
71
|
+
VALUES (?, ?, ?, ?);
|
|
72
|
+
"""
|
|
73
|
+
self.con.execute(insert_sql, [self.experiment_id, datetime.now(), event_type_name, event_data_serialized])
|
|
74
|
+
|
|
75
|
+
def _serialize_event_data(self, event: Any) -> Dict[str, Any]:
|
|
76
|
+
"""
|
|
77
|
+
Converts an event object's attributes into a dictionary suitable for JSON serialization.
|
|
78
|
+
This is a simplified approach. A more robust solution might use a serialization library
|
|
79
|
+
that understands dataclasses, enums, and Decimals.
|
|
80
|
+
"""
|
|
81
|
+
# Use dataclass `__dict__` for events defined as dataclasses
|
|
82
|
+
# Handle special cases like enums and Decimals
|
|
83
|
+
data = {}
|
|
84
|
+
for key, value in event.__dict__.items():
|
|
85
|
+
if isinstance(value, (MarketBar, Signal, Order, Fill, Trade)):
|
|
86
|
+
# Recursively serialize nested dataclass objects
|
|
87
|
+
data[key] = self._serialize_event_data(value)
|
|
88
|
+
elif isinstance(value, (int, float, str, bool, type(None))):
|
|
89
|
+
# Primitive types are fine
|
|
90
|
+
data[key] = value
|
|
91
|
+
elif hasattr(value, 'value'): # Handle enums
|
|
92
|
+
data[key] = value.value
|
|
93
|
+
elif isinstance(value, Decimal):
|
|
94
|
+
# Convert Decimal to string to preserve precision in JSON
|
|
95
|
+
data[key] = str(value)
|
|
96
|
+
elif isinstance(value, datetime):
|
|
97
|
+
# Convert datetime to ISO string
|
|
98
|
+
data[key] = value.isoformat()
|
|
99
|
+
else:
|
|
100
|
+
# Fallback for other types - might cause issues if not serializable
|
|
101
|
+
data[key] = repr(value) # Use repr as a very last resort
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
def close_connection(self):
|
|
105
|
+
"""
|
|
106
|
+
Closes the database connection.
|
|
107
|
+
"""
|
|
108
|
+
if self.con:
|
|
109
|
+
self.con.close()
|
|
110
|
+
|
|
111
|
+
def query_events(self, experiment_id: str = None, event_type: str = None, limit: int = None) -> duckdb.DuckDBPyRelation:
|
|
112
|
+
"""
|
|
113
|
+
Queries the event log. Allows filtering by experiment_id and event_type.
|
|
114
|
+
Returns a DuckDB relation object for further manipulation.
|
|
115
|
+
"""
|
|
116
|
+
sql = "SELECT * FROM events WHERE 1=1" # Start with a base condition
|
|
117
|
+
params = []
|
|
118
|
+
|
|
119
|
+
if experiment_id:
|
|
120
|
+
sql += " AND experiment_id = ?"
|
|
121
|
+
params.append(experiment_id)
|
|
122
|
+
|
|
123
|
+
if event_type:
|
|
124
|
+
sql += " AND event_type = ?"
|
|
125
|
+
params.append(event_type)
|
|
126
|
+
|
|
127
|
+
sql += " ORDER BY event_timestamp ASC" # Always order chronologically
|
|
128
|
+
|
|
129
|
+
if limit:
|
|
130
|
+
sql += " LIMIT ?"
|
|
131
|
+
params.append(limit)
|
|
132
|
+
|
|
133
|
+
return self.con.execute(sql, params)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Backtesting/analysis/reports/console_report.py
|
|
2
|
+
# Simple console report plugin
|
|
3
|
+
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import List
|
|
6
|
+
from ..result import ExperimentResult
|
|
7
|
+
from ...definition import Trade
|
|
8
|
+
|
|
9
|
+
class ConsoleTradeReport:
|
|
10
|
+
"""
|
|
11
|
+
A simple report plugin that prints a summary of the experiment results to the console.
|
|
12
|
+
"""
|
|
13
|
+
def generate_report(self, result: ExperimentResult):
|
|
14
|
+
"""
|
|
15
|
+
Generates and prints the report based on the ExperimentResult.
|
|
16
|
+
"""
|
|
17
|
+
print("\n" + "="*50)
|
|
18
|
+
print(f"REPORT FOR EXPERIMENT: {result.experiment_name}")
|
|
19
|
+
print(f"RUN ID: {result.experiment_id}")
|
|
20
|
+
print(f"STARTED: {result.started_at.isoformat()}")
|
|
21
|
+
print(f"FINISHED: {result.finished_at.isoformat()}")
|
|
22
|
+
print("-"*50)
|
|
23
|
+
|
|
24
|
+
closed_trades: List[Trade] = result.get_closed_trades()
|
|
25
|
+
total_trades = len(closed_trades)
|
|
26
|
+
total_pnl = result.get_total_pnl()
|
|
27
|
+
|
|
28
|
+
if total_trades == 0:
|
|
29
|
+
print("No trades executed.")
|
|
30
|
+
print("="*50)
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
winning_trades = [t for t in closed_trades if t.realized_pnl > 0]
|
|
34
|
+
losing_trades = [t for t in closed_trades if t.realized_pnl < 0]
|
|
35
|
+
|
|
36
|
+
win_rate = (len(winning_trades) / total_trades) * 100 if total_trades > 0 else 0
|
|
37
|
+
avg_trade_pnl = total_pnl / total_trades if total_trades > 0 else Decimal('0')
|
|
38
|
+
|
|
39
|
+
# Calculate Max Drawdown (simplified)
|
|
40
|
+
# This is a basic calculation assuming equity is known at each trade close.
|
|
41
|
+
# A more robust calculation would require equity snapshots throughout the run.
|
|
42
|
+
# For now, we'll calculate drawdown based on cumulative PnL.
|
|
43
|
+
cumulative_pnl = [Decimal('0')]
|
|
44
|
+
for trade in closed_trades:
|
|
45
|
+
cumulative_pnl.append(cumulative_pnl[-1] + trade.realized_pnl)
|
|
46
|
+
|
|
47
|
+
peak = Decimal('-Infinity')
|
|
48
|
+
max_drawdown = Decimal('0')
|
|
49
|
+
for pnl in cumulative_pnl:
|
|
50
|
+
if pnl > peak:
|
|
51
|
+
peak = pnl
|
|
52
|
+
drawdown = peak - pnl
|
|
53
|
+
if drawdown > max_drawdown:
|
|
54
|
+
max_drawdown = drawdown
|
|
55
|
+
|
|
56
|
+
# Calculate Sharpe Ratio (simplified, requires risk-free rate assumption)
|
|
57
|
+
# Annualized Sharpe = (Annualized Return - Risk_Free_Rate) / Annualized Volatility
|
|
58
|
+
# For simplicity, assume daily returns and risk-free rate of 0.
|
|
59
|
+
# Annualized Return = (Final Value / Initial Value)^(252/n_days) - 1
|
|
60
|
+
# Annualized Volatility = Std Dev of Daily Returns * sqrt(252)
|
|
61
|
+
# This requires daily equity values, which we don't have here.
|
|
62
|
+
# Let's skip Sharpe for this basic report or use a placeholder.
|
|
63
|
+
sharpe_ratio_placeholder = "N/A (requires daily equity curve)"
|
|
64
|
+
|
|
65
|
+
print(f"Total Trades: {total_trades}")
|
|
66
|
+
print(f"Winning Trades: {len(winning_trades)}")
|
|
67
|
+
print(f"Losing Trades: {len(losing_trades)}")
|
|
68
|
+
print(f"Win Rate: {win_rate:.2f}%")
|
|
69
|
+
print(f"Total PnL: {total_pnl:.2f}")
|
|
70
|
+
print(f"Average Trade PnL: {avg_trade_pnl:.2f}")
|
|
71
|
+
print(f"Max Drawdown: {max_drawdown:.2f}")
|
|
72
|
+
print(f"Sharpe Ratio: {sharpe_ratio_placeholder}") # Placeholder
|
|
73
|
+
print("="*50)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import List, Dict, Any
|
|
6
|
+
from ..execution import Trade
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ExperimentResult:
|
|
10
|
+
"""
|
|
11
|
+
Immutable aggregate containing the final state and event log of an experiment run.
|
|
12
|
+
"""
|
|
13
|
+
# Metadata
|
|
14
|
+
experiment_id: str # Unique identifier for this run
|
|
15
|
+
experiment_name: str
|
|
16
|
+
started_at: datetime
|
|
17
|
+
finished_at: datetime
|
|
18
|
+
|
|
19
|
+
# Final State Snapshots
|
|
20
|
+
portfolio_snapshot: Dict[str, Any] # e.g., {"cash": Decimal, "equity": Decimal, "margin_used": Decimal}
|
|
21
|
+
position_book_snapshot: Dict[str, Dict[str, Decimal]] # e.g., {"SYMBOL": {"net_size": Decimal, "avg_price": Decimal, "upl": Decimal}}
|
|
22
|
+
trade_book_snapshot: List[Dict[str, Any]] # List of serialized Trade objects
|
|
23
|
+
|
|
24
|
+
# Full Event Log (serialized)
|
|
25
|
+
event_log: List[Dict[str, Any]] # List of serialized events [{"type": str, "data": dict, "timestamp": datetime}]
|
|
26
|
+
|
|
27
|
+
def get_closed_trades(self) -> List[Trade]:
|
|
28
|
+
"""
|
|
29
|
+
Helper to deserialize and return the list of closed trades.
|
|
30
|
+
"""
|
|
31
|
+
# This is a simplified deserialization. In practice, you might use a dedicated serializer/deserializer.
|
|
32
|
+
# For now, assume the snapshot data matches the Trade dataclass structure.
|
|
33
|
+
return [Trade(**trade_dict) for trade_dict in self.trade_book_snapshot]
|
|
34
|
+
|
|
35
|
+
def get_total_pnl(self) -> Decimal:
|
|
36
|
+
"""
|
|
37
|
+
Calculates total realized PnL from closed trades.
|
|
38
|
+
"""
|
|
39
|
+
total = Decimal('0')
|
|
40
|
+
for trade in self.get_closed_trades():
|
|
41
|
+
total += trade.realized_pnl
|
|
42
|
+
return total
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Backtesting/definition/configs/base.py
|
|
2
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
3
|
+
|
|
4
|
+
class PluginConfig(BaseModel):
|
|
5
|
+
"""
|
|
6
|
+
Base configuration model for plugins' configuration.
|
|
7
|
+
Enforces the standard structure: name and flexible params.
|
|
8
|
+
"""
|
|
9
|
+
model_config = ConfigDict(frozen=True)
|
|
10
|
+
name: str
|
|
11
|
+
params: dict = {} # Defaults to empty dict if not provided
|
|
12
|
+
|
|
13
|
+
@field_validator('params', mode='before')
|
|
14
|
+
@classmethod
|
|
15
|
+
def validate_params(cls, v):
|
|
16
|
+
return {} if v is None else v
|
|
17
|
+
|
|
18
|
+
def __hash__(self):
|
|
19
|
+
return hash((type(self),) + tuple(self.__dict__.values()))
|
|
20
|
+
|
|
21
|
+
class StrategyConfig(PluginConfig):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
class MoneyManagementConfig(PluginConfig):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
class BrokerConfig(PluginConfig):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
class MarketDataConfig(PluginConfig):
|
|
31
|
+
pass
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
from .domain_models import *
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"MarketBar",
|
|
6
|
+
"SignalType",
|
|
7
|
+
"Signal",
|
|
8
|
+
"OrderType",
|
|
9
|
+
"OrderSide",
|
|
10
|
+
"OrderStatus",
|
|
11
|
+
"Order",
|
|
12
|
+
"Fill",
|
|
13
|
+
"EventType",
|
|
14
|
+
"Trade",
|
|
15
|
+
"MarketBarReceived",
|
|
16
|
+
"SignalGenerated",
|
|
17
|
+
"OrderSubmitted",
|
|
18
|
+
"OrderFilled",
|
|
19
|
+
"OrderCancelled",
|
|
20
|
+
"FillExecuted",
|
|
21
|
+
"TradeArchived",
|
|
22
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Backtesting/definition/core/domain_models.py
|
|
2
|
+
# Shared core domain models and events used by definition and execution
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
# --- Enums ---
|
|
10
|
+
class EventType(Enum):
|
|
11
|
+
MARKET_BAR_RECEIVED = "market_bar_received"
|
|
12
|
+
SIGNAL_GENERATED = "signal_generated"
|
|
13
|
+
ORDER_SUBMITTED = "order_submitted"
|
|
14
|
+
ORDER_FILLED = "order_filled"
|
|
15
|
+
ORDER_CANCELLED = "order_cancelled"
|
|
16
|
+
FILL_EXECUTED = "fill_executed"
|
|
17
|
+
POSITION_OPENED = "position_opened"
|
|
18
|
+
POSITION_CLOSED = "position_closed"
|
|
19
|
+
TRADE_ARCHIVED = "trade_archived"
|
|
20
|
+
PORTFOLIO_STATE_UPDATE = "portfolio_state_update"
|
|
21
|
+
|
|
22
|
+
class SignalType(Enum):
|
|
23
|
+
BUY_LONG = "buy_long"
|
|
24
|
+
SELL_SHORT = "sell_short"
|
|
25
|
+
EXIT_LONG = "exit_long"
|
|
26
|
+
EXIT_SHORT = "exit_short"
|
|
27
|
+
|
|
28
|
+
class OrderType(Enum):
|
|
29
|
+
MARKET = "market"
|
|
30
|
+
LIMIT = "limit"
|
|
31
|
+
STOP = "stop"
|
|
32
|
+
|
|
33
|
+
class OrderSide(Enum):
|
|
34
|
+
BUY = "buy"
|
|
35
|
+
SELL = "sell"
|
|
36
|
+
|
|
37
|
+
class OrderStatus(Enum):
|
|
38
|
+
SUBMITTED = "submitted"
|
|
39
|
+
PENDING = "pending"
|
|
40
|
+
FILLED = "filled"
|
|
41
|
+
PARTIALLY_FILLED = "partially_filled"
|
|
42
|
+
CANCELLED = "cancelled"
|
|
43
|
+
|
|
44
|
+
# --- Core Domain Data Structures ---
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class MarketBar:
|
|
47
|
+
"""
|
|
48
|
+
Represents a single bar/tick of market data.
|
|
49
|
+
"""
|
|
50
|
+
symbol: str
|
|
51
|
+
timestamp: datetime
|
|
52
|
+
open: Decimal
|
|
53
|
+
high: Decimal
|
|
54
|
+
low: Decimal
|
|
55
|
+
close: Decimal
|
|
56
|
+
volume: Decimal
|
|
57
|
+
|
|
58
|
+
def __repr__(self):
|
|
59
|
+
return f"MarketBar({self.symbol}, {self.timestamp}, O:{self.open}, H:{self.high}, L:{self.low}, C:{self.close}, V:{self.volume})"
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Signal:
|
|
63
|
+
"""
|
|
64
|
+
Represents a trading signal generated by a Strategy.
|
|
65
|
+
"""
|
|
66
|
+
id: str # Add an ID field for better tracking
|
|
67
|
+
symbol: str
|
|
68
|
+
signal_type: SignalType
|
|
69
|
+
strength: Decimal = Decimal('1.0')
|
|
70
|
+
meta: dict = field(default_factory=dict) # Use field for mutable defaults
|
|
71
|
+
|
|
72
|
+
def __repr__(self):
|
|
73
|
+
return f"Signal({self.id}, {self.symbol}, {self.signal_type}, Strength: {self.strength})"
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Order:
|
|
77
|
+
"""
|
|
78
|
+
Represents a trading order created by a MoneyManager based on a Signal.
|
|
79
|
+
This is the *immutable* version used for events/logs.
|
|
80
|
+
"""
|
|
81
|
+
order_id: str
|
|
82
|
+
signal_id: str
|
|
83
|
+
symbol: str
|
|
84
|
+
order_type: OrderType
|
|
85
|
+
side: OrderSide
|
|
86
|
+
quantity: Decimal
|
|
87
|
+
price: Decimal = None # Optional for MARKET orders
|
|
88
|
+
stop_price: Decimal = None # Optional for STOP orders
|
|
89
|
+
status: OrderStatus = OrderStatus.SUBMITTED
|
|
90
|
+
filled_quantity: Decimal = Decimal('0')
|
|
91
|
+
average_fill_price: Decimal = Decimal('0')
|
|
92
|
+
commission: Decimal = Decimal('0') # Added commission field
|
|
93
|
+
|
|
94
|
+
def __repr__(self):
|
|
95
|
+
return f"Order({self.order_id}, {self.symbol}, {self.side} {self.quantity} {self.order_type}, Status: {self.status})"
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class Fill:
|
|
99
|
+
"""
|
|
100
|
+
Represents the execution details of an Order (or part of it).
|
|
101
|
+
"""
|
|
102
|
+
fill_id: str
|
|
103
|
+
order_id: str
|
|
104
|
+
symbol: str
|
|
105
|
+
side: OrderSide
|
|
106
|
+
quantity: Decimal
|
|
107
|
+
fill_price: Decimal
|
|
108
|
+
fill_time: datetime
|
|
109
|
+
commission: Decimal = Decimal('0')
|
|
110
|
+
|
|
111
|
+
def __repr__(self):
|
|
112
|
+
return f"Fill({self.fill_id}, {self.order_id}, {self.side} {self.quantity} @ {self.fill_price}, Time: {self.fill_time})"
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Trade:
|
|
116
|
+
"""
|
|
117
|
+
Represents an immutable record of a closed trade.
|
|
118
|
+
"""
|
|
119
|
+
symbol: str
|
|
120
|
+
entry_time: Decimal # Or datetime if available in fill
|
|
121
|
+
exit_time: Decimal # Or datetime if available in fill
|
|
122
|
+
entry_price: Decimal
|
|
123
|
+
exit_price: Decimal
|
|
124
|
+
quantity: Decimal
|
|
125
|
+
side: str # 'LONG' or 'SHORT' based on entry side
|
|
126
|
+
realized_pnl: Decimal
|
|
127
|
+
commission: Decimal
|
|
128
|
+
|
|
129
|
+
# --- Event Definitions (Immutable wrappers around core data structures) ---
|
|
130
|
+
# These are the objects published by the engine and listened to by handlers.
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class MarketBarReceived:
|
|
134
|
+
bar: MarketBar
|
|
135
|
+
event_type: EventType = EventType.MARKET_BAR_RECEIVED
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class SignalGenerated:
|
|
139
|
+
signal: Signal
|
|
140
|
+
event_type: EventType = EventType.SIGNAL_GENERATED
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class OrderSubmitted:
|
|
144
|
+
order: Order
|
|
145
|
+
event_type: EventType = EventType.ORDER_SUBMITTED
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class OrderFilled:
|
|
149
|
+
order: Order
|
|
150
|
+
event_type: EventType = EventType.ORDER_FILLED
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class OrderCancelled:
|
|
154
|
+
order_id: str # Just the ID might suffice, or include the Order object if needed
|
|
155
|
+
event_type: EventType = EventType.ORDER_CANCELLED
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class FillExecuted:
|
|
159
|
+
fill: Fill
|
|
160
|
+
event_type: EventType = EventType.FILL_EXECUTED
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class TradeArchived:
|
|
164
|
+
trade: Trade
|
|
165
|
+
event_type: EventType = EventType.TRADE_ARCHIVED
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from multiprocessing.connection import default_family
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from pydantic import BaseModel, ValidationError, ConfigDict, Field
|
|
6
|
+
from .configs import (
|
|
7
|
+
StrategyConfig,
|
|
8
|
+
MoneyManagementConfig,
|
|
9
|
+
BrokerConfig,
|
|
10
|
+
MarketDataConfig,
|
|
11
|
+
)
|
|
12
|
+
from .plugins.plugin_registry import resolve, PluginNotFoundError
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
class Experiment(BaseModel):
|
|
16
|
+
"""
|
|
17
|
+
The Aggregate Root for a backtesting experiment.
|
|
18
|
+
Holds all configuration data and provides validation and serialization.
|
|
19
|
+
"""
|
|
20
|
+
model_config = ConfigDict(frozen=True) # Make the entire Experiment immutable after creation
|
|
21
|
+
|
|
22
|
+
# Metadata
|
|
23
|
+
name: str
|
|
24
|
+
author: str = "Unknown" # Optional default
|
|
25
|
+
version: str = "1.0" # Optional default
|
|
26
|
+
created_at: datetime = Field(default_factory=datetime.now) # Automatically set unless provided
|
|
27
|
+
|
|
28
|
+
# Plugin configurations
|
|
29
|
+
strategy_config: StrategyConfig
|
|
30
|
+
money_management_config: MoneyManagementConfig
|
|
31
|
+
broker_config: BrokerConfig
|
|
32
|
+
market_data_config: MarketDataConfig
|
|
33
|
+
|
|
34
|
+
def validate(self):
|
|
35
|
+
"""
|
|
36
|
+
Performs semantic validation by checking if all plugin names exist in the registry.
|
|
37
|
+
Raises PluginNotFoundError if any name is invalid.
|
|
38
|
+
This method should be called *after* plugins are registered.
|
|
39
|
+
"""
|
|
40
|
+
plugin_names_to_check = [
|
|
41
|
+
self.strategy_config.name,
|
|
42
|
+
self.money_management_config.name,
|
|
43
|
+
self.broker_config.name,
|
|
44
|
+
self.market_data_config.name,
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
for name in plugin_names_to_check:
|
|
48
|
+
try:
|
|
49
|
+
resolve(name)
|
|
50
|
+
except PluginNotFoundError:
|
|
51
|
+
# Re-raising with context about the specific config field
|
|
52
|
+
raise PluginNotFoundError(
|
|
53
|
+
f"Experiment validation failed: Plugin '{name}' referenced in "
|
|
54
|
+
f"configuration is not registered in the PluginRegistry."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_yaml(cls, file_path: str):
|
|
60
|
+
"""
|
|
61
|
+
Loads an Experiment definition from a YAML file.
|
|
62
|
+
Performs syntactic validation via Pydantic.
|
|
63
|
+
Does NOT perform semantic validation (registry check) here.
|
|
64
|
+
Call `experiment_instance.validate()` after loading if needed.
|
|
65
|
+
"""
|
|
66
|
+
path = Path(file_path)
|
|
67
|
+
if not path.exists():
|
|
68
|
+
raise FileNotFoundError(f"Experiment YAML file not found: {file_path}")
|
|
69
|
+
|
|
70
|
+
with open(path, 'r', encoding='utf-8') as file:
|
|
71
|
+
data = yaml.safe_load(file)
|
|
72
|
+
|
|
73
|
+
# Assuming the YAML structure wraps the fields under an 'experiment:' key
|
|
74
|
+
# as shown in the README example.
|
|
75
|
+
experiment_data = data.get('experiment')
|
|
76
|
+
if not experiment_data:
|
|
77
|
+
raise ValueError(f"YAML file {file_path} must contain an 'experiment:' section.")
|
|
78
|
+
|
|
79
|
+
# Remap keys from YAML format to Pydantic field names
|
|
80
|
+
config_map = {
|
|
81
|
+
"strategy": "strategy_config",
|
|
82
|
+
"money_management": "money_management_config",
|
|
83
|
+
"broker": "broker_config",
|
|
84
|
+
"market_data": "market_data_config"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
remapped_data = {}
|
|
88
|
+
for yaml_key, pydantic_field in config_map.items():
|
|
89
|
+
if yaml_key in experiment_data.get('configs', {}):
|
|
90
|
+
remapped_data[pydantic_field] = experiment_data['configs'][yaml_key]
|
|
91
|
+
|
|
92
|
+
# Add top-level metadata fields directly
|
|
93
|
+
for field in ['name', 'author', 'version']:
|
|
94
|
+
if field in experiment_data.get('metadata', {}):
|
|
95
|
+
remapped_data[field] = experiment_data['metadata'][field]
|
|
96
|
+
|
|
97
|
+
# Construct the final input for Pydantic
|
|
98
|
+
final_input = {
|
|
99
|
+
"name": experiment_data["name"],
|
|
100
|
+
"author": experiment_data.get("metadata", {}).get("author", "Unknown"),
|
|
101
|
+
"version": experiment_data.get("metadata", {}).get("version", "1.0"),
|
|
102
|
+
"strategy_config": remapped_data.get("strategy_config"),
|
|
103
|
+
"money_management_config": remapped_data.get("money_management_config"),
|
|
104
|
+
"broker_config": remapped_data.get("broker_config"),
|
|
105
|
+
"market_data_config": remapped_data.get("market_data_config"),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
return cls(**final_input)
|
|
110
|
+
except ValidationError as e:
|
|
111
|
+
# Optionally log the detailed Pydantic error
|
|
112
|
+
print(f"Pydantic validation error loading Experiment from {file_path}: {e}")
|
|
113
|
+
raise # Re-raise to fail fast
|
|
114
|
+
|
|
115
|
+
def to_yaml(self, file_path: str):
|
|
116
|
+
"""
|
|
117
|
+
Serializes the Experiment definition to a YAML file.
|
|
118
|
+
"""
|
|
119
|
+
path = Path(file_path)
|
|
120
|
+
# Prepare data structure matching the expected YAML input format
|
|
121
|
+
yaml_data = {
|
|
122
|
+
"experiment": {
|
|
123
|
+
"name": self.model_dump()['name'], # Access via model_dump for safety
|
|
124
|
+
"metadata": {
|
|
125
|
+
"author": self.model_dump()['author'],
|
|
126
|
+
"version": self.model_dump()['version'],
|
|
127
|
+
"created_at": self.model_dump()['created_at'].isoformat(), # Convert datetime to string
|
|
128
|
+
},
|
|
129
|
+
"configs": {
|
|
130
|
+
"strategy": self.strategy_config.model_dump(),
|
|
131
|
+
"money_management": self.money_management_config.model_dump(),
|
|
132
|
+
"broker": self.broker_config.model_dump(),
|
|
133
|
+
"market_data": self.market_data_config.model_dump(),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
with open(path, 'w', encoding='utf-8') as file:
|
|
139
|
+
yaml.dump(yaml_data, file, default_flow_style=False, indent=2)
|
|
140
|
+
|
|
141
|
+
def __repr__(self):
|
|
142
|
+
return f"Experiment(name='{self.name}', strategy='{self.strategy_config.name}')"
|