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.
Files changed (42) hide show
  1. Backtesting/__init__.py +0 -0
  2. Backtesting/analysis/__init__.py +11 -0
  3. Backtesting/analysis/persistence/__init__.py +5 -0
  4. Backtesting/analysis/persistence/event_sink.py +133 -0
  5. Backtesting/analysis/reports/__init__.py +5 -0
  6. Backtesting/analysis/reports/console_report.py +73 -0
  7. Backtesting/analysis/result.py +42 -0
  8. Backtesting/definition/__init__.py +7 -0
  9. Backtesting/definition/configs/__init__.py +8 -0
  10. Backtesting/definition/configs/base.py +31 -0
  11. Backtesting/definition/core/__init__.py +22 -0
  12. Backtesting/definition/core/domain_models.py +165 -0
  13. Backtesting/definition/experiment.py +142 -0
  14. Backtesting/definition/plugins/__init__.py +3 -0
  15. Backtesting/definition/plugins/base_interfaces.py +126 -0
  16. Backtesting/definition/plugins/plugin_registry.py +26 -0
  17. Backtesting/execution/__init__.py +22 -0
  18. Backtesting/execution/books/__init__.py +11 -0
  19. Backtesting/execution/books/order_book.py +130 -0
  20. Backtesting/execution/books/position_book.py +136 -0
  21. Backtesting/execution/books/trade_book.py +88 -0
  22. Backtesting/execution/engine.py +57 -0
  23. Backtesting/execution/event.py +44 -0
  24. Backtesting/execution/market_data.py +108 -0
  25. Backtesting/execution/pipeline.py +105 -0
  26. Backtesting/execution/portfolio.py +72 -0
  27. Backtesting/experiments/base.yaml +32 -0
  28. Backtesting/main.py +148 -0
  29. Backtesting/plugins/__init__.py +13 -0
  30. Backtesting/plugins/brokers/__init__.py +3 -0
  31. Backtesting/plugins/brokers/instant_market_fill.py +26 -0
  32. Backtesting/plugins/market_data/__init__.py +3 -0
  33. Backtesting/plugins/market_data/dummy_data_provider.py +32 -0
  34. Backtesting/plugins/money_managers/__init__.py +3 -0
  35. Backtesting/plugins/money_managers/fixed_quality.py +36 -0
  36. Backtesting/plugins/strategies/SMA_crossovr.py +52 -0
  37. Backtesting/plugins/strategies/__init__.py +3 -0
  38. py_backtesting_lib-0.1.0.dist-info/METADATA +281 -0
  39. py_backtesting_lib-0.1.0.dist-info/RECORD +42 -0
  40. py_backtesting_lib-0.1.0.dist-info/WHEEL +4 -0
  41. py_backtesting_lib-0.1.0.dist-info/entry_points.txt +2 -0
  42. py_backtesting_lib-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,126 @@
