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.

@@ -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(df, updated_data.drop(columns="missing"))
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="T", tz=pytz.UTC),
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
- """Test option chain caching matches Polygon pattern - ZERO TOLERANCE."""
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
- def test_chains_cached_basic_structure(self):
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
- asset = Asset("SPY", asset_type="stock")
1343
- test_date = date(2025, 9, 15)
1355
+ def fake_builder(**kwargs):
1356
+ calls.append(kwargs)
1357
+ return sample_chain
1344
1358
 
1345
- chains = thetadata_helper.get_chains_cached(username, password, asset, test_date)
1359
+ monkeypatch.setattr(thetadata_helper, "build_historical_chain", fake_builder)
1360
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
1346
1361
 
1347
- assert chains is not None, "Chains should not be None"
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
- # Verify at least one expiration exists
1356
- assert len(chains["Chains"]["CALL"]) > 0, "Should have at least one CALL expiration"
1357
- assert len(chains["Chains"]["PUT"]) > 0, "Should have at least one PUT expiration"
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
- print(f"✓ Chain structure valid: {len(chains['Chains']['CALL'])} expirations")
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
- def test_chains_cache_reuse(self):
1362
- """Test that second call reuses cached data (no API call)."""
1363
- import time
1364
- from pathlib import Path
1365
- from lumibot.constants import LUMIBOT_CACHE_FOLDER
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
- username = os.environ.get("THETADATA_USERNAME")
1368
- password = os.environ.get("THETADATA_PASSWORD")
1380
+ call_count = {"total": 0}
1369
1381
 
1370
- asset = Asset("AAPL", asset_type="stock")
1371
- test_date = date(2025, 9, 15)
1382
+ def fake_builder(**kwargs):
1383
+ call_count["total"] += 1
1384
+ return sample_chain
1372
1385
 
1373
- # CLEAR CACHE to ensure first call downloads fresh data
1374
- # This prevents cache pollution from previous tests in the suite
1375
- # Chains are stored in: LUMIBOT_CACHE_FOLDER / "thetadata" / "option" / "option_chains"
1376
- chain_folder = Path(LUMIBOT_CACHE_FOLDER) / "thetadata" / "option" / "option_chains"
1377
- if chain_folder.exists():
1378
- # Delete all AAPL chain cache files
1379
- for cache_file in chain_folder.glob("AAPL_*.parquet"):
1380
- try:
1381
- cache_file.unlink()
1382
- except Exception:
1383
- pass
1384
-
1385
- # Restart ThetaData Terminal to ensure fresh connection after cache clearing
1386
- # This is necessary because cache clearing may interfere with active connections
1387
- thetadata_helper.start_theta_data_client(username, password)
1388
- time.sleep(3) # Give Terminal time to fully connect
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
- # Verify connection is established
1391
- _, connected = thetadata_helper.check_connection(username, password)
1392
- assert connected, "ThetaData Terminal failed to connect"
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
- # First call - downloads (now guaranteed to be fresh)
1395
- start1 = time.time()
1396
- chains1 = thetadata_helper.get_chains_cached(username, password, asset, test_date)
1397
- time1 = time.time() - start1
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
- # Second call - should use cache
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
- # Verify same data
1405
- assert chains1 == chains2, "Cached chains should match original"
1529
+ with caplog.at_level(logging.WARNING):
1530
+ result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
1406
1531
 
1407
- # Second call should be MUCH faster (cached)
1408
- assert time2 < time1 * 0.1, f"Cache not working: time1={time1:.2f}s, time2={time2:.2f}s (should be 10x faster)"
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():