lumibot 4.1.3__py3-none-any.whl → 4.2.1__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 (163) hide show
  1. lumibot/backtesting/__init__.py +19 -5
  2. lumibot/backtesting/backtesting_broker.py +98 -18
  3. lumibot/backtesting/databento_backtesting.py +5 -686
  4. lumibot/backtesting/databento_backtesting_pandas.py +738 -0
  5. lumibot/backtesting/databento_backtesting_polars.py +860 -546
  6. lumibot/backtesting/fix_debug.py +37 -0
  7. lumibot/backtesting/thetadata_backtesting.py +9 -355
  8. lumibot/backtesting/thetadata_backtesting_pandas.py +1167 -0
  9. lumibot/brokers/alpaca.py +8 -1
  10. lumibot/brokers/schwab.py +12 -2
  11. lumibot/credentials.py +13 -0
  12. lumibot/data_sources/__init__.py +5 -8
  13. lumibot/data_sources/data_source.py +6 -2
  14. lumibot/data_sources/data_source_backtesting.py +30 -0
  15. lumibot/data_sources/databento_data.py +5 -390
  16. lumibot/data_sources/databento_data_pandas.py +440 -0
  17. lumibot/data_sources/databento_data_polars.py +15 -9
  18. lumibot/data_sources/pandas_data.py +30 -17
  19. lumibot/data_sources/polars_data.py +986 -0
  20. lumibot/data_sources/polars_mixin.py +472 -96
  21. lumibot/data_sources/polygon_data_polars.py +5 -0
  22. lumibot/data_sources/yahoo_data.py +9 -2
  23. lumibot/data_sources/yahoo_data_polars.py +5 -0
  24. lumibot/entities/__init__.py +15 -0
  25. lumibot/entities/asset.py +5 -28
  26. lumibot/entities/bars.py +89 -20
  27. lumibot/entities/data.py +29 -6
  28. lumibot/entities/data_polars.py +668 -0
  29. lumibot/entities/position.py +38 -4
  30. lumibot/strategies/_strategy.py +2 -1
  31. lumibot/strategies/strategy.py +61 -49
  32. lumibot/tools/backtest_cache.py +284 -0
  33. lumibot/tools/databento_helper.py +35 -35
  34. lumibot/tools/databento_helper_polars.py +738 -775
  35. lumibot/tools/futures_roll.py +251 -0
  36. lumibot/tools/indicators.py +135 -104
  37. lumibot/tools/polars_utils.py +142 -0
  38. lumibot/tools/thetadata_helper.py +1068 -134
  39. {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/METADATA +9 -1
  40. {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/RECORD +71 -147
  41. tests/backtest/test_databento.py +37 -6
  42. tests/backtest/test_databento_comprehensive_trading.py +8 -4
  43. tests/backtest/test_databento_parity.py +4 -2
  44. tests/backtest/test_debug_avg_fill_price.py +1 -1
  45. tests/backtest/test_example_strategies.py +11 -1
  46. tests/backtest/test_futures_edge_cases.py +3 -3
  47. tests/backtest/test_futures_single_trade.py +2 -2
  48. tests/backtest/test_futures_ultra_simple.py +2 -2
  49. tests/backtest/test_polars_lru_eviction.py +470 -0
  50. tests/backtest/test_yahoo.py +42 -0
  51. tests/test_asset.py +4 -4
  52. tests/test_backtest_cache_manager.py +149 -0
  53. tests/test_backtesting_data_source_env.py +6 -0
  54. tests/test_continuous_futures_resolution.py +60 -48
  55. tests/test_data_polars_parity.py +160 -0
  56. tests/test_databento_asset_validation.py +23 -5
  57. tests/test_databento_backtesting.py +1 -1
  58. tests/test_databento_backtesting_polars.py +312 -192
  59. tests/test_databento_data.py +220 -463
  60. tests/test_databento_live.py +10 -10
  61. tests/test_futures_roll.py +38 -0
  62. tests/test_indicator_subplots.py +101 -0
  63. tests/test_market_infinite_loop_bug.py +77 -3
  64. tests/test_polars_resample.py +67 -0
  65. tests/test_polygon_helper.py +46 -0
  66. tests/test_thetadata_backwards_compat.py +97 -0
  67. tests/test_thetadata_helper.py +222 -23
  68. tests/test_thetadata_pandas_verification.py +186 -0
  69. lumibot/__pycache__/__init__.cpython-312.pyc +0 -0
  70. lumibot/__pycache__/constants.cpython-312.pyc +0 -0
  71. lumibot/__pycache__/credentials.cpython-312.pyc +0 -0
  72. lumibot/backtesting/__pycache__/__init__.cpython-312.pyc +0 -0
  73. lumibot/backtesting/__pycache__/alpaca_backtesting.cpython-312.pyc +0 -0
  74. lumibot/backtesting/__pycache__/alpha_vantage_backtesting.cpython-312.pyc +0 -0
  75. lumibot/backtesting/__pycache__/backtesting_broker.cpython-312.pyc +0 -0
  76. lumibot/backtesting/__pycache__/ccxt_backtesting.cpython-312.pyc +0 -0
  77. lumibot/backtesting/__pycache__/databento_backtesting.cpython-312.pyc +0 -0
  78. lumibot/backtesting/__pycache__/interactive_brokers_rest_backtesting.cpython-312.pyc +0 -0
  79. lumibot/backtesting/__pycache__/pandas_backtesting.cpython-312.pyc +0 -0
  80. lumibot/backtesting/__pycache__/polygon_backtesting.cpython-312.pyc +0 -0
  81. lumibot/backtesting/__pycache__/thetadata_backtesting.cpython-312.pyc +0 -0
  82. lumibot/backtesting/__pycache__/yahoo_backtesting.cpython-312.pyc +0 -0
  83. lumibot/brokers/__pycache__/__init__.cpython-312.pyc +0 -0
  84. lumibot/brokers/__pycache__/alpaca.cpython-312.pyc +0 -0
  85. lumibot/brokers/__pycache__/bitunix.cpython-312.pyc +0 -0
  86. lumibot/brokers/__pycache__/broker.cpython-312.pyc +0 -0
  87. lumibot/brokers/__pycache__/ccxt.cpython-312.pyc +0 -0
  88. lumibot/brokers/__pycache__/example_broker.cpython-312.pyc +0 -0
  89. lumibot/brokers/__pycache__/interactive_brokers.cpython-312.pyc +0 -0
  90. lumibot/brokers/__pycache__/interactive_brokers_rest.cpython-312.pyc +0 -0
  91. lumibot/brokers/__pycache__/projectx.cpython-312.pyc +0 -0
  92. lumibot/brokers/__pycache__/schwab.cpython-312.pyc +0 -0
  93. lumibot/brokers/__pycache__/tradier.cpython-312.pyc +0 -0
  94. lumibot/brokers/__pycache__/tradovate.cpython-312.pyc +0 -0
  95. lumibot/data_sources/__pycache__/__init__.cpython-312.pyc +0 -0
  96. lumibot/data_sources/__pycache__/alpaca_data.cpython-312.pyc +0 -0
  97. lumibot/data_sources/__pycache__/alpha_vantage_data.cpython-312.pyc +0 -0
  98. lumibot/data_sources/__pycache__/bitunix_data.cpython-312.pyc +0 -0
  99. lumibot/data_sources/__pycache__/ccxt_backtesting_data.cpython-312.pyc +0 -0
  100. lumibot/data_sources/__pycache__/ccxt_data.cpython-312.pyc +0 -0
  101. lumibot/data_sources/__pycache__/data_source.cpython-312.pyc +0 -0
  102. lumibot/data_sources/__pycache__/data_source_backtesting.cpython-312.pyc +0 -0
  103. lumibot/data_sources/__pycache__/databento_data_polars_backtesting.cpython-312.pyc +0 -0
  104. lumibot/data_sources/__pycache__/databento_data_polars_live.cpython-312.pyc +0 -0
  105. lumibot/data_sources/__pycache__/example_broker_data.cpython-312.pyc +0 -0
  106. lumibot/data_sources/__pycache__/exceptions.cpython-312.pyc +0 -0
  107. lumibot/data_sources/__pycache__/interactive_brokers_data.cpython-312.pyc +0 -0
  108. lumibot/data_sources/__pycache__/interactive_brokers_rest_data.cpython-312.pyc +0 -0
  109. lumibot/data_sources/__pycache__/pandas_data.cpython-312.pyc +0 -0
  110. lumibot/data_sources/__pycache__/polars_mixin.cpython-312.pyc +0 -0
  111. lumibot/data_sources/__pycache__/polygon_data_polars.cpython-312.pyc +0 -0
  112. lumibot/data_sources/__pycache__/projectx_data.cpython-312.pyc +0 -0
  113. lumibot/data_sources/__pycache__/schwab_data.cpython-312.pyc +0 -0
  114. lumibot/data_sources/__pycache__/tradier_data.cpython-312.pyc +0 -0
  115. lumibot/data_sources/__pycache__/tradovate_data.cpython-312.pyc +0 -0
  116. lumibot/data_sources/__pycache__/yahoo_data_polars.cpython-312.pyc +0 -0
  117. lumibot/entities/__pycache__/__init__.cpython-312.pyc +0 -0
  118. lumibot/entities/__pycache__/asset.cpython-312.pyc +0 -0
  119. lumibot/entities/__pycache__/bar.cpython-312.pyc +0 -0
  120. lumibot/entities/__pycache__/bars.cpython-312.pyc +0 -0
  121. lumibot/entities/__pycache__/chains.cpython-312.pyc +0 -0
  122. lumibot/entities/__pycache__/data.cpython-312.pyc +0 -0
  123. lumibot/entities/__pycache__/dataline.cpython-312.pyc +0 -0
  124. lumibot/entities/__pycache__/order.cpython-312.pyc +0 -0
  125. lumibot/entities/__pycache__/position.cpython-312.pyc +0 -0
  126. lumibot/entities/__pycache__/quote.cpython-312.pyc +0 -0
  127. lumibot/entities/__pycache__/trading_fee.cpython-312.pyc +0 -0
  128. lumibot/example_strategies/__pycache__/__init__.cpython-312.pyc +0 -0
  129. lumibot/example_strategies/__pycache__/test_broker_functions.cpython-312-pytest-8.4.1.pyc +0 -0
  130. lumibot/strategies/__pycache__/__init__.cpython-312.pyc +0 -0
  131. lumibot/strategies/__pycache__/_strategy.cpython-312.pyc +0 -0
  132. lumibot/strategies/__pycache__/strategy.cpython-312.pyc +0 -0
  133. lumibot/strategies/__pycache__/strategy_executor.cpython-312.pyc +0 -0
  134. lumibot/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  135. lumibot/tools/__pycache__/alpaca_helpers.cpython-312.pyc +0 -0
  136. lumibot/tools/__pycache__/bitunix_helpers.cpython-312.pyc +0 -0
  137. lumibot/tools/__pycache__/black_scholes.cpython-312.pyc +0 -0
  138. lumibot/tools/__pycache__/ccxt_data_store.cpython-312.pyc +0 -0
  139. lumibot/tools/__pycache__/databento_helper.cpython-312.pyc +0 -0
  140. lumibot/tools/__pycache__/databento_helper_polars.cpython-312.pyc +0 -0
  141. lumibot/tools/__pycache__/debugers.cpython-312.pyc +0 -0
  142. lumibot/tools/__pycache__/decorators.cpython-312.pyc +0 -0
  143. lumibot/tools/__pycache__/helpers.cpython-312.pyc +0 -0
  144. lumibot/tools/__pycache__/indicators.cpython-312.pyc +0 -0
  145. lumibot/tools/__pycache__/lumibot_logger.cpython-312.pyc +0 -0
  146. lumibot/tools/__pycache__/pandas.cpython-312.pyc +0 -0
  147. lumibot/tools/__pycache__/polygon_helper.cpython-312.pyc +0 -0
  148. lumibot/tools/__pycache__/polygon_helper_async.cpython-312.pyc +0 -0
  149. lumibot/tools/__pycache__/polygon_helper_polars_optimized.cpython-312.pyc +0 -0
  150. lumibot/tools/__pycache__/projectx_helpers.cpython-312.pyc +0 -0
  151. lumibot/tools/__pycache__/schwab_helper.cpython-312.pyc +0 -0
  152. lumibot/tools/__pycache__/thetadata_helper.cpython-312.pyc +0 -0
  153. lumibot/tools/__pycache__/types.cpython-312.pyc +0 -0
  154. lumibot/tools/__pycache__/yahoo_helper.cpython-312.pyc +0 -0
  155. lumibot/tools/__pycache__/yahoo_helper_polars_optimized.cpython-312.pyc +0 -0
  156. lumibot/traders/__pycache__/__init__.cpython-312.pyc +0 -0
  157. lumibot/traders/__pycache__/trader.cpython-312.pyc +0 -0
  158. lumibot/trading_builtins/__pycache__/__init__.cpython-312.pyc +0 -0
  159. lumibot/trading_builtins/__pycache__/custom_stream.cpython-312.pyc +0 -0
  160. lumibot/trading_builtins/__pycache__/safe_list.cpython-312.pyc +0 -0
  161. {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/WHEEL +0 -0
  162. {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/licenses/LICENSE +0 -0
  163. {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  from decimal import Decimal
2
2
 
3
3
  import lumibot.entities as entities
4
+ from lumibot.entities.asset import StrEnum #todo: this should be centralized, and not repeated in Asset and Position
4
5
 
5
6
 
6
7
  class Position:
@@ -26,8 +27,36 @@ class Position:
26
27
  The assets that are free in the portfolio. (Crypto: only)
27
28
  avg_fill_price : float
28
29
  The average fill price of the position.
30
+ current_price : float
31
+ The current price of the asset.
32
+ market_value : float
33
+ The market value of the position.
34
+ pnl : float
35
+ The profit and loss of the position.
36
+ pnl_percent : float
37
+ The profit and loss of the position as a percentage of the average fill price.
38
+ asset_type : str
39
+ The type of the asset.
40
+ exchange : str
41
+ The exchange that the position is on.
42
+ currency : str
43
+ The currency that the position is denominated in.
44
+ multiplier : float
45
+ The multiplier of the asset.
46
+ expiration : datetime.date
47
+ The expiration of the asset. (Options and futures: only). Probably better to use on position.asset
48
+ strike : float
49
+ The strike price of the asset. (Options: only). Probably better to use on position.asset
50
+ option_type : str
51
+ The type of the option. (Options: only). Probably better to use on position.asset
52
+ side : PositionSide
53
+ The side of the position (LONG or SHORT)
29
54
  """
30
55
 
56
+ class PositionSide(StrEnum):
57
+ LONG = "LONG"
58
+ SHORT = "SHORT"
59
+
31
60
  def __init__(
32
61
  self,
33
62
  strategy,
@@ -38,6 +67,11 @@ class Position:
38
67
  available=0,
39
68
  avg_fill_price=None
40
69
  ):
70
+ """Creates a position.
71
+
72
+ NOTE: There are some properties that can be assigned to a position entity outside of the constructor (pnl, current_price, etc)
73
+
74
+ """
41
75
  self.strategy = strategy
42
76
  self.asset = asset
43
77
  self.symbol = self.asset.symbol
@@ -223,13 +257,13 @@ class Position:
223
257
  result['currency'] = self.currency
224
258
  if hasattr(self, 'multiplier'):
225
259
  result['multiplier'] = self.multiplier
226
- if hasattr(self, 'expiration'):
260
+ if hasattr(self, 'expiration'): #should probably use position.asset instead
227
261
  result['expiration'] = str(self.expiration) if self.expiration else None
228
- if hasattr(self, 'strike'):
262
+ if hasattr(self, 'strike'): #should probably use position.asset instead
229
263
  result['strike'] = float(self.strike) if self.strike else None
230
- if hasattr(self, 'option_type'):
264
+ if hasattr(self, 'option_type'): #should probably use position.asset instead
231
265
  result['option_type'] = self.option_type
232
- if hasattr(self, 'underlying_symbol'):
266
+ if hasattr(self, 'underlying_symbol'): #should probably use position.asset instead
233
267
  result['underlying_symbol'] = self.underlying_symbol
234
268
 
235
269
  # Handle orders carefully - ensure to_dict() is called properly
@@ -819,7 +819,7 @@ class _Strategy:
819
819
 
820
820
  assets = []
821
821
  for position in positions:
822
- if position.asset != self._quote_asset:
822
+ if position.asset != self._quote_asset and position.asset.asset_type != "option":
823
823
  assets.append(position.asset)
824
824
 
825
825
  # Early return if no assets - avoid expensive dividend API calls
@@ -827,6 +827,7 @@ class _Strategy:
827
827
  return self.cash
828
828
 
829
829
  dividends_per_share = self.get_yesterday_dividends(assets)
830
+
830
831
  for position in positions:
831
832
  asset = position.asset
832
833
  quantity = position.quantity
@@ -19,6 +19,7 @@ from termcolor import colored, COLORS
19
19
  from ..data_sources import DataSource
20
20
  from ..entities import Asset, Data, Order, Position, Quote, TradingFee
21
21
  from ..tools import get_risk_free_rate
22
+ from ..tools.polars_utils import PolarsResampleError, resample_polars_ohlc
22
23
  from ..traders import Trader
23
24
  from ._strategy import _Strategy
24
25
 
@@ -3353,7 +3354,7 @@ class Strategy(_Strategy):
3353
3354
  asset: Union[Asset, str],
3354
3355
  length: int,
3355
3356
  timestep: str = "",
3356
- timeshift: datetime.timedelta = None,
3357
+ timeshift: Union[int, datetime.timedelta, None] = None,
3357
3358
  quote: Asset = None,
3358
3359
  exchange: str = None,
3359
3360
  include_after_hours: bool = True,
@@ -3388,10 +3389,12 @@ class Strategy(_Strategy):
3388
3389
  When using multi-timeframe formats, the method automatically fetches the
3389
3390
  underlying minute or day data and resamples it to your desired timeframe.
3390
3391
  Default value depends on the data_source (minute for alpaca, day for yahoo, ...)
3391
- timeshift : timedelta
3392
- ``None`` by default. If specified indicates the time shift from
3393
- the present. If backtesting in Pandas, use integer representing
3394
- number of bars.
3392
+ timeshift : int, timedelta, or None
3393
+ ``None`` by default. When provided it shifts the data window relative to
3394
+ the current backtest time.
3395
+
3396
+ - Passing an ``int`` shifts by bars (positive = past, negative = future).
3397
+ - Passing a ``timedelta`` shifts by wall-clock time (e.g. ``timedelta(hours=-1)``).
3395
3398
  quote : Asset
3396
3399
  The quote currency for crypto currencies (e.g. USD, USDT, EUR, ...).
3397
3400
  Default is the quote asset for the strategy.
@@ -3517,6 +3520,7 @@ class Strategy(_Strategy):
3517
3520
  asset = self.crypto_assets_to_tuple(asset, quote)
3518
3521
  if not actual_timestep:
3519
3522
  actual_timestep = self.broker.data_source.get_timestep()
3523
+ effective_return_polars = return_polars
3520
3524
  # Call through to the appropriate data source. Only pass `return_polars` if supported
3521
3525
  # to maintain compatibility with live data sources that don't yet accept it.
3522
3526
  import inspect
@@ -3540,7 +3544,7 @@ class Strategy(_Strategy):
3540
3544
  return fn(
3541
3545
  asset,
3542
3546
  actual_length, # Use the actual length for fetching
3543
- return_polars=return_polars,
3547
+ return_polars=effective_return_polars,
3544
3548
  **common_kwargs,
3545
3549
  )
3546
3550
  else:
@@ -3557,49 +3561,57 @@ class Strategy(_Strategy):
3557
3561
  bars = _call_get_hist(self.broker.data_source)
3558
3562
 
3559
3563
  # If we need to resample the data
3560
- if needs_resampling and bars and bars.df is not None and not bars.df.empty:
3561
- try:
3562
- # Import pandas for resampling
3563
- import pandas as pd
3564
- from lumibot.entities import Bars
3565
-
3566
- # Get the dataframe
3567
- df = bars.df.copy()
3568
-
3569
- # Ensure datetime index
3570
- if not isinstance(df.index, pd.DatetimeIndex):
3571
- # Try to convert the index to datetime
3572
- df.index = pd.to_datetime(df.index)
3573
-
3574
- # Determine resampling rule
3575
- if base_unit == "minute":
3576
- resample_rule = f'{multiplier}min' # 'min' for minutes (pandas 2.0+ compatible)
3577
- elif base_unit == "day":
3578
- resample_rule = f'{multiplier}D' # D for days
3579
- else:
3580
- # Fallback to original if we can't determine the rule
3581
- return bars
3582
-
3583
- # Perform resampling with proper aggregation
3584
- resampled = df.resample(resample_rule, label='left', closed='left').agg({
3585
- 'open': 'first',
3586
- 'high': 'max',
3587
- 'low': 'min',
3588
- 'close': 'last',
3589
- 'volume': 'sum'
3590
- }).dropna()
3591
-
3592
- # Limit to requested length
3593
- if len(resampled) > length:
3594
- resampled = resampled.iloc[-length:]
3595
-
3596
- # Create new Bars object with resampled data
3597
- bars.df = resampled
3598
- bars.raw_data = resampled # Update raw_data as well if it exists
3599
-
3600
- except Exception as e:
3601
- # If resampling fails, log warning and return original data
3602
- self.logger.warning(f"Failed to resample data from {actual_timestep} to {original_timestep}: {e}")
3564
+ if needs_resampling and bars and len(bars) > 0:
3565
+ resampled_with_polars = False
3566
+ if return_polars:
3567
+ try:
3568
+ polars_frame = bars.polars_df
3569
+ resampled_frame = resample_polars_ohlc(polars_frame, multiplier, base_unit, length)
3570
+ if resampled_frame is not None and not resampled_frame.is_empty():
3571
+ bars.df = resampled_frame
3572
+ resampled_with_polars = True
3573
+ except PolarsResampleError as exc:
3574
+ self.logger.debug(
3575
+ "Unsupported polars resample for %s (%s); falling back to pandas.",
3576
+ asset,
3577
+ exc,
3578
+ )
3579
+ except Exception as exc:
3580
+ self.logger.warning(
3581
+ "Polars resample failed for %s; falling back to pandas. Error: %s",
3582
+ asset,
3583
+ exc,
3584
+ )
3585
+ if not resampled_with_polars:
3586
+ try:
3587
+ import pandas as pd
3588
+
3589
+ df = bars.pandas_df.copy()
3590
+ if not isinstance(df.index, pd.DatetimeIndex):
3591
+ df.index = pd.to_datetime(df.index)
3592
+
3593
+ if base_unit == "minute":
3594
+ resample_rule = f"{multiplier}min"
3595
+ elif base_unit == "day":
3596
+ resample_rule = f"{multiplier}D"
3597
+ else:
3598
+ return bars
3599
+
3600
+ resampled_df = df.resample(resample_rule, label='left', closed='left').agg({
3601
+ 'open': 'first',
3602
+ 'high': 'max',
3603
+ 'low': 'min',
3604
+ 'close': 'last',
3605
+ 'volume': 'sum'
3606
+ }).dropna()
3607
+
3608
+ if len(resampled_df) > length:
3609
+ resampled_df = resampled_df.iloc[-length:]
3610
+
3611
+ bars.df = resampled_df
3612
+ except Exception as e:
3613
+ # If resampling fails, log warning and return original data
3614
+ self.logger.warning(f"Failed to resample data from {actual_timestep} to {original_timestep}: {e}")
3603
3615
 
3604
3616
  return bars
3605
3617
 
@@ -0,0 +1,284 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import threading
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Callable, Dict, Optional
9
+
10
+ from lumibot.constants import LUMIBOT_CACHE_FOLDER
11
+ from lumibot.credentials import CACHE_REMOTE_CONFIG
12
+ from lumibot.tools.lumibot_logger import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class CacheMode(str, Enum):
18
+ DISABLED = "disabled"
19
+ S3_READWRITE = "s3_readwrite"
20
+ S3_READONLY = "s3_readonly"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class BacktestCacheSettings:
25
+ backend: str
26
+ mode: CacheMode
27
+ bucket: Optional[str] = None
28
+ prefix: str = ""
29
+ region: Optional[str] = None
30
+ access_key_id: Optional[str] = None
31
+ secret_access_key: Optional[str] = None
32
+ session_token: Optional[str] = None
33
+ version: str = "v1"
34
+
35
+ @staticmethod
36
+ def from_env(env: Dict[str, Optional[str]]) -> Optional["BacktestCacheSettings"]:
37
+ backend = (env.get("backend") or "local").strip().lower()
38
+ mode_raw = (env.get("mode") or "disabled").strip().lower()
39
+
40
+ if backend != "s3":
41
+ return None
42
+
43
+ if mode_raw in ("disabled", "off", "local"):
44
+ return None
45
+
46
+ if mode_raw in ("readwrite", "rw", "s3_readwrite"):
47
+ mode = CacheMode.S3_READWRITE
48
+ elif mode_raw in ("readonly", "ro", "s3_readonly"):
49
+ mode = CacheMode.S3_READONLY
50
+ else:
51
+ raise ValueError(
52
+ f"Unsupported LUMIBOT_CACHE_MODE '{mode_raw}'. "
53
+ "Expected one of: disabled, readwrite, readonly."
54
+ )
55
+
56
+ bucket = (env.get("s3_bucket") or "").strip()
57
+ if not bucket:
58
+ raise ValueError("LUMIBOT_CACHE_S3_BUCKET must be set when using the S3 cache backend.")
59
+
60
+ prefix = (env.get("s3_prefix") or "").strip().strip("/")
61
+ region = (env.get("s3_region") or "").strip() or None
62
+ access_key_id = (env.get("s3_access_key_id") or "").strip() or None
63
+ secret_access_key = (env.get("s3_secret_access_key") or "").strip() or None
64
+ session_token = (env.get("s3_session_token") or "").strip() or None
65
+ version = (env.get("s3_version") or "v1").strip().strip("/")
66
+
67
+ if not version:
68
+ version = "v1"
69
+
70
+ return BacktestCacheSettings(
71
+ backend=backend,
72
+ mode=mode,
73
+ bucket=bucket,
74
+ prefix=prefix,
75
+ region=region,
76
+ access_key_id=access_key_id,
77
+ secret_access_key=secret_access_key,
78
+ session_token=session_token,
79
+ version=version,
80
+ )
81
+
82
+
83
+ class _StubbedS3ErrorCodes:
84
+ NOT_FOUND = {"404", "400", "NoSuchKey", "NotFound"}
85
+
86
+
87
+ class BacktestCacheManager:
88
+ def __init__(
89
+ self,
90
+ settings: Optional[BacktestCacheSettings],
91
+ client_factory: Optional[Callable[[BacktestCacheSettings], object]] = None,
92
+ ) -> None:
93
+ self._settings = settings
94
+ self._client_factory = client_factory
95
+ self._client = None
96
+ self._client_lock = threading.Lock()
97
+
98
+ @property
99
+ def enabled(self) -> bool:
100
+ return bool(self._settings and self._settings.mode != CacheMode.DISABLED)
101
+
102
+ @property
103
+ def mode(self) -> CacheMode:
104
+ if not self.enabled:
105
+ return CacheMode.DISABLED
106
+ return self._settings.mode # type: ignore[return-value]
107
+
108
+ def ensure_local_file(
109
+ self,
110
+ local_path: Path,
111
+ payload: Optional[Dict[str, object]] = None,
112
+ force_download: bool = False,
113
+ ) -> bool:
114
+ if not self.enabled:
115
+ return False
116
+
117
+ if not isinstance(local_path, Path):
118
+ local_path = Path(local_path)
119
+
120
+ if local_path.exists() and not force_download:
121
+ return False
122
+
123
+ remote_key = self.remote_key_for(local_path, payload)
124
+ if remote_key is None:
125
+ return False
126
+
127
+ client = self._get_client()
128
+ tmp_path = local_path.with_suffix(local_path.suffix + ".s3tmp")
129
+ local_path.parent.mkdir(parents=True, exist_ok=True)
130
+
131
+ try:
132
+ client.download_file(self._settings.bucket, remote_key, str(tmp_path))
133
+ os.replace(tmp_path, local_path)
134
+ logger.debug(
135
+ "[REMOTE_CACHE][DOWNLOAD] %s -> %s", remote_key, local_path.as_posix()
136
+ )
137
+ return True
138
+ except Exception as exc: # pragma: no cover - narrow in helper
139
+ if tmp_path.exists():
140
+ tmp_path.unlink(missing_ok=True) # type: ignore[attr-defined]
141
+ if self._is_not_found_error(exc):
142
+ logger.debug(
143
+ "[REMOTE_CACHE][MISS] %s (reason=%s)", remote_key, self._describe_error(exc)
144
+ )
145
+ return False
146
+ raise
147
+
148
+ def on_local_update(
149
+ self,
150
+ local_path: Path,
151
+ payload: Optional[Dict[str, object]] = None,
152
+ ) -> bool:
153
+ if not self.enabled or self.mode != CacheMode.S3_READWRITE:
154
+ return False
155
+
156
+ if not isinstance(local_path, Path):
157
+ local_path = Path(local_path)
158
+
159
+ if not local_path.exists():
160
+ logger.warning(
161
+ "[REMOTE_CACHE][UPLOAD_SKIP] Local file %s does not exist.", local_path.as_posix()
162
+ )
163
+ return False
164
+
165
+ remote_key = self.remote_key_for(local_path, payload)
166
+ if remote_key is None:
167
+ return False
168
+
169
+ client = self._get_client()
170
+ client.upload_file(str(local_path), self._settings.bucket, remote_key)
171
+ logger.debug(
172
+ "[REMOTE_CACHE][UPLOAD] %s <- %s", remote_key, local_path.as_posix()
173
+ )
174
+ return True
175
+
176
+ def remote_key_for(
177
+ self,
178
+ local_path: Path,
179
+ payload: Optional[Dict[str, object]] = None,
180
+ ) -> Optional[str]:
181
+ if not self.enabled:
182
+ return None
183
+
184
+ if not isinstance(local_path, Path):
185
+ local_path = Path(local_path)
186
+
187
+ try:
188
+ relative_path = local_path.resolve().relative_to(Path(LUMIBOT_CACHE_FOLDER).resolve())
189
+ except ValueError:
190
+ logger.debug(
191
+ "[REMOTE_CACHE][SKIP] %s is outside the cache root.", local_path.as_posix()
192
+ )
193
+ return None
194
+
195
+ components = [
196
+ self._settings.prefix if self._settings and self._settings.prefix else None,
197
+ self._settings.version if self._settings else None,
198
+ relative_path.as_posix(),
199
+ ]
200
+ sanitized = [c.strip("/") for c in components if c]
201
+ remote_key = "/".join(sanitized)
202
+ return remote_key
203
+
204
+ def _get_client(self):
205
+ if not self.enabled:
206
+ raise RuntimeError("Remote cache manager is disabled.")
207
+
208
+ if self._client is None:
209
+ with self._client_lock:
210
+ if self._client is None:
211
+ if self._client_factory:
212
+ self._client = self._client_factory(self._settings)
213
+ else:
214
+ self._client = self._create_s3_client()
215
+ return self._client
216
+
217
+ def _create_s3_client(self):
218
+ try:
219
+ import boto3 # type: ignore
220
+ except ImportError as exc: # pragma: no cover - exercised when boto3 missing
221
+ raise RuntimeError(
222
+ "S3 cache backend requires boto3. Install it or disable the remote cache."
223
+ ) from exc
224
+
225
+ session = boto3.session.Session(
226
+ aws_access_key_id=self._settings.access_key_id,
227
+ aws_secret_access_key=self._settings.secret_access_key,
228
+ aws_session_token=self._settings.session_token,
229
+ region_name=self._settings.region,
230
+ )
231
+ return session.client("s3")
232
+
233
+ @staticmethod
234
+ def _is_not_found_error(exc: Exception) -> bool:
235
+ # Prefer botocore error codes if available
236
+ response = getattr(exc, "response", None)
237
+ if isinstance(response, dict):
238
+ error = response.get("Error") or {}
239
+ code = error.get("Code")
240
+ if isinstance(code, str) and code in _StubbedS3ErrorCodes.NOT_FOUND:
241
+ return True
242
+
243
+ # Handle stubbed errors (FileNotFoundError or message-based)
244
+ if isinstance(exc, FileNotFoundError):
245
+ return True
246
+
247
+ message = str(exc)
248
+ for token in _StubbedS3ErrorCodes.NOT_FOUND:
249
+ if token in message:
250
+ return True
251
+ return False
252
+
253
+ @staticmethod
254
+ def _describe_error(exc: Exception) -> str:
255
+ response = getattr(exc, "response", None)
256
+ if isinstance(response, dict):
257
+ error = response.get("Error") or {}
258
+ code = error.get("Code")
259
+ message = error.get("Message")
260
+ return f"{code}: {message}" if code or message else "unknown"
261
+ return str(exc)
262
+
263
+
264
+ _MANAGER_LOCK = threading.Lock()
265
+ _MANAGER_INSTANCE: Optional[BacktestCacheManager] = None
266
+
267
+
268
+ def get_backtest_cache() -> BacktestCacheManager:
269
+ global _MANAGER_INSTANCE
270
+ if _MANAGER_INSTANCE is None:
271
+ with _MANAGER_LOCK:
272
+ if _MANAGER_INSTANCE is None:
273
+ settings = BacktestCacheSettings.from_env(CACHE_REMOTE_CONFIG)
274
+ _MANAGER_INSTANCE = BacktestCacheManager(settings)
275
+ return _MANAGER_INSTANCE
276
+
277
+
278
+ def reset_backtest_cache_manager(for_testing: bool = False) -> None:
279
+ """Reset the cached manager instance (intended for unit tests)."""
280
+ global _MANAGER_INSTANCE
281
+ with _MANAGER_LOCK:
282
+ _MANAGER_INSTANCE = None
283
+ if not for_testing:
284
+ logger.debug("[REMOTE_CACHE] Manager reset requested.")
@@ -9,12 +9,18 @@ from decimal import Decimal
9
9
  import pandas as pd
10
10
  from lumibot import LUMIBOT_CACHE_FOLDER
11
11
  from lumibot.entities import Asset
12
- from lumibot.tools import databento_roll
12
+ from lumibot.tools import futures_roll
13
+ from termcolor import colored
13
14
 
14
15
  # Set up module-specific logger
15
16
  from lumibot.tools.lumibot_logger import get_logger
16
17
  logger = get_logger(__name__)
17
18
 
19
+
20
+ class DataBentoAuthenticationError(RuntimeError):
21
+ """Raised when DataBento rejects authentication credentials."""
22
+ pass
23
+
18
24
  # DataBento imports (will be installed as dependency)
19
25
  try:
20
26
  import databento as db
@@ -161,11 +167,16 @@ class DataBentoClient:
161
167
  continue
162
168
  else:
163
169
  logger.error(f"DataBento authentication failed after {self.max_retries} retries")
170
+ raise DataBentoAuthenticationError(
171
+ f"DataBento authentication failed after {self.max_retries} retries: {str(e)}"
172
+ ) from e
164
173
 
165
174
  # For non-auth errors, don't retry - fail fast
166
- logger.error("DATABENTO_API_ERROR: DataBento API error: %s | Symbols: %s, Start: %s, End: %s",
167
- str(e), symbols, start, end)
168
- raise e
175
+ logger.error(
176
+ "DATABENTO_API_ERROR: DataBento API error: %s | Symbols: %s, Start: %s, End: %s",
177
+ str(e), symbols, start, end
178
+ )
179
+ raise
169
180
 
170
181
  # This should never be reached, but just in case
171
182
  raise Exception(f"DataBento request failed after {self.max_retries} retries")
@@ -805,8 +816,17 @@ def get_price_data_from_databento(
805
816
 
806
817
  if roll_asset.asset_type == Asset.AssetType.CONT_FUTURE:
807
818
  schedule_start = start
808
- symbols = databento_roll.resolve_symbols_for_range(roll_asset, schedule_start, end)
809
- front_symbol = databento_roll.resolve_symbol_for_datetime(roll_asset, reference_date or start)
819
+ symbols = futures_roll.resolve_symbols_for_range(
820
+ roll_asset,
821
+ schedule_start,
822
+ end,
823
+ year_digits=1,
824
+ )
825
+ front_symbol = futures_roll.resolve_symbol_for_datetime(
826
+ roll_asset,
827
+ reference_date or start,
828
+ year_digits=1,
829
+ )
810
830
  if front_symbol not in symbols:
811
831
  symbols.insert(0, front_symbol)
812
832
  else:
@@ -879,6 +899,13 @@ def get_price_data_from_databento(
879
899
  end=end_naive,
880
900
  **kwargs,
881
901
  )
902
+ except DataBentoAuthenticationError as exc:
903
+ auth_msg = colored(
904
+ f"❌ DataBento authentication failed while requesting {symbol}: {exc}",
905
+ "red"
906
+ )
907
+ logger.error(auth_msg)
908
+ raise
882
909
  except Exception as exc:
883
910
  logger.warning(f"Error fetching {symbol} from DataBento: {exc}")
884
911
  continue
@@ -900,38 +927,11 @@ def get_price_data_from_databento(
900
927
  combined = pd.concat(frames, axis=0)
901
928
  combined.sort_index(inplace=True)
902
929
 
903
- definition_client: Optional[DataBentoClient] = None
904
-
905
- def get_definition(symbol_code: str) -> Optional[Dict]:
906
- nonlocal definition_client
907
- cache_key = (symbol_code, dataset)
908
- if cache_key in _INSTRUMENT_DEFINITION_CACHE:
909
- return _INSTRUMENT_DEFINITION_CACHE[cache_key]
910
- if definition_client is None:
911
- try:
912
- definition_client = DataBentoClient(api_key=api_key)
913
- except Exception as exc:
914
- logger.warning(f"Unable to create DataBento definition client: {exc}")
915
- return None
916
- try:
917
- definition = definition_client.get_instrument_definition(
918
- dataset=dataset,
919
- symbol=symbol_code,
920
- reference_date=reference_date or start,
921
- )
922
- except Exception as exc:
923
- logger.warning(f"Failed to fetch definition for {symbol_code}: {exc}")
924
- return None
925
- if definition:
926
- _INSTRUMENT_DEFINITION_CACHE[cache_key] = definition
927
- return definition
928
-
929
- schedule = databento_roll.build_roll_schedule(
930
+ schedule = futures_roll.build_roll_schedule(
930
931
  roll_asset,
931
932
  schedule_start,
932
933
  end,
933
- definition_provider=get_definition,
934
- roll_days=databento_roll.ROLL_DAYS_BEFORE_EXPIRATION,
934
+ year_digits=1,
935
935
  )
936
936
 
937
937
  if schedule: