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,108 @@
|
|
|
1
|
+
# Backtesting/execution/market_data.py
|
|
2
|
+
# Market data providers for the execution engine
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterator, List
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from ..definition import MarketBar, MarketDataProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CSVMarketDataProvider(MarketDataProvider):
|
|
13
|
+
"""
|
|
14
|
+
Provides market data from a local CSV file.
|
|
15
|
+
Assumes columns: timestamp, open, high, low, close, volume
|
|
16
|
+
"""
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
csv_file_path: str,
|
|
20
|
+
symbol: str,
|
|
21
|
+
timestamp_col: str = "timestamp",
|
|
22
|
+
open_col: str = "open",
|
|
23
|
+
high_col: str = "high",
|
|
24
|
+
low_col: str = "low",
|
|
25
|
+
close_col: str = "close",
|
|
26
|
+
volume_col: str = "volume",
|
|
27
|
+
):
|
|
28
|
+
self.path = Path(csv_file_path)
|
|
29
|
+
if not self.path.exists():
|
|
30
|
+
raise FileNotFoundError(f"Market data CSV file not found: {csv_file_path}")
|
|
31
|
+
|
|
32
|
+
self.symbol = symbol
|
|
33
|
+
self.ts_col = timestamp_col
|
|
34
|
+
self.o_col = open_col
|
|
35
|
+
self.h_col = high_col
|
|
36
|
+
self.l_col = low_col
|
|
37
|
+
self.c_col = close_col
|
|
38
|
+
self.v_col = volume_col
|
|
39
|
+
|
|
40
|
+
self.rows = []
|
|
41
|
+
with self.path.open(newline="", encoding="utf-8") as f:
|
|
42
|
+
reader = csv.DictReader(f)
|
|
43
|
+
required_cols = {self.ts_col, self.o_col, self.h_col, self.l_col, self.c_col, self.v_col}
|
|
44
|
+
missing = required_cols - set(reader.fieldnames or [])
|
|
45
|
+
if missing:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"CSV file {csv_file_path} is missing required columns: {missing}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
for line_num, row in enumerate(reader, start=2):
|
|
51
|
+
try:
|
|
52
|
+
timestamp = datetime.fromisoformat(row[self.ts_col].strip())
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Could not parse timestamp column '{self.ts_col}' in {csv_file_path} "
|
|
56
|
+
f"on line {line_num}: {e}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self.rows.append({
|
|
60
|
+
"timestamp": timestamp,
|
|
61
|
+
"open": Decimal(str(row[self.o_col]).strip()),
|
|
62
|
+
"high": Decimal(str(row[self.h_col]).strip()),
|
|
63
|
+
"low": Decimal(str(row[self.l_col]).strip()),
|
|
64
|
+
"close": Decimal(str(row[self.c_col]).strip()),
|
|
65
|
+
"volume": Decimal(str(row[self.v_col]).strip()),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
self.rows.sort(key=lambda x: x["timestamp"])
|
|
69
|
+
|
|
70
|
+
def get_bars(self) -> Iterator[MarketBar]:
|
|
71
|
+
"""
|
|
72
|
+
Yields MarketBar objects from the loaded CSV data.
|
|
73
|
+
"""
|
|
74
|
+
for row in self.rows:
|
|
75
|
+
yield MarketBar(
|
|
76
|
+
symbol=self.symbol,
|
|
77
|
+
timestamp=row["timestamp"],
|
|
78
|
+
open=row["open"],
|
|
79
|
+
high=row["high"],
|
|
80
|
+
low=row["low"],
|
|
81
|
+
close=row["close"],
|
|
82
|
+
volume=row["volume"],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_dummy_market_data(num_bars: int, start_price: Decimal = Decimal("1.0000")) -> List[MarketBar]:
|
|
87
|
+
"""
|
|
88
|
+
Creates a simple sequence of dummy market bars for testing.
|
|
89
|
+
Price increases by 0.0001 per bar.
|
|
90
|
+
"""
|
|
91
|
+
bars = []
|
|
92
|
+
current_price = start_price
|
|
93
|
+
|
|
94
|
+
for i in range(num_bars):
|
|
95
|
+
timestamp = datetime(2022, 1, 1, i)
|
|
96
|
+
bar = MarketBar(
|
|
97
|
+
symbol="DUMMY",
|
|
98
|
+
timestamp=timestamp,
|
|
99
|
+
open=current_price,
|
|
100
|
+
high=current_price + Decimal("0.0001"),
|
|
101
|
+
low=current_price - Decimal("0.0001"),
|
|
102
|
+
close=current_price + Decimal("0.0001") * (i % 2),
|
|
103
|
+
volume=Decimal("1000"),
|
|
104
|
+
)
|
|
105
|
+
bars.append(bar)
|
|
106
|
+
current_price += Decimal("0.0001")
|
|
107
|
+
|
|
108
|
+
return bars
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Backtesting/execution/pipeline.py
|
|
2
|
+
# Coordinates the flow: Strategy -> Signal -> MM -> Order -> Broker -> Fill
|
|
3
|
+
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from ..definition import EventType, SignalGenerated, OrderSubmitted, OrderFilled, OrderCancelled, FillExecuted, MarketBarReceived # Import specific events if needed
|
|
6
|
+
from Backtesting.execution.event import EventBus
|
|
7
|
+
from .books.order_book import OrderBook
|
|
8
|
+
from .books.position_book import PositionBook
|
|
9
|
+
from .books.trade_book import TradeBook
|
|
10
|
+
from .portfolio import Portfolio
|
|
11
|
+
from ..definition.core.domain_models import Fill, MarketBar # Import core models from definition
|
|
12
|
+
from ..definition.plugins import Strategy, MoneyManager, Broker # Import plugin interfaces
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExecutionPipeline:
|
|
16
|
+
"""
|
|
17
|
+
Orchestrates the core trading logic pipeline: Market Data -> Strategy -> Signal -> MM -> Order -> Broker -> Fill.
|
|
18
|
+
Integrates with Books, Portfolio, and EventBus.
|
|
19
|
+
"""
|
|
20
|
+
def __init__(self, strategy: Strategy, money_manager: MoneyManager, broker: Broker,
|
|
21
|
+
order_book: OrderBook, position_book: PositionBook, trade_book: TradeBook,
|
|
22
|
+
portfolio: Portfolio, event_bus: EventBus):
|
|
23
|
+
self.strategy = strategy
|
|
24
|
+
self.money_manager = money_manager
|
|
25
|
+
self.broker = broker
|
|
26
|
+
self.order_book = order_book
|
|
27
|
+
self.position_book = position_book
|
|
28
|
+
self.trade_book = trade_book
|
|
29
|
+
self.portfolio = portfolio
|
|
30
|
+
self.event_bus = event_bus
|
|
31
|
+
|
|
32
|
+
def process_bar(self, bar: MarketBar):
|
|
33
|
+
"""
|
|
34
|
+
Processes a single market bar through the entire pipeline.
|
|
35
|
+
"""
|
|
36
|
+
# 1. Mark-to-market positions based on the new bar's price BEFORE generating signals
|
|
37
|
+
self.portfolio.update_market_price(bar, self.position_book)
|
|
38
|
+
self.position_book.update_market_price(bar)
|
|
39
|
+
|
|
40
|
+
# 2. Strategy generates signals based on the bar
|
|
41
|
+
signals = self.strategy.on_data(bar)
|
|
42
|
+
|
|
43
|
+
for signal in signals:
|
|
44
|
+
# 3. Emit SignalGenerated event
|
|
45
|
+
signal_event = SignalGenerated(signal=signal)
|
|
46
|
+
self.event_bus.publish(signal_event)
|
|
47
|
+
|
|
48
|
+
# 4. Money Manager sizes the signal into an order
|
|
49
|
+
# Pass current portfolio state if needed by MM
|
|
50
|
+
portfolio_state = {"cash": self.portfolio.cash} # Simplified state
|
|
51
|
+
orders = self.money_manager.size_order(signal, portfolio_state)
|
|
52
|
+
|
|
53
|
+
for order in orders:
|
|
54
|
+
# 5. Add order to OrderBook
|
|
55
|
+
self.order_book.add_order(order)
|
|
56
|
+
|
|
57
|
+
# 6. Emit OrderSubmitted event
|
|
58
|
+
order_event = OrderSubmitted(order=order)
|
|
59
|
+
self.event_bus.publish(order_event)
|
|
60
|
+
|
|
61
|
+
# 7. Broker simulates the order execution against the current bar
|
|
62
|
+
fills = self.broker.process_order(order, bar)
|
|
63
|
+
|
|
64
|
+
for fill in fills:
|
|
65
|
+
# 8. Apply fill to books and portfolio
|
|
66
|
+
self.apply_fill(fill)
|
|
67
|
+
|
|
68
|
+
def apply_fill(self, fill: Fill):
|
|
69
|
+
order = self.order_book.get_order_by_event(fill.order_id)
|
|
70
|
+
if not order:
|
|
71
|
+
print(f"Warning: Fill {fill.fill_id} references unknown order {fill.order_id}. Cannot archive trade.")
|
|
72
|
+
order = None
|
|
73
|
+
|
|
74
|
+
# Get position state *before* applying fill
|
|
75
|
+
position_before = Decimal('0')
|
|
76
|
+
existing_pos = self.position_book.get_position(fill.symbol)
|
|
77
|
+
if existing_pos:
|
|
78
|
+
position_before = existing_pos.net_size
|
|
79
|
+
|
|
80
|
+
# ✅ CAPTURE ENTRY PRICE BEFORE MUTATION
|
|
81
|
+
entry_price = existing_pos.avg_entry_price if existing_pos else Decimal('0')
|
|
82
|
+
|
|
83
|
+
# Apply fill to OrderBook
|
|
84
|
+
self.order_book.apply_fill(fill)
|
|
85
|
+
|
|
86
|
+
# Apply fill to PositionBook (this mutates the position, setting avg_entry_price to 0 if closed)
|
|
87
|
+
self.position_book.apply_fill(fill)
|
|
88
|
+
|
|
89
|
+
# Get position state *after* applying fill
|
|
90
|
+
position_after = Decimal('0')
|
|
91
|
+
updated_pos = self.position_book.get_position(fill.symbol)
|
|
92
|
+
if updated_pos:
|
|
93
|
+
position_after = updated_pos.net_size
|
|
94
|
+
|
|
95
|
+
# Apply fill to Portfolio
|
|
96
|
+
self.portfolio.apply_fill(fill)
|
|
97
|
+
|
|
98
|
+
# Archive trade if position closed (crossed zero)
|
|
99
|
+
if order and (position_before > 0 and position_after <= 0) or (position_before < 0 and position_after >= 0):
|
|
100
|
+
# Use the captured entry_price (not mutated)
|
|
101
|
+
self.trade_book.apply_fill(fill, order, position_before, position_after, entry_price)
|
|
102
|
+
|
|
103
|
+
# Emit FillExecuted event
|
|
104
|
+
fill_event = FillExecuted(fill=fill)
|
|
105
|
+
self.event_bus.publish(fill_event)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from .books import PositionBook
|
|
4
|
+
from ..definition import Fill as EventFill, MarketBar as EventMarketBar, OrderSide
|
|
5
|
+
|
|
6
|
+
class Portfolio:
|
|
7
|
+
"""
|
|
8
|
+
Tracks the account's financial state: cash, equity, margin usage.
|
|
9
|
+
Equity is a derived value: cash + sum(unrealized_pnl of all positions).
|
|
10
|
+
State is updated only via the apply_* methods, driven by events from the EventBus.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(self, initial_cash: Decimal):
|
|
13
|
+
self._initial_cash = initial_cash
|
|
14
|
+
self._cash = initial_cash
|
|
15
|
+
self._margin_used = Decimal('0') # Placeholder for future margin calculation logic
|
|
16
|
+
|
|
17
|
+
def apply_fill(self, fill: EventFill):
|
|
18
|
+
"""
|
|
19
|
+
Updates the cash balance based on a Fill event (cost of purchase or proceeds from sale).
|
|
20
|
+
Commission is subtracted from cash.
|
|
21
|
+
"""
|
|
22
|
+
# Calculate the cost/proceeds of the fill
|
|
23
|
+
fill_value = fill.quantity * fill.fill_price
|
|
24
|
+
if fill.side == OrderSide.BUY:
|
|
25
|
+
# Buying reduces cash: fill cost + commission
|
|
26
|
+
self._cash -= fill_value + fill.commission
|
|
27
|
+
print(f"DEBUG: Portfolio cash decreased by {fill_value + fill.commission} (value {fill_value} + commission {fill.commission}) due to buy fill. New cash: {self._cash}")
|
|
28
|
+
elif fill.side == OrderSide.SELL:
|
|
29
|
+
# Selling increases cash: fill proceeds - commission
|
|
30
|
+
self._cash += fill_value - fill.commission
|
|
31
|
+
print(f"DEBUG: Portfolio cash increased by {fill_value - fill.commission} (value {fill_value} - commission {fill.commission}) due to sell fill. New cash: {self._cash}")
|
|
32
|
+
|
|
33
|
+
def update_market_price(self, bar: EventMarketBar, position_book: PositionBook):
|
|
34
|
+
"""
|
|
35
|
+
Updates the total equity by marking positions to market.
|
|
36
|
+
Does not directly change cash.
|
|
37
|
+
Requires the PositionBook to get unrealized PnL.
|
|
38
|
+
"""
|
|
39
|
+
# Equity = Cash + Unrealized PnL from all positions
|
|
40
|
+
# The cash component remains unchanged here.
|
|
41
|
+
# The unrealized PnL is updated within the PositionBook itself via its update_market_price method.
|
|
42
|
+
# Therefore, calculating equity involves summing the UPLs from the PositionBook.
|
|
43
|
+
unrealized_pnl_total = Decimal('0')
|
|
44
|
+
for position in position_book.get_all_positions().values():
|
|
45
|
+
unrealized_pnl_total += position.unrealized_pnl
|
|
46
|
+
|
|
47
|
+
equity = self._cash + unrealized_pnl_total
|
|
48
|
+
print(f"DEBUG: Portfolio equity updated to {equity} (Cash: {self._cash}, UPL: {unrealized_pnl_total})")
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def cash(self) -> Decimal:
|
|
52
|
+
"""Read-only property for the current cash balance."""
|
|
53
|
+
return self._cash
|
|
54
|
+
|
|
55
|
+
def get_equity(self, position_book: PositionBook) -> Decimal:
|
|
56
|
+
"""
|
|
57
|
+
Calculates and returns the total equity.
|
|
58
|
+
Equity = Cash + Unrealized PnL from all open positions.
|
|
59
|
+
Requires the PositionBook to get unrealized PnL.
|
|
60
|
+
"""
|
|
61
|
+
unrealized_pnl_total = Decimal('0')
|
|
62
|
+
for position in position_book.get_all_positions().values():
|
|
63
|
+
unrealized_pnl_total += position.unrealized_pnl
|
|
64
|
+
return self._cash + unrealized_pnl_total
|
|
65
|
+
|
|
66
|
+
def get_margin_usage(self) -> Decimal:
|
|
67
|
+
"""Placeholder for margin calculation."""
|
|
68
|
+
return self._margin_used
|
|
69
|
+
|
|
70
|
+
def get_initial_cash(self) -> Decimal:
|
|
71
|
+
"""Returns the initial cash balance."""
|
|
72
|
+
return self._initial_cash
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# experiments/beta_test.yaml
|
|
2
|
+
experiment:
|
|
3
|
+
name: "Beta Test - SMA Crossover Synthetic"
|
|
4
|
+
metadata:
|
|
5
|
+
author: "Zhiro"
|
|
6
|
+
version: "1.0"
|
|
7
|
+
|
|
8
|
+
configs:
|
|
9
|
+
strategy:
|
|
10
|
+
name: "SMA_Crossover_Strategy"
|
|
11
|
+
params:
|
|
12
|
+
fast_period: 5
|
|
13
|
+
slow_period: 10
|
|
14
|
+
symbol: "DUMMY"
|
|
15
|
+
|
|
16
|
+
money_management:
|
|
17
|
+
name: "Fixed_Qty_MoneyManager"
|
|
18
|
+
params:
|
|
19
|
+
trade_quantity: 10.0
|
|
20
|
+
|
|
21
|
+
broker:
|
|
22
|
+
name: "Simple_Market_Broker"
|
|
23
|
+
params:
|
|
24
|
+
commission_per_trade: 1.50
|
|
25
|
+
initial_capital: 100000
|
|
26
|
+
|
|
27
|
+
market_data:
|
|
28
|
+
name: "Dummy_Market_Data"
|
|
29
|
+
params:
|
|
30
|
+
num_bars: 300
|
|
31
|
+
symbol: "DUMMY"
|
|
32
|
+
start_price: 100.0
|
Backtesting/main.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
|
|
2
|
+
import typer
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from .definition import Experiment
|
|
8
|
+
from .definition.plugins.plugin_registry import resolve, PluginNotFoundError
|
|
9
|
+
from .execution.engine import ExecutionEngine
|
|
10
|
+
from .execution.market_data import CSVMarketDataProvider
|
|
11
|
+
from .analysis.result import ExperimentResult
|
|
12
|
+
from .analysis.reports.console_report import ConsoleTradeReport
|
|
13
|
+
from .analysis.persistence.event_sink import DuckDBEventSink
|
|
14
|
+
from .definition import Trade
|
|
15
|
+
from . import plugins # it will initialize default plugins
|
|
16
|
+
|
|
17
|
+
app = typer.Typer()
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def run(config_file: str = typer.Option(..., "--config", "-c", help="Path to the experiment YAML configuration file.")):
|
|
21
|
+
"""
|
|
22
|
+
Runs a backtesting experiment defined by the given YAML file.
|
|
23
|
+
"""
|
|
24
|
+
config_path = Path(config_file)
|
|
25
|
+
if not config_path.exists():
|
|
26
|
+
typer.echo(f"Error: Configuration file not found: {config_file}", err=True)
|
|
27
|
+
raise typer.Exit(code=1)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
# 1. Load and validate the experiment definition
|
|
31
|
+
experiment_def = Experiment.from_yaml(config_file)
|
|
32
|
+
experiment_def.validate()
|
|
33
|
+
print(f"Loaded experiment: {experiment_def.name}")
|
|
34
|
+
|
|
35
|
+
# 2. Resolve plugin classes
|
|
36
|
+
strategy_cls = resolve(experiment_def.strategy_config.name)
|
|
37
|
+
mm_cls = resolve(experiment_def.money_management_config.name)
|
|
38
|
+
broker_cls = resolve(experiment_def.broker_config.name)
|
|
39
|
+
market_data_cls = resolve(experiment_def.market_data_config.name)
|
|
40
|
+
|
|
41
|
+
strategy_params = experiment_def.strategy_config.params or {}
|
|
42
|
+
mm_params = experiment_def.money_management_config.params or {}
|
|
43
|
+
broker_params = experiment_def.broker_config.params or {}
|
|
44
|
+
market_data_params = experiment_def.market_data_config.params or {}
|
|
45
|
+
|
|
46
|
+
# Instantiate plugins
|
|
47
|
+
strategy = strategy_cls()
|
|
48
|
+
money_manager = mm_cls()
|
|
49
|
+
broker = broker_cls()
|
|
50
|
+
|
|
51
|
+
market_data_provider = market_data_cls(**market_data_params)
|
|
52
|
+
|
|
53
|
+
# Initialize plugins with their guaranteed-dict configs
|
|
54
|
+
strategy.initialize(strategy_params)
|
|
55
|
+
money_manager.initialize(mm_params)
|
|
56
|
+
broker.initialize(broker_params)
|
|
57
|
+
|
|
58
|
+
# 3. Extract initial capital safely using the guaranteed dict
|
|
59
|
+
initial_capital = Decimal(str(broker_params.get('initial_capital', 100000)))
|
|
60
|
+
|
|
61
|
+
# 4. Create unique run ID and start timestamp
|
|
62
|
+
run_id = str(uuid.uuid4())
|
|
63
|
+
start_time = datetime.now()
|
|
64
|
+
|
|
65
|
+
# 5. Initialize Event Sink and subscribe to the engine's event bus
|
|
66
|
+
event_sink = DuckDBEventSink()
|
|
67
|
+
|
|
68
|
+
engine = ExecutionEngine(
|
|
69
|
+
strategy=strategy,
|
|
70
|
+
money_manager=money_manager,
|
|
71
|
+
broker=broker,
|
|
72
|
+
initial_cash=initial_capital,
|
|
73
|
+
market_data_provider=market_data_provider
|
|
74
|
+
)
|
|
75
|
+
event_sink.subscribe_to_engine(engine.event_bus, run_id) # Pass the bus from the engine instance
|
|
76
|
+
|
|
77
|
+
# 6. Run the engine
|
|
78
|
+
print("Starting backtest...")
|
|
79
|
+
engine.run()
|
|
80
|
+
end_time = datetime.now()
|
|
81
|
+
print("Backtest completed.")
|
|
82
|
+
|
|
83
|
+
# 7. Construct the ExperimentResult aggregate
|
|
84
|
+
# Final state snapshots
|
|
85
|
+
portfolio_snap = {
|
|
86
|
+
"cash": engine.portfolio.cash,
|
|
87
|
+
"equity": engine.portfolio.get_equity(engine.position_book),
|
|
88
|
+
"margin_used": engine.portfolio.get_margin_usage(),
|
|
89
|
+
"initial_cash": engine.portfolio.get_initial_cash()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pos_snap = {}
|
|
93
|
+
for symbol, pos in engine.position_book.get_all_positions().items():
|
|
94
|
+
pos_snap[symbol] = {
|
|
95
|
+
"net_size": pos.net_size,
|
|
96
|
+
"avg_entry_price": pos.avg_entry_price,
|
|
97
|
+
"unrealized_pnl": pos.unrealized_pnl
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
trade_snap = [trade.__dict__ for trade in engine.trade_book.get_closed_trades()] # Serialize Trade objects
|
|
101
|
+
|
|
102
|
+
event_log_query_result = event_sink.query_events(experiment_id=run_id).fetchall()
|
|
103
|
+
|
|
104
|
+
event_log_serialized = [
|
|
105
|
+
{"id": row[0], "experiment_id": row[1], "timestamp": row[2], "type": row[3], "data": row[4]}
|
|
106
|
+
for row in event_log_query_result
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
result = ExperimentResult(
|
|
111
|
+
experiment_id=run_id,
|
|
112
|
+
experiment_name=experiment_def.name,
|
|
113
|
+
started_at=start_time,
|
|
114
|
+
finished_at=end_time,
|
|
115
|
+
portfolio_snapshot=portfolio_snap,
|
|
116
|
+
position_book_snapshot=pos_snap,
|
|
117
|
+
trade_book_snapshot=trade_snap,
|
|
118
|
+
event_log=event_log_serialized # Note: This fetches from DB, which might be redundant if DB is primary output
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# 8. Generate and print the report
|
|
122
|
+
report = ConsoleTradeReport()
|
|
123
|
+
report.generate_report(result)
|
|
124
|
+
|
|
125
|
+
# 9. Close resources
|
|
126
|
+
event_sink.close_connection()
|
|
127
|
+
|
|
128
|
+
print(f"\nExperiment '{experiment_def.name}' (ID: {run_id}) completed successfully.")
|
|
129
|
+
print(f"Results summary printed to console.")
|
|
130
|
+
print(f"Full event log persisted to database.")
|
|
131
|
+
|
|
132
|
+
except FileNotFoundError as e:
|
|
133
|
+
typer.echo(f"Error: {e}", err=True)
|
|
134
|
+
raise typer.Exit(code=1)
|
|
135
|
+
except ValueError as e: # Catch errors from CSV provider, YAML parsing, etc.
|
|
136
|
+
typer.echo(f"Configuration Error: {e}", err=True)
|
|
137
|
+
raise typer.Exit(code=1)
|
|
138
|
+
except PluginNotFoundError as e:
|
|
139
|
+
typer.echo(f"Plugin Error: {e}", err=True)
|
|
140
|
+
raise typer.Exit(code=1)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
typer.echo(f"An unexpected error occurred during execution: {e}", err=True)
|
|
143
|
+
typer.echo("Check logs for details.", err=True)
|
|
144
|
+
raise typer.Exit(code=1) # Propagate to trigger fail-fast behavior
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
app()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# plugins/__init__.py
|
|
2
|
+
from Backtesting.definition.plugins.plugin_registry import register
|
|
3
|
+
|
|
4
|
+
from .strategies import SMACrossoverStrategy
|
|
5
|
+
from .money_managers import FixedQtyMoneyManager
|
|
6
|
+
from .brokers import SimpleMarketBroker
|
|
7
|
+
from .market_data import DummyMarketDataProvider
|
|
8
|
+
|
|
9
|
+
# Register all plugins so the Experiment Aggregate can resolve them
|
|
10
|
+
register("SMA_Crossover_Strategy", SMACrossoverStrategy)
|
|
11
|
+
register("Fixed_Qty_MoneyManager", FixedQtyMoneyManager)
|
|
12
|
+
register("Simple_Market_Broker", SimpleMarketBroker)
|
|
13
|
+
register("Dummy_Market_Data", DummyMarketDataProvider)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from typing import List
|
|
3
|
+
import uuid
|
|
4
|
+
from Backtesting.definition.plugins import Broker
|
|
5
|
+
from Backtesting.definition.core.domain_models import Order, MarketBar, Fill
|
|
6
|
+
|
|
7
|
+
class SimpleMarketBroker(Broker):
|
|
8
|
+
def initialize(self, config: dict):
|
|
9
|
+
self.commission = Decimal(str(config.get('commission_per_trade', '1.50')))
|
|
10
|
+
|
|
11
|
+
def reset(self):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
def process_order(self, order: Order, current_market_bar: MarketBar) -> List[Fill]:
|
|
15
|
+
# Simple market fill at the current bar's close price
|
|
16
|
+
fill = Fill(
|
|
17
|
+
fill_id=str(uuid.uuid4()),
|
|
18
|
+
order_id=order.order_id,
|
|
19
|
+
symbol=order.symbol,
|
|
20
|
+
side=order.side,
|
|
21
|
+
quantity=order.quantity,
|
|
22
|
+
fill_price=current_market_bar.close,
|
|
23
|
+
fill_time=current_market_bar.timestamp,
|
|
24
|
+
commission=self.commission
|
|
25
|
+
)
|
|
26
|
+
return [fill]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Iterator
|
|
5
|
+
from Backtesting.execution.market_data import MarketDataProvider
|
|
6
|
+
from Backtesting.definition.core.domain_models import MarketBar
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DummyMarketDataProvider(MarketDataProvider):
|
|
10
|
+
"""Generates a synthetic sine-wave market to guarantee SMA crossovers."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, num_bars: int = 50, symbol: str = "DUMMY", start_price: Decimal = Decimal("100.0"), **kwargs):
|
|
13
|
+
self.num_bars = int(num_bars)
|
|
14
|
+
self.symbol = symbol
|
|
15
|
+
self.start_price = Decimal(str(start_price))
|
|
16
|
+
|
|
17
|
+
def get_bars(self) -> Iterator[MarketBar]:
|
|
18
|
+
base_time = datetime(2023, 1, 1)
|
|
19
|
+
for i in range(self.num_bars):
|
|
20
|
+
# Create a predictable wave: goes up, then down, triggering crossovers
|
|
21
|
+
trend = math.sin(i / 4.0) * 15
|
|
22
|
+
price = self.start_price + Decimal(str(trend))
|
|
23
|
+
|
|
24
|
+
yield MarketBar(
|
|
25
|
+
symbol=self.symbol,
|
|
26
|
+
timestamp=base_time + timedelta(hours=i),
|
|
27
|
+
open=price,
|
|
28
|
+
high=price + Decimal("1.0"),
|
|
29
|
+
low=price - Decimal("1.0"),
|
|
30
|
+
close=price,
|
|
31
|
+
volume=Decimal("1000")
|
|
32
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from typing import List
|
|
3
|
+
import uuid
|
|
4
|
+
from Backtesting.definition.plugins import MoneyManager
|
|
5
|
+
from Backtesting.definition.core.domain_models import Signal, Order, OrderType, OrderSide, OrderStatus, SignalType
|
|
6
|
+
|
|
7
|
+
class FixedQtyMoneyManager(MoneyManager):
|
|
8
|
+
def initialize(self, config: dict):
|
|
9
|
+
self.trade_quantity = Decimal(str(config.get('trade_quantity', '10.0')))
|
|
10
|
+
|
|
11
|
+
def reset(self):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
def size_order(self, signal: Signal, current_portfolio_state: dict) -> List[Order]:
|
|
15
|
+
if signal.signal_type == SignalType.BUY_LONG:
|
|
16
|
+
side = OrderSide.BUY
|
|
17
|
+
elif signal.signal_type == SignalType.EXIT_LONG:
|
|
18
|
+
side = OrderSide.SELL
|
|
19
|
+
else:
|
|
20
|
+
return [] # Ignore shorts for this simple beta test
|
|
21
|
+
|
|
22
|
+
order = Order(
|
|
23
|
+
order_id=str(uuid.uuid4()),
|
|
24
|
+
signal_id=signal.id,
|
|
25
|
+
symbol=signal.symbol,
|
|
26
|
+
order_type=OrderType.MARKET,
|
|
27
|
+
side=side,
|
|
28
|
+
quantity=self.trade_quantity,
|
|
29
|
+
price=None,
|
|
30
|
+
stop_price=None,
|
|
31
|
+
status=OrderStatus.SUBMITTED,
|
|
32
|
+
filled_quantity=Decimal('0'),
|
|
33
|
+
average_fill_price=Decimal('0'),
|
|
34
|
+
commission=Decimal('0')
|
|
35
|
+
)
|
|
36
|
+
return [order]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from typing import List
|
|
4
|
+
import uuid
|
|
5
|
+
from Backtesting.definition.plugins import Strategy
|
|
6
|
+
from Backtesting.definition.core.domain_models import MarketBar, Signal, SignalType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SMACrossoverStrategy(Strategy):
|
|
10
|
+
def initialize(self, config: dict):
|
|
11
|
+
self.fast_period = int(config.get('fast_period', 5))
|
|
12
|
+
self.slow_period = int(config.get('slow_period', 10))
|
|
13
|
+
self.symbol = config.get('symbol', 'DUMMY')
|
|
14
|
+
|
|
15
|
+
self.prices = deque(maxlen=self.slow_period)
|
|
16
|
+
self.prev_fast_sma = None
|
|
17
|
+
self.prev_slow_sma = None
|
|
18
|
+
|
|
19
|
+
def reset(self):
|
|
20
|
+
self.prices.clear()
|
|
21
|
+
self.prev_fast_sma = None
|
|
22
|
+
self.prev_slow_sma = None
|
|
23
|
+
|
|
24
|
+
def on_data(self, bar: MarketBar) -> List[Signal]:
|
|
25
|
+
self.prices.append(bar.close)
|
|
26
|
+
|
|
27
|
+
# Wait until we have enough data to calculate the slow SMA
|
|
28
|
+
if len(self.prices) < self.slow_period:
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
# Calculate SMAs using Decimal math to prevent drift
|
|
32
|
+
fast_sma = sum(list(self.prices)[-self.fast_period:], Decimal('0')) / self.fast_period
|
|
33
|
+
slow_sma = sum(self.prices, Decimal('0')) / self.slow_period
|
|
34
|
+
|
|
35
|
+
signals = []
|
|
36
|
+
if self.prev_fast_sma is not None and self.prev_slow_sma is not None:
|
|
37
|
+
# Golden Cross (Buy)
|
|
38
|
+
if self.prev_fast_sma <= self.prev_slow_sma and fast_sma > slow_sma:
|
|
39
|
+
signals.append(Signal(
|
|
40
|
+
id=str(uuid.uuid4()), symbol=self.symbol,
|
|
41
|
+
signal_type=SignalType.BUY_LONG, strength=Decimal('1.0')
|
|
42
|
+
))
|
|
43
|
+
# Death Cross (Exit)
|
|
44
|
+
elif self.prev_fast_sma >= self.prev_slow_sma and fast_sma < slow_sma:
|
|
45
|
+
signals.append(Signal(
|
|
46
|
+
id=str(uuid.uuid4()), symbol=self.symbol,
|
|
47
|
+
signal_type=SignalType.EXIT_LONG, strength=Decimal('1.0')
|
|
48
|
+
))
|
|
49
|
+
|
|
50
|
+
self.prev_fast_sma = fast_sma
|
|
51
|
+
self.prev_slow_sma = slow_sma
|
|
52
|
+
return signals
|