lumibot 4.1.3__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 (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 +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 +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.0.dist-info}/METADATA +9 -1
  40. {lumibot-4.1.3.dist-info → lumibot-4.2.0.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.0.dist-info}/WHEEL +0 -0
  162. {lumibot-4.1.3.dist-info → lumibot-4.2.0.dist-info}/licenses/LICENSE +0 -0
  163. {lumibot-4.1.3.dist-info → lumibot-4.2.0.dist-info}/top_level.txt +0 -0
@@ -1,201 +1,321 @@
1
- import unittest
2
- from datetime import datetime, timedelta
3
- from unittest.mock import patch
4
-
1
+ import pandas as pd
5
2
  import polars as pl
6
- import pytz
3
+ import pytest
4
+ from datetime import datetime, timezone, timedelta
5
+ from unittest.mock import MagicMock, patch
7
6
 
7
+ from lumibot.tools.databento_helper_polars import DataBentoAuthenticationError
8
8
  from lumibot.backtesting.databento_backtesting_polars import DataBentoDataBacktestingPolars
9
9
  from lumibot.entities import Asset
10
- from lumibot.tools.databento_helper_polars import get_price_data_from_databento_polars
11
-
12
-
13
- class TestDataBentoDataBacktestingPolars(unittest.TestCase):
14
- """Regression tests for the polars DataBento backtesting implementation."""
15
-
16
- def setUp(self):
17
- self.api_key = "test_key"
18
- self.start_date = datetime(2022, 1, 1)
19
- self.end_date = datetime(2022, 12, 31)
20
- self.asset = Asset("MNQ", asset_type=Asset.AssetType.CONT_FUTURE)
21
- self.utc = pytz.UTC
22
-
23
- @patch("lumibot.tools.databento_helper_polars.get_price_data_from_databento_polars")
24
- def test_daily_history_is_fetched_once_for_full_range(self, mock_get_data):
25
- """Daily data should be cached so repeated requests avoid redundant API calls."""
26
- date_range = pl.datetime_range(
27
- start=datetime(2021, 12, 1, tzinfo=self.utc),
28
- end=datetime(2022, 12, 30, tzinfo=self.utc),
29
- interval="1d",
30
- eager=True,
31
- )
32
- num_rows = len(date_range)
33
- base_values = [float(i) for i in range(num_rows)]
34
- mock_get_data.return_value = pl.DataFrame(
35
- {
36
- "datetime": date_range,
37
- "open": [v + 1.0 for v in base_values],
38
- "high": [v + 2.0 for v in base_values],
39
- "low": base_values,
40
- "close": [v + 1.5 for v in base_values],
41
- "volume": [1000.0] * num_rows,
42
- }
43
- )
44
-
45
- datasource = DataBentoDataBacktestingPolars(
46
- datetime_start=self.start_date,
47
- datetime_end=self.end_date,
48
- api_key=self.api_key,
49
- )
50
-
51
- # First request should trigger a fetch
52
- datasource._datetime = self.start_date + timedelta(days=40)
53
- first_bars = datasource.get_historical_prices(
54
- self.asset,
55
- length=20,
56
- timestep="day",
57
- return_polars=True,
58
- )
59
-
60
- self.assertIsNotNone(first_bars)
61
- self.assertGreaterEqual(first_bars.df.height, 20)
62
- self.assertEqual(mock_get_data.call_count, 1)
63
-
64
- metadata = datasource._cache_metadata.get((self.asset, "day"))
65
- self.assertIsNotNone(metadata)
66
-
67
- # Subsequent request later in the backtest should use cached data only
68
- datasource._datetime = self.end_date - timedelta(days=5)
69
- second_bars = datasource.get_historical_prices(
70
- self.asset,
71
- length=20,
72
- timestep="day",
73
- return_polars=True,
74
- )
75
-
76
- self.assertIsNotNone(second_bars)
77
- self.assertGreaterEqual(second_bars.df.height, 20)
78
- self.assertEqual(mock_get_data.call_count, 1, "Expected cached data to satisfy the second call")
79
-
80
- metadata = datasource._cache_metadata.get((self.asset, "day"))
81
- self.assertIsNotNone(metadata)
82
- max_dt = datasource._to_naive_datetime(metadata.get("max_dt"))
83
- expected_end = datasource._to_naive_datetime(datasource.datetime_end)
84
- # Allow a small tolerance because fetched data is midnight whereas the backtest end is end-of-day
85
- self.assertGreaterEqual(max_dt, expected_end - timedelta(days=2))
86
-
87
- @patch("lumibot.tools.databento_helper_polars.get_price_data_from_databento_polars")
88
- def test_minute_history_request_has_valid_range(self, mock_get_data):
89
- """Minute requests should never invert the start/end timestamps handed to DataBento."""
90
-
91
- captured = {}
92
-
93
- def fake_databento_fetch(api_key, asset, start, end, timestep, venue=None, force_cache_update=False, reference_date=None, **kwargs):
94
- captured["start"] = start
95
- captured["end"] = end
96
- date_range = pl.datetime_range(
97
- start=datetime(2022, 1, 31, 22, 0, tzinfo=self.utc),
98
- end=datetime(2022, 2, 1, 0, 0, tzinfo=self.utc),
99
- interval="1m",
100
- eager=True,
101
- )
102
- base_values = [float(i) for i in range(len(date_range))]
103
- return pl.DataFrame(
104
- {
105
- "datetime": date_range,
106
- "open": base_values,
107
- "high": [v + 1.0 for v in base_values],
108
- "low": [v - 1.0 for v in base_values],
109
- "close": base_values,
110
- "volume": [10.0] * len(base_values),
111
- "symbol": ["MNQH2"] * len(base_values),
112
- }
113
- )
114
10
 
