lumibot 4.0.23__py3-none-any.whl → 4.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.
Potentially problematic release.
This version of lumibot might be problematic. Click here for more details.
- lumibot/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/__pycache__/constants.cpython-312.pyc +0 -0
- lumibot/__pycache__/credentials.cpython-312.pyc +0 -0
- lumibot/backtesting/__init__.py +6 -5
- lumibot/backtesting/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/alpaca_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/alpha_vantage_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/backtesting_broker.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/ccxt_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/databento_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/interactive_brokers_rest_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/pandas_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/polygon_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/thetadata_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/yahoo_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/backtesting_broker.py +209 -9
- lumibot/backtesting/databento_backtesting.py +141 -24
- lumibot/backtesting/thetadata_backtesting.py +63 -42
- lumibot/brokers/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/alpaca.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/bitunix.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/broker.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/ccxt.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/example_broker.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/interactive_brokers.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/interactive_brokers_rest.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/projectx.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/schwab.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/tradier.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/tradovate.cpython-312.pyc +0 -0
- lumibot/brokers/alpaca.py +11 -1
- lumibot/brokers/tradeovate.py +475 -0
- lumibot/components/grok_news_helper.py +284 -0
- lumibot/components/options_helper.py +90 -34
- lumibot/credentials.py +3 -0
- lumibot/data_sources/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/alpaca_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/alpha_vantage_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/bitunix_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/ccxt_backtesting_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/ccxt_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/data_source.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/data_source_backtesting.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/databento_data_polars_backtesting.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/databento_data_polars_live.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/example_broker_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/exceptions.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/interactive_brokers_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/interactive_brokers_rest_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/pandas_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/polars_mixin.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/polygon_data_polars.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/projectx_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/schwab_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/tradier_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/tradovate_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/yahoo_data_polars.cpython-312.pyc +0 -0
- lumibot/data_sources/data_source_backtesting.py +3 -5
- lumibot/data_sources/databento_data_polars_backtesting.py +194 -48
- lumibot/data_sources/pandas_data.py +6 -3
- lumibot/data_sources/polars_mixin.py +126 -21
- lumibot/data_sources/tradeovate_data.py +80 -0
- lumibot/data_sources/tradier_data.py +2 -1
- lumibot/entities/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/asset.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/bar.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/bars.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/chains.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/data.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/dataline.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/order.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/position.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/quote.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/trading_fee.cpython-312.pyc +0 -0
- lumibot/entities/asset.py +8 -0
- lumibot/entities/order.py +1 -1
- lumibot/entities/quote.py +14 -0
- lumibot/example_strategies/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/example_strategies/__pycache__/test_broker_functions.cpython-312-pytest-8.4.1.pyc +0 -0
- lumibot/strategies/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/strategies/__pycache__/_strategy.cpython-312.pyc +0 -0
- lumibot/strategies/__pycache__/strategy.cpython-312.pyc +0 -0
- lumibot/strategies/__pycache__/strategy_executor.cpython-312.pyc +0 -0
- lumibot/strategies/_strategy.py +95 -27
- lumibot/strategies/strategy.py +5 -6
- lumibot/strategies/strategy_executor.py +2 -2
- lumibot/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/alpaca_helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/bitunix_helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/black_scholes.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/ccxt_data_store.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/databento_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/databento_helper_polars.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/debugers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/decorators.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/indicators.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/lumibot_logger.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/pandas.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/polygon_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/polygon_helper_async.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/polygon_helper_polars_optimized.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/projectx_helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/schwab_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/thetadata_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/types.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/yahoo_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/yahoo_helper_polars_optimized.cpython-312.pyc +0 -0
- lumibot/tools/databento_helper.py +384 -133
- lumibot/tools/databento_helper_polars.py +218 -156
- lumibot/tools/databento_roll.py +216 -0
- lumibot/tools/lumibot_logger.py +32 -17
- lumibot/tools/polygon_helper.py +65 -0
- lumibot/tools/thetadata_helper.py +588 -70
- lumibot/traders/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/traders/__pycache__/trader.cpython-312.pyc +0 -0
- lumibot/traders/trader.py +1 -1
- lumibot/trading_builtins/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/trading_builtins/__pycache__/custom_stream.cpython-312.pyc +0 -0
- lumibot/trading_builtins/__pycache__/safe_list.cpython-312.pyc +0 -0
- {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/METADATA +1 -2
- {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/RECORD +160 -44
- tests/backtest/check_timing_offset.py +198 -0
- tests/backtest/check_volume_spike.py +112 -0
- tests/backtest/comprehensive_comparison.py +166 -0
- tests/backtest/debug_comparison.py +91 -0
- tests/backtest/diagnose_price_difference.py +97 -0
- tests/backtest/direct_api_comparison.py +203 -0
- tests/backtest/profile_thetadata_vs_polygon.py +255 -0
- tests/backtest/root_cause_analysis.py +109 -0
- tests/backtest/test_accuracy_verification.py +244 -0
- tests/backtest/test_daily_data_timestamp_comparison.py +801 -0
- tests/backtest/test_databento.py +4 -0
- tests/backtest/test_databento_comprehensive_trading.py +564 -0
- tests/backtest/test_debug_avg_fill_price.py +112 -0
- tests/backtest/test_dividends.py +8 -3
- tests/backtest/test_example_strategies.py +54 -47
- tests/backtest/test_futures_edge_cases.py +451 -0
- tests/backtest/test_futures_single_trade.py +270 -0
- tests/backtest/test_futures_ultra_simple.py +191 -0
- tests/backtest/test_index_data_verification.py +348 -0
- tests/backtest/test_polygon.py +45 -24
- tests/backtest/test_thetadata.py +246 -60
- tests/backtest/test_thetadata_comprehensive.py +729 -0
- tests/backtest/test_thetadata_vs_polygon.py +557 -0
- tests/backtest/test_yahoo.py +1 -2
- tests/conftest.py +20 -0
- tests/test_backtesting_data_source_env.py +249 -0
- tests/test_backtesting_quiet_logs_complete.py +10 -11
- tests/test_databento_helper.py +73 -86
- tests/test_databento_timezone_fixes.py +21 -4
- tests/test_get_historical_prices.py +6 -6
- tests/test_options_helper.py +162 -40
- tests/test_polygon_helper.py +21 -13
- tests/test_quiet_logs_requirements.py +5 -5
- tests/test_thetadata_helper.py +487 -171
- tests/test_yahoo_data.py +125 -0
- {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/LICENSE +0 -0
- {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/WHEEL +0 -0
- {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/top_level.txt +0 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
lumibot/backtesting/__init__.py
CHANGED
|
@@ -8,8 +8,9 @@ from .polygon_backtesting import PolygonDataBacktesting
|
|
|
8
8
|
from .thetadata_backtesting import ThetaDataBacktesting
|
|
9
9
|
from .yahoo_backtesting import YahooDataBacktesting
|
|
10
10
|
|
|
11
|
-
# Import DataBento backtesting
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
# Import DataBento backtesting
|
|
12
|
+
# Polars version (NEW DEFAULT - faster performance)
|
|
13
|
+
from lumibot.data_sources.databento_data_polars_backtesting import DataBentoDataPolarsBacktesting as DataBentoDataBacktesting
|
|
14
|
+
|
|
15
|
+
# Pandas version (stable fallback, kept for compatibility)
|
|
16
|
+
from .databento_backtesting import DataBentoDataBacktesting as DataBentoDataBacktestingPandas
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -17,6 +17,91 @@ from lumibot.trading_builtins import CustomStream
|
|
|
17
17
|
logger = get_logger(__name__)
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
# Typical initial margin requirements for common futures contracts
|
|
21
|
+
# Used for backtesting to simulate margin deduction/release
|
|
22
|
+
TYPICAL_FUTURES_MARGINS = {
|
|
23
|
+
# CME Micro E-mini Futures
|
|
24
|
+
"MES": 1300, # Micro E-mini S&P 500 (~$1,300)
|
|
25
|
+
"MNQ": 1700, # Micro E-mini Nasdaq-100 (~$1,700)
|
|
26
|
+
"MYM": 1100, # Micro E-mini Dow (~$1,100)
|
|
27
|
+
"M2K": 800, # Micro E-mini Russell 2000 (~$800)
|
|
28
|
+
"MCL": 1500, # Micro Crude Oil (~$1,500)
|
|
29
|
+
"MGC": 1200, # Micro Gold (~$1,200)
|
|
30
|
+
|
|
31
|
+
# CME Standard E-mini Futures
|
|
32
|
+
"ES": 13000, # E-mini S&P 500 (~$13,000)
|
|
33
|
+
"NQ": 17000, # E-mini Nasdaq-100 (~$17,000)
|
|
34
|
+
"YM": 11000, # E-mini Dow (~$11,000)
|
|
35
|
+
"RTY": 8000, # E-mini Russell 2000 (~$8,000)
|
|
36
|
+
|
|
37
|
+
# CME Full-Size Futures
|
|
38
|
+
"CL": 8000, # Crude Oil (~$8,000)
|
|
39
|
+
"GC": 10000, # Gold (~$10,000)
|
|
40
|
+
"SI": 14000, # Silver (~$14,000)
|
|
41
|
+
"NG": 3000, # Natural Gas (~$3,000)
|
|
42
|
+
"HG": 4000, # Copper (~$4,000)
|
|
43
|
+
|
|
44
|
+
# CME Currency Futures
|
|
45
|
+
"6E": 2500, # Euro FX (~$2,500)
|
|
46
|
+
"6J": 3000, # Japanese Yen (~$3,000)
|
|
47
|
+
"6B": 2800, # British Pound (~$2,800)
|
|
48
|
+
"6C": 2000, # Canadian Dollar (~$2,000)
|
|
49
|
+
|
|
50
|
+
# CME Interest Rate Futures
|
|
51
|
+
"ZB": 4000, # 30-Year T-Bond (~$4,000)
|
|
52
|
+
"ZN": 2000, # 10-Year T-Note (~$2,000)
|
|
53
|
+
"ZF": 1500, # 5-Year T-Note (~$1,500)
|
|
54
|
+
"ZT": 800, # 2-Year T-Note (~$800)
|
|
55
|
+
|
|
56
|
+
# CME Agricultural Futures
|
|
57
|
+
"ZC": 2000, # Corn (~$2,000)
|
|
58
|
+
"ZS": 3000, # Soybeans (~$3,000)
|
|
59
|
+
"ZW": 2500, # Wheat (~$2,500)
|
|
60
|
+
"ZL": 1500, # Soybean Oil (~$1,500)
|
|
61
|
+
|
|
62
|
+
# Default for unknown futures
|
|
63
|
+
"DEFAULT": 5000, # Conservative default
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_futures_margin_requirement(asset: Asset) -> float:
|
|
68
|
+
"""
|
|
69
|
+
Get the initial margin requirement for a futures contract.
|
|
70
|
+
|
|
71
|
+
This is used in backtesting to simulate the margin deduction when opening
|
|
72
|
+
a futures position and margin release when closing.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
asset: The futures Asset object
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
float: Initial margin requirement in dollars
|
|
79
|
+
|
|
80
|
+
Note:
|
|
81
|
+
These are TYPICAL values and may not match current broker requirements.
|
|
82
|
+
For live trading, brokers handle margin internally.
|
|
83
|
+
"""
|
|
84
|
+
symbol = asset.symbol.upper()
|
|
85
|
+
|
|
86
|
+
# Try exact match first
|
|
87
|
+
if symbol in TYPICAL_FUTURES_MARGINS:
|
|
88
|
+
return TYPICAL_FUTURES_MARGINS[symbol]
|
|
89
|
+
|
|
90
|
+
# Try base symbol (remove month/year codes like "ESH4" -> "ES")
|
|
91
|
+
# Most futures symbols are 2-3 characters followed by month/year
|
|
92
|
+
base_symbol = ''.join(c for c in symbol if c.isalpha())
|
|
93
|
+
if base_symbol in TYPICAL_FUTURES_MARGINS:
|
|
94
|
+
return TYPICAL_FUTURES_MARGINS[base_symbol]
|
|
95
|
+
|
|
96
|
+
# Unknown contract - use conservative default
|
|
97
|
+
logger.warning(
|
|
98
|
+
f"Unknown futures contract '{symbol}'. Using default margin of "
|
|
99
|
+
f"${TYPICAL_FUTURES_MARGINS['DEFAULT']:.2f}. "
|
|
100
|
+
f"Consider adding this contract to TYPICAL_FUTURES_MARGINS."
|
|
101
|
+
)
|
|
102
|
+
return TYPICAL_FUTURES_MARGINS["DEFAULT"]
|
|
103
|
+
|
|
104
|
+
|
|
20
105
|
class BacktestingBroker(Broker):
|
|
21
106
|
# Metainfo
|
|
22
107
|
IS_BACKTESTING_BROKER = True
|
|
@@ -215,17 +300,30 @@ class BacktestingBroker(Broker):
|
|
|
215
300
|
trading_day = search.iloc[0]
|
|
216
301
|
open_time = trading_day.market_open
|
|
217
302
|
|
|
303
|
+
# DEBUG: Log what's happening
|
|
304
|
+
print(f"[BROKER DEBUG] get_time_to_open: now={now}, next_trading_day={trading_day.name}, open_time={open_time}")
|
|
305
|
+
|
|
218
306
|
# For Backtesting, sometimes the user can just pass in dates (i.e. 2023-08-01) and not datetimes
|
|
219
307
|
# In this case the "now" variable is starting at midnight, so we need to adjust the open_time to be actual
|
|
220
|
-
# market open time. In the case where the user passes in a
|
|
308
|
+
# market open time. In the case where the user passes in a valid trading day, use that time
|
|
221
309
|
# as the start of trading instead of market open.
|
|
310
|
+
# BUT: Only do this if the current day (now.date()) is actually a trading day
|
|
222
311
|
if self.IS_BACKTESTING_BROKER and now > open_time:
|
|
223
|
-
|
|
312
|
+
# Check if now.date() is in trading days before overriding
|
|
313
|
+
now_date = now.date() if hasattr(now, 'date') else now
|
|
314
|
+
trading_day_dates = self._trading_days.index.date
|
|
315
|
+
if now_date in trading_day_dates:
|
|
316
|
+
print(f"[BROKER DEBUG] Overriding open_time to datetime_start because now ({now}) is on a trading day but after market open")
|
|
317
|
+
open_time = self.data_source.datetime_start
|
|
318
|
+
else:
|
|
319
|
+
print(f"[BROKER DEBUG] NOT overriding open_time because now ({now}) is NOT a trading day")
|
|
224
320
|
|
|
225
321
|
if now >= open_time:
|
|
322
|
+
print(f"[BROKER DEBUG] Market already open: now={now} >= open_time={open_time}, returning 0")
|
|
226
323
|
return 0
|
|
227
324
|
|
|
228
325
|
delta = open_time - now
|
|
326
|
+
print(f"[BROKER DEBUG] Market opens in {delta.total_seconds()} seconds")
|
|
229
327
|
return delta.total_seconds()
|
|
230
328
|
|
|
231
329
|
def get_time_to_close(self):
|
|
@@ -262,24 +360,30 @@ class BacktestingBroker(Broker):
|
|
|
262
360
|
def _await_market_to_open(self, timedelta=None, strategy=None):
|
|
263
361
|
# Process outstanding orders first before waiting for market to open
|
|
264
362
|
# or else they don't get processed until the next day
|
|
363
|
+
print(f"[BROKER DEBUG] _await_market_to_open called, current datetime={self.datetime}, timedelta={timedelta}")
|
|
265
364
|
self.process_pending_orders(strategy=strategy)
|
|
266
365
|
|
|
267
366
|
time_to_open = self.get_time_to_open()
|
|
367
|
+
print(f"[BROKER DEBUG] get_time_to_open returned: {time_to_open}")
|
|
268
368
|
|
|
269
369
|
# If None is returned, it means we've reached the end of available trading days
|
|
270
370
|
if time_to_open is None:
|
|
271
371
|
logger.info("Backtesting reached end of available trading days data")
|
|
372
|
+
print(f"[BROKER DEBUG] time_to_open is None, returning early")
|
|
272
373
|
return
|
|
273
374
|
|
|
274
375
|
# Allow the caller to specify a buffer (in minutes) before the actual open
|
|
275
376
|
if timedelta:
|
|
276
377
|
time_to_open -= 60 * timedelta
|
|
378
|
+
print(f"[BROKER DEBUG] Adjusted time_to_open for timedelta buffer: {time_to_open}")
|
|
277
379
|
|
|
278
380
|
# Only advance time if there is something positive to advance;
|
|
279
381
|
# prevents zero or negative time updates.
|
|
280
382
|
if time_to_open <= 0:
|
|
383
|
+
print(f"[BROKER DEBUG] time_to_open <= 0 ({time_to_open}), returning without advancing time")
|
|
281
384
|
return
|
|
282
385
|
|
|
386
|
+
print(f"[BROKER DEBUG] Advancing time by {time_to_open} seconds")
|
|
283
387
|
self._update_datetime(time_to_open)
|
|
284
388
|
|
|
285
389
|
def _await_market_to_close(self, timedelta=None, strategy=None):
|
|
@@ -499,8 +603,16 @@ class BacktestingBroker(Broker):
|
|
|
499
603
|
def _cancel_inline(order: Order):
|
|
500
604
|
if order.identifier in canceled_identifiers:
|
|
501
605
|
return
|
|
502
|
-
|
|
503
|
-
|
|
606
|
+
|
|
607
|
+
# BUGFIX: Only process CANCELED event if the order is actually active
|
|
608
|
+
# Don't try to cancel orders that are already filled or canceled
|
|
609
|
+
if order.is_active():
|
|
610
|
+
canceled_identifiers.add(order.identifier)
|
|
611
|
+
self._process_trade_event(order, self.CANCELED_ORDER)
|
|
612
|
+
else:
|
|
613
|
+
logger.debug(f"Order {order.identifier} not active (status={order.status}), skipping cancel event")
|
|
614
|
+
canceled_identifiers.add(order.identifier)
|
|
615
|
+
|
|
504
616
|
for child in order.child_orders:
|
|
505
617
|
_cancel_inline(child)
|
|
506
618
|
|
|
@@ -920,9 +1032,92 @@ class BacktestingBroker(Broker):
|
|
|
920
1032
|
asset_type = getattr(order.asset, "asset_type", None)
|
|
921
1033
|
quote_asset_type = getattr(order.quote, "asset_type", None) if hasattr(order, "quote") and order.quote else None
|
|
922
1034
|
|
|
1035
|
+
# For futures, use margin-based cash management (not full notional value)
|
|
1036
|
+
# Futures don't tie up full contract value - only margin requirement
|
|
1037
|
+
if (
|
|
1038
|
+
not is_multileg_parent
|
|
1039
|
+
and asset_type in (Asset.AssetType.FUTURE, Asset.AssetType.CONT_FUTURE)
|
|
1040
|
+
):
|
|
1041
|
+
# Reconstruct position state BEFORE this order to determine if opening/closing
|
|
1042
|
+
futures_qty_before = 0
|
|
1043
|
+
futures_entry_price = None
|
|
1044
|
+
|
|
1045
|
+
# Look through filled_orders to find position before this order
|
|
1046
|
+
for filled_order in self._filled_orders.get_list():
|
|
1047
|
+
if (filled_order.asset == order.asset
|
|
1048
|
+
and filled_order.strategy == order.strategy
|
|
1049
|
+
and filled_order != order): # Don't count the current order
|
|
1050
|
+
|
|
1051
|
+
if filled_order.side in (Order.OrderSide.BUY, "buy", "buy_to_open"):
|
|
1052
|
+
futures_qty_before += filled_order.quantity
|
|
1053
|
+
# Track most recent BUY entry price (for long positions)
|
|
1054
|
+
if filled_order.avg_fill_price:
|
|
1055
|
+
futures_entry_price = float(filled_order.avg_fill_price)
|
|
1056
|
+
elif filled_order.side in (Order.OrderSide.SELL, Order.OrderSide.SELL_TO_CLOSE, "sell", "sell_to_close"):
|
|
1057
|
+
futures_qty_before -= filled_order.quantity
|
|
1058
|
+
# Track most recent SELL entry price (for short positions)
|
|
1059
|
+
# Note: This gets overwritten by SELL_TO_CLOSE, which is correct
|
|
1060
|
+
# We want the opening SELL price, not closing prices
|
|
1061
|
+
if (filled_order.side in (Order.OrderSide.SELL, "sell") # Opening short
|
|
1062
|
+
and filled_order.avg_fill_price):
|
|
1063
|
+
futures_entry_price = float(filled_order.avg_fill_price)
|
|
1064
|
+
|
|
1065
|
+
# Determine if this order is opening or closing a position
|
|
1066
|
+
is_opening = (futures_qty_before == 0)
|
|
1067
|
+
is_closing_long = (
|
|
1068
|
+
futures_qty_before > 0
|
|
1069
|
+
and order.side in (Order.OrderSide.SELL, Order.OrderSide.SELL_TO_CLOSE, "sell", "sell_to_close")
|
|
1070
|
+
)
|
|
1071
|
+
is_closing_short = (
|
|
1072
|
+
futures_qty_before < 0
|
|
1073
|
+
and order.side in (Order.OrderSide.BUY, Order.OrderSide.BUY_TO_OPEN, "buy", "buy_to_open")
|
|
1074
|
+
)
|
|
1075
|
+
is_closing = is_closing_long or is_closing_short
|
|
1076
|
+
|
|
1077
|
+
# Get margin requirement and multiplier
|
|
1078
|
+
margin_per_contract = get_futures_margin_requirement(order.asset)
|
|
1079
|
+
multiplier = getattr(order.asset, "multiplier", 1)
|
|
1080
|
+
total_margin = margin_per_contract * float(filled_quantity)
|
|
1081
|
+
|
|
1082
|
+
current_cash = strategy.cash
|
|
1083
|
+
|
|
1084
|
+
if is_opening:
|
|
1085
|
+
# ENTRY (long or short): Deduct initial margin from cash
|
|
1086
|
+
new_cash = current_cash - total_margin
|
|
1087
|
+
strategy._set_cash_position(new_cash)
|
|
1088
|
+
|
|
1089
|
+
elif is_closing:
|
|
1090
|
+
# EXIT (close long or cover short): Release margin and apply realized P&L
|
|
1091
|
+
if futures_entry_price:
|
|
1092
|
+
exit_price = float(price)
|
|
1093
|
+
|
|
1094
|
+
# For shorts, P&L is inverted: profit when price goes down
|
|
1095
|
+
if futures_qty_before < 0:
|
|
1096
|
+
# Closing short: P&L = (entry - exit) × qty × multiplier
|
|
1097
|
+
realized_pnl = (futures_entry_price - exit_price) * float(filled_quantity) * float(multiplier)
|
|
1098
|
+
else:
|
|
1099
|
+
# Closing long: P&L = (exit - entry) × qty × multiplier
|
|
1100
|
+
realized_pnl = (exit_price - futures_entry_price) * float(filled_quantity) * float(multiplier)
|
|
1101
|
+
|
|
1102
|
+
# Update cash: release margin + add realized P&L
|
|
1103
|
+
new_cash = current_cash + total_margin + realized_pnl
|
|
1104
|
+
strategy._set_cash_position(new_cash)
|
|
1105
|
+
else:
|
|
1106
|
+
# No entry price found - just release margin (shouldn't happen normally)
|
|
1107
|
+
logger.warning(
|
|
1108
|
+
f"No entry price found for futures exit: {order.asset.symbol}. "
|
|
1109
|
+
f"Only releasing margin, no P&L applied."
|
|
1110
|
+
)
|
|
1111
|
+
new_cash = current_cash + total_margin
|
|
1112
|
+
strategy._set_cash_position(new_cash)
|
|
1113
|
+
else:
|
|
1114
|
+
# Adding to existing position: deduct margin for additional contracts
|
|
1115
|
+
new_cash = current_cash - total_margin
|
|
1116
|
+
strategy._set_cash_position(new_cash)
|
|
1117
|
+
|
|
923
1118
|
# For crypto base with forex quote (like BTC/USD where USD is forex), use cash
|
|
924
1119
|
# For crypto base with crypto quote (like BTC/USDT where both are crypto), use positions
|
|
925
|
-
|
|
1120
|
+
elif (
|
|
926
1121
|
not is_multileg_parent
|
|
927
1122
|
and asset_type == Asset.AssetType.CRYPTO
|
|
928
1123
|
and quote_asset_type == Asset.AssetType.FOREX
|
|
@@ -964,16 +1159,21 @@ class BacktestingBroker(Broker):
|
|
|
964
1159
|
self._apply_trade_cost(strategy, trade_cost)
|
|
965
1160
|
|
|
966
1161
|
def _process_crypto_quote(self, order, quantity, price):
|
|
967
|
-
"""Override to skip
|
|
968
|
-
# Check
|
|
1162
|
+
"""Override to skip quote processing for assets that use direct cash updates or margin-based trading."""
|
|
1163
|
+
# Check asset types
|
|
969
1164
|
asset_type = getattr(order.asset, "asset_type", None)
|
|
970
1165
|
quote_asset_type = getattr(order.quote, "asset_type", None) if hasattr(order, "quote") and order.quote else None
|
|
971
1166
|
|
|
972
|
-
#
|
|
1167
|
+
# Skip position-based quote processing for:
|
|
1168
|
+
# 1. Crypto+forex trades (handled with direct cash updates)
|
|
1169
|
+
# 2. Futures contracts (use margin, only realize P&L on close, not full notional)
|
|
973
1170
|
if asset_type == Asset.AssetType.CRYPTO and quote_asset_type == Asset.AssetType.FOREX:
|
|
974
1171
|
return
|
|
975
1172
|
|
|
976
|
-
|
|
1173
|
+
if asset_type in (Asset.AssetType.FUTURE, Asset.AssetType.CONT_FUTURE):
|
|
1174
|
+
return
|
|
1175
|
+
|
|
1176
|
+
# For other asset types (crypto+crypto, stocks, etc.), use the original position-based processing
|
|
977
1177
|
super()._process_crypto_quote(order, quantity, price)
|
|
978
1178
|
|
|
979
1179
|
def calculate_trade_cost(self, order: Order, strategy, price: float):
|
|
@@ -71,6 +71,15 @@ class DataBentoDataBacktesting(PandasData):
|
|
|
71
71
|
# Track data requests to avoid repeated log messages
|
|
72
72
|
self._logged_requests = set()
|
|
73
73
|
|
|
74
|
+
# OPTIMIZATION: Iteration-level caching to avoid redundant filtering
|
|
75
|
+
# Cache filtered DataFrames per iteration (datetime)
|
|
76
|
+
self._filtered_bars_cache = {} # {(asset_key, length, timestep, timeshift, dt): DataFrame}
|
|
77
|
+
self._last_price_cache = {} # {(asset_key, dt): price}
|
|
78
|
+
self._cache_datetime = None # Track when to invalidate cache
|
|
79
|
+
|
|
80
|
+
# Track which futures assets we've fetched multipliers for (to avoid redundant API calls)
|
|
81
|
+
self._multiplier_fetched_assets = set()
|
|
82
|
+
|
|
74
83
|
# Verify DataBento availability
|
|
75
84
|
if not databento_helper.DATABENTO_AVAILABLE:
|
|
76
85
|
logger.error("DataBento package not available. Please install with: pip install databento")
|
|
@@ -78,6 +87,81 @@ class DataBentoDataBacktesting(PandasData):
|
|
|
78
87
|
|
|
79
88
|
logger.info(f"DataBento backtesting initialized for period: {datetime_start} to {datetime_end}")
|
|
80
89
|
|
|
90
|
+
def _check_and_clear_cache(self):
|
|
91
|
+
"""
|
|
92
|
+
OPTIMIZATION: Clear iteration caches when datetime changes.
|
|
93
|
+
This ensures fresh filtering for each new iteration while reusing
|
|
94
|
+
results within the same iteration.
|
|
95
|
+
"""
|
|
96
|
+
current_dt = self.get_datetime()
|
|
97
|
+
if self._cache_datetime != current_dt:
|
|
98
|
+
self._filtered_bars_cache.clear()
|
|
99
|
+
self._last_price_cache.clear()
|
|
100
|
+
self._cache_datetime = current_dt
|
|
101
|
+
|
|
102
|
+
def _ensure_futures_multiplier(self, asset):
|
|
103
|
+
"""
|
|
104
|
+
Ensure futures asset has correct multiplier set.
|
|
105
|
+
|
|
106
|
+
This method is idempotent and cached - safe to call multiple times.
|
|
107
|
+
Only fetches multiplier once per unique asset.
|
|
108
|
+
|
|
109
|
+
Design rationale:
|
|
110
|
+
- Futures multipliers must be fetched from data provider (e.g., DataBento)
|
|
111
|
+
- Asset class defaults to multiplier=1
|
|
112
|
+
- Data source is responsible for updating multiplier on first use
|
|
113
|
+
- Lazy fetching is more efficient than prefetching all possible assets
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
asset : Asset
|
|
118
|
+
The asset to ensure has correct multiplier
|
|
119
|
+
"""
|
|
120
|
+
# Skip if not a futures asset
|
|
121
|
+
if asset.asset_type not in (Asset.AssetType.FUTURE, Asset.AssetType.CONT_FUTURE):
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Skip if multiplier already set to non-default value
|
|
125
|
+
if asset.multiplier != 1:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# Create cache key to track which assets we've already processed
|
|
129
|
+
# Use symbol + asset_type + expiration to handle different contracts
|
|
130
|
+
cache_key = (asset.symbol, asset.asset_type, getattr(asset, 'expiration', None))
|
|
131
|
+
|
|
132
|
+
# Check if we already tried to fetch for this asset
|
|
133
|
+
if cache_key in self._multiplier_fetched_assets:
|
|
134
|
+
return # Already attempted (even if failed, don't retry every time)
|
|
135
|
+
|
|
136
|
+
# Mark as attempted to avoid redundant API calls
|
|
137
|
+
self._multiplier_fetched_assets.add(cache_key)
|
|
138
|
+
|
|
139
|
+
# Fetch and set multiplier from DataBento
|
|
140
|
+
try:
|
|
141
|
+
client = databento_helper.DataBentoClient(self._api_key)
|
|
142
|
+
|
|
143
|
+
# Resolve symbol based on asset type
|
|
144
|
+
if asset.asset_type == Asset.AssetType.CONT_FUTURE:
|
|
145
|
+
resolved_symbol = databento_helper._format_futures_symbol_for_databento(
|
|
146
|
+
asset, reference_date=self.datetime_start
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
resolved_symbol = databento_helper._format_futures_symbol_for_databento(asset)
|
|
150
|
+
|
|
151
|
+
# Fetch multiplier from DataBento instrument definition
|
|
152
|
+
databento_helper._fetch_and_update_futures_multiplier(
|
|
153
|
+
client=client,
|
|
154
|
+
asset=asset,
|
|
155
|
+
resolved_symbol=resolved_symbol,
|
|
156
|
+
dataset="GLBX.MDP3",
|
|
157
|
+
reference_date=self.datetime_start
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
logger.info(f"Successfully set multiplier for {asset.symbol}: {asset.multiplier}")
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.warning(f"Could not fetch multiplier for {asset.symbol}: {e}")
|
|
164
|
+
|
|
81
165
|
def prefetch_data(self, assets, timestep="minute"):
|
|
82
166
|
"""
|
|
83
167
|
Prefetch all required data for the specified assets for the entire backtest period.
|
|
@@ -186,6 +270,9 @@ class DataBentoDataBacktesting(PandasData):
|
|
|
186
270
|
else:
|
|
187
271
|
search_asset = (search_asset, quote_asset)
|
|
188
272
|
|
|
273
|
+
# Ensure futures have correct multiplier set
|
|
274
|
+
self._ensure_futures_multiplier(asset_separated)
|
|
275
|
+
|
|
189
276
|
# If this asset was already prefetched, we don't need to do anything
|
|
190
277
|
if search_asset in self._prefetched_assets:
|
|
191
278
|
return
|
|
@@ -293,7 +380,7 @@ class DataBentoDataBacktesting(PandasData):
|
|
|
293
380
|
def get_last_price(self, asset, quote=None, exchange=None):
|
|
294
381
|
"""
|
|
295
382
|
Get the last price for an asset at the current backtest time
|
|
296
|
-
|
|
383
|
+
|
|
297
384
|
Parameters
|
|
298
385
|
----------
|
|
299
386
|
asset : Asset
|
|
@@ -302,30 +389,39 @@ class DataBentoDataBacktesting(PandasData):
|
|
|
302
389
|
Quote asset (not typically used with DataBento)
|
|
303
390
|
exchange : str, optional
|
|
304
391
|
Exchange filter
|
|
305
|
-
|
|
392
|
+
|
|
306
393
|
Returns
|
|
307
394
|
-------
|
|
308
395
|
float, Decimal, or None
|
|
309
396
|
Last price at current backtest time
|
|
310
397
|
"""
|
|
311
398
|
try:
|
|
312
|
-
#
|
|
399
|
+
# OPTIMIZATION: Check cache first
|
|
400
|
+
self._check_and_clear_cache()
|
|
313
401
|
current_dt = self.get_datetime()
|
|
314
|
-
|
|
402
|
+
|
|
315
403
|
# Try to get data from our cached pandas_data first
|
|
316
404
|
search_asset = asset
|
|
317
405
|
quote_asset = quote if quote is not None else Asset("USD", "forex")
|
|
318
|
-
|
|
406
|
+
|
|
319
407
|
if isinstance(search_asset, tuple):
|
|
320
408
|
asset_separated, quote_asset = search_asset
|
|
321
409
|
else:
|
|
322
410
|
search_asset = (search_asset, quote_asset)
|
|
323
411
|
asset_separated = asset
|
|
324
|
-
|
|
412
|
+
|
|
413
|
+
# Ensure futures have correct multiplier set
|
|
414
|
+
self._ensure_futures_multiplier(asset_separated)
|
|
415
|
+
|
|
416
|
+
# OPTIMIZATION: Check iteration cache
|
|
417
|
+
cache_key = (search_asset, current_dt)
|
|
418
|
+
if cache_key in self._last_price_cache:
|
|
419
|
+
return self._last_price_cache[cache_key]
|
|
420
|
+
|
|
325
421
|
if search_asset in self.pandas_data:
|
|
326
422
|
asset_data = self.pandas_data[search_asset]
|
|
327
423
|
df = asset_data.df
|
|
328
|
-
|
|
424
|
+
|
|
329
425
|
if not df.empty and 'close' in df.columns:
|
|
330
426
|
# Ensure current_dt is timezone-aware for comparison
|
|
331
427
|
current_dt_aware = to_datetime_aware(current_dt)
|
|
@@ -341,11 +437,14 @@ class DataBentoDataBacktesting(PandasData):
|
|
|
341
437
|
|
|
342
438
|
# Filter to data up to current backtest time (exclude current bar unless broker overrides)
|
|
343
439
|
filtered_df = df[df.index <= cutoff_dt]
|
|
344
|
-
|
|
440
|
+
|
|
345
441
|
if not filtered_df.empty:
|
|
346
442
|
last_price = filtered_df['close'].iloc[-1]
|
|
347
443
|
if not pd.isna(last_price):
|
|
348
|
-
|
|
444
|
+
price = float(last_price)
|
|
445
|
+
# OPTIMIZATION: Cache the result
|
|
446
|
+
self._last_price_cache[cache_key] = price
|
|
447
|
+
return price
|
|
349
448
|
|
|
350
449
|
# If no cached data, try to get recent data
|
|
351
450
|
logger.warning(f"No cached data for {asset.symbol}, attempting direct fetch")
|
|
@@ -470,34 +569,50 @@ class DataBentoDataBacktesting(PandasData):
|
|
|
470
569
|
):
|
|
471
570
|
"""
|
|
472
571
|
Override parent method to fetch data from DataBento instead of pre-loaded data store
|
|
473
|
-
|
|
572
|
+
|
|
474
573
|
This method is called by get_historical_prices and is responsible for actually
|
|
475
574
|
fetching the data from the DataBento API.
|
|
476
575
|
"""
|
|
477
576
|
timestep = timestep if timestep else "minute"
|
|
478
|
-
|
|
479
|
-
# Check
|
|
480
|
-
|
|
481
|
-
self.
|
|
482
|
-
|
|
577
|
+
|
|
578
|
+
# OPTIMIZATION: Check iteration cache first
|
|
579
|
+
self._check_and_clear_cache()
|
|
580
|
+
current_dt = self.get_datetime()
|
|
581
|
+
|
|
483
582
|
# Get data from our cached pandas_data
|
|
484
583
|
search_asset = asset
|
|
485
584
|
quote_asset = quote if quote is not None else Asset("USD", "forex")
|
|
486
|
-
|
|
585
|
+
|
|
487
586
|
if isinstance(search_asset, tuple):
|
|
488
587
|
asset_separated, quote_asset = search_asset
|
|
489
588
|
else:
|
|
490
589
|
search_asset = (search_asset, quote_asset)
|
|
491
590
|
asset_separated = asset
|
|
492
|
-
|
|
591
|
+
|
|
592
|
+
# OPTIMIZATION: Build cache key and check cache
|
|
593
|
+
# Convert timeshift to consistent format for caching
|
|
594
|
+
timeshift_key = 0
|
|
595
|
+
if timeshift:
|
|
596
|
+
if isinstance(timeshift, int):
|
|
597
|
+
timeshift_key = timeshift
|
|
598
|
+
else:
|
|
599
|
+
timeshift_key = int(timeshift.total_seconds() / 60)
|
|
600
|
+
|
|
601
|
+
cache_key = (search_asset, length, timestep, timeshift_key, current_dt)
|
|
602
|
+
if cache_key in self._filtered_bars_cache:
|
|
603
|
+
return self._filtered_bars_cache[cache_key]
|
|
604
|
+
|
|
605
|
+
# Check if we need to fetch data by calling _update_pandas_data first
|
|
606
|
+
# This will only fetch if data is not already cached or prefetched
|
|
607
|
+
self._update_pandas_data(asset, quote, length, timestep)
|
|
608
|
+
|
|
493
609
|
# Check if we have data in pandas_data cache
|
|
494
610
|
if search_asset in self.pandas_data:
|
|
495
611
|
asset_data = self.pandas_data[search_asset]
|
|
496
612
|
df = asset_data.df
|
|
497
|
-
|
|
613
|
+
|
|
498
614
|
if not df.empty:
|
|
499
615
|
# Apply timeshift if specified
|
|
500
|
-
current_dt = self.get_datetime()
|
|
501
616
|
shift_seconds = 0
|
|
502
617
|
if timeshift:
|
|
503
618
|
if isinstance(timeshift, int):
|
|
@@ -506,10 +621,10 @@ class DataBentoDataBacktesting(PandasData):
|
|
|
506
621
|
else:
|
|
507
622
|
shift_seconds = timeshift.total_seconds()
|
|
508
623
|
current_dt = current_dt - timeshift
|
|
509
|
-
|
|
624
|
+
|
|
510
625
|
# Ensure current_dt is timezone-aware for comparison
|
|
511
626
|
current_dt_aware = to_datetime_aware(current_dt)
|
|
512
|
-
|
|
627
|
+
|
|
513
628
|
# Step back one bar to avoid exposing the in-progress bar
|
|
514
629
|
bar_delta = timedelta(minutes=1)
|
|
515
630
|
if asset_data.timestep == "hour":
|
|
@@ -521,14 +636,16 @@ class DataBentoDataBacktesting(PandasData):
|
|
|
521
636
|
|
|
522
637
|
# Filter data up to current backtest time (exclude current bar unless broker overrides)
|
|
523
638
|
filtered_df = df[df.index <= cutoff_dt] if shift_seconds > 0 else df[df.index < current_dt_aware]
|
|
524
|
-
|
|
639
|
+
|
|
525
640
|
# Take the last 'length' bars
|
|
526
641
|
result_df = filtered_df.tail(length)
|
|
527
|
-
|
|
642
|
+
|
|
643
|
+
# OPTIMIZATION: Cache the result before returning
|
|
528
644
|
if not result_df.empty:
|
|
529
|
-
|
|
645
|
+
self._filtered_bars_cache[cache_key] = result_df
|
|
530
646
|
return result_df
|
|
531
647
|
else:
|
|
648
|
+
self._filtered_bars_cache[cache_key] = None
|
|
532
649
|
return None
|
|
533
650
|
else:
|
|
534
651
|
return None
|