lumibot 4.1.3__py3-none-any.whl → 4.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lumibot might be problematic. Click here for more details.

Files changed (163) hide show
  1. lumibot/backtesting/__init__.py +19 -5
  2. lumibot/backtesting/backtesting_broker.py +98 -18
  3. lumibot/backtesting/databento_backtesting.py +5 -686
  4. lumibot/backtesting/databento_backtesting_pandas.py +738 -0
  5. lumibot/backtesting/databento_backtesting_polars.py +860 -546
  6. lumibot/backtesting/fix_debug.py +37 -0
  7. lumibot/backtesting/thetadata_backtesting.py +9 -355
  8. lumibot/backtesting/thetadata_backtesting_pandas.py +1167 -0
  9. lumibot/brokers/alpaca.py +8 -1
  10. lumibot/brokers/schwab.py +12 -2
  11. lumibot/credentials.py +13 -0
  12. lumibot/data_sources/__init__.py +5 -8
  13. lumibot/data_sources/data_source.py +6 -2
  14. lumibot/data_sources/data_source_backtesting.py +30 -0
  15. lumibot/data_sources/databento_data.py +5 -390
  16. lumibot/data_sources/databento_data_pandas.py +440 -0
  17. lumibot/data_sources/databento_data_polars.py +15 -9
  18. lumibot/data_sources/pandas_data.py +30 -17
  19. lumibot/data_sources/polars_data.py +986 -0
  20. lumibot/data_sources/polars_mixin.py +472 -96
  21. lumibot/data_sources/polygon_data_polars.py +5 -0
  22. lumibot/data_sources/yahoo_data.py +9 -2
  23. lumibot/data_sources/yahoo_data_polars.py +5 -0
  24. lumibot/entities/__init__.py +15 -0
  25. lumibot/entities/asset.py +5 -28
  26. lumibot/entities/bars.py +89 -20
  27. lumibot/entities/data.py +29 -6
  28. lumibot/entities/data_polars.py +668 -0
  29. lumibot/entities/position.py +38 -4
  30. lumibot/strategies/_strategy.py +2 -1
  31. lumibot/strategies/strategy.py +61 -49
  32. lumibot/tools/backtest_cache.py +284 -0
  33. lumibot/tools/databento_helper.py +35 -35
  34. lumibot/tools/databento_helper_polars.py +738 -775
  35. lumibot/tools/futures_roll.py +251 -0
  36. lumibot/tools/indicators.py +135 -104
  37. lumibot/tools/polars_utils.py +142 -0
  38. lumibot/tools/thetadata_helper.py +1068 -134
  39. {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/METADATA +9 -1
  40. {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/RECORD +71 -147
  41. tests/backtest/test_databento.py +37 -6
  42. tests/backtest/test_databento_comprehensive_trading.py +8 -4
  43. tests/backtest/test_databento_parity.py +4 -2
  44. tests/backtest/test_debug_avg_fill_price.py +1 -1
  45. tests/backtest/test_example_strategies.py +11 -1
  46. tests/backtest/test_futures_edge_cases.py +3 -3
  47. tests/backtest/test_futures_single_trade.py +2 -2
  48. tests/backtest/test_futures_ultra_simple.py +2 -2
  49. tests/backtest/test_polars_lru_eviction.py +470 -0
  50. tests/backtest/test_yahoo.py +42 -0
  51. tests/test_asset.py +4 -4
  52. tests/test_backtest_cache_manager.py +149 -0
  53. tests/test_backtesting_data_source_env.py +6 -0
  54. tests/test_continuous_futures_resolution.py +60 -48
  55. tests/test_data_polars_parity.py +160 -0
  56. tests/test_databento_asset_validation.py +23 -5
  57. tests/test_databento_backtesting.py +1 -1
  58. tests/test_databento_backtesting_polars.py +312 -192
  59. tests/test_databento_data.py +220 -463
  60. tests/test_databento_live.py +10 -10
  61. tests/test_futures_roll.py +38 -0
  62. tests/test_indicator_subplots.py +101 -0
  63. tests/test_market_infinite_loop_bug.py +77 -3
  64. tests/test_polars_resample.py +67 -0
  65. tests/test_polygon_helper.py +46 -0
  66. tests/test_thetadata_backwards_compat.py +97 -0
  67. tests/test_thetadata_helper.py +222 -23
  68. tests/test_thetadata_pandas_verification.py +186 -0
  69. lumibot/__pycache__/__init__.cpython-312.pyc +0 -0
  70. lumibot/__pycache__/constants.cpython-312.pyc +0 -0
  71. lumibot/__pycache__/credentials.cpython-312.pyc +0 -0
  72. lumibot/backtesting/__pycache__/__init__.cpython-312.pyc +0 -0
  73. lumibot/backtesting/__pycache__/alpaca_backtesting.cpython-312.pyc +0 -0
  74. lumibot/backtesting/__pycache__/alpha_vantage_backtesting.cpython-312.pyc +0 -0
  75. lumibot/backtesting/__pycache__/backtesting_broker.cpython-312.pyc +0 -0
  76. lumibot/backtesting/__pycache__/ccxt_backtesting.cpython-312.pyc +0 -0
  77. lumibot/backtesting/__pycache__/databento_backtesting.cpython-312.pyc +0 -0
  78. lumibot/backtesting/__pycache__/interactive_brokers_rest_backtesting.cpython-312.pyc +0 -0
  79. lumibot/backtesting/__pycache__/pandas_backtesting.cpython-312.pyc +0 -0
  80. lumibot/backtesting/__pycache__/polygon_backtesting.cpython-312.pyc +0 -0
  81. lumibot/backtesting/__pycache__/thetadata_backtesting.cpython-312.pyc +0 -0
  82. lumibot/backtesting/__pycache__/yahoo_backtesting.cpython-312.pyc +0 -0
  83. lumibot/brokers/__pycache__/__init__.cpython-312.pyc +0 -0
  84. lumibot/brokers/__pycache__/alpaca.cpython-312.pyc +0 -0
  85. lumibot/brokers/__pycache__/bitunix.cpython-312.pyc +0 -0
  86. lumibot/brokers/__pycache__/broker.cpython-312.pyc +0 -0
  87. lumibot/brokers/__pycache__/ccxt.cpython-312.pyc +0 -0
  88. lumibot/brokers/__pycache__/example_broker.cpython-312.pyc +0 -0
  89. lumibot/brokers/__pycache__/interactive_brokers.cpython-312.pyc +0 -0
  90. lumibot/brokers/__pycache__/interactive_brokers_rest.cpython-312.pyc +0 -0
  91. lumibot/brokers/__pycache__/projectx.cpython-312.pyc +0 -0
  92. lumibot/brokers/__pycache__/schwab.cpython-312.pyc +0 -0
  93. lumibot/brokers/__pycache__/tradier.cpython-312.pyc +0 -0
  94. lumibot/brokers/__pycache__/tradovate.cpython-312.pyc +0 -0
  95. lumibot/data_sources/__pycache__/__init__.cpython-312.pyc +0 -0
  96. lumibot/data_sources/__pycache__/alpaca_data.cpython-312.pyc +0 -0
  97. lumibot/data_sources/__pycache__/alpha_vantage_data.cpython-312.pyc +0 -0
  98. lumibot/data_sources/__pycache__/bitunix_data.cpython-312.pyc +0 -0
  99. lumibot/data_sources/__pycache__/ccxt_backtesting_data.cpython-312.pyc +0 -0
  100. lumibot/data_sources/__pycache__/ccxt_data.cpython-312.pyc +0 -0
  101. lumibot/data_sources/__pycache__/data_source.cpython-312.pyc +0 -0
  102. lumibot/data_sources/__pycache__/data_source_backtesting.cpython-312.pyc +0 -0
  103. lumibot/data_sources/__pycache__/databento_data_polars_backtesting.cpython-312.pyc +0 -0
  104. lumibot/data_sources/__pycache__/databento_data_polars_live.cpython-312.pyc +0 -0
  105. lumibot/data_sources/__pycache__/example_broker_data.cpython-312.pyc +0 -0
  106. lumibot/data_sources/__pycache__/exceptions.cpython-312.pyc +0 -0
  107. lumibot/data_sources/__pycache__/interactive_brokers_data.cpython-312.pyc +0 -0
  108. lumibot/data_sources/__pycache__/interactive_brokers_rest_data.cpython-312.pyc +0 -0
  109. lumibot/data_sources/__pycache__/pandas_data.cpython-312.pyc +0 -0
  110. lumibot/data_sources/__pycache__/polars_mixin.cpython-312.pyc +0 -0
  111. lumibot/data_sources/__pycache__/polygon_data_polars.cpython-312.pyc +0 -0
  112. lumibot/data_sources/__pycache__/projectx_data.cpython-312.pyc +0 -0
  113. lumibot/data_sources/__pycache__/schwab_data.cpython-312.pyc +0 -0
  114. lumibot/data_sources/__pycache__/tradier_data.cpython-312.pyc +0 -0
  115. lumibot/data_sources/__pycache__/tradovate_data.cpython-312.pyc +0 -0
  116. lumibot/data_sources/__pycache__/yahoo_data_polars.cpython-312.pyc +0 -0
  117. lumibot/entities/__pycache__/__init__.cpython-312.pyc +0 -0
  118. lumibot/entities/__pycache__/asset.cpython-312.pyc +0 -0
  119. lumibot/entities/__pycache__/bar.cpython-312.pyc +0 -0
  120. lumibot/entities/__pycache__/bars.cpython-312.pyc +0 -0
  121. lumibot/entities/__pycache__/chains.cpython-312.pyc +0 -0
  122. lumibot/entities/__pycache__/data.cpython-312.pyc +0 -0
  123. lumibot/entities/__pycache__/dataline.cpython-312.pyc +0 -0
  124. lumibot/entities/__pycache__/order.cpython-312.pyc +0 -0
  125. lumibot/entities/__pycache__/position.cpython-312.pyc +0 -0
  126. lumibot/entities/__pycache__/quote.cpython-312.pyc +0 -0
  127. lumibot/entities/__pycache__/trading_fee.cpython-312.pyc +0 -0
  128. lumibot/example_strategies/__pycache__/__init__.cpython-312.pyc +0 -0
  129. lumibot/example_strategies/__pycache__/test_broker_functions.cpython-312-pytest-8.4.1.pyc +0 -0
  130. lumibot/strategies/__pycache__/__init__.cpython-312.pyc +0 -0
  131. lumibot/strategies/__pycache__/_strategy.cpython-312.pyc +0 -0
  132. lumibot/strategies/__pycache__/strategy.cpython-312.pyc +0 -0
  133. lumibot/strategies/__pycache__/strategy_executor.cpython-312.pyc +0 -0
  134. lumibot/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  135. lumibot/tools/__pycache__/alpaca_helpers.cpython-312.pyc +0 -0
  136. lumibot/tools/__pycache__/bitunix_helpers.cpython-312.pyc +0 -0
  137. lumibot/tools/__pycache__/black_scholes.cpython-312.pyc +0 -0
  138. lumibot/tools/__pycache__/ccxt_data_store.cpython-312.pyc +0 -0
  139. lumibot/tools/__pycache__/databento_helper.cpython-312.pyc +0 -0
  140. lumibot/tools/__pycache__/databento_helper_polars.cpython-312.pyc +0 -0
  141. lumibot/tools/__pycache__/debugers.cpython-312.pyc +0 -0
  142. lumibot/tools/__pycache__/decorators.cpython-312.pyc +0 -0
  143. lumibot/tools/__pycache__/helpers.cpython-312.pyc +0 -0
  144. lumibot/tools/__pycache__/indicators.cpython-312.pyc +0 -0
  145. lumibot/tools/__pycache__/lumibot_logger.cpython-312.pyc +0 -0
  146. lumibot/tools/__pycache__/pandas.cpython-312.pyc +0 -0
  147. lumibot/tools/__pycache__/polygon_helper.cpython-312.pyc +0 -0
  148. lumibot/tools/__pycache__/polygon_helper_async.cpython-312.pyc +0 -0
  149. lumibot/tools/__pycache__/polygon_helper_polars_optimized.cpython-312.pyc +0 -0
  150. lumibot/tools/__pycache__/projectx_helpers.cpython-312.pyc +0 -0
  151. lumibot/tools/__pycache__/schwab_helper.cpython-312.pyc +0 -0
  152. lumibot/tools/__pycache__/thetadata_helper.cpython-312.pyc +0 -0
  153. lumibot/tools/__pycache__/types.cpython-312.pyc +0 -0
  154. lumibot/tools/__pycache__/yahoo_helper.cpython-312.pyc +0 -0
  155. lumibot/tools/__pycache__/yahoo_helper_polars_optimized.cpython-312.pyc +0 -0
  156. lumibot/traders/__pycache__/__init__.cpython-312.pyc +0 -0
  157. lumibot/traders/__pycache__/trader.cpython-312.pyc +0 -0
  158. lumibot/trading_builtins/__pycache__/__init__.cpython-312.pyc +0 -0
  159. lumibot/trading_builtins/__pycache__/custom_stream.cpython-312.pyc +0 -0
  160. lumibot/trading_builtins/__pycache__/safe_list.cpython-312.pyc +0 -0
  161. {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/WHEEL +0 -0
  162. {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/licenses/LICENSE +0 -0
  163. {lumibot-4.1.3.dist-info → lumibot-4.2.1.dist-info}/top_level.txt +0 -0
@@ -13,6 +13,8 @@ import time
13
13
  from unittest.mock import patch, MagicMock
14
14
  from lumibot.entities import Asset
15
15
  from lumibot.tools import thetadata_helper
16
+ from lumibot.backtesting import ThetaDataBacktestingPandas
17
+ from lumibot.tools.backtest_cache import CacheMode
16
18
 
17
19
 
18
20
  @patch('lumibot.tools.thetadata_helper.update_cache')
@@ -72,11 +74,12 @@ def test_get_price_data_without_cached_data(mock_build_cache_filename, mock_get_
72
74
  # Arrange
73
75
  mock_build_cache_filename.return_value.exists.return_value = False
74
76
  mock_get_missing_dates.return_value = [datetime.datetime(2025, 9, 2)]
75
- mock_get_historical_data.return_value = pd.DataFrame({
76
- "datetime": pd.date_range("2023-07-01", periods=5, freq="min"),
77
+ raw_df = pd.DataFrame({
78
+ "datetime": pd.date_range("2023-07-01 09:30:00", periods=5, freq="min", tz="UTC"),
77
79
  "price": [100, 101, 102, 103, 104]
78
- })
79
- mock_update_df.return_value = mock_get_historical_data.return_value
80
+ }).set_index("datetime")
81
+ mock_get_historical_data.return_value = raw_df.reset_index()
82
+ mock_update_df.return_value = raw_df
80
83
 
81
84
  asset = Asset(asset_type="stock", symbol="AAPL")
82
85
  start = datetime.datetime(2025, 9, 2)
@@ -106,17 +109,20 @@ def test_get_price_data_partial_cache_hit(mock_build_cache_filename, mock_load_c
106
109
  mock_get_historical_data, mock_update_df, mock_update_cache):
107
110
  # Arrange
108
111
  cached_data = pd.DataFrame({
109
- "datetime": pd.date_range("2023-07-01", periods=5, freq='min'),
110
- "price": [100, 101, 102, 103, 104]
111
- })
112
+ "datetime": pd.date_range("2023-07-01 09:30:00", periods=5, freq='min', tz="UTC"),
113
+ "price": [100, 101, 102, 103, 104],
114
+ "missing": [False] * 5,
115
+ }).set_index("datetime")
112
116
  mock_build_cache_filename.return_value.exists.return_value = True
113
117
  mock_load_cache.return_value = cached_data
114
118
  mock_get_missing_dates.return_value = [datetime.datetime(2025, 9, 3)]
115
- mock_get_historical_data.return_value = pd.DataFrame({
116
- "datetime": pd.date_range("2023-07-02", periods=5, freq='min'),
117
- "price": [110, 111, 112, 113, 114]
118
- })
119
- updated_data = pd.concat([cached_data, mock_get_historical_data.return_value])
119
+ new_chunk = pd.DataFrame({
120
+ "datetime": pd.date_range("2023-07-02 09:30:00", periods=5, freq='min', tz="UTC"),
121
+ "price": [110, 111, 112, 113, 114],
122
+ "missing": [False] * 5,
123
+ }).set_index("datetime")
124
+ mock_get_historical_data.return_value = new_chunk.reset_index()
125
+ updated_data = pd.concat([cached_data, new_chunk]).sort_index()
120
126
  mock_update_df.return_value = updated_data
121
127
 
122
128
  asset = Asset(asset_type="stock", symbol="AAPL")
@@ -132,10 +138,84 @@ def test_get_price_data_partial_cache_hit(mock_build_cache_filename, mock_load_c
132
138
  assert df is not None
133
139
  assert len(df) == 10 # Combined cached and fetched data
134
140
  mock_get_historical_data.assert_called_once()
135
- assert mock_update_df.return_value.equals(df)
141
+ pd.testing.assert_frame_equal(df, updated_data.drop(columns="missing"))
136
142
  mock_update_cache.assert_called_once()
137
143
 
138
144
 
145
+ def test_get_price_data_daily_placeholders_prevent_refetch(monkeypatch, tmp_path):
146
+ from lumibot.constants import LUMIBOT_DEFAULT_PYTZ
147
+
148
+ cache_root = tmp_path / "cache_root"
149
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(cache_root))
150
+ thetadata_helper.reset_connection_diagnostics()
151
+
152
+ asset = Asset(asset_type="stock", symbol="PLTR")
153
+ start = LUMIBOT_DEFAULT_PYTZ.localize(datetime.datetime(2024, 1, 1))
154
+ end = LUMIBOT_DEFAULT_PYTZ.localize(datetime.datetime(2024, 1, 3))
155
+ trading_days = [
156
+ datetime.date(2024, 1, 1),
157
+ datetime.date(2024, 1, 2),
158
+ datetime.date(2024, 1, 3),
159
+ ]
160
+
161
+ partial_df = pd.DataFrame(
162
+ {
163
+ "datetime": pd.to_datetime(["2024-01-01", "2024-01-02"], utc=True),
164
+ "open": [10.0, 11.0],
165
+ "high": [11.0, 12.0],
166
+ "low": [9.5, 10.5],
167
+ "close": [10.5, 11.5],
168
+ "volume": [1_000, 1_200],
169
+ }
170
+ )
171
+
172
+ progress_stub = MagicMock()
173
+ progress_stub.update.return_value = None
174
+ progress_stub.close.return_value = None
175
+
176
+ with patch("lumibot.tools.thetadata_helper.tqdm", return_value=progress_stub), \
177
+ patch("lumibot.tools.thetadata_helper.get_trading_dates", return_value=trading_days):
178
+ eod_mock = MagicMock(return_value=partial_df)
179
+ with patch("lumibot.tools.thetadata_helper.get_historical_eod_data", eod_mock):
180
+ first = thetadata_helper.get_price_data(
181
+ "user",
182
+ "pass",
183
+ asset,
184
+ start,
185
+ end,
186
+ "day",
187
+ )
188
+
189
+ assert eod_mock.call_count == 1
190
+ assert len(first) == 2
191
+ assert set(first.index.date) == {datetime.date(2024, 1, 1), datetime.date(2024, 1, 2)}
192
+
193
+ cache_file = thetadata_helper.build_cache_filename(asset, "day", "ohlc")
194
+ loaded = thetadata_helper.load_cache(cache_file)
195
+ assert len(loaded) == 3
196
+ assert "missing" in loaded.columns
197
+ assert int(loaded["missing"].sum()) == 1
198
+ missing_dates = {idx.date() for idx, flag in loaded["missing"].items() if flag}
199
+ assert missing_dates == {datetime.date(2024, 1, 3)}
200
+
201
+ # Second run should reuse cache entirely
202
+ eod_second_mock = MagicMock(return_value=partial_df)
203
+ with patch("lumibot.tools.thetadata_helper.tqdm", return_value=progress_stub), \
204
+ patch("lumibot.tools.thetadata_helper.get_trading_dates", return_value=trading_days), \
205
+ patch("lumibot.tools.thetadata_helper.get_historical_eod_data", eod_second_mock):
206
+ second = thetadata_helper.get_price_data(
207
+ "user",
208
+ "pass",
209
+ asset,
210
+ start,
211
+ end,
212
+ "day",
213
+ )
214
+
215
+ assert eod_second_mock.call_count == 0
216
+ assert len(second) == 2
217
+ assert set(second.index.date) == {datetime.date(2024, 1, 1), datetime.date(2024, 1, 2)}
218
+
139
219
  @patch('lumibot.tools.thetadata_helper.update_cache')
140
220
  @patch('lumibot.tools.thetadata_helper.update_df')
141
221
  @patch('lumibot.tools.thetadata_helper.get_historical_data')
@@ -158,7 +238,8 @@ def test_get_price_data_empty_response(mock_build_cache_filename, mock_get_missi
158
238
  df = thetadata_helper.get_price_data("test_user", "test_password", asset, start, end, timespan, dt=dt)
159
239
 
160
240
  # Assert
161
- assert df is None # Expect None due to empty data returned
241
+ assert df is not None
242
+ assert df.empty
162
243
  mock_update_df.assert_not_called()
163
244
 
164
245
 
@@ -363,6 +444,72 @@ def test_update_cache(mocker, tmpdir, df_all, df_cached, datastyle):
363
444
  assert cache_file.exists()
364
445
 
365
446
 
447
+ def test_get_price_data_invokes_remote_cache_manager(tmp_path, monkeypatch):
448
+ asset = Asset(asset_type="stock", symbol="AAPL")
449
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
450
+ cache_file = thetadata_helper.build_cache_filename(asset, "minute", "ohlc")
451
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
452
+
453
+ df = pd.DataFrame(
454
+ {
455
+ "datetime": pd.date_range("2024-01-01 09:30:00", periods=2, freq="T", tz=pytz.UTC),
456
+ "open": [100.0, 101.0],
457
+ "high": [101.0, 102.0],
458
+ "low": [99.5, 100.5],
459
+ "close": [100.5, 101.5],
460
+ "volume": [1_000, 1_200],
461
+ "missing": [False, False],
462
+ }
463
+ )
464
+ df.to_parquet(cache_file, engine="pyarrow", compression="snappy", index=False)
465
+
466
+ class DummyManager:
467
+ def __init__(self):
468
+ self.ensure_calls = []
469
+ self.upload_calls = []
470
+ self.enabled = True
471
+ self._mode = CacheMode.S3_READWRITE
472
+
473
+ @property
474
+ def mode(self):
475
+ return self._mode
476
+
477
+ def ensure_local_file(self, local_path, payload=None, force_download=False):
478
+ self.ensure_calls.append((Path(local_path), payload))
479
+ return False
480
+
481
+ def on_local_update(self, local_path, payload=None):
482
+ self.upload_calls.append((Path(local_path), payload))
483
+ return True
484
+
485
+ dummy_manager = DummyManager()
486
+ monkeypatch.setattr(thetadata_helper, "get_backtest_cache", lambda: dummy_manager)
487
+ monkeypatch.setattr(thetadata_helper, "get_missing_dates", lambda df_all, *_args, **_kwargs: [])
488
+
489
+ start = datetime.datetime(2024, 1, 1, 9, 30, tzinfo=pytz.UTC)
490
+ end = datetime.datetime(2024, 1, 1, 9, 31, tzinfo=pytz.UTC)
491
+
492
+ result = thetadata_helper.get_price_data(
493
+ username="user",
494
+ password="pass",
495
+ asset=asset,
496
+ start=start,
497
+ end=end,
498
+ timespan="minute",
499
+ quote_asset=None,
500
+ dt=None,
501
+ datastyle="ohlc",
502
+ include_after_hours=True,
503
+ return_polars=False,
504
+ )
505
+
506
+ assert dummy_manager.ensure_calls, "Expected remote cache ensure call"
507
+ ensure_path, ensure_payload = dummy_manager.ensure_calls[0]
508
+ assert ensure_path == cache_file
509
+ assert ensure_payload["provider"] == "thetadata"
510
+ assert isinstance(result, pd.DataFrame)
511
+ assert not dummy_manager.upload_calls, "Cache hit should not trigger upload"
512
+
366
513
 
367
514
  @pytest.mark.parametrize(
368
515
  "df_cached, datastyle",
@@ -406,9 +553,8 @@ def test_load_data_from_cache(mocker, tmpdir, df_cached, datastyle):
406
553
  mocker.patch.object(thetadata_helper, "LUMIBOT_CACHE_FOLDER", tmpdir)
407
554
  cache_file = Path(tmpdir / "thetadata" / f"stock_SPY_1D_{datastyle}.parquet")
408
555
 
409
- # No cache file
410
- with pytest.raises(FileNotFoundError):
411
- thetadata_helper.load_cache(cache_file)
556
+ # No cache file should return None (not raise)
557
+ assert thetadata_helper.load_cache(cache_file) is None
412
558
 
413
559
  # Cache file exists
414
560
  cache_file.parent.mkdir(parents=True, exist_ok=True)
@@ -543,7 +689,7 @@ def test_update_df_with_overlapping_data():
543
689
  assert len(df_new) == 5
544
690
  assert df_new["c"].iloc[0] == 2
545
691
  assert df_new["c"].iloc[2] == 10
546
- assert df_new["c"].iloc[3] == 14 # This is the overlapping row, should keep the first value from df_all
692
+ assert df_new["c"].iloc[3] == 18 # Overlap prefers latest data
547
693
  assert df_new["c"].iloc[4] == 22
548
694
  # Note: The -1 minute adjustment was removed from implementation
549
695
  assert df_new.index[0] == pd.DatetimeIndex(["2025-09-02 13:30:00+00:00"])[0]
@@ -725,8 +871,12 @@ def test_get_request_error_in_json(mock_get, mock_check_connection):
725
871
 
726
872
  # Assert
727
873
  mock_get.assert_called_with(url, headers=headers, params=querystring)
728
- mock_check_connection.assert_called_with(username="test_user", password="test_password")
729
- assert mock_check_connection.call_count == 2
874
+ mock_check_connection.assert_called_with(
875
+ username="test_user",
876
+ password="test_password",
877
+ wait_for_connection=True,
878
+ )
879
+ assert mock_check_connection.call_count == 5
730
880
 
731
881
 
732
882
  @patch('lumibot.tools.thetadata_helper.check_connection')
@@ -744,8 +894,12 @@ def test_get_request_exception_handling(mock_get, mock_check_connection):
744
894
 
745
895
  # Assert
746
896
  mock_get.assert_called_with(url, headers=headers, params=querystring)
747
- mock_check_connection.assert_called_with(username="test_user", password="test_password")
748
- assert mock_check_connection.call_count == 2
897
+ mock_check_connection.assert_called_with(
898
+ username="test_user",
899
+ password="test_password",
900
+ wait_for_connection=True,
901
+ )
902
+ assert mock_check_connection.call_count == 3
749
903
 
750
904
 
751
905
  @patch('lumibot.tools.thetadata_helper.get_request')
@@ -1278,4 +1432,49 @@ class TestThetaDataChainsCaching:
1278
1432
 
1279
1433
 
1280
1434
  if __name__ == '__main__':
1281
- pytest.main()
1435
+ pytest.main()
1436
+
1437
+
1438
+ def test_thetadata_no_future_minutes(monkeypatch):
1439
+ tz = pytz.timezone('America/New_York')
1440
+ now = tz.localize(datetime.datetime(2025, 1, 6, 9, 30))
1441
+ frame = pd.DataFrame(
1442
+ {
1443
+ 'datetime': [
1444
+ tz.localize(datetime.datetime(2025, 1, 6, 9, 29)),
1445
+ tz.localize(datetime.datetime(2025, 1, 6, 9, 31)),
1446
+ ],
1447
+ 'open': [4330.0, 4332.0],
1448
+ 'high': [4331.0, 4333.0],
1449
+ 'low': [4329.5, 4331.5],
1450
+ 'close': [4330.5, 4332.5],
1451
+ 'volume': [1_200, 1_250],
1452
+ 'missing': [False, False],
1453
+ }
1454
+ )
1455
+
1456
+ monkeypatch.setattr(thetadata_helper, 'get_price_data', lambda *args, **kwargs: frame.copy())
1457
+ monkeypatch.setattr(thetadata_helper, 'reset_theta_terminal_tracking', lambda: None)
1458
+
1459
+ data_source = ThetaDataBacktestingPandas(
1460
+ datetime_start=now - datetime.timedelta(days=1),
1461
+ datetime_end=now + datetime.timedelta(days=1),
1462
+ username='user',
1463
+ password='pass',
1464
+ use_quote_data=False,
1465
+ )
1466
+ data_source._datetime = now
1467
+
1468
+ asset = Asset('MES', asset_type=Asset.AssetType.CONT_FUTURE)
1469
+
1470
+ bars = data_source.get_historical_prices(
1471
+ asset,
1472
+ length=1,
1473
+ timestep='minute',
1474
+ quote=Asset('USD', asset_type=Asset.AssetType.FOREX),
1475
+ timeshift=datetime.timedelta(minutes=-1),
1476
+ )
1477
+
1478
+ assert bars is not None
1479
+ assert len(bars.df) == 1
1480
+ assert bars.df.index[-1].tz_convert(tz) <= now
@@ -0,0 +1,186 @@
1
+ """
2
+ Verification test for ThetaData pandas implementation.
3
+
4
+ This test verifies that the pandas implementation:
5
+ 1. Works correctly with caching (cold→warm produces 0 requests)
6
+ 2. Produces consistent results between cold and warm runs
7
+ 3. Returns correct data for the WeeklyMomentumOptionsStrategy symbols
8
+
9
+ This establishes the baseline before cloning to polars.
10
+ """
11
+
12
+ import os
13
+ import shutil
14
+ import json
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ import pytest
18
+
19
+ from lumibot.backtesting import ThetaDataBacktestingPandas
20
+ from lumibot.strategies import Strategy
21
+ from lumibot.entities import Asset
22
+ from lumibot.credentials import THETADATA_CONFIG
23
+
24
+
25
+ def get_cache_dir():
26
+ """Get the ThetaData cache directory."""
27
+ return Path.home() / "Library" / "Caches" / "lumibot" / "1.0" / "thetadata"
28
+
29
+
30
+ def clear_cache():
31
+ """Clear all ThetaData cache files."""
32
+ cache_dir = get_cache_dir()
33
+ if cache_dir.exists():
34
+ print(f"Clearing cache at {cache_dir}")
35
+ shutil.rmtree(cache_dir)
36
+ cache_dir.mkdir(parents=True, exist_ok=True)
37
+ else:
38
+ cache_dir.mkdir(parents=True, exist_ok=True)
39
+ print("Cache cleared")
40
+
41
+
42
+ def count_cache_files():
43
+ """Count the number of cache files."""
44
+ cache_dir = get_cache_dir()
45
+ if not cache_dir.exists():
46
+ return 0
47
+ return len(list(cache_dir.glob("*.parquet")))
48
+
49
+
50
+ class WeeklyMomentumOptionsStrategy(Strategy):
51
+ """Simplified version of WeeklyMomentumOptionsStrategy for testing."""
52
+
53
+ def initialize(self):
54
+ self.sleeptime = "1D"
55
+ self.data_fetches = []
56
+ self.symbols = ["SPY", "QQQ", "IWM"]
57
+
58
+ def on_trading_iteration(self):
59
+ # Fetch historical data for each symbol
60
+ for symbol in self.symbols:
61
+ asset = Asset(symbol, asset_type=Asset.AssetType.STOCK)
62
+
63
+ # Get 5 days of daily data
64
+ daily_bars = self.get_historical_prices(asset, length=5, timestep="day")
65
+ if daily_bars and hasattr(daily_bars, 'df'):
66
+ self.data_fetches.append({
67
+ "symbol": symbol,
68
+ "timestep": "day",
69
+ "length": 5,
70
+ "rows": len(daily_bars.df)
71
+ })
72
+
73
+ # Get 10 minutes of minute data
74
+ minute_bars = self.get_historical_prices(asset, length=10, timestep="minute")
75
+ if minute_bars and hasattr(minute_bars, 'df'):
76
+ self.data_fetches.append({
77
+ "symbol": symbol,
78
+ "timestep": "minute",
79
+ "length": 10,
80
+ "rows": len(minute_bars.df)
81
+ })
82
+
83
+
84
+ def run_backtest(run_type):
85
+ """Run a backtest and return the strategy data_fetches."""
86
+ print(f"\n{'='*60}")
87
+ print(f"Running {run_type.upper()} backtest with pandas")
88
+ print(f"{'='*60}")
89
+
90
+ cache_before = count_cache_files()
91
+ print(f"Cache files before: {cache_before}")
92
+
93
+ # Run backtest using Strategy.run_backtest() class method to get both results and strategy
94
+ results, strategy_instance = WeeklyMomentumOptionsStrategy.run_backtest(
95
+ ThetaDataBacktestingPandas,
96
+ backtesting_start=datetime(2025, 3, 1),
97
+ backtesting_end=datetime(2025, 3, 14),
98
+ budget=100000,
99
+ show_plot=False,
100
+ show_tearsheet=False,
101
+ save_tearsheet=False,
102
+ show_indicators=False,
103
+ quiet_logs=True,
104
+ show_progress_bar=False,
105
+ )
106
+
107
+ cache_after = count_cache_files()
108
+ print(f"Cache files after: {cache_after}")
109
+ print(f"New cache files created: {cache_after - cache_before}")
110
+
111
+ # Get portfolio value from strategy instance
112
+ portfolio_value = strategy_instance.portfolio_value
113
+
114
+ # Get data fetches count
115
+ data_fetches = len(strategy_instance.data_fetches) if hasattr(strategy_instance, 'data_fetches') else 0
116
+
117
+ print(f"Portfolio value: ${portfolio_value:,.2f}")
118
+ print(f"Data fetches: {data_fetches}")
119
+ print(f"Results: {results}")
120
+
121
+ return {
122
+ "run_type": run_type,
123
+ "portfolio_value": portfolio_value,
124
+ "data_fetches": data_fetches,
125
+ "cache_before": cache_before,
126
+ "cache_after": cache_after,
127
+ "new_cache_files": cache_after - cache_before,
128
+ "fetch_details": strategy_instance.data_fetches if hasattr(strategy_instance, 'data_fetches') else [],
129
+ "results": results
130
+ }
131
+
132
+
133
+ @pytest.mark.apitest
134
+ @pytest.mark.skipif(
135
+ not THETADATA_CONFIG.get("THETADATA_USERNAME") or not THETADATA_CONFIG.get("THETADATA_PASSWORD"),
136
+ reason="ThetaData credentials not configured - skipping API test"
137
+ )
138
+ def test_pandas_cold_warm():
139
+ """Test that pandas implementation works correctly with caching."""
140
+
141
+ # Clear cache and run cold
142
+ clear_cache()
143
+ cold_results = run_backtest("cold")
144
+
145
+ # Run warm (cache should be used)
146
+ warm_results = run_backtest("warm")
147
+
148
+ # Verify results
149
+ print(f"\n{'='*60}")
150
+ print("VERIFICATION RESULTS")
151
+ print(f"{'='*60}")
152
+
153
+ # Check 1: Cold run should create cache files
154
+ assert cold_results["new_cache_files"] > 0, "Cold run should create cache files"
155
+ print(f"✓ Cold run created {cold_results['new_cache_files']} cache files")
156
+
157
+ # Check 2: Warm run should not create new cache files
158
+ assert warm_results["new_cache_files"] == 0, "Warm run should not create new cache files"
159
+ print(f"✓ Warm run created {warm_results['new_cache_files']} new cache files (expected 0)")
160
+
161
+ # Check 3: Portfolio values should match
162
+ pv_diff = abs(cold_results["portfolio_value"] - warm_results["portfolio_value"])
163
+ assert pv_diff < 0.01, f"Portfolio values should match (diff: ${pv_diff:,.2f})"
164
+ print(f"✓ Portfolio values match: ${cold_results['portfolio_value']:,.2f}")
165
+
166
+ # Check 4: Data fetches should match
167
+ assert cold_results["data_fetches"] == warm_results["data_fetches"], "Data fetches should match"
168
+ print(f"✓ Data fetches match: {cold_results['data_fetches']}")
169
+
170
+ # Save results for reference
171
+ results_path = Path("/Users/robertgrzesik/Documents/Development/lumivest_bot_server/strategies/lumibot/logs/pandas_verification_results.json")
172
+ results_path.parent.mkdir(parents=True, exist_ok=True)
173
+ with open(results_path, 'w') as f:
174
+ json.dump({
175
+ "cold": cold_results,
176
+ "warm": warm_results
177
+ }, f, indent=2, default=str)
178
+
179
+ print(f"\n✓ Results saved to {results_path}")
180
+ print("\n✅ ALL CHECKS PASSED - Pandas implementation is working correctly")
181
+
182
+ return cold_results, warm_results
183
+
184
+
185
+ if __name__ == "__main__":
186
+ test_pandas_cold_warm()