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
tests/test_thetadata_helper.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
"datetime": pd.date_range("2023-07-02", periods=5, freq='min'),
|
|
117
|
-
"price": [110, 111, 112, 113, 114]
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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] ==
|
|
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(
|
|
729
|
-
|
|
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(
|
|
748
|
-
|
|
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()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|