1
+ # Backtesting/definition/plugins/base_interfaces.py
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List, Iterator
5
+ from ..core.domain_models import Signal, Order, Fill, MarketBar # Import from the new core module
6
+
7
+
8
+ # --- Strategy Interface ---
9
+ class Strategy(ABC):
10
+ """
11
+ Abstract base class for trading strategies.
12
+ Strategies receive market data and emit Signals.
13
+ """
14
+ @abstractmethod
15
+ def on_data(self, bar: MarketBar) -> List[Signal]:
16
+ """
17
+ Called by the execution engine when new market data arrives.
18
+ Should return a list of Signals based on the data.
19
+ """
20
+ pass
21
+
22
+ @abstractmethod
23
+ def initialize(self, config: dict): # Consider using Pydantic models instead of raw dict later
24
+ """
25
+ Called once at the beginning of the experiment to set up the strategy.
26
+ """
27
+ pass
28
+
29
+ @abstractmethod
30
+ def reset(self):
31
+ """
32
+ Called to reset the strategy's internal state (e.g., for multiple runs).
33
+ """
34
+ pass
35
+
36
+
37
+ # --- Money Manager Interface ---
38
+ class MoneyManager(ABC):
39
+ """
40
+ Abstract base class for money/risk management logic.
41
+ MoneyManagers take Signals and convert them into Orders based on risk rules.
42
+ """
43
+ @abstractmethod
44
+ def size_order(self, signal: Signal, current_portfolio_state: dict) -> List[Order]: # Simplified state dict for now
45
+ """
46
+ Takes a Signal and current portfolio state, returns a list of Orders.
47
+ Could return an empty list to reject the signal.
48
+ """
49
+ pass
50
+
51
+ @abstractmethod
52
+ def initialize(self, config: dict):
53
+ """
54
+ Called once at the beginning of the experiment.
55
+ """
56
+ pass
57
+
58
+ @abstractmethod
59
+ def reset(self):
60
+ """
61
+ Called to reset the MM's state.
62
+ """
63
+ pass
64
+
65
+
66
+ # --- Broker Interface ---
67
+ class Broker(ABC):
68
+ """
69
+ Abstract base class for broker simulation.
70
+ Brokers take Orders and simulate their execution (Fills).
71
+ """
72
+ @abstractmethod
73
+ def process_order(self, order: Order, current_market_bar: MarketBar) -> List[Fill]: # Could also return updated Order status
74
+ """
75
+ Simulates the execution of an order against the current market bar.
76
+ Returns a list of Fills (could be empty if not filled).
77
+ """
78
+ pass
79
+
80
+ @abstractmethod
81
+ def initialize(self, config: dict):
82
+ """
83
+ Called once at the beginning of the experiment.
84
+ """
85
+ pass
86
+
87
+ @abstractmethod
88
+ def reset(self):
89
+ """
90
+ Called to reset the broker's state (e.g., open orders).
91
+ """
92
+ pass
93
+
94
+
95
+ # --- Reporter Interface (Bonus, though mentioned in docs) ---
96
+ class Reporter(ABC):
97
+ """
98
+ Abstract base class for analysis/reporting plugins.
99
+ Reporters process the final ExperimentResult.
100
+ """
101
+ @abstractmethod
102
+ def generate_report(self, result_data: dict): # Or accept ExperimentResult object directly
103
+ """
104
+ Processes the final result data and generates a report/metrics.
105
+ """
106
+ pass
107
+
108
+ @abstractmethod
109
+ def initialize(self, config: dict):
110
+ """
111
+ Initialize the reporter.
112
+ """
113
+ pass
114
+
115
+
116
+ class MarketDataProvider(ABC):
117
+ """
118
+ Abstract base class for market data providers.
119
+ Providers yield historical or live market data bars.
120
+ """
121
+ @abstractmethod
122
+ def get_bars(self) -> Iterator[MarketBar]:
123
+ """
124
+ Yields MarketBar objects.
125
+ """
126
+ pass
@@ -0,0 +1,26 @@
1
+ from typing import Type, Dict, Any
2
+ # Import the new MarketDataProvider interface
3
+ from .base_interfaces import Strategy, MoneyManager, Broker, Reporter, MarketDataProvider
4
+
5
+ _REGISTRY: Dict[str, Type[Any]] = {}
6
+
7
+
8
+ class PluginNotFoundError(Exception):
9
+ pass
10
+
11
+
12
+ def register(plugin_name: str, plugin_class: Type[Any]):
13
+ if plugin_name in _REGISTRY:
14
+ raise ValueError(f"Plugin name '{plugin_name}' is already registered.")
15
+
16
+ if not issubclass(plugin_class, (Strategy, MoneyManager, Broker, Reporter, MarketDataProvider)):
17
+ raise TypeError(f"Class {plugin_class} does not inherit from a known plugin base interface.")
18
+
19
+ _REGISTRY[plugin_name] = plugin_class
20
+ print(f"DEBUG: Registered plugin '{plugin_name}' -> {plugin_class}")
21
+
22
+
23
+ def resolve(plugin_name: str) -> Type[Any]:
24
+ if plugin_name not in _REGISTRY:
25
+ raise PluginNotFoundError(f"Plugin '{plugin_name}' not found in registry.")
26
+ return _REGISTRY[plugin_name]
@@ -0,0 +1,22 @@
1
+ # Backtesting/execution/__init__.py
2
+
3
+ from .engine import ExecutionEngine
4
+ from .market_data import MarketDataProvider, create_dummy_market_data
5
+ from .pipeline import ExecutionPipeline
6
+ from .event import EventBus
7
+ from ..definition import Trade
8
+ from .books import OrderBook, PositionBook, TradeBook
9
+ from .portfolio import Portfolio
10
+
11
+ __all__ = [
12
+ "ExecutionEngine",
13
+ "MarketDataProvider",
14
+ "create_dummy_market_data",
15
+ "ExecutionPipeline",
16
+ "EventBus",
17
+ "OrderBook",
18
+ "PositionBook",
19
+ "TradeBook",
20
+ "Portfolio",
21
+ "Trade"
22
+ ]
@@ -0,0 +1,11 @@
1
+ from .order_book import OrderBook, MutableOrder
2
+ from .position_book import PositionBook, Position
3
+ from .trade_book import TradeBook
4
+
5
+ __all__ = [
6
+ "OrderBook",
7
+ "MutableOrder",
8
+ "PositionBook",
9
+ "Position",
10
+ "TradeBook",
11
+ ]
@@ -0,0 +1,130 @@
1
+
2
+ from typing import Dict, List
3
+ from decimal import Decimal
4
+ from ...definition import Order as EventOrder, Fill as EventFill, OrderStatus, OrderSide, OrderType
5
+ from dataclasses import dataclass
6
+
7
+ # --- Mutable Runtime Order Object ---
8
+ @dataclass
9
+ class MutableOrder:
10
+ """A mutable version of Order used internally by OrderBook to track state."""
11
+ order_id: str
12
+ signal_id: str
13
+ symbol: str
14
+ order_type: OrderType
15
+ side: OrderSide
16
+ quantity: Decimal
17
+ price: Decimal = None
18
+ stop_price: Decimal = None
19
+ status: OrderStatus = OrderStatus.SUBMITTED # Use the enum from models
20
+ filled_quantity: Decimal = Decimal('0')
21
+ average_fill_price: Decimal = Decimal('0')
22
+ commission: Decimal = Decimal('0')
23
+
24
+ def update_from_event_order(self, event_order: EventOrder):
25
+ """Updates this mutable order's state from an event order."""
26
+ # This might be used if the initial order submission comes as an event
27
+ # For now, OrderBook.add_order might just take the event_order and convert it.
28
+ pass
29
+
30
+ def to_event_order(self) -> EventOrder:
31
+ """Converts this mutable order back to an immutable event order if needed."""
32
+ # Useful for publishing state changes or logging
33
+ return EventOrder(
34
+ order_id=self.order_id,
35
+ signal_id=self.signal_id,
36
+ symbol=self.symbol,
37
+ order_type=self.order_type,
38
+ side=self.side,
39
+ quantity=self.quantity,
40
+ price=self.price,
41
+ stop_price=self.stop_price,
42
+ status=self.status,
43
+ filled_quantity=self.filled_quantity,
44
+ average_fill_price=self.average_fill_price,
45
+ commission=self.commission
46
+ )
47
+
48
+ # --- OrderBook Implementation ---
49
+ class OrderBook:
50
+ """
51
+ Tracks the lifecycle of orders (submitted, pending, filled, cancelled).
52
+ State is updated only via the apply_* methods, driven by events from the EventBus.
53
+ Uses a mutable internal representation for tracking state changes during execution.
54
+ """
55
+
56
+ def __init__(self):
57
+ # Dictionary mapping order_id to the MutableOrder object
58
+ self._orders: Dict[str, MutableOrder] = {}
59
+
60
+ def add_order(self, order: EventOrder):
61
+ """Add a new order to the book."""
62
+ if order.order_id in self._orders:
63
+ # This could happen if an event is processed twice or there's a bug
64
+ print(f"Warning: Order ID {order.order_id} already exists in OrderBook.")
65
+ return
66
+ # Convert immutable event order to mutable internal representation
67
+ mutable_order = MutableOrder(
68
+ order_id=order.order_id,
69
+ signal_id=order.signal_id,
70
+ symbol=order.symbol,
71
+ order_type=order.order_type,
72
+ side=order.side,
73
+ quantity=order.quantity,
74
+ price=order.price,
75
+ stop_price=order.stop_price,
76
+ status=order.status,
77
+ filled_quantity=order.filled_quantity,
78
+ average_fill_price=order.average_fill_price,
79
+ commission=order.commission
80
+ )
81
+ self._orders[order.order_id] = mutable_order
82
+ print(f"DEBUG: Added order {order.order_id} to OrderBook.")
83
+
84
+ def cancel_order(self, order_id: str):
85
+ """Cancel an existing order."""
86
+ if order_id in self._orders:
87
+ order = self._orders[order_id]
88
+ order.status = OrderStatus.CANCELLED # Modify the mutable state
89
+ print(f"DEBUG: Cancelled order {order_id}.")
90
+ else:
91
+ print(f"Warning: Attempted to cancel non-existent order {order_id}.")
92
+
93
+ def apply_fill(self, fill: EventFill):
94
+ """
95
+ Applies a Fill event to update the corresponding mutable order's state.
96
+ """
97
+ order_id = fill.order_id
98
+ if order_id not in self._orders:
99
+ print(f"Warning: Fill received for unknown order {order_id}. Skipping.")
100
+ return
101
+
102
+ order = self._orders[order_id]
103
+ # Update filled quantity and average fill price based on the new fill
104
+ total_filled_qty = order.filled_quantity + fill.quantity
105
+ total_cost = (order.filled_quantity * order.average_fill_price) + (fill.quantity * fill.fill_price)
106
+
107
+ order.filled_quantity = total_filled_qty
108
+ if total_filled_qty > 0: # Avoid division by zero
109
+ order.average_fill_price = total_cost / total_filled_qty
110
+
111
+ # Determine if the order is fully filled
112
+ if total_filled_qty >= order.quantity:
113
+ order.status = OrderStatus.FILLED # Modify the mutable state
114
+ print(f"DEBUG: Order {order.order_id} is now FILLED.")
115
+ else:
116
+ order.status = OrderStatus.PARTIALLY_FILLED # Modify the mutable state
117
+ print(f"DEBUG: Order {order.order_id} is PARTIALLY_FILLED. {total_filled_qty}/{order.quantity}")
118
+
119
+
120
+ def get_open_orders(self) -> List[MutableOrder]:
121
+ """Returns a list of orders that are not fully filled or cancelled."""
122
+ return [order for order in self._orders.values() if order.status in (OrderStatus.SUBMITTED, OrderStatus.PENDING, OrderStatus.PARTIALLY_FILLED)]
123
+
124
+ def get_order(self, order_id: str) -> MutableOrder:
125
+ """Retrieve an order by its ID."""
126
+ return self._orders.get(order_id)
127
+
128
+ def get_order_by_event(self, order_id: str) -> MutableOrder: # Updated signature to take ID
129
+ """Helper to get the mutable order corresponding to an event order ID."""
130
+ return self._orders.get(order_id)
@@ -0,0 +1,136 @@
1
+
2
+ from typing import Dict, Optional
3
+ from decimal import Decimal
4
+ from ...definition import Fill as EventFill, MarketBar as EventMarketBar, OrderSide
5
+
6
+ class Position:
7
+ """
8
+ Represents an open position for a specific symbol.
9
+ Stores average entry price and net size (positive for long, negative for short).
10
+ """
11
+ def __init__(self, symbol: str):
12
+ self.symbol = symbol
13
+ self.avg_entry_price: Decimal = Decimal('0')
14
+ self.net_size: Decimal = Decimal('0')
15
+ self.unrealized_pnl: Decimal = Decimal('0')
16
+
17
+ def update_from_fill(self, fill: EventFill):
18
+ """
19
+ Updates the position based on a Fill event.
20
+ Handles both opening/increasing and closing/decreasing the position.
21
+ """
22
+ if fill.symbol != self.symbol:
23
+ raise ValueError(f"Fill symbol {fill.symbol} does not match position symbol {self.symbol}")
24
+
25
+ old_net_size = self.net_size
26
+ old_avg_price = self.avg_entry_price
27
+
28
+ # Calculate new net size
29
+ direction_multiplier = 1 if fill.side == OrderSide.BUY else -1
30
+ fill_size = fill.quantity * direction_multiplier
31
+ new_net_size = self.net_size + fill_size
32
+
33
+ if new_net_size == 0:
34
+ # Position is fully closed
35
+ self.net_size = Decimal('0') # <-- FIX: Set net_size to 0 first
36
+ self.avg_entry_price = Decimal('0')
37
+ # Realized PnL was calculated and handled by TradeBook or Portfolio logic upon closure
38
+ # Here, Unrealized PnL becomes 0 as the position is gone.
39
+ self.unrealized_pnl = Decimal('0')
40
+ print(f"DEBUG: Position for {self.symbol} fully closed.")
41
+ elif (old_net_size > 0 and new_net_size < 0) or (old_net_size < 0 and new_net_size > 0):
42
+ # Position reversed direction (e.g., Long 100 -> Sell 150 -> Short 50)
43
+ # Cost basis resets based on the remaining size after closing the original position
44
+ # For simplicity in this model, we assume FIFO for reversal, meaning the original position is closed first.
45
+ # However, true FIFO requires tracking individual lots. This simplified version recalculates average.
46
+ # Let's recalculate based on the new side's fills contributing to the new average.
47
+ # New average price calculation after reversal is complex. Simplifying:
48
+ # Assume the old position is closed at the fill price, and the new position starts fresh.
49
+ # This is a simplification. A full FIFO model would be needed for exact lot tracking.
50
+ # For now, let's adjust average price if the sign changed due to the fill.
51
+ if (old_net_size > 0 and fill.side == OrderSide.SELL) or (old_net_size < 0 and fill.side == OrderSide.BUY):
52
+ # Closing part/all of existing position first
53
+ qty_to_close = min(abs(old_net_size), fill.quantity)
54
+ realized_pnl_from_close = qty_to_close * (fill.fill_price - old_avg_price) * (1 if old_net_size > 0 else -1)
55
+ # The remaining fill quantity opens a new position on the opposite side
56
+ remaining_fill_qty = fill.quantity - qty_to_close
57
+ if remaining_fill_qty > 0:
58
+ self.avg_entry_price = fill.fill_price
59
+ self.net_size = remaining_fill_qty * direction_multiplier
60
+ else:
61
+ # Exact close, net_size should be 0, handled above
62
+ pass
63
+ else: # Opening more of the same side or starting new after reversal
64
+ total_value = (self.net_size * self.avg_entry_price) + (fill_size * fill.fill_price)
65
+ self.avg_entry_price = total_value / new_net_size if new_net_size != 0 else Decimal('0')
66
+ self.net_size = new_net_size
67
+ else:
68
+ # Same direction or increasing/decreasing without crossing zero
69
+ total_value = (self.net_size * self.avg_entry_price) + (fill_size * fill.fill_price)
70
+ self.avg_entry_price = total_value / new_net_size if new_net_size != 0 else Decimal('0')
71
+ self.net_size = new_net_size
72
+
73
+ print(f"DEBUG: Position {self.symbol} updated: Size {self.net_size}, Avg Price {self.avg_entry_price}")
74
+
75
+ def update_unrealized_pnl(self, current_market_price: Decimal):
76
+ """
77
+ Updates the unrealized PnL based on the current market price.
78
+ """
79
+ if self.net_size == 0:
80
+ self.unrealized_pnl = Decimal('0')
81
+ else:
82
+ price_diff = current_market_price - self.avg_entry_price
83
+ self.unrealized_pnl = self.net_size * price_diff
84
+ print(f"DEBUG: Position {self.symbol} UPL updated to {self.unrealized_pnl} at market price {current_market_price}")
85
+
86
+
87
+ class PositionBook:
88
+ """
89
+ Manages open positions per symbol.
90
+ State is updated only via the apply_* methods, driven by events from the EventBus.
91
+ """
92
+ def __init__(self):
93
+ # Dictionary mapping symbol to Position object
94
+ self._positions: Dict[str, Position] = {}
95
+
96
+ def apply_fill(self, fill: EventFill):
97
+ """
98
+ Applies a Fill event to update the corresponding position.
99
+ If no position exists for the symbol, a new one is created.
100
+ """
101
+ symbol = fill.symbol
102
+ if symbol not in self._positions:
103
+ if fill.quantity == 0:
104
+ print(f"Warning: Fill with zero quantity for new symbol {symbol}. Ignoring.")
105
+ return
106
+ # Create new position if none exists
107
+ self._positions[symbol] = Position(symbol)
108
+
109
+ position = self._positions[symbol]
110
+ position.update_from_fill(fill)
111
+
112
+ # Check if position was closed (net_size became 0)
113
+ if position.net_size == 0:
114
+ del self._positions[symbol] # Remove the position object if closed
115
+ print(f"DEBUG: Removed closed position for {symbol} from PositionBook.")
116
+
117
+
118
+ def update_market_price(self, bar: EventMarketBar):
119
+ """
120
+ Updates the unrealized PnL for the position in the symbol represented by the market bar.
121
+ """
122
+ symbol = bar.symbol
123
+ if symbol in self._positions:
124
+ position = self._positions[symbol]
125
+ # Using 'close' price as the current market price for marking to market
126
+ position.update_unrealized_pnl(Decimal(str(bar.close)))
127
+ else:
128
+ print(f"DEBUG: No position for symbol {bar.symbol} to update UPL.")
129
+
130
+ def get_position(self, symbol: str) -> Optional[Position]:
131
+ """Retrieve the position for a given symbol."""
132
+ return self._positions.get(symbol)
133
+
134
+ def get_all_positions(self) -> Dict[str, Position]:
135
+ """Get a dictionary of all open positions."""
136
+ return self._positions.copy() # Return a shallow copy to prevent external modification
@@ -0,0 +1,88 @@
1
+
2
+ from typing import Dict, List
3
+ from decimal import Decimal
4
+ from dataclasses import dataclass
5
+ from ...definition import Fill as EventFill, Order as EventOrder, OrderSide # Import event versions
6
+
7
+ @dataclass(frozen=True) # Use frozen dataclass for immutability
8
+ class Trade:
9
+ """
10
+ Represents an immutable record of a closed trade.
11
+ """
12
+ symbol: str
13
+ entry_time: Decimal # Or datetime if available in fill
14
+ exit_time: Decimal # Or datetime if available in fill
15
+ entry_price: Decimal
16
+ exit_price: Decimal
17
+ quantity: Decimal
18
+ side: str # 'LONG' or 'SHORT' based on entry side
19
+ realized_pnl: Decimal
20
+ commission: Decimal
21
+
22
+ class TradeBook:
23
+ """
24
+ Archives closed trades as immutable Trade records.
25
+ State is updated only via the apply_* methods, driven by events from the EventBus.
26
+ """
27
+ def __init__(self):
28
+ self._trades: List[Trade] = []
29
+
30
+ def apply_fill(self, fill: EventFill, order: EventOrder, position_before_fill: Decimal, position_after_fill: Decimal, entry_price: Decimal):
31
+ """
32
+ Applies a Fill event. If the fill results in a position being closed (crossing zero),
33
+ an immutable Trade record is created and stored.
34
+
35
+ Args:
36
+ fill: The fill event.
37
+ order: The order associated with the fill (for commission).
38
+ position_before_fill: Net size of the position *before* this fill was applied.
39
+ position_after_fill: Net size of the position *after* this fill was applied.
40
+ entry_price: The average entry price of the position being closed.
41
+ This should come from the PositionBook *before* the fill is applied.
42
+ """
43
+ # Determine if this fill closed part or all of an existing position
44
+ # A trade is considered "closed" when the net position crosses zero or reaches zero
45
+ # due to this fill.
46
+
47
+ # Scenario 1: Position was open, fill closes it entirely or partially towards zero
48
+ # Scenario 2: Position was open, fill reverses the position (closes old, opens new)
49
+ # Scenario 3: No position existed, fill opens a new one (doesn't close anything)
50
+
51
+ # We need to know the *change* in position size caused by this fill
52
+ fill_size = fill.quantity if fill.side == OrderSide.BUY else -fill.quantity
53
+
54
+ # Check if the fill crossed the zero line (closed the position)
55
+ if (position_before_fill > 0 and position_after_fill <= 0) or (position_before_fill < 0 and position_after_fill >= 0):
56
+ # This fill resulted in closing the existing position.
57
+ # Determine the quantity that actually closed the old position.
58
+ # For simplicity, assume FIFO for closing. The quantity closed is the minimum
59
+ # of the fill quantity and the absolute size of the position before the fill.
60
+ qty_closing_existing = min(abs(position_before_fill), fill.quantity)
61
+
62
+ # Calculate entry details (using the provided entry_price from the position)
63
+ exit_price = fill.fill_price
64
+ # Realized PnL is calculated based on the entry and exit prices for the quantity closed
65
+ realized_pnl_for_closed_qty = qty_closing_existing * (exit_price - entry_price) * (1 if position_before_fill > 0 else -1)
66
+ # Commission allocation: simplest is to allocate proportionally based on quantity closed vs order quantity
67
+ commission_for_closed_qty = (order.commission / order.quantity) * qty_closing_existing if order.quantity > 0 else Decimal('0') # Assuming commission scales linearly
68
+
69
+ # Create the Trade record
70
+ # Note: Using fill timestamps for simplicity. More accurate entry/exit times require more state tracking.
71
+ trade = Trade(
72
+ symbol=fill.symbol,
73
+ entry_time=Decimal('0'), # Placeholder - need entry fill time
74
+ exit_time=Decimal('0'), # Placeholder - need exit fill time
75
+ entry_price=entry_price, # <-- FIX: Use the provided entry_price
76
+ exit_price=exit_price,
77
+ quantity=qty_closing_existing,
78
+ side='LONG' if position_before_fill > 0 else 'SHORT',
79
+ realized_pnl=realized_pnl_for_closed_qty - commission_for_closed_qty, # Subtract commission
80
+ commission=commission_for_closed_qty
81
+ )
82
+ self._trades.append(trade)
83
+ print(f"DEBUG: Archived Trade: {trade}")
84
+
85
+
86
+ def get_closed_trades(self) -> List[Trade]:
87
+ """Returns a list of all closed trades."""
88
+ return self._trades.copy() # Return a shallow copy to prevent external modification
@@ -0,0 +1,57 @@
1
+ # Backtesting/execution/engine.py
2
+ # The main execution engine orchestrating the event loop
3
+
4
+ from decimal import Decimal
5
+ from .pipeline import ExecutionPipeline
6
+ from .market_data import MarketDataProvider
7
+ from Backtesting.execution.event import EventBus
8
+ from .books import OrderBook, PositionBook, TradeBook
9
+ from .portfolio import Portfolio
10
+ from ..definition.plugins import Strategy, MoneyManager, Broker # Import plugin interfaces
11
+
12
+ class ExecutionEngine:
13
+ """
14
+ The main orchestrator of the backtesting event loop.
15
+ Iterates over market data and delegates processing to the ExecutionPipeline.
16
+ Enforces determinism and fail-fast behavior.
17
+ """
18
+ def __init__(self, strategy: Strategy, money_manager: MoneyManager, broker: Broker,
19
+ initial_cash: Decimal, market_data_provider: MarketDataProvider):
20
+ self.event_bus = EventBus()
21
+ self.order_book = OrderBook()
22
+ self.position_book = PositionBook()
23
+ self.trade_book = TradeBook()
24
+ self.portfolio = Portfolio(initial_cash)
25
+
26
+ self.pipeline = ExecutionPipeline(
27
+ strategy=strategy,
28
+ money_manager=money_manager,
29
+ broker=broker,
30
+ order_book=self.order_book,
31
+ position_book=self.position_book,
32
+ trade_book=self.trade_book,
33
+ portfolio=self.portfolio,
34
+ event_bus=self.event_bus
35
+ )
36
+ self.market_data_provider = market_data_provider
37
+
38
+ def run(self):
39
+ """
40
+ Executes the main event loop: iterate over bars, process each through the pipeline.
41
+ """
42
+ for bar in self.market_data_provider.get_bars():
43
+ # Process the bar through the entire pipeline
44
+ try:
45
+ self.pipeline.process_bar(bar)
46
+ except Exception as e:
47
+ # Fail-fast: Stop execution immediately if any plugin throws an unexpected error
48
+ print(f"ExecutionEngine halted due to error in pipeline: {e}")
49
+ raise # Re-raise to propagate the error up the call stack
50
+
51
+ # After loop completes, print final summary
52
+ print("\n--- Execution Summary ---")
53
+ print(f"Final Cash: {self.portfolio.cash}")
54
+ print(f"Final Equity: {self.portfolio.get_equity(self.position_book)}")
55
+ print(f"Open Positions: {len(self.position_book.get_all_positions())}")
56
+ print(f"Closed Trades: {len(self.trade_book.get_closed_trades())}")
57
+ print(f"Total Events Published: {len(self.event_bus._handlers)}") # This count is approximate, counts handler lists, not events published
@@ -0,0 +1,44 @@
1
+
2
+ from typing import Type, Callable, Dict, List, Any
3
+
4
+ class EventBus:
5
+ """
6
+ A synchronous, in-memory Pub/Sub system for dispatching domain events.
7
+ Handlers are called in the order they were subscribed.
8
+ """
9
+ def __init__(self):
10
+ # Dictionary mapping event type to a list of handler functions
11
+ self._handlers: Dict[Type[Any], List[Callable[[Any], None]]] = {}
12
+
13
+ def subscribe(self, event_type: Type[Any], handler: Callable[[Any], None]):
14
+ """Subscribe a handler function to a specific event type."""
15
+ if event_type not in self._handlers:
16
+ self._handlers[event_type] = []
17
+ self._handlers[event_type].append(handler)
18
+ print(f"DEBUG: Subscribed handler {handler.__name__} to event {event_type.__name__}")
19
+
20
+ def unsubscribe(self, event_type: Type[Any], handler: Callable[[Any], None]):
21
+ """Unsubscribe a handler function from a specific event type."""
22
+ if event_type in self._handlers:
23
+ try:
24
+ self._handlers[event_type].remove(handler)
25
+ print(f"DEBUG: Unsubscribed handler {handler.__name__} from event {event_type.__name__}")
26
+ except ValueError:
27
+ print(f"Handler {handler.__name__} was not subscribed to {event_type.__name__}.")
28
+
29
+ def publish(self, event: Any):
30
+ """Publish an event to all subscribed handlers."""
31
+ event_type = type(event)
32
+ print(f"DEBUG: Publishing event {event_type.__name__}: {event}")
33
+
34
+ # Find handlers for the specific event type
35
+ specific_handlers = self._handlers.get(event_type, [])
36
+ for handler in specific_handlers:
37
+ handler(event)
38
+
39
+ # Also find handlers for parent types (polymorphism)
40
+ for registered_type in self._handlers:
41
+ if issubclass(event_type, registered_type) and registered_type != event_type:
42
+ parent_handlers = self._handlers[registered_type]
43
+ for handler in parent_handlers:
44
+ handler(event)