115
- mock_get_data.side_effect = fake_databento_fetch
116
-
117
- datasource = DataBentoDataBacktestingPolars(
118
- datetime_start=datetime(2022, 1, 1),
119
- datetime_end=datetime(2022, 1, 31),
120
- api_key=self.api_key,
121
- )
122
-
123
- datasource._datetime = pytz.timezone("America/New_York").localize(datetime(2022, 1, 31, 18, 0))
124
- bars = datasource.get_historical_prices(
125
- self.asset,
126
- length=30,
127
- timestep="minute",
128
- return_polars=True,
129
- )
130
-
131
- self.assertIsNotNone(bars)
132
- self.assertIn("datetime", bars.df.columns)
133
- self.assertIn("start", captured)
134
- self.assertIn("end", captured)
135
- self.assertLess(captured["start"], captured["end"], "Expected start < end for DataBento request")
136
-
137
- @patch("lumibot.tools.databento_helper_polars._load_cache", return_value=None)
138
- @patch("lumibot.tools.databento_helper_polars._save_cache")
139
- @patch("lumibot.tools.databento_helper_polars.DataBentoClientPolars.get_hybrid_historical_data")
140
- def test_continuous_futures_roll_filters_front_month(self, mock_get_range, mock_save_cache, mock_load_cache):
141
- """Combined contract data should reduce to the front month according to roll rules."""
142
-
143
- def make_df(start_dt, end_dt, symbol_code):
144
- rng = pl.datetime_range(
145
- start=start_dt,
146
- end=end_dt,
147
- interval="1d",
148
- eager=True,
149
- )
150
- base = [float(i) for i in range(len(rng))]
151
- return pl.DataFrame(
11
+ API_KEY = "test_key"
12
+
13
+
14
+ @pytest.fixture
15
+ def mocked_polars_helper(monkeypatch):
16
+ monkeypatch.setattr(
17
+ "lumibot.tools.databento_helper_polars.DataBentoClient",
18
+ MagicMock(),
19
+ )
20
+ monkeypatch.setattr(
21
+ "lumibot.tools.databento_helper_polars.DATABENTO_AVAILABLE",
22
+ True,
23
+ )
24
+ monkeypatch.setattr(
25
+ "lumibot.tools.databento_helper_polars._fetch_and_update_futures_multiplier",
26
+ lambda *args, **kwargs: None,
27
+ )
28
+
29
+
30
+ def _polars_frame(start_minute: int, rows: int = 5) -> pl.DataFrame:
31
+ base = datetime(2025, 1, 6, 14, 0, tzinfo=timezone.utc)
32
+ datetimes = [base + timedelta(minutes=start_minute + i) for i in range(rows)]
33
+ return pl.DataFrame(
34
+ {
35
+ "datetime": datetimes,
36
+ "open": [100.0 + i * 0.1 for i in range(rows)],
37
+ "high": [100.2 + i * 0.1 for i in range(rows)],
38
+ "low": [99.8 + i * 0.1 for i in range(rows)],
39
+ "close": [100.1 + i * 0.1 for i in range(rows)],
40
+ "volume": [1200 + i * 5 for i in range(rows)],
41
+ }
42
+ )
43
+
44
+
45
+ @pytest.mark.usefixtures("mocked_polars_helper")
46
+ def test_initialization_sets_properties():
47
+ start = datetime(2025, 1, 1, tzinfo=timezone.utc)
48
+ end = datetime(2025, 1, 10, tzinfo=timezone.utc)
49
+ backtester = DataBentoDataBacktestingPolars(
50
+ datetime_start=start,
51
+ datetime_end=end,
52
+ api_key=API_KEY,
53
+ )
54
+
55
+ assert backtester._api_key == API_KEY
56
+ assert backtester.datetime_start == start
57
+ # Implementation subtracts one minute from the end boundary to keep the last
58
+ # candle fully formed.
59
+ assert backtester.datetime_end == end - timedelta(minutes=1)
60
+
61
+
62
+ @pytest.mark.usefixtures("mocked_polars_helper")
63
+ @patch(
64
+ "lumibot.backtesting.databento_backtesting_polars.databento_helper.get_price_data_from_databento"
65
+ )
66
+ def test_prefetch_data_populates_cache(mock_get_data):
67
+ mock_get_data.return_value = _polars_frame(0, rows=8)
68
+ start = datetime(2025, 2, 3, tzinfo=timezone.utc)
69
+ end = datetime(2025, 2, 5, tzinfo=timezone.utc)
70
+ asset = Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE)
71
+
72
+ backtester = DataBentoDataBacktestingPolars(
73
+ datetime_start=start,
74
+ datetime_end=end,
75
+ api_key=API_KEY,
76
+ show_progress_bar=False,
77
+ )
78
+
79
+ if not hasattr(backtester, "prefetch_data"):
80
+ pytest.skip("prefetch_data not implemented for polars backtesting backend")
81
+
82
+ backtester.prefetch_data([asset], timestep="minute")
83
+ assert (asset, Asset("USD", "forex")) in backtester._prefetched_assets
84
+ mock_get_data.assert_called_once()
85
+
86
+
87
+ @pytest.mark.usefixtures("mocked_polars_helper")
88
+ @patch(
89
+ "lumibot.backtesting.databento_backtesting_polars.databento_helper.get_price_data_from_databento"
90
+ )
91
+ def test_get_historical_prices_returns_bars(mock_get_data):
92
+ mock_get_data.return_value = _polars_frame(0, rows=20)
93
+ start = datetime(2025, 3, 3, tzinfo=timezone.utc)
94
+ end = datetime(2025, 3, 4, tzinfo=timezone.utc)
95
+ asset = Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE)
96
+
97
+ backtester = DataBentoDataBacktestingPolars(
98
+ datetime_start=start,
99
+ datetime_end=end,
100
+ api_key=API_KEY,
101
+ show_progress_bar=False,
102
+ )
103
+
104
+ bars = backtester.get_historical_prices(asset, length=10, timestep="minute")
105
+ assert bars is not None
106
+ assert bars.polars_df.height == 10
107
+ mock_get_data.assert_called()
108
+
109
+
110
+ @pytest.mark.usefixtures("mocked_polars_helper")
111
+ @patch(
112
+ "lumibot.backtesting.databento_backtesting_polars.databento_helper.get_price_data_from_databento"
113
+ )
114
+ def test_get_last_price_returns_close(mock_get_data):
115
+ mock_get_data.return_value = _polars_frame(0, rows=5)
116
+ start = datetime(2025, 4, 1, tzinfo=timezone.utc)
117
+ end = datetime(2025, 4, 2, tzinfo=timezone.utc)
118
+ asset = Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE)
119
+
120
+ backtester = DataBentoDataBacktestingPolars(
121
+ datetime_start=start,
122
+ datetime_end=end,
123
+ api_key=API_KEY,
124
+ show_progress_bar=False,
125
+ )
126
+
127
+ price = backtester.get_last_price(asset)
128
+ expected = mock_get_data.return_value.tail(1)["close"][0]
129
+ assert price == pytest.approx(float(expected))
130
+
131
+
132
+ @pytest.mark.usefixtures("mocked_polars_helper")
133
+ def test_get_historical_prices_non_future_returns_none():
134
+ start = datetime(2025, 5, 1, tzinfo=timezone.utc)
135
+ end = datetime(2025, 5, 2, tzinfo=timezone.utc)
136
+ asset = Asset("AAPL", asset_type=Asset.AssetType.STOCK)
137
+
138
+ backtester = DataBentoDataBacktestingPolars(
139
+ datetime_start=start,
140
+ datetime_end=end,
141
+ api_key=API_KEY,
142
+ show_progress_bar=False,
143
+ )
144
+
145
+ bars = backtester.get_historical_prices(asset, length=5)
146
+ assert bars is None
147
+
148
+
149
+ @pytest.mark.usefixtures("mocked_polars_helper")
150
+ def test_databento_polars_quote_midpoint(monkeypatch):
151
+ start = datetime(2025, 6, 1, tzinfo=timezone.utc)
152
+ end = datetime(2025, 6, 2, tzinfo=timezone.utc)
153
+ asset = Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE)
154
+
155
+ backtester = DataBentoDataBacktestingPolars(
156
+ datetime_start=start,
157
+ datetime_end=end,
158
+ api_key=API_KEY,
159
+ show_progress_bar=False,
160
+ )
161
+
162
+ current_dt = datetime(2025, 6, 1, 15, 30, tzinfo=timezone.utc)
163
+ monkeypatch.setattr(backtester, "get_datetime", lambda: current_dt)
164
+
165
+ sample_df = pl.DataFrame(
166
+ {
167
+ "datetime": [current_dt],
168
+ "open": [4300.0],
169
+ "high": [4301.0],
170
+ "low": [4299.5],
171
+ "close": [4300.5],
172
+ "volume": [1500],
173
+ "bid": [4299.75],
174
+ "ask": [4301.25],
175
+ "bid_size": [5],
176
+ "ask_size": [6],
177
+ }
178
+ )
179
+
180
+ def fake_pull(self, *_args, **_kwargs):
181
+ return sample_df
182
+
183
+ monkeypatch.setattr(backtester, "_pull_source_symbol_bars", fake_pull.__get__(backtester, type(backtester)))
184
+
185
+ quote = backtester.get_quote(asset)
186
+ expected_mid = (sample_df["bid"][0] + sample_df["ask"][0]) / 2.0
187
+
188
+ assert quote.mid_price == pytest.approx(expected_mid)
189
+ assert quote.price == pytest.approx(expected_mid)
190
+ assert getattr(quote, "source", None) == "polars"
191
+
192
+
193
+ @pytest.mark.usefixtures("mocked_polars_helper")
194
+ def test_get_historical_prices_reuses_cache(monkeypatch, tmp_path):
195
+ """Second identical request should hit disk cache instead of refetching."""
196
+
197
+ cache_dir = tmp_path / "databento_cache"
198
+ cache_dir.mkdir()
199
+
200
+ monkeypatch.setattr(
201
+ "lumibot.tools.databento_helper_polars.LUMIBOT_DATABENTO_CACHE_FOLDER",
202
+ str(cache_dir),
203
+ raising=False,
204
+ )
205
+ monkeypatch.setattr(
206
+ "lumibot.backtesting.databento_backtesting_polars.databento_helper.LUMIBOT_DATABENTO_CACHE_FOLDER",
207
+ str(cache_dir),
208
+ raising=False,
209
+ )
210
+
211
+ fetch_calls = 0
212
+
213
+ class FakeClient:
214
+ def __init__(self, *args, **kwargs):
215
+ pass
216
+
217
+ def get_historical_data(self, dataset, symbols, schema, start, end, **kwargs):
218
+ nonlocal fetch_calls
219
+ fetch_calls += 1
220
+ index = pd.date_range(start=start, periods=5, freq="1min", tz="UTC")
221
+ return pd.DataFrame(
152
222
  {
153
- "datetime": rng,
154
- "open": base,
155
- "high": [v + 1.0 for v in base],
156
- "low": [v - 1.0 for v in base],
157
- "close": base,
158
- "volume": [1_000.0] * len(rng),
159
- "symbol": [symbol_code] * len(rng),
223
+ "ts_event": index,
224
+ "open": [100.0 + i for i in range(5)],
225
+ "high": [100.5 + i for i in range(5)],
226
+ "low": [99.5 + i for i in range(5)],
227
+ "close": [100.2 + i for i in range(5)],
228
+ "volume": [1_000 + 10 * i for i in range(5)],
160
229
  }
161
230
  )
