lumibot 4.1.2__py3-none-any.whl → 4.2.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 (164) 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 +1178 -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 +31 -9
  31. lumibot/strategies/strategy.py +61 -49
  32. lumibot/tools/backtest_cache.py +284 -0
  33. lumibot/tools/databento_helper.py +65 -42
  34. lumibot/tools/databento_helper_polars.py +748 -778
  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.2.dist-info → lumibot-4.2.0.dist-info}/METADATA +9 -1
  40. {lumibot-4.1.2.dist-info → lumibot-4.2.0.dist-info}/RECORD +72 -148
  41. tests/backtest/test_databento.py +37 -6
  42. tests/backtest/test_databento_comprehensive_trading.py +70 -87
  43. tests/backtest/test_databento_parity.py +31 -7
  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 +96 -63
  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 +50 -10
  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_helper.py +6 -1
  61. tests/test_databento_live.py +10 -10
  62. tests/test_futures_roll.py +38 -0
  63. tests/test_indicator_subplots.py +101 -0
  64. tests/test_market_infinite_loop_bug.py +77 -3
  65. tests/test_polars_resample.py +67 -0
  66. tests/test_polygon_helper.py +46 -0
  67. tests/test_thetadata_backwards_compat.py +97 -0
  68. tests/test_thetadata_helper.py +222 -23
  69. tests/test_thetadata_pandas_verification.py +186 -0
  70. lumibot/__pycache__/__init__.cpython-312.pyc +0 -0
  71. lumibot/__pycache__/constants.cpython-312.pyc +0 -0
  72. lumibot/__pycache__/credentials.cpython-312.pyc +0 -0
  73. lumibot/backtesting/__pycache__/__init__.cpython-312.pyc +0 -0
  74. lumibot/backtesting/__pycache__/alpaca_backtesting.cpython-312.pyc +0 -0
  75. lumibot/backtesting/__pycache__/alpha_vantage_backtesting.cpython-312.pyc +0 -0
  76. lumibot/backtesting/__pycache__/backtesting_broker.cpython-312.pyc +0 -0
  77. lumibot/backtesting/__pycache__/ccxt_backtesting.cpython-312.pyc +0 -0
  78. lumibot/backtesting/__pycache__/databento_backtesting.cpython-312.pyc +0 -0
  79. lumibot/backtesting/__pycache__/interactive_brokers_rest_backtesting.cpython-312.pyc +0 -0
  80. lumibot/backtesting/__pycache__/pandas_backtesting.cpython-312.pyc +0 -0
  81. lumibot/backtesting/__pycache__/polygon_backtesting.cpython-312.pyc +0 -0
  82. lumibot/backtesting/__pycache__/thetadata_backtesting.cpython-312.pyc +0 -0
  83. lumibot/backtesting/__pycache__/yahoo_backtesting.cpython-312.pyc +0 -0
  84. lumibot/brokers/__pycache__/__init__.cpython-312.pyc +0 -0
  85. lumibot/brokers/__pycache__/alpaca.cpython-312.pyc +0 -0
  86. lumibot/brokers/__pycache__/bitunix.cpython-312.pyc +0 -0
  87. lumibot/brokers/__pycache__/broker.cpython-312.pyc +0 -0
  88. lumibot/brokers/__pycache__/ccxt.cpython-312.pyc +0 -0
  89. lumibot/brokers/__pycache__/example_broker.cpython-312.pyc +0 -0
  90. lumibot/brokers/__pycache__/interactive_brokers.cpython-312.pyc +0 -0
  91. lumibot/brokers/__pycache__/interactive_brokers_rest.cpython-312.pyc +0 -0
  92. lumibot/brokers/__pycache__/projectx.cpython-312.pyc +0 -0
  93. lumibot/brokers/__pycache__/schwab.cpython-312.pyc +0 -0
  94. lumibot/brokers/__pycache__/tradier.cpython-312.pyc +0 -0
  95. lumibot/brokers/__pycache__/tradovate.cpython-312.pyc +0 -0
  96. lumibot/data_sources/__pycache__/__init__.cpython-312.pyc +0 -0
  97. lumibot/data_sources/__pycache__/alpaca_data.cpython-312.pyc +0 -0
  98. lumibot/data_sources/__pycache__/alpha_vantage_data.cpython-312.pyc +0 -0
  99. lumibot/data_sources/__pycache__/bitunix_data.cpython-312.pyc +0 -0
  100. lumibot/data_sources/__pycache__/ccxt_backtesting_data.cpython-312.pyc +0 -0
  101. lumibot/data_sources/__pycache__/ccxt_data.cpython-312.pyc +0 -0
  102. lumibot/data_sources/__pycache__/data_source.cpython-312.pyc +0 -0
  103. lumibot/data_sources/__pycache__/data_source_backtesting.cpython-312.pyc +0 -0
  104. lumibot/data_sources/__pycache__/databento_data_polars_backtesting.cpython-312.pyc +0 -0
  105. lumibot/data_sources/__pycache__/databento_data_polars_live.cpython-312.pyc +0 -0
  106. lumibot/data_sources/__pycache__/example_broker_data.cpython-312.pyc +0 -0
  107. lumibot/data_sources/__pycache__/exceptions.cpython-312.pyc +0 -0
  108. lumibot/data_sources/__pycache__/interactive_brokers_data.cpython-312.pyc +0 -0
  109. lumibot/data_sources/__pycache__/interactive_brokers_rest_data.cpython-312.pyc +0 -0
  110. lumibot/data_sources/__pycache__/pandas_data.cpython-312.pyc +0 -0
  111. lumibot/data_sources/__pycache__/polars_mixin.cpython-312.pyc +0 -0
  112. lumibot/data_sources/__pycache__/polygon_data_polars.cpython-312.pyc +0 -0
  113. lumibot/data_sources/__pycache__/projectx_data.cpython-312.pyc +0 -0
  114. lumibot/data_sources/__pycache__/schwab_data.cpython-312.pyc +0 -0
  115. lumibot/data_sources/__pycache__/tradier_data.cpython-312.pyc +0 -0
  116. lumibot/data_sources/__pycache__/tradovate_data.cpython-312.pyc +0 -0
  117. lumibot/data_sources/__pycache__/yahoo_data_polars.cpython-312.pyc +0 -0
  118. lumibot/entities/__pycache__/__init__.cpython-312.pyc +0 -0
  119. lumibot/entities/__pycache__/asset.cpython-312.pyc +0 -0
  120. lumibot/entities/__pycache__/bar.cpython-312.pyc +0 -0
  121. lumibot/entities/__pycache__/bars.cpython-312.pyc +0 -0
  122. lumibot/entities/__pycache__/chains.cpython-312.pyc +0 -0
  123. lumibot/entities/__pycache__/data.cpython-312.pyc +0 -0
  124. lumibot/entities/__pycache__/dataline.cpython-312.pyc +0 -0
  125. lumibot/entities/__pycache__/order.cpython-312.pyc +0 -0
  126. lumibot/entities/__pycache__/position.cpython-312.pyc +0 -0
  127. lumibot/entities/__pycache__/quote.cpython-312.pyc +0 -0
  128. lumibot/entities/__pycache__/trading_fee.cpython-312.pyc +0 -0
  129. lumibot/example_strategies/__pycache__/__init__.cpython-312.pyc +0 -0
  130. lumibot/example_strategies/__pycache__/test_broker_functions.cpython-312-pytest-8.4.1.pyc +0 -0
  131. lumibot/strategies/__pycache__/__init__.cpython-312.pyc +0 -0
  132. lumibot/strategies/__pycache__/_strategy.cpython-312.pyc +0 -0
  133. lumibot/strategies/__pycache__/strategy.cpython-312.pyc +0 -0
  134. lumibot/strategies/__pycache__/strategy_executor.cpython-312.pyc +0 -0
  135. lumibot/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  136. lumibot/tools/__pycache__/alpaca_helpers.cpython-312.pyc +0 -0
  137. lumibot/tools/__pycache__/bitunix_helpers.cpython-312.pyc +0 -0
  138. lumibot/tools/__pycache__/black_scholes.cpython-312.pyc +0 -0
  139. lumibot/tools/__pycache__/ccxt_data_store.cpython-312.pyc +0 -0
  140. lumibot/tools/__pycache__/databento_helper.cpython-312.pyc +0 -0
  141. lumibot/tools/__pycache__/databento_helper_polars.cpython-312.pyc +0 -0
  142. lumibot/tools/__pycache__/debugers.cpython-312.pyc +0 -0
  143. lumibot/tools/__pycache__/decorators.cpython-312.pyc +0 -0
  144. lumibot/tools/__pycache__/helpers.cpython-312.pyc +0 -0
  145. lumibot/tools/__pycache__/indicators.cpython-312.pyc +0 -0
  146. lumibot/tools/__pycache__/lumibot_logger.cpython-312.pyc +0 -0
  147. lumibot/tools/__pycache__/pandas.cpython-312.pyc +0 -0
  148. lumibot/tools/__pycache__/polygon_helper.cpython-312.pyc +0 -0
  149. lumibot/tools/__pycache__/polygon_helper_async.cpython-312.pyc +0 -0
  150. lumibot/tools/__pycache__/polygon_helper_polars_optimized.cpython-312.pyc +0 -0
  151. lumibot/tools/__pycache__/projectx_helpers.cpython-312.pyc +0 -0
  152. lumibot/tools/__pycache__/schwab_helper.cpython-312.pyc +0 -0
  153. lumibot/tools/__pycache__/thetadata_helper.cpython-312.pyc +0 -0
  154. lumibot/tools/__pycache__/types.cpython-312.pyc +0 -0
  155. lumibot/tools/__pycache__/yahoo_helper.cpython-312.pyc +0 -0
  156. lumibot/tools/__pycache__/yahoo_helper_polars_optimized.cpython-312.pyc +0 -0
  157. lumibot/traders/__pycache__/__init__.cpython-312.pyc +0 -0
  158. lumibot/traders/__pycache__/trader.cpython-312.pyc +0 -0
  159. lumibot/trading_builtins/__pycache__/__init__.cpython-312.pyc +0 -0
  160. lumibot/trading_builtins/__pycache__/custom_stream.cpython-312.pyc +0 -0
  161. lumibot/trading_builtins/__pycache__/safe_list.cpython-312.pyc +0 -0
  162. {lumibot-4.1.2.dist-info → lumibot-4.2.0.dist-info}/WHEEL +0 -0
  163. {lumibot-4.1.2.dist-info → lumibot-4.2.0.dist-info}/licenses/LICENSE +0 -0
  164. {lumibot-4.1.2.dist-info → lumibot-4.2.0.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
@@ -1264,9 +1265,24 @@ class _Strategy:
1264
1265
  if show_indicators is None:
1265
1266
  show_indicators = SHOW_INDICATORS
1266
1267
 
1267
- # Auto-select datasource from environment variable if None
1268
- if datasource_class is None:
1269
- from lumibot.credentials import BACKTESTING_DATA_SOURCE
1268
+ from lumibot.credentials import BACKTESTING_DATA_SOURCE as _DEFAULT_BACKTESTING_DATA_SOURCE
1269
+
1270
+ # Determine whether an environment override exists. When BACKTESTING_DATA_SOURCE
1271
+ # is set (and not blank/\"none\"), it should take precedence even if a
1272
+ # datasource_class argument was provided.
1273
+ env_override_raw = os.environ.get("BACKTESTING_DATA_SOURCE")
1274
+ env_override_name = None
1275
+
1276
+ if env_override_raw is not None:
1277
+ trimmed = env_override_raw.strip()
1278
+ if trimmed and trimmed.lower() != "none":
1279
+ env_override_name = trimmed.lower()
1280
+ elif datasource_class is None:
1281
+ # No override provided and no class in code – fall back to the default
1282
+ # configured in credentials (ThetaData unless the project overrides it).
1283
+ env_override_name = _DEFAULT_BACKTESTING_DATA_SOURCE.lower()
1284
+
1285
+ if env_override_name is not None:
1270
1286
  from lumibot.backtesting import (
1271
1287
  PolygonDataBacktesting,
1272
1288
  ThetaDataBacktesting,
@@ -1285,18 +1301,24 @@ class _Strategy:
1285
1301
  "databento": DataBentoDataBacktesting,
1286
1302
  }
1287
1303
 
1288
- datasource_name = BACKTESTING_DATA_SOURCE.lower()
1289
- if datasource_name not in datasource_map:
1304
+ if env_override_name not in datasource_map:
1305
+ label = env_override_raw or _DEFAULT_BACKTESTING_DATA_SOURCE
1290
1306
  raise ValueError(
1291
- f"Unknown BACKTESTING_DATA_SOURCE: '{BACKTESTING_DATA_SOURCE}'. "
1307
+ f"Unknown BACKTESTING_DATA_SOURCE: '{label}'. "
1292
1308
  f"Valid options: {list(datasource_map.keys())}"
1293
1309
  )
1294
1310
 
1295
- datasource_class = datasource_map[datasource_name]
1311
+ datasource_class = datasource_map[env_override_name]
1312
+ label = env_override_raw or _DEFAULT_BACKTESTING_DATA_SOURCE
1296
1313
  get_logger(__name__).info(colored(
1297
- f"Auto-selected backtesting data source from BACKTESTING_DATA_SOURCE env var: {BACKTESTING_DATA_SOURCE}",
1314
+ f"Using BACKTESTING_DATA_SOURCE setting for backtest data: {label}",
1298
1315
  "green"
1299
1316
  ))
1317
+ elif datasource_class is None:
1318
+ raise ValueError(
1319
+ "No backtesting data source provided. Set BACKTESTING_DATA_SOURCE in the environment "
1320
+ "or pass datasource_class when calling backtest()."
1321
+ )
1300
1322
 
1301
1323
  # Make sure polygon_api_key is set if using PolygonDataBacktesting
1302
1324
  polygon_api_key = polygon_api_key if polygon_api_key is not None else POLYGON_API_KEY
@@ -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.")