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.

Files changed (160) hide show
  1. lumibot/__pycache__/__init__.cpython-312.pyc +0 -0
  2. lumibot/__pycache__/constants.cpython-312.pyc +0 -0
  3. lumibot/__pycache__/credentials.cpython-312.pyc +0 -0
  4. lumibot/backtesting/__init__.py +6 -5
  5. lumibot/backtesting/__pycache__/__init__.cpython-312.pyc +0 -0
  6. lumibot/backtesting/__pycache__/alpaca_backtesting.cpython-312.pyc +0 -0
  7. lumibot/backtesting/__pycache__/alpha_vantage_backtesting.cpython-312.pyc +0 -0
  8. lumibot/backtesting/__pycache__/backtesting_broker.cpython-312.pyc +0 -0
  9. lumibot/backtesting/__pycache__/ccxt_backtesting.cpython-312.pyc +0 -0
  10. lumibot/backtesting/__pycache__/databento_backtesting.cpython-312.pyc +0 -0
  11. lumibot/backtesting/__pycache__/interactive_brokers_rest_backtesting.cpython-312.pyc +0 -0
  12. lumibot/backtesting/__pycache__/pandas_backtesting.cpython-312.pyc +0 -0
  13. lumibot/backtesting/__pycache__/polygon_backtesting.cpython-312.pyc +0 -0
  14. lumibot/backtesting/__pycache__/thetadata_backtesting.cpython-312.pyc +0 -0
  15. lumibot/backtesting/__pycache__/yahoo_backtesting.cpython-312.pyc +0 -0
  16. lumibot/backtesting/backtesting_broker.py +209 -9
  17. lumibot/backtesting/databento_backtesting.py +141 -24
  18. lumibot/backtesting/thetadata_backtesting.py +63 -42
  19. lumibot/brokers/__pycache__/__init__.cpython-312.pyc +0 -0
  20. lumibot/brokers/__pycache__/alpaca.cpython-312.pyc +0 -0
  21. lumibot/brokers/__pycache__/bitunix.cpython-312.pyc +0 -0
  22. lumibot/brokers/__pycache__/broker.cpython-312.pyc +0 -0
  23. lumibot/brokers/__pycache__/ccxt.cpython-312.pyc +0 -0
  24. lumibot/brokers/__pycache__/example_broker.cpython-312.pyc +0 -0
  25. lumibot/brokers/__pycache__/interactive_brokers.cpython-312.pyc +0 -0
  26. lumibot/brokers/__pycache__/interactive_brokers_rest.cpython-312.pyc +0 -0
  27. lumibot/brokers/__pycache__/projectx.cpython-312.pyc +0 -0
  28. lumibot/brokers/__pycache__/schwab.cpython-312.pyc +0 -0
  29. lumibot/brokers/__pycache__/tradier.cpython-312.pyc +0 -0
  30. lumibot/brokers/__pycache__/tradovate.cpython-312.pyc +0 -0
  31. lumibot/brokers/alpaca.py +11 -1
  32. lumibot/brokers/tradeovate.py +475 -0
  33. lumibot/components/grok_news_helper.py +284 -0
  34. lumibot/components/options_helper.py +90 -34
  35. lumibot/credentials.py +3 -0
  36. lumibot/data_sources/__pycache__/__init__.cpython-312.pyc +0 -0
  37. lumibot/data_sources/__pycache__/alpaca_data.cpython-312.pyc +0 -0
  38. lumibot/data_sources/__pycache__/alpha_vantage_data.cpython-312.pyc +0 -0
  39. lumibot/data_sources/__pycache__/bitunix_data.cpython-312.pyc +0 -0
  40. lumibot/data_sources/__pycache__/ccxt_backtesting_data.cpython-312.pyc +0 -0
  41. lumibot/data_sources/__pycache__/ccxt_data.cpython-312.pyc +0 -0
  42. lumibot/data_sources/__pycache__/data_source.cpython-312.pyc +0 -0
  43. lumibot/data_sources/__pycache__/data_source_backtesting.cpython-312.pyc +0 -0
  44. lumibot/data_sources/__pycache__/databento_data_polars_backtesting.cpython-312.pyc +0 -0
  45. lumibot/data_sources/__pycache__/databento_data_polars_live.cpython-312.pyc +0 -0
  46. lumibot/data_sources/__pycache__/example_broker_data.cpython-312.pyc +0 -0
  47. lumibot/data_sources/__pycache__/exceptions.cpython-312.pyc +0 -0
  48. lumibot/data_sources/__pycache__/interactive_brokers_data.cpython-312.pyc +0 -0
  49. lumibot/data_sources/__pycache__/interactive_brokers_rest_data.cpython-312.pyc +0 -0
  50. lumibot/data_sources/__pycache__/pandas_data.cpython-312.pyc +0 -0
  51. lumibot/data_sources/__pycache__/polars_mixin.cpython-312.pyc +0 -0
  52. lumibot/data_sources/__pycache__/polygon_data_polars.cpython-312.pyc +0 -0
  53. lumibot/data_sources/__pycache__/projectx_data.cpython-312.pyc +0 -0
  54. lumibot/data_sources/__pycache__/schwab_data.cpython-312.pyc +0 -0
  55. lumibot/data_sources/__pycache__/tradier_data.cpython-312.pyc +0 -0
  56. lumibot/data_sources/__pycache__/tradovate_data.cpython-312.pyc +0 -0
  57. lumibot/data_sources/__pycache__/yahoo_data_polars.cpython-312.pyc +0 -0
  58. lumibot/data_sources/data_source_backtesting.py +3 -5
  59. lumibot/data_sources/databento_data_polars_backtesting.py +194 -48
  60. lumibot/data_sources/pandas_data.py +6 -3
  61. lumibot/data_sources/polars_mixin.py +126 -21
  62. lumibot/data_sources/tradeovate_data.py +80 -0
  63. lumibot/data_sources/tradier_data.py +2 -1
  64. lumibot/entities/__pycache__/__init__.cpython-312.pyc +0 -0
  65. lumibot/entities/__pycache__/asset.cpython-312.pyc +0 -0
  66. lumibot/entities/__pycache__/bar.cpython-312.pyc +0 -0
  67. lumibot/entities/__pycache__/bars.cpython-312.pyc +0 -0
  68. lumibot/entities/__pycache__/chains.cpython-312.pyc +0 -0
  69. lumibot/entities/__pycache__/data.cpython-312.pyc +0 -0
  70. lumibot/entities/__pycache__/dataline.cpython-312.pyc +0 -0
  71. lumibot/entities/__pycache__/order.cpython-312.pyc +0 -0
  72. lumibot/entities/__pycache__/position.cpython-312.pyc +0 -0
  73. lumibot/entities/__pycache__/quote.cpython-312.pyc +0 -0
  74. lumibot/entities/__pycache__/trading_fee.cpython-312.pyc +0 -0
  75. lumibot/entities/asset.py +8 -0
  76. lumibot/entities/order.py +1 -1
  77. lumibot/entities/quote.py +14 -0
  78. lumibot/example_strategies/__pycache__/__init__.cpython-312.pyc +0 -0
  79. lumibot/example_strategies/__pycache__/test_broker_functions.cpython-312-pytest-8.4.1.pyc +0 -0
  80. lumibot/strategies/__pycache__/__init__.cpython-312.pyc +0 -0
  81. lumibot/strategies/__pycache__/_strategy.cpython-312.pyc +0 -0
  82. lumibot/strategies/__pycache__/strategy.cpython-312.pyc +0 -0
  83. lumibot/strategies/__pycache__/strategy_executor.cpython-312.pyc +0 -0
  84. lumibot/strategies/_strategy.py +95 -27
  85. lumibot/strategies/strategy.py +5 -6
  86. lumibot/strategies/strategy_executor.py +2 -2
  87. lumibot/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  88. lumibot/tools/__pycache__/alpaca_helpers.cpython-312.pyc +0 -0
  89. lumibot/tools/__pycache__/bitunix_helpers.cpython-312.pyc +0 -0
  90. lumibot/tools/__pycache__/black_scholes.cpython-312.pyc +0 -0
  91. lumibot/tools/__pycache__/ccxt_data_store.cpython-312.pyc +0 -0
  92. lumibot/tools/__pycache__/databento_helper.cpython-312.pyc +0 -0
  93. lumibot/tools/__pycache__/databento_helper_polars.cpython-312.pyc +0 -0
  94. lumibot/tools/__pycache__/debugers.cpython-312.pyc +0 -0
  95. lumibot/tools/__pycache__/decorators.cpython-312.pyc +0 -0
  96. lumibot/tools/__pycache__/helpers.cpython-312.pyc +0 -0
  97. lumibot/tools/__pycache__/indicators.cpython-312.pyc +0 -0
  98. lumibot/tools/__pycache__/lumibot_logger.cpython-312.pyc +0 -0
  99. lumibot/tools/__pycache__/pandas.cpython-312.pyc +0 -0
  100. lumibot/tools/__pycache__/polygon_helper.cpython-312.pyc +0 -0
  101. lumibot/tools/__pycache__/polygon_helper_async.cpython-312.pyc +0 -0
  102. lumibot/tools/__pycache__/polygon_helper_polars_optimized.cpython-312.pyc +0 -0
  103. lumibot/tools/__pycache__/projectx_helpers.cpython-312.pyc +0 -0
  104. lumibot/tools/__pycache__/schwab_helper.cpython-312.pyc +0 -0
  105. lumibot/tools/__pycache__/thetadata_helper.cpython-312.pyc +0 -0
  106. lumibot/tools/__pycache__/types.cpython-312.pyc +0 -0
  107. lumibot/tools/__pycache__/yahoo_helper.cpython-312.pyc +0 -0
  108. lumibot/tools/__pycache__/yahoo_helper_polars_optimized.cpython-312.pyc +0 -0
  109. lumibot/tools/databento_helper.py +384 -133
  110. lumibot/tools/databento_helper_polars.py +218 -156
  111. lumibot/tools/databento_roll.py +216 -0
  112. lumibot/tools/lumibot_logger.py +32 -17
  113. lumibot/tools/polygon_helper.py +65 -0
  114. lumibot/tools/thetadata_helper.py +588 -70
  115. lumibot/traders/__pycache__/__init__.cpython-312.pyc +0 -0
  116. lumibot/traders/__pycache__/trader.cpython-312.pyc +0 -0
  117. lumibot/traders/trader.py +1 -1
  118. lumibot/trading_builtins/__pycache__/__init__.cpython-312.pyc +0 -0
  119. lumibot/trading_builtins/__pycache__/custom_stream.cpython-312.pyc +0 -0
  120. lumibot/trading_builtins/__pycache__/safe_list.cpython-312.pyc +0 -0
  121. {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/METADATA +1 -2
  122. {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/RECORD +160 -44
  123. tests/backtest/check_timing_offset.py +198 -0
  124. tests/backtest/check_volume_spike.py +112 -0
  125. tests/backtest/comprehensive_comparison.py +166 -0
  126. tests/backtest/debug_comparison.py +91 -0
  127. tests/backtest/diagnose_price_difference.py +97 -0
  128. tests/backtest/direct_api_comparison.py +203 -0
  129. tests/backtest/profile_thetadata_vs_polygon.py +255 -0
  130. tests/backtest/root_cause_analysis.py +109 -0
  131. tests/backtest/test_accuracy_verification.py +244 -0
  132. tests/backtest/test_daily_data_timestamp_comparison.py +801 -0
  133. tests/backtest/test_databento.py +4 -0
  134. tests/backtest/test_databento_comprehensive_trading.py +564 -0
  135. tests/backtest/test_debug_avg_fill_price.py +112 -0
  136. tests/backtest/test_dividends.py +8 -3
  137. tests/backtest/test_example_strategies.py +54 -47
  138. tests/backtest/test_futures_edge_cases.py +451 -0
  139. tests/backtest/test_futures_single_trade.py +270 -0
  140. tests/backtest/test_futures_ultra_simple.py +191 -0
  141. tests/backtest/test_index_data_verification.py +348 -0
  142. tests/backtest/test_polygon.py +45 -24
  143. tests/backtest/test_thetadata.py +246 -60
  144. tests/backtest/test_thetadata_comprehensive.py +729 -0
  145. tests/backtest/test_thetadata_vs_polygon.py +557 -0
  146. tests/backtest/test_yahoo.py +1 -2
  147. tests/conftest.py +20 -0
  148. tests/test_backtesting_data_source_env.py +249 -0
  149. tests/test_backtesting_quiet_logs_complete.py +10 -11
  150. tests/test_databento_helper.py +73 -86
  151. tests/test_databento_timezone_fixes.py +21 -4
  152. tests/test_get_historical_prices.py +6 -6
  153. tests/test_options_helper.py +162 -40
  154. tests/test_polygon_helper.py +21 -13
  155. tests/test_quiet_logs_requirements.py +5 -5
  156. tests/test_thetadata_helper.py +487 -171
  157. tests/test_yahoo_data.py +125 -0
  158. {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/LICENSE +0 -0
  159. {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/WHEEL +0 -0
  160. {lumibot-4.0.23.dist-info → lumibot-4.1.0.dist-info}/top_level.txt +0 -0
@@ -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 - use pandas version (polars version is slow)
12
- try:
13
- from .databento_backtesting import DataBentoDataBacktesting
14
- except ImportError:
15
- from .databento_backtesting_polars import DataBentoDataBacktestingPolars as DataBentoDataBacktesting
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
@@ -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 time inside a valid trading day, use that time
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
- open_time = self.data_source.datetime_start
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
- canceled_identifiers.add(order.identifier)
503
- self._process_trade_event(order, self.CANCELED_ORDER)
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
- if (
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 crypto quote processing for crypto+forex trades that are handled with direct cash updates."""
968
- # Check if this is a crypto+forex trade
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
- # For crypto+forex trades, skip position-based quote processing since we handle cash directly
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
- # For crypto+crypto trades, use the original position-based processing
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
- # For backtesting, we get the price at the current simulation time
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
- return float(last_price)
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 if we need to fetch data by calling _update_pandas_data first
480
- # This will only fetch if data is not already cached or prefetched
481
- self._update_pandas_data(asset, quote, length, timestep)
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
- # Return DataFrame directly like other data sources
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