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.

@@ -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
- counter = 0
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
- counter += 1
1464
- if counter % 10 == 0:
1465
- logger.info("Waiting for ThetaTerminal connection (attempt %s/%s).", counter, max_retries)
1466
- time.sleep(sleep_interval)
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
- if not connected and counter >= max_retries:
1469
- logger.error("Cannot connect to Theta Data after %s attempts.", counter)
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
- return client, connected
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 response.status_code == 472:
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 response.status_code != 200:
1510
- logger.warning(f"Non-200 status code {response.status_code}: {response.text[:200]}")
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
- response.status_code
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 ValueError("Cannot connect to Theta Data!")
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 get_expirations(username: str, password: str, ticker: str, after_date: date):
1887
- """
1888
- Get a list of expiration dates for the given ticker
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
- Parameters
1891
- ----------
1892
- username : str
1893
- Your ThetaData username
1894
- password : str
1895
- Your ThetaData password
1896
- ticker : str
1897
- The ticker for the asset we are getting data for
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
- Returns
1900
- -------
1901
- list[str]
1902
- A list of expiration dates for the given ticker
1903
- """
1904
- # Use v2 API endpoint
1905
- url = f"{BASE_URL}/v2/list/expirations"
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
- querystring = {"root": ticker}
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
- # Send the request
1912
- json_resp = get_request(url=url, headers=headers, querystring=querystring, username=username, password=password)
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
- # Convert to pandas dataframe
1915
- df = pd.DataFrame(json_resp["response"], columns=json_resp["header"]["format"])
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
- # Convert df to a list of the first (and only) column
1918
- expirations = df.iloc[:, 0].tolist()
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
- # Convert after_date to a number
1921
- after_date_int = int(after_date.strftime("%Y%m%d"))
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
- # Convert from "YYYYMMDD" (an int) to "YYYY-MM-DD" (a string)
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
- # Add the dashes to the string
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(f"No suitable file found for {asset.symbol} on {current_date}. Downloading...")
2068
- print(f"\nDownloading option chain for {asset} on {current_date}. This will be cached for future use.")
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
- # Get expirations and strikes using existing functions
2071
- expirations = get_expirations(username, password, asset.symbol, current_date)
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
- "Multiplier": 100,
2075
- "Exchange": "SMART",
2076
- "Chains": {
2077
- "CALL": defaultdict(list),
2078
- "PUT": defaultdict(list)
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.5
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.28.0
54
+ Requires-Dist: boto3>=1.40.64
55
55
  Dynamic: author
56
56
  Dynamic: author-email
57
57
  Dynamic: classifier