lumibot 4.2.5__py3-none-any.whl → 4.2.9__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 +12 -6
- lumibot/tools/ccxt_data_store.py +1 -1
- lumibot/tools/databento_helper.py +17 -9
- lumibot/tools/thetadata_helper.py +348 -95
- {lumibot-4.2.5.dist-info → lumibot-4.2.9.dist-info}/METADATA +2 -2
- {lumibot-4.2.5.dist-info → lumibot-4.2.9.dist-info}/RECORD +16 -15
- 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 +260 -63
- {lumibot-4.2.5.dist-info → lumibot-4.2.9.dist-info}/WHEEL +0 -0
- {lumibot-4.2.5.dist-info → lumibot-4.2.9.dist-info}/licenses/LICENSE +0 -0
- {lumibot-4.2.5.dist-info → lumibot-4.2.9.dist-info}/top_level.txt +0 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import time
|
|
3
3
|
import os
|
|
4
4
|
import signal
|
|
5
|
-
from typing import Dict, List, Optional
|
|
5
|
+
from typing import Dict, List, Optional, Tuple
|
|
6
|
+
from collections import defaultdict
|
|
6
7
|
from datetime import date, datetime, timedelta, timezone
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
import pytz
|
|
@@ -25,6 +26,7 @@ CONNECTION_RETRY_SLEEP = 1.0
|
|
|
25
26
|
CONNECTION_MAX_RETRIES = 60
|
|
26
27
|
BOOT_GRACE_PERIOD = 5.0
|
|
27
28
|
MAX_RESTART_ATTEMPTS = 3
|
|
29
|
+
MAX_TERMINAL_RESTART_CYCLES = 3
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
def _resolve_asset_folder(asset_obj: Asset) -> str:
|
|
@@ -42,6 +44,12 @@ THETA_DATA_PROCESS = None
|
|
|
42
44
|
THETA_DATA_PID = None
|
|
43
45
|
THETA_DATA_LOG_HANDLE = None
|
|
44
46
|
|
|
47
|
+
|
|
48
|
+
class ThetaDataConnectionError(RuntimeError):
|
|
49
|
+
"""Raised when ThetaTerminal cannot reconnect to Theta Data after multiple restarts."""
|
|
50
|
+
|
|
51
|
+
pass
|
|
52
|
+
|
|
45
53
|
def reset_connection_diagnostics():
|
|
46
54
|
"""Reset ThetaData connection counters (useful for tests)."""
|
|
47
55
|
CONNECTION_DIAGNOSTICS.update({
|
|
@@ -49,6 +57,7 @@ def reset_connection_diagnostics():
|
|
|
49
57
|
"start_terminal_calls": 0,
|
|
50
58
|
"network_requests": 0,
|
|
51
59
|
"placeholder_writes": 0,
|
|
60
|
+
"terminal_restarts": 0,
|
|
52
61
|
})
|
|
53
62
|
|
|
54
63
|
|
|
@@ -197,6 +206,7 @@ CONNECTION_DIAGNOSTICS = {
|
|
|
197
206
|
"start_terminal_calls": 0,
|
|
198
207
|
"network_requests": 0,
|
|
199
208
|
"placeholder_writes": 0,
|
|
209
|
+
"terminal_restarts": 0,
|
|
200
210
|
}
|
|
201
211
|
|
|
202
212
|
|
|
@@ -1394,7 +1404,6 @@ def check_connection(username: str, password: str, wait_for_connection: bool = F
|
|
|
1394
1404
|
|
|
1395
1405
|
max_retries = CONNECTION_MAX_RETRIES
|
|
1396
1406
|
sleep_interval = CONNECTION_RETRY_SLEEP
|
|
1397
|
-
restart_attempts = 0
|
|
1398
1407
|
client = None
|
|
1399
1408
|
|
|
1400
1409
|
def probe_status() -> Optional[str]:
|
|
@@ -1434,47 +1443,72 @@ def check_connection(username: str, password: str, wait_for_connection: bool = F
|
|
|
1434
1443
|
logger.debug("ThetaTerminal running but not yet CONNECTED; waiting for status.")
|
|
1435
1444
|
return check_connection(username=username, password=password, wait_for_connection=True)
|
|
1436
1445
|
|
|
1437
|
-
|
|
1438
|
-
connected = False
|
|
1439
|
-
|
|
1440
|
-
while counter < max_retries:
|
|
1441
|
-
status_text = probe_status()
|
|
1442
|
-
if status_text == "CONNECTED":
|
|
1443
|
-
if counter:
|
|
1444
|
-
logger.info("ThetaTerminal connected after %s attempt(s).", counter + 1)
|
|
1445
|
-
connected = True
|
|
1446
|
-
break
|
|
1447
|
-
elif status_text == "DISCONNECTED":
|
|
1448
|
-
logger.debug("ThetaTerminal reports DISCONNECTED; will retry.")
|
|
1449
|
-
elif status_text is not None:
|
|
1450
|
-
logger.debug(f"ThetaTerminal returned unexpected status: {status_text}")
|
|
1451
|
-
|
|
1452
|
-
if not is_process_alive():
|
|
1453
|
-
if restart_attempts >= MAX_RESTART_ATTEMPTS:
|
|
1454
|
-
logger.error("ThetaTerminal not running after %s restart attempts.", restart_attempts)
|
|
1455
|
-
break
|
|
1456
|
-
restart_attempts += 1
|
|
1457
|
-
logger.warning("ThetaTerminal process is not running (restart #%s).", restart_attempts)
|
|
1458
|
-
client = start_theta_data_client(username=username, password=password)
|
|
1459
|
-
time.sleep(max(BOOT_GRACE_PERIOD, sleep_interval))
|
|
1460
|
-
counter = 0
|
|
1461
|
-
continue
|
|
1446
|
+
total_restart_cycles = 0
|
|
1462
1447
|
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1448
|
+
while True:
|
|
1449
|
+
counter = 0
|
|
1450
|
+
restart_attempts = 0
|
|
1451
|
+
|
|
1452
|
+
while counter < max_retries:
|
|
1453
|
+
status_text = probe_status()
|
|
1454
|
+
if status_text == "CONNECTED":
|
|
1455
|
+
if counter or total_restart_cycles:
|
|
1456
|
+
logger.info(
|
|
1457
|
+
"ThetaTerminal connected after %s attempt(s) (restart cycles=%s).",
|
|
1458
|
+
counter + 1,
|
|
1459
|
+
total_restart_cycles,
|
|
1460
|
+
)
|
|
1461
|
+
return client, True
|
|
1462
|
+
elif status_text == "DISCONNECTED":
|
|
1463
|
+
logger.debug("ThetaTerminal reports DISCONNECTED; will retry.")
|
|
1464
|
+
elif status_text is not None:
|
|
1465
|
+
logger.debug(f"ThetaTerminal returned unexpected status: {status_text}")
|
|
1466
|
+
|
|
1467
|
+
if not is_process_alive():
|
|
1468
|
+
if restart_attempts >= MAX_RESTART_ATTEMPTS:
|
|
1469
|
+
logger.error("ThetaTerminal not running after %s restart attempts.", restart_attempts)
|
|
1470
|
+
break
|
|
1471
|
+
restart_attempts += 1
|
|
1472
|
+
logger.warning("ThetaTerminal process is not running (restart #%s).", restart_attempts)
|
|
1473
|
+
client = start_theta_data_client(username=username, password=password)
|
|
1474
|
+
CONNECTION_DIAGNOSTICS["terminal_restarts"] = CONNECTION_DIAGNOSTICS.get("terminal_restarts", 0) + 1
|
|
1475
|
+
time.sleep(max(BOOT_GRACE_PERIOD, sleep_interval))
|
|
1476
|
+
counter = 0
|
|
1477
|
+
continue
|
|
1467
1478
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1479
|
+
counter += 1
|
|
1480
|
+
if counter % 10 == 0:
|
|
1481
|
+
logger.info("Waiting for ThetaTerminal connection (attempt %s/%s).", counter, max_retries)
|
|
1482
|
+
time.sleep(sleep_interval)
|
|
1483
|
+
|
|
1484
|
+
total_restart_cycles += 1
|
|
1485
|
+
if total_restart_cycles > MAX_TERMINAL_RESTART_CYCLES:
|
|
1486
|
+
logger.error(
|
|
1487
|
+
"Unable to connect to Theta Data after %s restart cycle(s) (%s attempts each).",
|
|
1488
|
+
MAX_TERMINAL_RESTART_CYCLES,
|
|
1489
|
+
max_retries,
|
|
1490
|
+
)
|
|
1491
|
+
raise ThetaDataConnectionError(
|
|
1492
|
+
f"Unable to connect to Theta Data after {MAX_TERMINAL_RESTART_CYCLES} restart cycle(s)."
|
|
1493
|
+
)
|
|
1470
1494
|
|
|
1471
|
-
|
|
1495
|
+
logger.warning(
|
|
1496
|
+
"ThetaTerminal still disconnected after %s attempts; restarting (cycle %s/%s).",
|
|
1497
|
+
max_retries,
|
|
1498
|
+
total_restart_cycles,
|
|
1499
|
+
MAX_TERMINAL_RESTART_CYCLES,
|
|
1500
|
+
)
|
|
1501
|
+
client = start_theta_data_client(username=username, password=password)
|
|
1502
|
+
CONNECTION_DIAGNOSTICS["terminal_restarts"] = CONNECTION_DIAGNOSTICS.get("terminal_restarts", 0) + 1
|
|
1503
|
+
time.sleep(max(BOOT_GRACE_PERIOD, sleep_interval))
|
|
1472
1504
|
|
|
1473
1505
|
|
|
1474
1506
|
def get_request(url: str, headers: dict, querystring: dict, username: str, password: str):
|
|
1475
1507
|
all_responses = []
|
|
1476
1508
|
next_page_url = None
|
|
1477
1509
|
page_count = 0
|
|
1510
|
+
consecutive_disconnects = 0
|
|
1511
|
+
restart_budget = 3
|
|
1478
1512
|
|
|
1479
1513
|
# Lightweight liveness probe before issuing the request
|
|
1480
1514
|
check_connection(username=username, password=password, wait_for_connection=False)
|
|
@@ -1497,25 +1531,52 @@ def get_request(url: str, headers: dict, querystring: dict, username: str, passw
|
|
|
1497
1531
|
)
|
|
1498
1532
|
|
|
1499
1533
|
response = requests.get(request_url, headers=headers, params=request_params)
|
|
1534
|
+
status_code = response.status_code
|
|
1500
1535
|
# Status code 472 means "No data" - this is valid, return None
|
|
1501
|
-
if
|
|
1536
|
+
if status_code == 472:
|
|
1502
1537
|
logger.warning(f"No data available for request: {response.text[:200]}")
|
|
1503
1538
|
# DEBUG-LOG: API response - no data
|
|
1504
1539
|
logger.debug(
|
|
1505
1540
|
"[THETA][DEBUG][API][RESPONSE] status=472 result=NO_DATA"
|
|
1506
1541
|
)
|
|
1542
|
+
consecutive_disconnects = 0
|
|
1507
1543
|
return None
|
|
1544
|
+
elif status_code == 474:
|
|
1545
|
+
consecutive_disconnects += 1
|
|
1546
|
+
logger.warning("Received 474 from Theta Data (attempt %s): %s", counter + 1, response.text[:200])
|
|
1547
|
+
if consecutive_disconnects >= 2:
|
|
1548
|
+
if restart_budget <= 0:
|
|
1549
|
+
logger.error("Restart budget exhausted after repeated 474 responses.")
|
|
1550
|
+
raise ValueError("Cannot connect to Theta Data!")
|
|
1551
|
+
logger.warning(
|
|
1552
|
+
"Restarting ThetaTerminal after %s consecutive 474 responses (restart budget remaining %s).",
|
|
1553
|
+
consecutive_disconnects,
|
|
1554
|
+
restart_budget - 1,
|
|
1555
|
+
)
|
|
1556
|
+
restart_budget -= 1
|
|
1557
|
+
start_theta_data_client(username=username, password=password)
|
|
1558
|
+
CONNECTION_DIAGNOSTICS["terminal_restarts"] = CONNECTION_DIAGNOSTICS.get("terminal_restarts", 0) + 1
|
|
1559
|
+
check_connection(username=username, password=password, wait_for_connection=True)
|
|
1560
|
+
time.sleep(max(BOOT_GRACE_PERIOD, CONNECTION_RETRY_SLEEP))
|
|
1561
|
+
consecutive_disconnects = 0
|
|
1562
|
+
counter = 0
|
|
1563
|
+
else:
|
|
1564
|
+
check_connection(username=username, password=password, wait_for_connection=True)
|
|
1565
|
+
time.sleep(CONNECTION_RETRY_SLEEP)
|
|
1566
|
+
continue
|
|
1508
1567
|
# If status code is not 200, then we are not connected
|
|
1509
|
-
elif
|
|
1510
|
-
logger.warning(f"Non-200 status code {
|
|
1568
|
+
elif status_code != 200:
|
|
1569
|
+
logger.warning(f"Non-200 status code {status_code}: {response.text[:200]}")
|
|
1511
1570
|
# DEBUG-LOG: API response - error
|
|
1512
1571
|
logger.debug(
|
|
1513
1572
|
"[THETA][DEBUG][API][RESPONSE] status=%d result=ERROR",
|
|
1514
|
-
|
|
1573
|
+
status_code
|
|
1515
1574
|
)
|
|
1516
1575
|
check_connection(username=username, password=password, wait_for_connection=True)
|
|
1576
|
+
consecutive_disconnects = 0
|
|
1517
1577
|
else:
|
|
1518
1578
|
json_resp = response.json()
|
|
1579
|
+
consecutive_disconnects = 0
|
|
1519
1580
|
|
|
1520
1581
|
# DEBUG-LOG: API response - success
|
|
1521
1582
|
response_rows = len(json_resp.get("response", [])) if isinstance(json_resp.get("response"), list) else 0
|
|
@@ -1541,6 +1602,9 @@ def get_request(url: str, headers: dict, querystring: dict, username: str, passw
|
|
|
1541
1602
|
else:
|
|
1542
1603
|
break
|
|
1543
1604
|
|
|
1605
|
+
except ThetaDataConnectionError as exc:
|
|
1606
|
+
logger.error("Theta Data connection failed after supervised restarts: %s", exc)
|
|
1607
|
+
raise
|
|
1544
1608
|
except Exception as e:
|
|
1545
1609
|
logger.warning(f"Exception during request (attempt {counter + 1}): {e}")
|
|
1546
1610
|
check_connection(username=username, password=password, wait_for_connection=True)
|
|
@@ -1550,7 +1614,7 @@ def get_request(url: str, headers: dict, querystring: dict, username: str, passw
|
|
|
1550
1614
|
|
|
1551
1615
|
counter += 1
|
|
1552
1616
|
if counter > 1:
|
|
1553
|
-
raise
|
|
1617
|
+
raise ThetaDataConnectionError("Unable to connect to Theta Data after repeated retries.")
|
|
1554
1618
|
|
|
1555
1619
|
# Store this page's response data
|
|
1556
1620
|
page_count += 1
|
|
@@ -1883,55 +1947,242 @@ def get_historical_data(asset: Asset, start_dt: datetime, end_dt: datetime, ivl:
|
|
|
1883
1947
|
return df
|
|
1884
1948
|
|
|
1885
1949
|
|
|
1886
|
-
def
|
|
1887
|
-
"""
|
|
1888
|
-
|
|
1950
|
+
def _normalize_expiration_value(raw_value: object) -> Optional[str]:
|
|
1951
|
+
"""Convert ThetaData expiration payloads to ISO date strings."""
|
|
1952
|
+
if raw_value is None or (isinstance(raw_value, float) and pd.isna(raw_value)):
|
|
1953
|
+
return None
|
|
1889
1954
|
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1955
|
+
if isinstance(raw_value, (int, float)):
|
|
1956
|
+
try:
|
|
1957
|
+
digits = int(raw_value)
|
|
1958
|
+
except (TypeError, ValueError):
|
|
1959
|
+
return None
|
|
1960
|
+
if digits <= 0:
|
|
1961
|
+
return None
|
|
1962
|
+
text = f"{digits:08d}"
|
|
1963
|
+
return f"{text[0:4]}-{text[4:6]}-{text[6:8]}"
|
|
1898
1964
|
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1965
|
+
text_value = str(raw_value).strip()
|
|
1966
|
+
if not text_value:
|
|
1967
|
+
return None
|
|
1968
|
+
if text_value.isdigit() and len(text_value) == 8:
|
|
1969
|
+
return f"{text_value[0:4]}-{text_value[4:6]}-{text_value[6:8]}"
|
|
1970
|
+
if len(text_value.split("-")) == 3:
|
|
1971
|
+
return text_value
|
|
1972
|
+
return None
|
|
1906
1973
|
|
|
1907
|
-
|
|
1974
|
+
|
|
1975
|
+
def _normalize_strike_value(raw_value: object) -> Optional[float]:
|
|
1976
|
+
"""Convert ThetaData strike payloads to float strikes in dollars."""
|
|
1977
|
+
if raw_value is None or (isinstance(raw_value, float) and pd.isna(raw_value)):
|
|
1978
|
+
return None
|
|
1979
|
+
|
|
1980
|
+
try:
|
|
1981
|
+
strike = float(raw_value)
|
|
1982
|
+
except (TypeError, ValueError):
|
|
1983
|
+
return None
|
|
1984
|
+
|
|
1985
|
+
if strike <= 0:
|
|
1986
|
+
return None
|
|
1987
|
+
|
|
1988
|
+
# ThetaData encodes strikes in thousandths of a dollar for integer payloads
|
|
1989
|
+
if strike > 10000:
|
|
1990
|
+
strike /= 1000.0
|
|
1991
|
+
|
|
1992
|
+
return round(strike, 4)
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
def _detect_column(df: pd.DataFrame, candidates: Tuple[str, ...]) -> Optional[str]:
|
|
1996
|
+
"""Find the first column name matching the provided candidates (case-insensitive)."""
|
|
1997
|
+
normalized = {str(col).strip().lower(): col for col in df.columns}
|
|
1998
|
+
for candidate in candidates:
|
|
1999
|
+
lookup = candidate.lower()
|
|
2000
|
+
if lookup in normalized:
|
|
2001
|
+
return normalized[lookup]
|
|
2002
|
+
return None
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
def build_historical_chain(
|
|
2006
|
+
username: str,
|
|
2007
|
+
password: str,
|
|
2008
|
+
asset: Asset,
|
|
2009
|
+
as_of_date: date,
|
|
2010
|
+
max_expirations: int = 120,
|
|
2011
|
+
max_consecutive_misses: int = 10,
|
|
2012
|
+
) -> Dict[str, Dict[str, List[float]]]:
|
|
2013
|
+
"""Build an as-of option chain by filtering live expirations against quote availability."""
|
|
2014
|
+
|
|
2015
|
+
if as_of_date is None:
|
|
2016
|
+
raise ValueError("as_of_date must be provided to build a historical chain")
|
|
1908
2017
|
|
|
1909
2018
|
headers = {"Accept": "application/json"}
|
|
2019
|
+
expirations_resp = get_request(
|
|
2020
|
+
url=f"{BASE_URL}/v2/list/expirations",
|
|
2021
|
+
headers=headers,
|
|
2022
|
+
querystring={"root": asset.symbol},
|
|
2023
|
+
username=username,
|
|
2024
|
+
password=password,
|
|
2025
|
+
)
|
|
1910
2026
|
|
|
1911
|
-
|
|
1912
|
-
|
|
2027
|
+
if not expirations_resp or not expirations_resp.get("response"):
|
|
2028
|
+
logger.warning(
|
|
2029
|
+
"ThetaData returned no expirations for %s; cannot build chain for %s.",
|
|
2030
|
+
asset.symbol,
|
|
2031
|
+
as_of_date,
|
|
2032
|
+
)
|
|
2033
|
+
return None
|
|
1913
2034
|
|
|
1914
|
-
|
|
1915
|
-
|
|
2035
|
+
exp_df = pd.DataFrame(expirations_resp["response"], columns=expirations_resp["header"]["format"])
|
|
2036
|
+
if exp_df.empty:
|
|
2037
|
+
logger.warning(
|
|
2038
|
+
"ThetaData returned empty expiration list for %s; cannot build chain for %s.",
|
|
2039
|
+
asset.symbol,
|
|
2040
|
+
as_of_date,
|
|
2041
|
+
)
|
|
2042
|
+
return None
|
|
1916
2043
|
|
|
1917
|
-
|
|
1918
|
-
|
|
2044
|
+
expiration_values: List[int] = sorted(int(value) for value in exp_df.iloc[:, 0].tolist())
|
|
2045
|
+
as_of_int = int(as_of_date.strftime("%Y%m%d"))
|
|
1919
2046
|
|
|
1920
|
-
|
|
1921
|
-
|
|
2047
|
+
chains: Dict[str, Dict[str, List[float]]] = {"CALL": {}, "PUT": {}}
|
|
2048
|
+
expirations_added = 0
|
|
2049
|
+
consecutive_misses = 0
|
|
2050
|
+
|
|
2051
|
+
def expiration_has_data(expiration_str: str, strike_thousandths: int, right: str) -> bool:
|
|
2052
|
+
querystring = {
|
|
2053
|
+
"root": asset.symbol,
|
|
2054
|
+
"exp": expiration_str,
|
|
2055
|
+
"strike": strike_thousandths,
|
|
2056
|
+
"right": right,
|
|
2057
|
+
}
|
|
2058
|
+
resp = get_request(
|
|
2059
|
+
url=f"{BASE_URL}/list/dates/option/quote",
|
|
2060
|
+
headers=headers,
|
|
2061
|
+
querystring=querystring,
|
|
2062
|
+
username=username,
|
|
2063
|
+
password=password,
|
|
2064
|
+
)
|
|
2065
|
+
if not resp or resp.get("header", {}).get("error_type") == "NO_DATA":
|
|
2066
|
+
return False
|
|
2067
|
+
dates = resp.get("response", [])
|
|
2068
|
+
return as_of_int in dates if dates else False
|
|
2069
|
+
|
|
2070
|
+
for exp_value in expiration_values:
|
|
2071
|
+
if exp_value < as_of_int:
|
|
2072
|
+
continue
|
|
2073
|
+
|
|
2074
|
+
expiration_iso = _normalize_expiration_value(exp_value)
|
|
2075
|
+
if not expiration_iso:
|
|
2076
|
+
continue
|
|
2077
|
+
|
|
2078
|
+
strike_resp = get_request(
|
|
2079
|
+
url=f"{BASE_URL}/v2/list/strikes",
|
|
2080
|
+
headers=headers,
|
|
2081
|
+
querystring={"root": asset.symbol, "exp": str(exp_value)},
|
|
2082
|
+
username=username,
|
|
2083
|
+
password=password,
|
|
2084
|
+
)
|
|
2085
|
+
if not strike_resp or not strike_resp.get("response"):
|
|
2086
|
+
logger.debug(
|
|
2087
|
+
"No strikes for %s exp %s; skipping.",
|
|
2088
|
+
asset.symbol,
|
|
2089
|
+
expiration_iso,
|
|
2090
|
+
)
|
|
2091
|
+
consecutive_misses += 1
|
|
2092
|
+
if consecutive_misses >= max_consecutive_misses:
|
|
2093
|
+
break
|
|
2094
|
+
continue
|
|
2095
|
+
|
|
2096
|
+
strike_df = pd.DataFrame(strike_resp["response"], columns=strike_resp["header"]["format"])
|
|
2097
|
+
if strike_df.empty:
|
|
2098
|
+
consecutive_misses += 1
|
|
2099
|
+
if consecutive_misses >= max_consecutive_misses:
|
|
2100
|
+
break
|
|
2101
|
+
continue
|
|
2102
|
+
|
|
2103
|
+
strike_values = sorted({round(value / 1000.0, 4) for value in strike_df.iloc[:, 0].tolist()})
|
|
2104
|
+
if not strike_values:
|
|
2105
|
+
consecutive_misses += 1
|
|
2106
|
+
if consecutive_misses >= max_consecutive_misses:
|
|
2107
|
+
break
|
|
2108
|
+
continue
|
|
2109
|
+
|
|
2110
|
+
# Use the median strike to validate whether the expiration existed on the backtest date
|
|
2111
|
+
median_index = len(strike_values) // 2
|
|
2112
|
+
probe_strike = strike_values[median_index]
|
|
2113
|
+
probe_thousandths = int(round(probe_strike * 1000))
|
|
2114
|
+
|
|
2115
|
+
has_call_data = expiration_has_data(str(exp_value), probe_thousandths, "C")
|
|
2116
|
+
has_put_data = has_call_data or expiration_has_data(str(exp_value), probe_thousandths, "P")
|
|
2117
|
+
|
|
2118
|
+
if not (has_call_data or has_put_data):
|
|
2119
|
+
logger.debug(
|
|
2120
|
+
"Expiration %s for %s not active on %s; skipping.",
|
|
2121
|
+
expiration_iso,
|
|
2122
|
+
asset.symbol,
|
|
2123
|
+
as_of_date,
|
|
2124
|
+
)
|
|
2125
|
+
consecutive_misses += 1
|
|
2126
|
+
if consecutive_misses >= max_consecutive_misses:
|
|
2127
|
+
logger.debug(
|
|
2128
|
+
"Encountered %d consecutive inactive expirations for %s; stopping scan.",
|
|
2129
|
+
max_consecutive_misses,
|
|
2130
|
+
asset.symbol,
|
|
2131
|
+
)
|
|
2132
|
+
break
|
|
2133
|
+
continue
|
|
2134
|
+
|
|
2135
|
+
chains["CALL"][expiration_iso] = strike_values
|
|
2136
|
+
chains["PUT"][expiration_iso] = list(strike_values)
|
|
2137
|
+
expirations_added += 1
|
|
2138
|
+
consecutive_misses = 0
|
|
2139
|
+
|
|
2140
|
+
if expirations_added >= max_expirations:
|
|
2141
|
+
break
|
|
2142
|
+
|
|
2143
|
+
logger.debug(
|
|
2144
|
+
"Built ThetaData historical chain for %s on %s (expirations=%d)",
|
|
2145
|
+
asset.symbol,
|
|
2146
|
+
as_of_date,
|
|
2147
|
+
expirations_added,
|
|
2148
|
+
)
|
|
2149
|
+
|
|
2150
|
+
if not chains["CALL"] and not chains["PUT"]:
|
|
2151
|
+
logger.warning(
|
|
2152
|
+
"No expirations with data found for %s on %s.",
|
|
2153
|
+
asset.symbol,
|
|
2154
|
+
as_of_date,
|
|
2155
|
+
)
|
|
2156
|
+
return None
|
|
2157
|
+
|
|
2158
|
+
return {
|
|
2159
|
+
"Multiplier": 100,
|
|
2160
|
+
"Exchange": "SMART",
|
|
2161
|
+
"Chains": chains,
|
|
2162
|
+
}
|
|
1922
2163
|
|
|
1923
|
-
# Filter out any dates before after_date
|
|
1924
|
-
expirations = [x for x in expirations if x >= after_date_int]
|
|
1925
2164
|
|
|
1926
|
-
|
|
2165
|
+
def get_expirations(username: str, password: str, ticker: str, after_date: date):
|
|
2166
|
+
"""Legacy helper retained for backward compatibility; prefer build_historical_chain."""
|
|
2167
|
+
logger.warning(
|
|
2168
|
+
"get_expirations is deprecated and provides live expirations only. "
|
|
2169
|
+
"Use build_historical_chain for historical backtests (ticker=%s, after=%s).",
|
|
2170
|
+
ticker,
|
|
2171
|
+
after_date,
|
|
2172
|
+
)
|
|
2173
|
+
|
|
2174
|
+
url = f"{BASE_URL}/v2/list/expirations"
|
|
2175
|
+
querystring = {"root": ticker}
|
|
2176
|
+
headers = {"Accept": "application/json"}
|
|
2177
|
+
json_resp = get_request(url=url, headers=headers, querystring=querystring, username=username, password=password)
|
|
2178
|
+
df = pd.DataFrame(json_resp["response"], columns=json_resp["header"]["format"])
|
|
2179
|
+
expirations = df.iloc[:, 0].tolist()
|
|
2180
|
+
after_date_int = int(after_date.strftime("%Y%m%d"))
|
|
2181
|
+
expirations = [x for x in expirations if x >= after_date_int]
|
|
1927
2182
|
expirations_final = []
|
|
1928
2183
|
for expiration in expirations:
|
|
1929
2184
|
expiration_str = str(expiration)
|
|
1930
|
-
|
|
1931
|
-
expiration_str = f"{expiration_str[:4]}-{expiration_str[4:6]}-{expiration_str[6:]}"
|
|
1932
|
-
# Add the string to the list
|
|
1933
|
-
expirations_final.append(expiration_str)
|
|
1934
|
-
|
|
2185
|
+
expirations_final.append(f"{expiration_str[:4]}-{expiration_str[4:6]}-{expiration_str[6:]}")
|
|
1935
2186
|
return expirations_final
|
|
1936
2187
|
|
|
1937
2188
|
|
|
@@ -2017,8 +2268,6 @@ def get_chains_cached(
|
|
|
2017
2268
|
}
|
|
2018
2269
|
}
|
|
2019
2270
|
"""
|
|
2020
|
-
from collections import defaultdict
|
|
2021
|
-
|
|
2022
2271
|
logger.debug(f"get_chains_cached called for {asset.symbol} on {current_date}")
|
|
2023
2272
|
|
|
2024
2273
|
# 1) If current_date is None => bail out
|
|
@@ -2063,28 +2312,32 @@ def get_chains_cached(
|
|
|
2063
2312
|
|
|
2064
2313
|
return data
|
|
2065
2314
|
|
|
2066
|
-
# 4) No suitable file => fetch from ThetaData
|
|
2067
|
-
logger.debug(
|
|
2068
|
-
|
|
2315
|
+
# 4) No suitable file => fetch from ThetaData using exp=0 chain builder
|
|
2316
|
+
logger.debug(
|
|
2317
|
+
f"No suitable cache file found for {asset.symbol} on {current_date}; building historical chain."
|
|
2318
|
+
)
|
|
2319
|
+
print(
|
|
2320
|
+
f"\nDownloading option chain for {asset} on {current_date}. This will be cached for future use."
|
|
2321
|
+
)
|
|
2069
2322
|
|
|
2070
|
-
|
|
2071
|
-
|
|
2323
|
+
chains_dict = build_historical_chain(
|
|
2324
|
+
username=username,
|
|
2325
|
+
password=password,
|
|
2326
|
+
asset=asset,
|
|
2327
|
+
as_of_date=current_date,
|
|
2328
|
+
)
|
|
2072
2329
|
|
|
2073
|
-
chains_dict
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2330
|
+
if chains_dict is None:
|
|
2331
|
+
logger.warning(
|
|
2332
|
+
"ThetaData returned no option data for %s on %s; skipping cache write.",
|
|
2333
|
+
asset.symbol,
|
|
2334
|
+
current_date,
|
|
2335
|
+
)
|
|
2336
|
+
return {
|
|
2337
|
+
"Multiplier": 100,
|
|
2338
|
+
"Exchange": "SMART",
|
|
2339
|
+
"Chains": {"CALL": {}, "PUT": {}},
|
|
2079
2340
|
}
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
for expiration_str in expirations:
|
|
2083
|
-
expiration = date.fromisoformat(expiration_str)
|
|
2084
|
-
strikes = get_strikes(username, password, asset.symbol, expiration)
|
|
2085
|
-
|
|
2086
|
-
chains_dict["Chains"]["CALL"][expiration_str] = sorted(strikes)
|
|
2087
|
-
chains_dict["Chains"]["PUT"][expiration_str] = sorted(strikes)
|
|
2088
2341
|
|
|
2089
2342
|
# 5) Save to cache file for future reuse
|
|
2090
2343
|
cache_file = chain_folder / f"{asset.symbol}_{current_date.isoformat()}.parquet"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lumibot
|
|
3
|
-
Version: 4.2.
|
|
3
|
+
Version: 4.2.9
|
|
4
4
|
Summary: Backtesting and Trading Library, Made by Lumiwealth
|
|
5
5
|
Home-page: https://github.com/Lumiwealth/lumibot
|
|
6
6
|
Author: Robert Grzesik
|
|
@@ -51,7 +51,7 @@ Requires-Dist: schwab-py>=1.5.0
|
|
|
51
51
|
Requires-Dist: Flask>=2.3
|
|
52
52
|
Requires-Dist: free-proxy
|
|
53
53
|
Requires-Dist: requests-oauthlib
|
|
54
|
-
Requires-Dist: boto3>=1.
|
|
54
|
+
Requires-Dist: boto3>=1.40.64
|
|
55
55
|
Dynamic: author
|
|
56
56
|
Dynamic: author-email
|
|
57
57
|
Dynamic: classifier
|