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_databento_live.py
CHANGED
|
@@ -27,13 +27,13 @@ load_dotenv()
|
|
|
27
27
|
def test_symbol_resolution():
|
|
28
28
|
"""Test that symbols are properly resolved to contract codes"""
|
|
29
29
|
from lumibot.entities import Asset
|
|
30
|
-
from lumibot.data_sources.
|
|
30
|
+
from lumibot.data_sources.databento_data_polars import DataBentoDataPolars
|
|
31
31
|
|
|
32
32
|
print("\n" + "="*60)
|
|
33
33
|
print("TEST 1: Symbol Resolution")
|
|
34
34
|
print("="*60)
|
|
35
35
|
|
|
36
|
-
data_source =
|
|
36
|
+
data_source = DataBentoDataPolars(
|
|
37
37
|
api_key=os.getenv('DATABENTO_API_KEY'),
|
|
38
38
|
has_paid_subscription=True,
|
|
39
39
|
enable_live_stream=False # Don't need streaming for this test
|
|
@@ -97,14 +97,14 @@ def test_live_api_connection():
|
|
|
97
97
|
def test_minute_bar_aggregation():
|
|
98
98
|
"""Test minute bar aggregation with <1 minute lag"""
|
|
99
99
|
from lumibot.entities import Asset
|
|
100
|
-
from lumibot.data_sources.
|
|
100
|
+
from lumibot.data_sources.databento_data_polars import DataBentoDataPolars
|
|
101
101
|
|
|
102
102
|
print("\n" + "="*60)
|
|
103
103
|
print("TEST 3: Minute Bar Aggregation & Latency")
|
|
104
104
|
print("="*60)
|
|
105
105
|
|
|
106
106
|
# Initialize with Live API
|
|
107
|
-
data_source =
|
|
107
|
+
data_source = DataBentoDataPolars(
|
|
108
108
|
api_key=os.getenv('DATABENTO_API_KEY'),
|
|
109
109
|
has_paid_subscription=True,
|
|
110
110
|
enable_live_stream=True
|
|
@@ -175,13 +175,13 @@ def test_minute_bar_aggregation():
|
|
|
175
175
|
)
|
|
176
176
|
def test_api_routing():
|
|
177
177
|
"""Test that correct API is used based on time range"""
|
|
178
|
-
from lumibot.data_sources.
|
|
178
|
+
from lumibot.data_sources.databento_data_polars import DataBentoDataPolars
|
|
179
179
|
|
|
180
180
|
print("\n" + "="*60)
|
|
181
181
|
print("TEST 4: API Routing (Live vs Historical)")
|
|
182
182
|
print("="*60)
|
|
183
183
|
|
|
184
|
-
data_source =
|
|
184
|
+
data_source = DataBentoDataPolars(
|
|
185
185
|
api_key=os.getenv('DATABENTO_API_KEY'),
|
|
186
186
|
has_paid_subscription=True,
|
|
187
187
|
enable_live_stream=True
|
|
@@ -219,13 +219,13 @@ def test_api_routing():
|
|
|
219
219
|
def test_long_time_periods():
|
|
220
220
|
"""Test different time periods including long periods (500+ bars)"""
|
|
221
221
|
from lumibot.entities import Asset
|
|
222
|
-
from lumibot.data_sources.
|
|
222
|
+
from lumibot.data_sources.databento_data_polars import DataBentoDataPolars
|
|
223
223
|
|
|
224
224
|
print("\n" + "="*60)
|
|
225
225
|
print("TEST 5: Long Time Period Handling (500+ bars)")
|
|
226
226
|
print("="*60)
|
|
227
227
|
|
|
228
|
-
data_source =
|
|
228
|
+
data_source = DataBentoDataPolars(
|
|
229
229
|
api_key=os.getenv('DATABENTO_API_KEY'),
|
|
230
230
|
has_paid_subscription=True,
|
|
231
231
|
enable_live_stream=True
|
|
@@ -324,14 +324,14 @@ def test_long_time_periods():
|
|
|
324
324
|
def test_continuous_latency_monitoring():
|
|
325
325
|
"""Run continuous tests to verify consistent <1 minute lag"""
|
|
326
326
|
from lumibot.entities import Asset
|
|
327
|
-
from lumibot.data_sources.
|
|
327
|
+
from lumibot.data_sources.databento_data_polars import DataBentoDataPolars
|
|
328
328
|
|
|
329
329
|
print("\n" + "="*60)
|
|
330
330
|
print("TEST 6: Continuous Latency Monitoring")
|
|
331
331
|
print("="*60)
|
|
332
332
|
print("Running 5 consecutive tests to verify consistent low latency...")
|
|
333
333
|
|
|
334
|
-
data_source =
|
|
334
|
+
data_source = DataBentoDataPolars(
|
|
335
335
|
api_key=os.getenv('DATABENTO_API_KEY'),
|
|
336
336
|
has_paid_subscription=True,
|
|
337
337
|
enable_live_stream=True
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
import pytz
|
|
4
|
+
|
|
5
|
+
from lumibot.entities import Asset
|
|
6
|
+
from lumibot.tools import futures_roll
|
|
7
|
+
|
|
8
|
+
NY = pytz.timezone("America/New_York")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _dt(year: int, month: int, day: int) -> datetime.datetime:
|
|
12
|
+
return NY.localize(datetime.datetime(year, month, day))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_equity_index_roll_eight_business_days_before_expiry():
|
|
16
|
+
asset = Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE)
|
|
17
|
+
|
|
18
|
+
year, month = futures_roll.determine_contract_year_month(asset.symbol, _dt(2025, 9, 8))
|
|
19
|
+
assert (year, month) == (2025, 9)
|
|
20
|
+
|
|
21
|
+
year, month = futures_roll.determine_contract_year_month(asset.symbol, _dt(2025, 9, 10))
|
|
22
|
+
assert (year, month) == (2025, 12)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_fallback_mid_month_preserved_for_unknown_symbols():
|
|
26
|
+
asset = Asset("XYZ", asset_type=Asset.AssetType.CONT_FUTURE)
|
|
27
|
+
|
|
28
|
+
year, month = futures_roll.determine_contract_year_month(asset.symbol, _dt(2025, 3, 16))
|
|
29
|
+
assert (year, month) == (2025, 6)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_resolve_symbols_for_range_produces_sequential_contracts():
|
|
33
|
+
asset = Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE)
|
|
34
|
+
start = _dt(2025, 8, 1)
|
|
35
|
+
end = _dt(2025, 12, 31)
|
|
36
|
+
|
|
37
|
+
symbols = futures_roll.resolve_symbols_for_range(asset, start, end, year_digits=1)
|
|
38
|
+
assert symbols == ["MESU5", "MESZ5", "MESH6"], symbols
|
tests/test_indicator_subplots.py
CHANGED
|
@@ -3,10 +3,12 @@ from datetime import datetime as DateTime
|
|
|
3
3
|
from unittest.mock import MagicMock
|
|
4
4
|
|
|
5
5
|
import pandas as pd
|
|
6
|
+
import plotly.graph_objects as go
|
|
6
7
|
|
|
7
8
|
from lumibot.backtesting import PandasDataBacktesting
|
|
8
9
|
from lumibot.strategies.strategy import Strategy
|
|
9
10
|
from lumibot.entities import Asset
|
|
11
|
+
from lumibot.tools.indicators import _build_trade_marker_tooltip, plot_returns
|
|
10
12
|
|
|
11
13
|
from tests.fixtures import pandas_data_fixture
|
|
12
14
|
|
|
@@ -179,6 +181,105 @@ class TestIndicators:
|
|
|
179
181
|
logger.info(f"Result: {result}")
|
|
180
182
|
assert result is not None
|
|
181
183
|
|
|
184
|
+
|
|
185
|
+
def _make_trade_row_for_tooltip(status, trade_cost=pd.NA):
|
|
186
|
+
return pd.Series(
|
|
187
|
+
{
|
|
188
|
+
"status": status,
|
|
189
|
+
"filled_quantity": 10,
|
|
190
|
+
"price": 2.5,
|
|
191
|
+
"asset.multiplier": 100,
|
|
192
|
+
"trade_cost": trade_cost,
|
|
193
|
+
"symbol": "WDC",
|
|
194
|
+
"asset.asset_type": "option",
|
|
195
|
+
"asset.right": "CALL",
|
|
196
|
+
"asset.strike": 86,
|
|
197
|
+
"asset.expiration": "2025-09-19",
|
|
198
|
+
"type": "limit",
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_cash_settled_tooltip_generated_without_trade_cost():
|
|
204
|
+
tooltip = _build_trade_marker_tooltip(_make_trade_row_for_tooltip("cash_settled", trade_cost=pd.NA))
|
|
205
|
+
assert tooltip is not None
|
|
206
|
+
assert "cash_settled" in tooltip
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_non_terminal_status_filtered_out():
|
|
210
|
+
assert _build_trade_marker_tooltip(_make_trade_row_for_tooltip("new", trade_cost=pd.NA)) is None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_plot_returns_preserves_cash_settled_status(tmp_path, monkeypatch):
|
|
214
|
+
plot_path = tmp_path / "plot.html"
|
|
215
|
+
|
|
216
|
+
def _fake_write_html(self, file, auto_open=True, **kwargs):
|
|
217
|
+
# Prevent plotly from opening a browser during the test
|
|
218
|
+
return file
|
|
219
|
+
|
|
220
|
+
monkeypatch.setattr(go.Figure, "write_html", _fake_write_html, raising=False)
|
|
221
|
+
|
|
222
|
+
idx = pd.to_datetime(
|
|
223
|
+
["2025-09-04 00:00:00-04:00", "2025-09-20 00:00:00-04:00"]
|
|
224
|
+
).tz_convert("UTC")
|
|
225
|
+
|
|
226
|
+
strategy_df = pd.DataFrame(
|
|
227
|
+
{
|
|
228
|
+
"return": [0.0, 0.0],
|
|
229
|
+
"cash": [100000, 120000],
|
|
230
|
+
"positions": [
|
|
231
|
+
[{"asset": "WDC", "quantity": 25}],
|
|
232
|
+
[],
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
index=idx,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
benchmark_df = pd.DataFrame(
|
|
239
|
+
{
|
|
240
|
+
"return": [0.0, 0.0],
|
|
241
|
+
"open": [1.0, 1.0],
|
|
242
|
+
"high": [1.0, 1.0],
|
|
243
|
+
"low": [1.0, 1.0],
|
|
244
|
+
"close": [1.0, 1.0],
|
|
245
|
+
},
|
|
246
|
+
index=idx,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
trades_df = pd.DataFrame(
|
|
250
|
+
[
|
|
251
|
+
{
|
|
252
|
+
"time": "2025-09-20 00:00:00-04:00",
|
|
253
|
+
"side": "sell",
|
|
254
|
+
"status": "cash_settled",
|
|
255
|
+
"filled_quantity": 25,
|
|
256
|
+
"symbol": "WDC",
|
|
257
|
+
"asset.asset_type": "option",
|
|
258
|
+
"asset.right": "CALL",
|
|
259
|
+
"asset.strike": 86,
|
|
260
|
+
"asset.expiration": "2025-09-19",
|
|
261
|
+
"price": 20.86,
|
|
262
|
+
"type": "cash_settled",
|
|
263
|
+
"asset.multiplier": 100,
|
|
264
|
+
"trade_cost": pd.NA,
|
|
265
|
+
}
|
|
266
|
+
]
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
plot_returns(
|
|
270
|
+
strategy_df,
|
|
271
|
+
"Strategy",
|
|
272
|
+
benchmark_df,
|
|
273
|
+
"Benchmark",
|
|
274
|
+
plot_file_html=str(plot_path),
|
|
275
|
+
trades_df=trades_df,
|
|
276
|
+
show_plot=True,
|
|
277
|
+
initial_budget=1,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
trades_csv = pd.read_csv(plot_path.with_suffix(".csv"))
|
|
281
|
+
assert "cash_settled" in trades_csv["status"].tolist()
|
|
282
|
+
|
|
182
283
|
def test_named_lines(self, pandas_data_fixture):
|
|
183
284
|
"""Test the named lines"""
|
|
184
285
|
strategy_name = "TestIndicatorStrategy"
|
|
@@ -16,11 +16,16 @@ STATUS: ✅ FIXED - ES futures now complete normally (1 restart vs infinite)
|
|
|
16
16
|
|
|
17
17
|
import unittest
|
|
18
18
|
from unittest.mock import patch
|
|
19
|
-
from datetime import datetime
|
|
19
|
+
from datetime import datetime, timedelta
|
|
20
20
|
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from lumibot.credentials import DATABENTO_CONFIG
|
|
21
24
|
from lumibot.strategies import Strategy
|
|
22
|
-
from lumibot.entities import Asset, TradingFee
|
|
23
|
-
from lumibot.backtesting import DataBentoDataBacktesting
|
|
25
|
+
from lumibot.entities import Asset, TradingFee, Bars, Order
|
|
26
|
+
from lumibot.backtesting import BacktestingBroker, DataBentoDataBacktesting
|
|
27
|
+
|
|
28
|
+
DATABENTO_API_KEY = DATABENTO_CONFIG.get("API_KEY")
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
class ESFuturesTestStrategy(Strategy):
|
|
@@ -39,10 +44,13 @@ class TestESFuturesHangBug(unittest.TestCase):
|
|
|
39
44
|
"""Test that ES futures strategies no longer hang/restart infinitely"""
|
|
40
45
|
|
|
41
46
|
def setUp(self):
|
|
47
|
+
if not DATABENTO_API_KEY or DATABENTO_API_KEY == "<your key here>":
|
|
48
|
+
self.skipTest("DataBento API key required for DataBento backtesting tests")
|
|
42
49
|
self.backtesting_params = {
|
|
43
50
|
'datasource_class': DataBentoDataBacktesting,
|
|
44
51
|
'backtesting_start': datetime(2025, 6, 5),
|
|
45
52
|
'backtesting_end': datetime(2025, 6, 6),
|
|
53
|
+
'api_key': DATABENTO_API_KEY,
|
|
46
54
|
'show_plot': False,
|
|
47
55
|
'show_tearsheet': False,
|
|
48
56
|
'show_indicators': False,
|
|
@@ -299,3 +307,69 @@ class TestESFuturesHangBug(unittest.TestCase):
|
|
|
299
307
|
if __name__ == '__main__':
|
|
300
308
|
print("🧪 Testing ES Futures hang bug fix...")
|
|
301
309
|
unittest.main(verbosity=2)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_broker_timeshift_guard():
|
|
313
|
+
captured = []
|
|
314
|
+
|
|
315
|
+
class StubDataSource:
|
|
316
|
+
SOURCE = "DATABENTO_POLARS"
|
|
317
|
+
IS_BACKTESTING_DATA_SOURCE = True
|
|
318
|
+
|
|
319
|
+
def __init__(self):
|
|
320
|
+
self._datetime = datetime(2025, 6, 5, 14, 30)
|
|
321
|
+
|
|
322
|
+
def get_historical_prices(self, asset, length, quote=None, timeshift=None, **kwargs):
|
|
323
|
+
captured.append(timeshift)
|
|
324
|
+
index = pd.DatetimeIndex([self._datetime - timedelta(minutes=1)])
|
|
325
|
+
frame = pd.DataFrame(
|
|
326
|
+
{
|
|
327
|
+
'open': [4300.0],
|
|
328
|
+
'high': [4301.0],
|
|
329
|
+
'low': [4299.5],
|
|
330
|
+
'close': [4300.5],
|
|
331
|
+
'volume': [1500],
|
|
332
|
+
},
|
|
333
|
+
index=index,
|
|
334
|
+
)
|
|
335
|
+
target_asset = asset[0] if isinstance(asset, tuple) else asset
|
|
336
|
+
return Bars(frame, self.SOURCE, target_asset, raw=frame)
|
|
337
|
+
|
|
338
|
+
def get_datetime(self):
|
|
339
|
+
return self._datetime
|
|
340
|
+
|
|
341
|
+
broker = BacktestingBroker(data_source=StubDataSource())
|
|
342
|
+
broker._datetime = broker.data_source.get_datetime()
|
|
343
|
+
|
|
344
|
+
order = Order(
|
|
345
|
+
strategy="stub",
|
|
346
|
+
asset=Asset("MES", asset_type=Asset.AssetType.CONT_FUTURE),
|
|
347
|
+
quantity=1,
|
|
348
|
+
side=Order.OrderSide.BUY,
|
|
349
|
+
)
|
|
350
|
+
order.order_type = Order.OrderType.MARKET
|
|
351
|
+
order.quote = Asset("USD", asset_type=Asset.AssetType.FOREX)
|
|
352
|
+
broker._new_orders.append(order)
|
|
353
|
+
|
|
354
|
+
class StubStrategy:
|
|
355
|
+
name = "stub"
|
|
356
|
+
buy_trading_fees = []
|
|
357
|
+
sell_trading_fees = []
|
|
358
|
+
timestep = 'minute'
|
|
359
|
+
bars_lookback = 1
|
|
360
|
+
|
|
361
|
+
def __init__(self, broker):
|
|
362
|
+
self.broker = broker
|
|
363
|
+
self.cash = 100000.0
|
|
364
|
+
self.quote_asset = Asset('USD', asset_type=Asset.AssetType.FOREX)
|
|
365
|
+
|
|
366
|
+
def log_message(self, *args, **kwargs):
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
def _set_cash_position(self, value):
|
|
370
|
+
self.cash = value
|
|
371
|
+
|
|
372
|
+
broker.process_pending_orders(strategy=StubStrategy(broker))
|
|
373
|
+
|
|
374
|
+
assert captured, "BacktestingBroker did not request historical data"
|
|
375
|
+
assert captured[0] == timedelta(minutes=-2)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from lumibot.tools.polars_utils import PolarsResampleError, resample_polars_ohlc
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _minute_frame(rows: int = 10) -> pl.DataFrame:
|
|
10
|
+
start = datetime(2024, 1, 1, 9, 30)
|
|
11
|
+
datetimes = [start + timedelta(minutes=i) for i in range(rows)]
|
|
12
|
+
return pl.DataFrame(
|
|
13
|
+
{
|
|
14
|
+
"datetime": datetimes,
|
|
15
|
+
"open": [100 + i for i in range(rows)],
|
|
16
|
+
"high": [101 + i for i in range(rows)],
|
|
17
|
+
"low": [99 + i for i in range(rows)],
|
|
18
|
+
"close": [100.5 + i for i in range(rows)],
|
|
19
|
+
"volume": [1_000 + 10 * i for i in range(rows)],
|
|
20
|
+
"signal": [i for i in range(rows)],
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_resample_polars_minute_to_5min():
|
|
26
|
+
df = _minute_frame(10)
|
|
27
|
+
resampled = resample_polars_ohlc(df, multiplier=5, base_unit="minute", length=2)
|
|
28
|
+
|
|
29
|
+
assert resampled.height == 2
|
|
30
|
+
# First bucket should cover rows 0-4
|
|
31
|
+
first = resampled.row(0, named=True)
|
|
32
|
+
assert first["open"] == 100
|
|
33
|
+
assert first["high"] == 105
|
|
34
|
+
assert first["low"] == 99
|
|
35
|
+
assert first["close"] == pytest.approx(104.5)
|
|
36
|
+
assert first["volume"] == sum(1_000 + 10 * i for i in range(5))
|
|
37
|
+
# signal column keeps last observation in the bucket
|
|
38
|
+
assert first["signal"] == 4
|
|
39
|
+
|
|
40
|
+
# Tail limiting should keep last 2 buckets only
|
|
41
|
+
assert resampled.row(1, named=True)["signal"] == 9
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_resample_polars_day_bucket():
|
|
45
|
+
start = datetime(2024, 1, 1, 0, 0)
|
|
46
|
+
datetimes = [start + timedelta(hours=i) for i in range(48)]
|
|
47
|
+
df = pl.DataFrame(
|
|
48
|
+
{
|
|
49
|
+
"datetime": datetimes,
|
|
50
|
+
"open": [10 + i for i in range(48)],
|
|
51
|
+
"high": [12 + i for i in range(48)],
|
|
52
|
+
"low": [8 + i for i in range(48)],
|
|
53
|
+
"close": [11 + i for i in range(48)],
|
|
54
|
+
"volume": [100 + i for i in range(48)],
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
resampled = resample_polars_ohlc(df, multiplier=1, base_unit="day", length=None)
|
|
59
|
+
assert resampled.height == 2
|
|
60
|
+
assert resampled["open"][0] == 10
|
|
61
|
+
assert resampled["close"][1] == 11 + 47
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_resample_polars_invalid_unit():
|
|
65
|
+
df = _minute_frame(2)
|
|
66
|
+
with pytest.raises(PolarsResampleError):
|
|
67
|
+
resample_polars_ohlc(df, multiplier=1, base_unit="hour")
|
tests/test_polygon_helper.py
CHANGED
|
@@ -7,6 +7,7 @@ import pytest
|
|
|
7
7
|
import pytz
|
|
8
8
|
|
|
9
9
|
from lumibot.entities import Asset
|
|
10
|
+
from lumibot.backtesting import PolygonDataBacktesting
|
|
10
11
|
from lumibot.entities.chains import normalize_option_chains
|
|
11
12
|
from lumibot.tools import polygon_helper as ph
|
|
12
13
|
|
|
@@ -131,6 +132,8 @@ class TestPolygonHelpers:
|
|
|
131
132
|
assert datetime.date(2023, 7, 10) in trading_dates
|
|
132
133
|
|
|
133
134
|
def test_get_polygon_symbol(self, mocker):
|
|
135
|
+
|
|
136
|
+
|
|
134
137
|
polygon_client = mocker.MagicMock()
|
|
135
138
|
|
|
136
139
|
# ------- Unsupported Asset Type
|
|
@@ -568,3 +571,46 @@ class TestPolygonPriceData:
|
|
|
568
571
|
# Should return identical data once normalized
|
|
569
572
|
assert normalize_option_chains(result_second) == normalized_first
|
|
570
573
|
assert mock_polyclient.list_options_contracts.call_count == 0
|
|
574
|
+
|
|
575
|
+
def test_polygon_no_future_bars_before_open(self, monkeypatch):
|
|
576
|
+
tz = pytz.timezone('America/New_York')
|
|
577
|
+
now = tz.localize(datetime.datetime(2023, 11, 1, 9, 30))
|
|
578
|
+
frame = pd.DataFrame(
|
|
579
|
+
{
|
|
580
|
+
'open': [377.0, 378.5],
|
|
581
|
+
'high': [377.5, 379.0],
|
|
582
|
+
'low': [376.8, 378.2],
|
|
583
|
+
'close': [377.2, 378.9],
|
|
584
|
+
'volume': [10_000, 10_500],
|
|
585
|
+
},
|
|
586
|
+
index=pd.DatetimeIndex([
|
|
587
|
+
tz.localize(datetime.datetime(2023, 11, 1, 9, 29)),
|
|
588
|
+
tz.localize(datetime.datetime(2023, 11, 1, 9, 31)),
|
|
589
|
+
]),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
monkeypatch.setattr(
|
|
593
|
+
'lumibot.backtesting.polygon_backtesting.polygon_helper.get_price_data_from_polygon',
|
|
594
|
+
lambda *args, **kwargs: frame,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
data_source = PolygonDataBacktesting(
|
|
598
|
+
datetime_start=now - datetime.timedelta(days=1),
|
|
599
|
+
datetime_end=now + datetime.timedelta(days=1),
|
|
600
|
+
api_key='dummy',
|
|
601
|
+
)
|
|
602
|
+
data_source._datetime = now
|
|
603
|
+
asset = Asset('SPY')
|
|
604
|
+
quote = Asset('USD', 'forex')
|
|
605
|
+
|
|
606
|
+
bars = data_source.get_historical_prices(
|
|
607
|
+
asset,
|
|
608
|
+
length=1,
|
|
609
|
+
timestep='minute',
|
|
610
|
+
quote=quote,
|
|
611
|
+
timeshift=datetime.timedelta(minutes=-1),
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
assert bars.df.index[-1] <= now
|
|
615
|
+
|
|
616
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ThetaData pandas compatibility tests.
|
|
3
|
+
|
|
4
|
+
Ensures the helper returns pandas DataFrames by default and raises a clear
|
|
5
|
+
error when callers request polars output (which is intentionally unsupported
|
|
6
|
+
in this branch).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from lumibot.entities import Asset
|
|
14
|
+
from lumibot.tools import thetadata_helper
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _mock_cache_frame(start: datetime, rows: int = 8) -> pd.DataFrame:
|
|
18
|
+
index = pd.date_range(start=start, periods=rows, freq="1min", tz="UTC")
|
|
19
|
+
df = pd.DataFrame(
|
|
20
|
+
{
|
|
21
|
+
"open": [200 + i for i in range(rows)],
|
|
22
|
+
"high": [200.5 + i for i in range(rows)],
|
|
23
|
+
"low": [199.5 + i for i in range(rows)],
|
|
24
|
+
"close": [200.25 + i for i in range(rows)],
|
|
25
|
+
"volume": [10_000 + 50 * i for i in range(rows)],
|
|
26
|
+
"missing": [False] * rows,
|
|
27
|
+
},
|
|
28
|
+
index=index,
|
|
29
|
+
)
|
|
30
|
+
return df
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_get_price_data_returns_pandas_when_cache_hit(monkeypatch, tmp_path):
|
|
34
|
+
"""Cache path with no missing intervals should return pandas DataFrame."""
|
|
35
|
+
cache_file = tmp_path / "spy.minute.ohlc.parquet"
|
|
36
|
+
cache_file.write_text("placeholder")
|
|
37
|
+
|
|
38
|
+
mock_df = _mock_cache_frame(datetime(2025, 1, 1, tzinfo=timezone.utc))
|
|
39
|
+
|
|
40
|
+
monkeypatch.setattr(
|
|
41
|
+
thetadata_helper,
|
|
42
|
+
"build_cache_filename",
|
|
43
|
+
lambda *args, **kwargs: cache_file,
|
|
44
|
+
)
|
|
45
|
+
monkeypatch.setattr(thetadata_helper, "load_cache", lambda _: mock_df)
|
|
46
|
+
monkeypatch.setattr(thetadata_helper, "get_missing_dates", lambda *args, **kwargs: [])
|
|
47
|
+
monkeypatch.setattr(thetadata_helper, "update_cache", lambda *args, **kwargs: None)
|
|
48
|
+
|
|
49
|
+
asset = Asset("SPY", asset_type=Asset.AssetType.STOCK)
|
|
50
|
+
|
|
51
|
+
result = thetadata_helper.get_price_data(
|
|
52
|
+
username="demo",
|
|
53
|
+
password="demo",
|
|
54
|
+
asset=asset,
|
|
55
|
+
start=datetime(2025, 1, 1, tzinfo=timezone.utc),
|
|
56
|
+
end=datetime(2025, 1, 2, tzinfo=timezone.utc),
|
|
57
|
+
timespan="minute",
|
|
58
|
+
datastyle="ohlc",
|
|
59
|
+
include_after_hours=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
assert isinstance(result, pd.DataFrame)
|
|
63
|
+
assert not result.empty
|
|
64
|
+
assert "missing" not in result.columns
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_get_price_data_polars_request_rejected(monkeypatch, tmp_path):
|
|
68
|
+
"""Requesting return_polars=True should raise a clear ValueError."""
|
|
69
|
+
cache_file = tmp_path / "spy.minute.ohlc.parquet"
|
|
70
|
+
cache_file.write_text("placeholder")
|
|
71
|
+
|
|
72
|
+
mock_df = _mock_cache_frame(datetime(2025, 1, 1, tzinfo=timezone.utc))
|
|
73
|
+
|
|
74
|
+
monkeypatch.setattr(
|
|
75
|
+
thetadata_helper,
|
|
76
|
+
"build_cache_filename",
|
|
77
|
+
lambda *args, **kwargs: cache_file,
|
|
78
|
+
)
|
|
79
|
+
monkeypatch.setattr(thetadata_helper, "load_cache", lambda _: mock_df)
|
|
80
|
+
monkeypatch.setattr(thetadata_helper, "get_missing_dates", lambda *args, **kwargs: [])
|
|
81
|
+
|
|
82
|
+
asset = Asset("SPY", asset_type=Asset.AssetType.STOCK)
|
|
83
|
+
|
|
84
|
+
with pytest.raises(ValueError) as excinfo:
|
|
85
|
+
thetadata_helper.get_price_data(
|
|
86
|
+
username="demo",
|
|
87
|
+
password="demo",
|
|
88
|
+
asset=asset,
|
|
89
|
+
start=datetime(2025, 1, 1, tzinfo=timezone.utc),
|
|
90
|
+
end=datetime(2025, 1, 2, tzinfo=timezone.utc),
|
|
91
|
+
timespan="minute",
|
|
92
|
+
datastyle="ohlc",
|
|
93
|
+
include_after_hours=True,
|
|
94
|
+
return_polars=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
assert "polars output" in str(excinfo.value).lower()
|