py-backtesting-lib 0.1.0__tar.gz

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.
Files changed (55) hide show
  1. py_backtesting_lib-0.1.0/.coverage +0 -0
  2. py_backtesting_lib-0.1.0/.gitignore +9 -0
  3. py_backtesting_lib-0.1.0/.idea/.gitignore +5 -0
  4. py_backtesting_lib-0.1.0/Backtesting/__init__.py +0 -0
  5. py_backtesting_lib-0.1.0/Backtesting/analysis/__init__.py +11 -0
  6. py_backtesting_lib-0.1.0/Backtesting/analysis/persistence/__init__.py +5 -0
  7. py_backtesting_lib-0.1.0/Backtesting/analysis/persistence/event_sink.py +133 -0
  8. py_backtesting_lib-0.1.0/Backtesting/analysis/reports/__init__.py +5 -0
  9. py_backtesting_lib-0.1.0/Backtesting/analysis/reports/console_report.py +73 -0
  10. py_backtesting_lib-0.1.0/Backtesting/analysis/result.py +42 -0
  11. py_backtesting_lib-0.1.0/Backtesting/definition/__init__.py +7 -0
  12. py_backtesting_lib-0.1.0/Backtesting/definition/configs/__init__.py +8 -0
  13. py_backtesting_lib-0.1.0/Backtesting/definition/configs/base.py +31 -0
  14. py_backtesting_lib-0.1.0/Backtesting/definition/core/__init__.py +22 -0
  15. py_backtesting_lib-0.1.0/Backtesting/definition/core/domain_models.py +165 -0
  16. py_backtesting_lib-0.1.0/Backtesting/definition/experiment.py +142 -0
  17. py_backtesting_lib-0.1.0/Backtesting/definition/plugins/__init__.py +3 -0
  18. py_backtesting_lib-0.1.0/Backtesting/definition/plugins/base_interfaces.py +126 -0
  19. py_backtesting_lib-0.1.0/Backtesting/definition/plugins/plugin_registry.py +26 -0
  20. py_backtesting_lib-0.1.0/Backtesting/execution/__init__.py +22 -0
  21. py_backtesting_lib-0.1.0/Backtesting/execution/books/__init__.py +11 -0
  22. py_backtesting_lib-0.1.0/Backtesting/execution/books/order_book.py +130 -0
  23. py_backtesting_lib-0.1.0/Backtesting/execution/books/position_book.py +136 -0
  24. py_backtesting_lib-0.1.0/Backtesting/execution/books/trade_book.py +88 -0
  25. py_backtesting_lib-0.1.0/Backtesting/execution/engine.py +57 -0
  26. py_backtesting_lib-0.1.0/Backtesting/execution/event.py +44 -0
  27. py_backtesting_lib-0.1.0/Backtesting/execution/market_data.py +108 -0
  28. py_backtesting_lib-0.1.0/Backtesting/execution/pipeline.py +105 -0
  29. py_backtesting_lib-0.1.0/Backtesting/execution/portfolio.py +72 -0
  30. py_backtesting_lib-0.1.0/Backtesting/experiments/base.yaml +32 -0
  31. py_backtesting_lib-0.1.0/Backtesting/main.py +148 -0
  32. py_backtesting_lib-0.1.0/Backtesting/plugins/__init__.py +13 -0
  33. py_backtesting_lib-0.1.0/Backtesting/plugins/brokers/__init__.py +3 -0
  34. py_backtesting_lib-0.1.0/Backtesting/plugins/brokers/instant_market_fill.py +26 -0
  35. py_backtesting_lib-0.1.0/Backtesting/plugins/market_data/__init__.py +3 -0
  36. py_backtesting_lib-0.1.0/Backtesting/plugins/market_data/dummy_data_provider.py +32 -0
  37. py_backtesting_lib-0.1.0/Backtesting/plugins/money_managers/__init__.py +3 -0
  38. py_backtesting_lib-0.1.0/Backtesting/plugins/money_managers/fixed_quality.py +36 -0
  39. py_backtesting_lib-0.1.0/Backtesting/plugins/strategies/SMA_crossovr.py +52 -0
  40. py_backtesting_lib-0.1.0/Backtesting/plugins/strategies/__init__.py +3 -0
  41. py_backtesting_lib-0.1.0/LICENSE +21 -0
  42. py_backtesting_lib-0.1.0/PKG-INFO +281 -0
  43. py_backtesting_lib-0.1.0/README.md +255 -0
  44. py_backtesting_lib-0.1.0/backtest_events.duckdb +0 -0
  45. py_backtesting_lib-0.1.0/docs/ARCHITECTURE.md +310 -0
  46. py_backtesting_lib-0.1.0/docs/DECISIONS.md +326 -0
  47. py_backtesting_lib-0.1.0/docs/JOURNAL.md +73 -0
  48. py_backtesting_lib-0.1.0/pyproject.toml +45 -0
  49. py_backtesting_lib-0.1.0/requirements.txt +20 -0
  50. py_backtesting_lib-0.1.0/test/__init__.py +0 -0
  51. py_backtesting_lib-0.1.0/test/analysis/test_analysis.py +352 -0
  52. py_backtesting_lib-0.1.0/test/definition/test_experiment.py +252 -0
  53. py_backtesting_lib-0.1.0/test/execution/.coverage +0 -0
  54. py_backtesting_lib-0.1.0/test/execution/test_engine.py +559 -0
  55. py_backtesting_lib-0.1.0/test/execution/test_eventbus.py +241 -0
Binary file
@@ -0,0 +1,9 @@
1
+
2
+ !.gitignore
3
+ .env.example
4
+ .idea
5
+ .venv
6
+ .specs
7
+ __pycache__
8
+ */__pycache__*
9
+ htmlcov
@@ -0,0 +1,5 @@
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
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,5 @@
1
+ # Backtesting/analysis/persistence/__init__.py
2
+
3
+ from .event_sink import DuckDBEventSink
4
+
5
+ __all__ = ["DuckDBEventSink"]
@@ -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,5 @@
1
+ # Backtesting/analysis/reports/__init__.py
2
+
3
+ from .console_report import ConsoleTradeReport
4
+
5
+ __all__ = ["ConsoleTradeReport"]
@@ -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,7 @@
1
+
2
+ from .plugins import *
3
+ from .configs import *
4
+ from .core import *
5
+ from .experiment import Experiment
6
+
7
+ __all__ = ["Experiment"] + configs.__all__ + plugins.__all__ + core.__all__
@@ -0,0 +1,8 @@
1
+ from .base import StrategyConfig, MarketDataConfig, MoneyManagementConfig, BrokerConfig
2
+
3
+ __all__ = [
4
+ "MarketDataConfig",
5
+ "StrategyConfig",
6
+ "MoneyManagementConfig",
7
+ "BrokerConfig",
8
+ ]
@@ -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}')"
@@ -0,0 +1,3 @@
1
+ from .base_interfaces import Strategy, MoneyManager, Broker, Reporter, MarketDataProvider
2
+
3
+ __all__ = ["Strategy", "MoneyManager", "Broker", "Reporter", "MarketDataProvider"]