162
231
 
163
- def fetch_side_effect(dataset, symbols, schema, start, end, **kwargs):
164
- if symbols == "MNQZ4":
165
- return make_df(
166
- datetime(2024, 12, 10, tzinfo=self.utc),
167
- datetime(2024, 12, 14, tzinfo=self.utc),
168
- "MNQZ4",
169
- )
170
- if symbols == "MNQH5":
171
- return make_df(
172
- datetime(2024, 12, 15, tzinfo=self.utc),
173
- datetime(2024, 12, 20, tzinfo=self.utc),
174
- "MNQH5",
175
- )
176
- return pl.DataFrame({})
177
-
178
- mock_get_range.side_effect = fetch_side_effect
179
-
180
- result = get_price_data_from_databento_polars(
181
- api_key=self.api_key,
182
- asset=self.asset,
183
- start=datetime(2024, 12, 10),
184
- end=datetime(2024, 12, 20),
185
- timestep="day",
186
- force_cache_update=True,
187
- )
188
-
189
- self.assertIsNotNone(result)
190
- self.assertIn("symbol", result.columns)
191
- unique_symbols = set(result["symbol"].to_list())
192
- self.assertEqual(unique_symbols, {"MNQZ4", "MNQH5"})
193
-
194
- # Convert Python datetime to Polars datetime to ensure consistent precision
195
- roll_date = pl.lit(datetime(2024, 12, 15, tzinfo=self.utc))
196
- post_roll = result.filter(pl.col("datetime") >= roll_date)
197
- self.assertTrue((post_roll["symbol"] == "MNQH5").all(), "Expected post-roll data to use next quarter contract")
198
-
199
-
200
- if __name__ == "__main__":
201
- unittest.main()
232
+ monkeypatch.setattr(
233
+ "lumibot.tools.databento_helper_polars.DataBentoClient",
234
+ FakeClient,
235
+ )
236
+
237
+ start = datetime(2025, 7, 1, tzinfo=timezone.utc)
238
+ end = datetime(2025, 7, 2, tzinfo=timezone.utc)
239
+ asset = Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE)
240
+
241
+ backtester = DataBentoDataBacktestingPolars(
242
+ datetime_start=start,
243
+ datetime_end=end,
244
+ api_key=API_KEY,
245
+ show_progress_bar=False,
246
+ )
247
+
248
+ first_bars = backtester.get_historical_prices(asset, length=5, timestep="minute", return_polars=True)
249
+ second_bars = backtester.get_historical_prices(asset, length=5, timestep="minute", return_polars=True)
250
+
251
+ assert first_bars is not None and second_bars is not None
252
+ pd.testing.assert_frame_equal(second_bars.pandas_df, first_bars.pandas_df)
253
+ assert fetch_calls == 1, "Expected cached response on second call"
254
+ assert list(cache_dir.glob("*.parquet")), "Cache directory should contain parquet artifacts"
255
+
256
+
257
+ @pytest.mark.usefixtures("mocked_polars_helper")
258
+ def test_auth_failure_propagates(monkeypatch):
259
+ start = datetime(2025, 1, 6, tzinfo=timezone.utc)
260
+ end = datetime(2025, 1, 7, tzinfo=timezone.utc)
261
+ asset = Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE)
262
+
263
+ def boom(*args, **kwargs):
264
+ raise DataBentoAuthenticationError("401 auth_authentication_failed")
265
+
266
+ monkeypatch.setattr(
267
+ "lumibot.backtesting.databento_backtesting_polars.databento_helper.get_price_data_from_databento",
268
+ boom,
269
+ )
270
+ monkeypatch.setattr(
271
+ "lumibot.tools.databento_helper_polars.get_price_data_from_databento",
272
+ boom,
273
+ )
274
+
275
+ backtester = DataBentoDataBacktestingPolars(
276
+ datetime_start=start,
277
+ datetime_end=end,
278
+ api_key=API_KEY,
279
+ show_progress_bar=False,
280
+ )
281
+
282
+ with pytest.raises(DataBentoAuthenticationError):
283
+ backtester.get_historical_prices(asset, length=1, timestep="minute", return_polars=True)
284
+
285
+ @patch(
286
+ "lumibot.backtesting.databento_backtesting_polars.databento_helper.get_price_data_from_databento"
287
+ )
288
+ @pytest.mark.usefixtures("mocked_polars_helper")
289
+ def test_polars_no_future_minutes(mock_get_data, mocked_polars_helper):
290
+ base = datetime(2025, 1, 6, 14, 30, tzinfo=timezone.utc)
291
+ frame = pl.DataFrame(
292
+ {
293
+ "datetime": [base - timedelta(minutes=1), base + timedelta(minutes=1)],
294
+ "open": [4300.0, 4302.0],
295
+ "high": [4300.5, 4302.5],
296
+ "low": [4299.5, 4301.5],
297
+ "close": [4300.2, 4302.2],
298
+ "volume": [1500, 1510],
299
+ }
300
+ )
301
+ mock_get_data.return_value = frame
302
+
303
+ asset = Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE)
304
+ backtester = DataBentoDataBacktestingPolars(
305
+ datetime_start=base - timedelta(days=1),
306
+ datetime_end=base + timedelta(days=1),
307
+ api_key=API_KEY,
308
+ show_progress_bar=False,
309
+ )
310
+ backtester._datetime = base
311
+
312
+ bars = backtester.get_historical_prices(
313
+ asset,
314
+ length=1,
315
+ timestep="minute",
316
+ return_polars=True,
317
+ )
318
+
319
+ assert bars is not None
320
+ # Ensure we never look past the current iteration timestamp.
321
+ assert bars.polars_df["datetime"][-1] <= base