lumibot 4.2.4__py3-none-any.whl → 4.2.7__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/databento_backtesting_pandas.py +32 -7
- lumibot/backtesting/thetadata_backtesting_pandas.py +1 -1
- lumibot/components/options_helper.py +86 -23
- lumibot/strategies/_strategy.py +24 -4
- lumibot/strategies/strategy_executor.py +1 -3
- lumibot/tools/ccxt_data_store.py +1 -1
- lumibot/tools/databento_helper.py +17 -9
- lumibot/tools/thetadata_helper.py +255 -59
- {lumibot-4.2.4.dist-info → lumibot-4.2.7.dist-info}/METADATA +2 -2
- {lumibot-4.2.4.dist-info → lumibot-4.2.7.dist-info}/RECORD +18 -17
- tests/test_backtesting_datetime_normalization.py +4 -0
- tests/test_options_helper.py +45 -3
- tests/test_projectx_timestep_alias.py +1 -2
- tests/test_strategy_price_guard.py +50 -0
- tests/test_thetadata_helper.py +187 -63
- {lumibot-4.2.4.dist-info → lumibot-4.2.7.dist-info}/WHEEL +0 -0
- {lumibot-4.2.4.dist-info → lumibot-4.2.7.dist-info}/licenses/LICENSE +0 -0
- {lumibot-4.2.4.dist-info → lumibot-4.2.7.dist-info}/top_level.txt +0 -0
tests/test_thetadata_helper.py
CHANGED
|
@@ -138,7 +138,11 @@ def test_get_price_data_partial_cache_hit(mock_build_cache_filename, mock_load_c
|
|
|
138
138
|
assert df is not None
|
|
139
139
|
assert len(df) == 10 # Combined cached and fetched data
|
|
140
140
|
mock_get_historical_data.assert_called_once()
|
|
141
|
-
pd.testing.assert_frame_equal(
|
|
141
|
+
pd.testing.assert_frame_equal(
|
|
142
|
+
df,
|
|
143
|
+
updated_data.drop(columns="missing"),
|
|
144
|
+
check_dtype=False,
|
|
145
|
+
)
|
|
142
146
|
mock_update_cache.assert_called_once()
|
|
143
147
|
|
|
144
148
|
|
|
@@ -452,7 +456,7 @@ def test_get_price_data_invokes_remote_cache_manager(tmp_path, monkeypatch):
|
|
|
452
456
|
|
|
453
457
|
df = pd.DataFrame(
|
|
454
458
|
{
|
|
455
|
-
"datetime": pd.date_range("2024-01-01 09:30:00", periods=2, freq="
|
|
459
|
+
"datetime": pd.date_range("2024-01-01 09:30:00", periods=2, freq="min", tz=pytz.UTC),
|
|
456
460
|
"open": [100.0, 101.0],
|
|
457
461
|
"high": [101.0, 102.0],
|
|
458
462
|
"low": [99.5, 100.5],
|
|
@@ -1330,83 +1334,203 @@ class TestThetaDataProcessHealthCheck:
|
|
|
1330
1334
|
assert thetadata_helper.is_process_alive() is True, "New process should be alive"
|
|
1331
1335
|
|
|
1332
1336
|
|
|
1333
|
-
@pytest.mark.apitest
|
|
1334
1337
|
class TestThetaDataChainsCaching:
|
|
1335
|
-
"""
|
|
1338
|
+
"""Unit coverage for historical chain caching and normalization."""
|
|
1339
|
+
|
|
1340
|
+
def test_chains_cached_basic_structure(self, tmp_path, monkeypatch):
|
|
1341
|
+
asset = Asset("TEST", asset_type="stock")
|
|
1342
|
+
test_date = date(2024, 11, 7)
|
|
1343
|
+
|
|
1344
|
+
sample_chain = {
|
|
1345
|
+
"Multiplier": 100,
|
|
1346
|
+
"Exchange": "SMART",
|
|
1347
|
+
"Chains": {
|
|
1348
|
+
"CALL": {"2024-11-15": [100.0, 105.0]},
|
|
1349
|
+
"PUT": {"2024-11-15": [90.0, 95.0]},
|
|
1350
|
+
},
|
|
1351
|
+
}
|
|
1336
1352
|
|
|
1337
|
-
|
|
1338
|
-
"""Test chain caching returns correct structure."""
|
|
1339
|
-
username = os.environ.get("THETADATA_USERNAME")
|
|
1340
|
-
password = os.environ.get("THETADATA_PASSWORD")
|
|
1353
|
+
calls = []
|
|
1341
1354
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1355
|
+
def fake_builder(**kwargs):
|
|
1356
|
+
calls.append(kwargs)
|
|
1357
|
+
return sample_chain
|
|
1344
1358
|
|
|
1345
|
-
|
|
1359
|
+
monkeypatch.setattr(thetadata_helper, "build_historical_chain", fake_builder)
|
|
1360
|
+
monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
|
|
1346
1361
|
|
|
1347
|
-
|
|
1348
|
-
assert "Multiplier" in chains, "Missing Multiplier"
|
|
1349
|
-
assert chains["Multiplier"] == 100, f"Multiplier should be 100, got {chains['Multiplier']}"
|
|
1350
|
-
assert "Exchange" in chains, "Missing Exchange"
|
|
1351
|
-
assert "Chains" in chains, "Missing Chains"
|
|
1352
|
-
assert "CALL" in chains["Chains"], "Missing CALL chains"
|
|
1353
|
-
assert "PUT" in chains["Chains"], "Missing PUT chains"
|
|
1362
|
+
result = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
|
|
1354
1363
|
|
|
1355
|
-
|
|
1356
|
-
assert len(
|
|
1357
|
-
|
|
1364
|
+
assert result == sample_chain
|
|
1365
|
+
assert len(calls) == 1
|
|
1366
|
+
builder_call = calls[0]
|
|
1367
|
+
assert builder_call["asset"] == asset
|
|
1368
|
+
assert builder_call["as_of_date"] == test_date
|
|
1358
1369
|
|
|
1359
|
-
|
|
1370
|
+
def test_chains_cache_reuse(self, tmp_path, monkeypatch):
|
|
1371
|
+
asset = Asset("REUSE", asset_type="stock")
|
|
1372
|
+
test_date = date(2024, 11, 8)
|
|
1360
1373
|
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1374
|
+
sample_chain = {
|
|
1375
|
+
"Multiplier": 100,
|
|
1376
|
+
"Exchange": "SMART",
|
|
1377
|
+
"Chains": {"CALL": {"2024-11-22": [110.0]}, "PUT": {"2024-11-22": [95.0]}},
|
|
1378
|
+
}
|
|
1366
1379
|
|
|
1367
|
-
|
|
1368
|
-
password = os.environ.get("THETADATA_PASSWORD")
|
|
1380
|
+
call_count = {"total": 0}
|
|
1369
1381
|
|
|
1370
|
-
|
|
1371
|
-
|
|
1382
|
+
def fake_builder(**kwargs):
|
|
1383
|
+
call_count["total"] += 1
|
|
1384
|
+
return sample_chain
|
|
1372
1385
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1386
|
+
monkeypatch.setattr(thetadata_helper, "build_historical_chain", fake_builder)
|
|
1387
|
+
monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
|
|
1388
|
+
|
|
1389
|
+
first = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
|
|
1390
|
+
second = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
|
|
1391
|
+
|
|
1392
|
+
assert first == sample_chain
|
|
1393
|
+
assert second == sample_chain
|
|
1394
|
+
assert call_count["total"] == 1, "Builder should only run once due to cache reuse"
|
|
1395
|
+
|
|
1396
|
+
def test_chain_cache_respects_recent_file(self, tmp_path, monkeypatch):
|
|
1397
|
+
asset = Asset("RECENT", asset_type="stock")
|
|
1398
|
+
test_date = date(2024, 11, 30)
|
|
1399
|
+
|
|
1400
|
+
sample_chain = {
|
|
1401
|
+
"Multiplier": 100,
|
|
1402
|
+
"Exchange": "SMART",
|
|
1403
|
+
"Chains": {"CALL": {"2024-12-06": [120.0]}, "PUT": {"2024-12-06": [80.0]}},
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
|
|
1407
|
+
|
|
1408
|
+
cache_folder = Path(tmp_path) / "thetadata" / "stock" / "option_chains"
|
|
1409
|
+
cache_folder.mkdir(parents=True, exist_ok=True)
|
|
1410
|
+
|
|
1411
|
+
cache_file = cache_folder / f"{asset.symbol}_{test_date.isoformat()}.parquet"
|
|
1412
|
+
pd.DataFrame({"data": [sample_chain]}).to_parquet(cache_file, compression="snappy", engine="pyarrow")
|
|
1413
|
+
|
|
1414
|
+
# Builder should not be invoked because cache hit satisfies tolerance window
|
|
1415
|
+
def fail_builder(**kwargs):
|
|
1416
|
+
raise AssertionError("build_historical_chain should not be called when cache is fresh")
|
|
1417
|
+
|
|
1418
|
+
monkeypatch.setattr(thetadata_helper, "build_historical_chain", fail_builder)
|
|
1419
|
+
|
|
1420
|
+
result = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
|
|
1421
|
+
assert result == sample_chain
|
|
1422
|
+
|
|
1423
|
+
def test_chains_cached_handles_none_builder(self, tmp_path, monkeypatch, caplog):
|
|
1424
|
+
asset = Asset("NONE", asset_type="stock")
|
|
1425
|
+
test_date = date(2024, 11, 28)
|
|
1426
|
+
|
|
1427
|
+
monkeypatch.setattr(thetadata_helper, "build_historical_chain", lambda **kwargs: None)
|
|
1428
|
+
monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
|
|
1429
|
+
|
|
1430
|
+
with caplog.at_level(logging.WARNING):
|
|
1431
|
+
result = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
|
|
1432
|
+
|
|
1433
|
+
cache_folder = Path(tmp_path) / "thetadata" / "stock" / "option_chains"
|
|
1434
|
+
assert not cache_folder.exists() or not list(cache_folder.glob("*.parquet"))
|
|
1435
|
+
|
|
1436
|
+
assert result == {
|
|
1437
|
+
"Multiplier": 100,
|
|
1438
|
+
"Exchange": "SMART",
|
|
1439
|
+
"Chains": {"CALL": {}, "PUT": {}},
|
|
1440
|
+
}
|
|
1441
|
+
assert "ThetaData returned no option data" in caplog.text
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def test_build_historical_chain_parses_quote_payload(monkeypatch):
|
|
1445
|
+
asset = Asset("CVNA", asset_type="stock")
|
|
1446
|
+
as_of_date = date(2024, 11, 7)
|
|
1447
|
+
as_of_int = int(as_of_date.strftime("%Y%m%d"))
|
|
1448
|
+
|
|
1449
|
+
def fake_get_request(url, headers, querystring, username, password):
|
|
1450
|
+
if url.endswith("/v2/list/expirations"):
|
|
1451
|
+
return {
|
|
1452
|
+
"header": {"format": ["date"]},
|
|
1453
|
+
"response": [[20241115], [20241205], [20250124]],
|
|
1454
|
+
}
|
|
1455
|
+
if url.endswith("/v2/list/strikes"):
|
|
1456
|
+
exp = querystring["exp"]
|
|
1457
|
+
if exp == "20241115":
|
|
1458
|
+
return {
|
|
1459
|
+
"header": {"format": ["strike"]},
|
|
1460
|
+
"response": [[100000], [105000]],
|
|
1461
|
+
}
|
|
1462
|
+
if exp == "20241205":
|
|
1463
|
+
return {
|
|
1464
|
+
"header": {"format": ["strike"]},
|
|
1465
|
+
"response": [[110000]],
|
|
1466
|
+
}
|
|
1467
|
+
return {
|
|
1468
|
+
"header": {"format": ["strike"]},
|
|
1469
|
+
"response": [[120000]],
|
|
1470
|
+
}
|
|
1471
|
+
if url.endswith("/list/dates/option/quote"):
|
|
1472
|
+
exp = querystring["exp"]
|
|
1473
|
+
if exp == "20241115":
|
|
1474
|
+
return {
|
|
1475
|
+
"header": {"format": None, "error_type": "null"},
|
|
1476
|
+
"response": [as_of_int, as_of_int + 1],
|
|
1477
|
+
}
|
|
1478
|
+
return {
|
|
1479
|
+
"header": {"format": None, "error_type": "NO_DATA"},
|
|
1480
|
+
"response": [],
|
|
1481
|
+
}
|
|
1482
|
+
raise AssertionError(f"Unexpected URL {url}")
|
|
1483
|
+
|
|
1484
|
+
monkeypatch.setattr(thetadata_helper, "get_request", fake_get_request)
|
|
1485
|
+
|
|
1486
|
+
result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
|
|
1487
|
+
|
|
1488
|
+
assert result["Multiplier"] == 100
|
|
1489
|
+
assert set(result["Chains"].keys()) == {"CALL", "PUT"}
|
|
1490
|
+
assert list(result["Chains"]["CALL"].keys()) == ["2024-11-15"]
|
|
1491
|
+
assert result["Chains"]["CALL"]["2024-11-15"] == [100.0, 105.0]
|
|
1492
|
+
assert result["Chains"]["PUT"]["2024-11-15"] == [100.0, 105.0]
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def test_build_historical_chain_returns_none_when_no_dates(monkeypatch, caplog):
|
|
1496
|
+
asset = Asset("NONE", asset_type="stock")
|
|
1497
|
+
as_of_date = date(2024, 11, 28)
|
|
1498
|
+
|
|
1499
|
+
as_of_int = int(as_of_date.strftime("%Y%m%d"))
|
|
1500
|
+
|
|
1501
|
+
def fake_get_request(url, headers, querystring, username, password):
|
|
1502
|
+
if url.endswith("/v2/list/expirations"):
|
|
1503
|
+
return {"header": {"format": ["date"]}, "response": [[20241129], [20241206]]}
|
|
1504
|
+
if url.endswith("/v2/list/strikes"):
|
|
1505
|
+
return {"header": {"format": ["strike"]}, "response": [[150000], [155000]]}
|
|
1506
|
+
if url.endswith("/list/dates/option/quote"):
|
|
1507
|
+
return {"header": {"format": None, "error_type": "NO_DATA"}, "response": []}
|
|
1508
|
+
raise AssertionError(f"Unexpected URL {url}")
|
|
1509
|
+
|
|
1510
|
+
monkeypatch.setattr(thetadata_helper, "get_request", fake_get_request)
|
|
1511
|
+
|
|
1512
|
+
with caplog.at_level(logging.WARNING):
|
|
1513
|
+
result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
|
|
1514
|
+
|
|
1515
|
+
assert result is None
|
|
1516
|
+
assert f"No expirations with data found for {asset.symbol}" in caplog.text
|
|
1389
1517
|
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1518
|
+
def test_build_historical_chain_empty_response(monkeypatch, caplog):
|
|
1519
|
+
asset = Asset("EMPTY", asset_type="stock")
|
|
1520
|
+
as_of_date = date(2024, 11, 9)
|
|
1393
1521
|
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1522
|
+
def fake_get_request(url, headers, querystring, username, password):
|
|
1523
|
+
if url.endswith("/v2/list/expirations"):
|
|
1524
|
+
return {"header": {"format": ["date"]}, "response": []}
|
|
1525
|
+
raise AssertionError("Unexpected call after empty expirations")
|
|
1398
1526
|
|
|
1399
|
-
|
|
1400
|
-
start2 = time.time()
|
|
1401
|
-
chains2 = thetadata_helper.get_chains_cached(username, password, asset, test_date)
|
|
1402
|
-
time2 = time.time() - start2
|
|
1527
|
+
monkeypatch.setattr(thetadata_helper, "get_request", fake_get_request)
|
|
1403
1528
|
|
|
1404
|
-
|
|
1405
|
-
|
|
1529
|
+
with caplog.at_level(logging.WARNING):
|
|
1530
|
+
result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
|
|
1406
1531
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
print(f"✓ Cache speedup: {time1/time2:.1f}x faster ({time1:.2f}s -> {time2:.4f}s)")
|
|
1532
|
+
assert result is None
|
|
1533
|
+
assert "returned no expirations" in caplog.text
|
|
1410
1534
|
|
|
1411
1535
|
|
|
1412
1536
|
def test_finalize_day_frame_handles_dst_fallback():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|