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.
- lumibot/backtesting/__init__.py +19 -5
- lumibot/backtesting/backtesting_broker.py +98 -18
- lumibot/backtesting/databento_backtesting.py +5 -686
- lumibot/backtesting/databento_backtesting_pandas.py +738 -0
- lumibot/backtesting/databento_backtesting_polars.py +860 -546
- lumibot/backtesting/fix_debug.py +37 -0
- lumibot/backtesting/thetadata_backtesting.py +9 -355
- lumibot/backtesting/thetadata_backtesting_pandas.py +1167 -0
- lumibot/brokers/alpaca.py +8 -1
- lumibot/brokers/schwab.py +12 -2
- lumibot/credentials.py +13 -0
- lumibot/data_sources/__init__.py +5 -8
- lumibot/data_sources/data_source.py +6 -2
- lumibot/data_sources/data_source_backtesting.py +30 -0
- lumibot/data_sources/databento_data.py +5 -390
- lumibot/data_sources/databento_data_pandas.py +440 -0
- lumibot/data_sources/databento_data_polars.py +15 -9
- lumibot/data_sources/pandas_data.py +30 -17
- lumibot/data_sources/polars_data.py +986 -0
- lumibot/data_sources/polars_mixin.py +472 -96
- lumibot/data_sources/polygon_data_polars.py +5 -0
- lumibot/data_sources/yahoo_data.py +9 -2
- lumibot/data_sources/yahoo_data_polars.py +5 -0
- lumibot/entities/__init__.py +15 -0
- lumibot/entities/asset.py +5 -28
- lumibot/entities/bars.py +89 -20
- lumibot/entities/data.py +29 -6
- lumibot/entities/data_polars.py +668 -0
- lumibot/entities/position.py +38 -4
- lumibot/strategies/_strategy.py +2 -1
- lumibot/strategies/strategy.py +61 -49
- lumibot/tools/backtest_cache.py +284 -0
- lumibot/tools/databento_helper.py +35 -35
- lumibot/tools/databento_helper_polars.py +738 -775
- lumibot/tools/futures_roll.py +251 -0
- lumibot/tools/indicators.py +135 -104
- lumibot/tools/polars_utils.py +142 -0
- lumibot/tools/thetadata_helper.py +1068 -134
- {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/METADATA +9 -1
- {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/RECORD +71 -147
- tests/backtest/test_databento.py +37 -6
- tests/backtest/test_databento_comprehensive_trading.py +8 -4
- tests/backtest/test_databento_parity.py +4 -2
- tests/backtest/test_debug_avg_fill_price.py +1 -1
- tests/backtest/test_example_strategies.py +11 -1
- tests/backtest/test_futures_edge_cases.py +3 -3
- tests/backtest/test_futures_single_trade.py +2 -2
- tests/backtest/test_futures_ultra_simple.py +2 -2
- tests/backtest/test_polars_lru_eviction.py +470 -0
- tests/backtest/test_yahoo.py +42 -0
- tests/test_asset.py +4 -4
- tests/test_backtest_cache_manager.py +149 -0
- tests/test_backtesting_data_source_env.py +6 -0
- tests/test_continuous_futures_resolution.py +60 -48
- tests/test_data_polars_parity.py +160 -0
- tests/test_databento_asset_validation.py +23 -5
- tests/test_databento_backtesting.py +1 -1
- tests/test_databento_backtesting_polars.py +312 -192
- tests/test_databento_data.py +220 -463
- tests/test_databento_live.py +10 -10
- tests/test_futures_roll.py +38 -0
- tests/test_indicator_subplots.py +101 -0
- tests/test_market_infinite_loop_bug.py +77 -3
- tests/test_polars_resample.py +67 -0
- tests/test_polygon_helper.py +46 -0
- tests/test_thetadata_backwards_compat.py +97 -0
- tests/test_thetadata_helper.py +222 -23
- tests/test_thetadata_pandas_verification.py +186 -0
- lumibot/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/__pycache__/constants.cpython-312.pyc +0 -0
- lumibot/__pycache__/credentials.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/alpaca_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/alpha_vantage_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/backtesting_broker.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/ccxt_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/databento_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/interactive_brokers_rest_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/pandas_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/polygon_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/thetadata_backtesting.cpython-312.pyc +0 -0
- lumibot/backtesting/__pycache__/yahoo_backtesting.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/alpaca.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/bitunix.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/broker.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/ccxt.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/example_broker.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/interactive_brokers.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/interactive_brokers_rest.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/projectx.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/schwab.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/tradier.cpython-312.pyc +0 -0
- lumibot/brokers/__pycache__/tradovate.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/alpaca_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/alpha_vantage_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/bitunix_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/ccxt_backtesting_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/ccxt_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/data_source.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/data_source_backtesting.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/databento_data_polars_backtesting.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/databento_data_polars_live.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/example_broker_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/exceptions.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/interactive_brokers_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/interactive_brokers_rest_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/pandas_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/polars_mixin.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/polygon_data_polars.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/projectx_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/schwab_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/tradier_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/tradovate_data.cpython-312.pyc +0 -0
- lumibot/data_sources/__pycache__/yahoo_data_polars.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/asset.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/bar.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/bars.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/chains.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/data.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/dataline.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/order.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/position.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/quote.cpython-312.pyc +0 -0
- lumibot/entities/__pycache__/trading_fee.cpython-312.pyc +0 -0
- lumibot/example_strategies/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/example_strategies/__pycache__/test_broker_functions.cpython-312-pytest-8.4.1.pyc +0 -0
- lumibot/strategies/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/strategies/__pycache__/_strategy.cpython-312.pyc +0 -0
- lumibot/strategies/__pycache__/strategy.cpython-312.pyc +0 -0
- lumibot/strategies/__pycache__/strategy_executor.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/alpaca_helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/bitunix_helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/black_scholes.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/ccxt_data_store.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/databento_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/databento_helper_polars.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/debugers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/decorators.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/indicators.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/lumibot_logger.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/pandas.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/polygon_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/polygon_helper_async.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/polygon_helper_polars_optimized.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/projectx_helpers.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/schwab_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/thetadata_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/types.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/yahoo_helper.cpython-312.pyc +0 -0
- lumibot/tools/__pycache__/yahoo_helper_polars_optimized.cpython-312.pyc +0 -0
- lumibot/traders/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/traders/__pycache__/trader.cpython-312.pyc +0 -0
- lumibot/trading_builtins/__pycache__/__init__.cpython-312.pyc +0 -0
- lumibot/trading_builtins/__pycache__/custom_stream.cpython-312.pyc +0 -0
- lumibot/trading_builtins/__pycache__/safe_list.cpython-312.pyc +0 -0
- {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/WHEEL +0 -0
- {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/licenses/LICENSE +0 -0
- {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/top_level.txt +0 -0
lumibot/entities/position.py
CHANGED
|
@@ -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
|
lumibot/strategies/_strategy.py
CHANGED
|
@@ -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
|
lumibot/strategies/strategy.py
CHANGED
|
@@ -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.
|
|
3393
|
-
the
|
|
3394
|
-
|
|
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=
|
|
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
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
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
|
|
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(
|
|
167
|
-
|
|
168
|
-
|
|
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 =
|
|
809
|
-
|
|
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
|
-
|
|
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
|
-
|
|
934
|
-
roll_days=databento_roll.ROLL_DAYS_BEFORE_EXPIRATION,
|
|
934
|
+
year_digits=1,
|
|
935
935
|
)
|
|
936
936
|
|
|
937
937
|
if schedule:
|