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