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.

@@ -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
@@ -311,7 +312,13 @@ def get_price_data(
311
312
  )
312
313
 
313
314
  if cache_file.exists():
314
- logger.info(f"\nLoading '{datastyle}' pricing data for {asset} / {quote_asset} with '{timespan}' timespan from cache file...")
315
+ logger.debug(
316
+ "\nLoading '%s' pricing data for %s / %s with '%s' timespan from cache file...",
317
+ datastyle,
318
+ asset,
319
+ quote_asset,
320
+ timespan,
321
+ )
315
322
  df_cached = load_cache(cache_file)
316
323
  if df_cached is not None and not df_cached.empty:
317
324
  df_all = df_cached.copy() # Make a copy so we can check the original later for differences
@@ -372,7 +379,7 @@ def get_price_data(
372
379
  )
373
380
  if not missing_dates:
374
381
  if df_all is not None and not df_all.empty:
375
- logger.info("ThetaData cache HIT for %s %s %s (%d rows).", asset, timespan, datastyle, len(df_all))
382
+ logger.debug("ThetaData cache HIT for %s %s %s (%d rows).", asset, timespan, datastyle, len(df_all))
376
383
  # DEBUG-LOG: Cache hit
377
384
  logger.debug(
378
385
  "[THETA][DEBUG][CACHE][HIT] asset=%s timespan=%s datastyle=%s rows=%d start=%s end=%s",
@@ -1877,55 +1884,242 @@ def get_historical_data(asset: Asset, start_dt: datetime, end_dt: datetime, ivl:
1877
1884
  return df
1878
1885
 
1879
1886
 
1880
- def get_expirations(username: str, password: str, ticker: str, after_date: date):
1881
- """
1882
- Get a list of expiration dates for the given ticker
1887
+ def _normalize_expiration_value(raw_value: object) -> Optional[str]:
1888
+ """Convert ThetaData expiration payloads to ISO date strings."""
1889
+ if raw_value is None or (isinstance(raw_value, float) and pd.isna(raw_value)):
1890
+ return None
1883
1891
 
1884
- Parameters
1885
- ----------
1886
- username : str
1887
- Your ThetaData username
1888
- password : str
1889
- Your ThetaData password
1890
- ticker : str
1891
- The ticker for the asset we are getting data for
1892
+ if isinstance(raw_value, (int, float)):
1893
+ try:
1894
+ digits = int(raw_value)
1895
+ except (TypeError, ValueError):
1896
+ return None
1897
+ if digits <= 0:
1898
+ return None
1899
+ text = f"{digits:08d}"
1900
+ return f"{text[0:4]}-{text[4:6]}-{text[6:8]}"
1892
1901
 
1893
- Returns
1894
- -------
1895
- list[str]
1896
- A list of expiration dates for the given ticker
1897
- """
1898
- # Use v2 API endpoint
1899
- url = f"{BASE_URL}/v2/list/expirations"
1902
+ text_value = str(raw_value).strip()
1903
+ if not text_value:
1904
+ return None
1905
+ if text_value.isdigit() and len(text_value) == 8:
1906
+ return f"{text_value[0:4]}-{text_value[4:6]}-{text_value[6:8]}"
1907
+ if len(text_value.split("-")) == 3:
1908
+ return text_value
1909
+ return None
1900
1910
 
1901
- querystring = {"root": ticker}
1911
+
1912
+ def _normalize_strike_value(raw_value: object) -> Optional[float]:
1913
+ """Convert ThetaData strike payloads to float strikes in dollars."""
1914
+ if raw_value is None or (isinstance(raw_value, float) and pd.isna(raw_value)):
1915
+ return None
1916
+
1917
+ try:
1918
+ strike = float(raw_value)
1919
+ except (TypeError, ValueError):
1920
+ return None
1921
+
1922
+ if strike <= 0:
1923
+ return None
1924
+
1925
+ # ThetaData encodes strikes in thousandths of a dollar for integer payloads
1926
+ if strike > 10000:
1927
+ strike /= 1000.0
1928
+
1929
+ return round(strike, 4)
1930
+
1931
+
1932
+ def _detect_column(df: pd.DataFrame, candidates: Tuple[str, ...]) -> Optional[str]:
1933
+ """Find the first column name matching the provided candidates (case-insensitive)."""
1934
+ normalized = {str(col).strip().lower(): col for col in df.columns}
1935
+ for candidate in candidates:
1936
+ lookup = candidate.lower()
1937
+ if lookup in normalized:
1938
+ return normalized[lookup]
1939
+ return None
1940
+
1941
+
1942
+ def build_historical_chain(
1943
+ username: str,
1944
+ password: str,
1945
+ asset: Asset,
1946
+ as_of_date: date,
1947
+ max_expirations: int = 120,
1948
+ max_consecutive_misses: int = 10,
1949
+ ) -> Dict[str, Dict[str, List[float]]]:
1950
+ """Build an as-of option chain by filtering live expirations against quote availability."""
1951
+
1952
+ if as_of_date is None:
1953
+ raise ValueError("as_of_date must be provided to build a historical chain")
1902
1954
 
1903
1955
  headers = {"Accept": "application/json"}
1956
+ expirations_resp = get_request(
1957
+ url=f"{BASE_URL}/v2/list/expirations",
1958
+ headers=headers,
1959
+ querystring={"root": asset.symbol},
1960
+ username=username,
1961
+ password=password,
1962
+ )
1904
1963
 
1905
- # Send the request
1906
- json_resp = get_request(url=url, headers=headers, querystring=querystring, username=username, password=password)
1964
+ if not expirations_resp or not expirations_resp.get("response"):
1965
+ logger.warning(
1966
+ "ThetaData returned no expirations for %s; cannot build chain for %s.",
1967
+ asset.symbol,
1968
+ as_of_date,
1969
+ )
1970
+ return None
1907
1971
 
1908
- # Convert to pandas dataframe
1909
- df = pd.DataFrame(json_resp["response"], columns=json_resp["header"]["format"])
1972
+ exp_df = pd.DataFrame(expirations_resp["response"], columns=expirations_resp["header"]["format"])
1973
+ if exp_df.empty:
1974
+ logger.warning(
1975
+ "ThetaData returned empty expiration list for %s; cannot build chain for %s.",
1976
+ asset.symbol,
1977
+ as_of_date,
1978
+ )
1979
+ return None
1910
1980
 
1911
- # Convert df to a list of the first (and only) column
1912
- expirations = df.iloc[:, 0].tolist()
1981
+ expiration_values: List[int] = sorted(int(value) for value in exp_df.iloc[:, 0].tolist())
1982
+ as_of_int = int(as_of_date.strftime("%Y%m%d"))
1913
1983
 
1914
- # Convert after_date to a number
1915
- after_date_int = int(after_date.strftime("%Y%m%d"))
1984
+ chains: Dict[str, Dict[str, List[float]]] = {"CALL": {}, "PUT": {}}
1985
+ expirations_added = 0
1986
+ consecutive_misses = 0
1916
1987
 
1917
- # Filter out any dates before after_date
1918
- expirations = [x for x in expirations if x >= after_date_int]
1988
+ def expiration_has_data(expiration_str: str, strike_thousandths: int, right: str) -> bool:
1989
+ querystring = {
1990
+ "root": asset.symbol,
1991
+ "exp": expiration_str,
1992
+ "strike": strike_thousandths,
1993
+ "right": right,
1994
+ }
1995
+ resp = get_request(
1996
+ url=f"{BASE_URL}/list/dates/option/quote",
1997
+ headers=headers,
1998
+ querystring=querystring,
1999
+ username=username,
2000
+ password=password,
2001
+ )
2002
+ if not resp or resp.get("header", {}).get("error_type") == "NO_DATA":
2003
+ return False
2004
+ dates = resp.get("response", [])
2005
+ return as_of_int in dates if dates else False
2006
+
2007
+ for exp_value in expiration_values:
2008
+ if exp_value < as_of_int:
2009
+ continue
1919
2010
 
1920
- # Convert from "YYYYMMDD" (an int) to "YYYY-MM-DD" (a string)
2011
+ expiration_iso = _normalize_expiration_value(exp_value)
2012
+ if not expiration_iso:
2013
+ continue
2014
+
2015
+ strike_resp = get_request(
2016
+ url=f"{BASE_URL}/v2/list/strikes",
2017
+ headers=headers,
2018
+ querystring={"root": asset.symbol, "exp": str(exp_value)},
2019
+ username=username,
2020
+ password=password,
2021
+ )
2022
+ if not strike_resp or not strike_resp.get("response"):
2023
+ logger.debug(
2024
+ "No strikes for %s exp %s; skipping.",
2025
+ asset.symbol,
2026
+ expiration_iso,
2027
+ )
2028
+ consecutive_misses += 1
2029
+ if consecutive_misses >= max_consecutive_misses:
2030
+ break
2031
+ continue
2032
+
2033
+ strike_df = pd.DataFrame(strike_resp["response"], columns=strike_resp["header"]["format"])
2034
+ if strike_df.empty:
2035
+ consecutive_misses += 1
2036
+ if consecutive_misses >= max_consecutive_misses:
2037
+ break
2038
+ continue
2039
+
2040
+ strike_values = sorted({round(value / 1000.0, 4) for value in strike_df.iloc[:, 0].tolist()})
2041
+ if not strike_values:
2042
+ consecutive_misses += 1
2043
+ if consecutive_misses >= max_consecutive_misses:
2044
+ break
2045
+ continue
2046
+
2047
+ # Use the median strike to validate whether the expiration existed on the backtest date
2048
+ median_index = len(strike_values) // 2
2049
+ probe_strike = strike_values[median_index]
2050
+ probe_thousandths = int(round(probe_strike * 1000))
2051
+
2052
+ has_call_data = expiration_has_data(str(exp_value), probe_thousandths, "C")
2053
+ has_put_data = has_call_data or expiration_has_data(str(exp_value), probe_thousandths, "P")
2054
+
2055
+ if not (has_call_data or has_put_data):
2056
+ logger.debug(
2057
+ "Expiration %s for %s not active on %s; skipping.",
2058
+ expiration_iso,
2059
+ asset.symbol,
2060
+ as_of_date,
2061
+ )
2062
+ consecutive_misses += 1
2063
+ if consecutive_misses >= max_consecutive_misses:
2064
+ logger.debug(
2065
+ "Encountered %d consecutive inactive expirations for %s; stopping scan.",
2066
+ max_consecutive_misses,
2067
+ asset.symbol,
2068
+ )
2069
+ break
2070
+ continue
2071
+
2072
+ chains["CALL"][expiration_iso] = strike_values
2073
+ chains["PUT"][expiration_iso] = list(strike_values)
2074
+ expirations_added += 1
2075
+ consecutive_misses = 0
2076
+
2077
+ if expirations_added >= max_expirations:
2078
+ break
2079
+
2080
+ logger.debug(
2081
+ "Built ThetaData historical chain for %s on %s (expirations=%d)",
2082
+ asset.symbol,
2083
+ as_of_date,
2084
+ expirations_added,
2085
+ )
2086
+
2087
+ if not chains["CALL"] and not chains["PUT"]:
2088
+ logger.warning(
2089
+ "No expirations with data found for %s on %s.",
2090
+ asset.symbol,
2091
+ as_of_date,
2092
+ )
2093
+ return None
2094
+
2095
+ return {
2096
+ "Multiplier": 100,
2097
+ "Exchange": "SMART",
2098
+ "Chains": chains,
2099
+ }
2100
+
2101
+
2102
+ def get_expirations(username: str, password: str, ticker: str, after_date: date):
2103
+ """Legacy helper retained for backward compatibility; prefer build_historical_chain."""
2104
+ logger.warning(
2105
+ "get_expirations is deprecated and provides live expirations only. "
2106
+ "Use build_historical_chain for historical backtests (ticker=%s, after=%s).",
2107
+ ticker,
2108
+ after_date,
2109
+ )
2110
+
2111
+ url = f"{BASE_URL}/v2/list/expirations"
2112
+ querystring = {"root": ticker}
2113
+ headers = {"Accept": "application/json"}
2114
+ json_resp = get_request(url=url, headers=headers, querystring=querystring, username=username, password=password)
2115
+ df = pd.DataFrame(json_resp["response"], columns=json_resp["header"]["format"])
2116
+ expirations = df.iloc[:, 0].tolist()
2117
+ after_date_int = int(after_date.strftime("%Y%m%d"))
2118
+ expirations = [x for x in expirations if x >= after_date_int]
1921
2119
  expirations_final = []
1922
2120
  for expiration in expirations:
1923
2121
  expiration_str = str(expiration)
1924
- # Add the dashes to the string
1925
- expiration_str = f"{expiration_str[:4]}-{expiration_str[4:6]}-{expiration_str[6:]}"
1926
- # Add the string to the list
1927
- expirations_final.append(expiration_str)
1928
-
2122
+ expirations_final.append(f"{expiration_str[:4]}-{expiration_str[4:6]}-{expiration_str[6:]}")
1929
2123
  return expirations_final
1930
2124
 
1931
2125
 
@@ -2011,8 +2205,6 @@ def get_chains_cached(
2011
2205
  }
2012
2206
  }
2013
2207
  """
2014
- from collections import defaultdict
2015
-
2016
2208
  logger.debug(f"get_chains_cached called for {asset.symbol} on {current_date}")
2017
2209
 
2018
2210
  # 1) If current_date is None => bail out
@@ -2057,28 +2249,32 @@ def get_chains_cached(
2057
2249
 
2058
2250
  return data
2059
2251
 
2060
- # 4) No suitable file => fetch from ThetaData
2061
- logger.debug(f"No suitable file found for {asset.symbol} on {current_date}. Downloading...")
2062
- print(f"\nDownloading option chain for {asset} on {current_date}. This will be cached for future use.")
2252
+ # 4) No suitable file => fetch from ThetaData using exp=0 chain builder
2253
+ logger.debug(
2254
+ f"No suitable cache file found for {asset.symbol} on {current_date}; building historical chain."
2255
+ )
2256
+ print(
2257
+ f"\nDownloading option chain for {asset} on {current_date}. This will be cached for future use."
2258
+ )
2063
2259
 
2064
- # Get expirations and strikes using existing functions
2065
- expirations = get_expirations(username, password, asset.symbol, current_date)
2260
+ chains_dict = build_historical_chain(
2261
+ username=username,
2262
+ password=password,
2263
+ asset=asset,
2264
+ as_of_date=current_date,
2265
+ )
2066
2266
 
2067
- chains_dict = {
2068
- "Multiplier": 100,
2069
- "Exchange": "SMART",
2070
- "Chains": {
2071
- "CALL": defaultdict(list),
2072
- "PUT": defaultdict(list)
2267
+ if chains_dict is None:
2268
+ logger.warning(
2269
+ "ThetaData returned no option data for %s on %s; skipping cache write.",
2270
+ asset.symbol,
2271
+ current_date,
2272
+ )
2273
+ return {
2274
+ "Multiplier": 100,
2275
+ "Exchange": "SMART",
2276
+ "Chains": {"CALL": {}, "PUT": {}},
2073
2277
  }
2074
- }
2075
-
2076
- for expiration_str in expirations:
2077
- expiration = date.fromisoformat(expiration_str)
2078
- strikes = get_strikes(username, password, asset.symbol, expiration)
2079
-
2080
- chains_dict["Chains"]["CALL"][expiration_str] = sorted(strikes)
2081
- chains_dict["Chains"]["PUT"][expiration_str] = sorted(strikes)
2082
2278
 
2083
2279
  # 5) Save to cache file for future reuse
2084
2280
  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.4
3
+ Version: 4.2.7
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
@@ -7,14 +7,14 @@ lumibot/backtesting/alpha_vantage_backtesting.py,sha256=LR3UNhJrdAKdvadiThVKdKrM
7
7
  lumibot/backtesting/backtesting_broker.py,sha256=C0zn__i0aoyd6u147oizRYO2Akqa5JWs8znl6siLd8Y,76003
8
8
  lumibot/backtesting/ccxt_backtesting.py,sha256=O-RjuNpx5y4f-hKKwwUbrU7hAVkGEegmnvH_9nQWhAo,246
9
9
  lumibot/backtesting/databento_backtesting.py,sha256=zQS6IaamDTQREcjViguk_y4b_HF1xyV3gUrDuHnkUUY,430
10
- lumibot/backtesting/databento_backtesting_pandas.py,sha256=kK6XXmm9veZSt-z1HBKdEzM_LkrVt2-DVfajtkTDdCI,31229
10
+ lumibot/backtesting/databento_backtesting_pandas.py,sha256=xIQ1SnPo47rxIH7hfrgWzhTuVxxG8LDklY3Z8Tu5idU,32440
11
11
  lumibot/backtesting/databento_backtesting_polars.py,sha256=qanSLBsw4aBEFVfMuwSWzTDtaXQlOfcrvp6ERLWkRVo,45786
12
12
  lumibot/backtesting/fix_debug.py,sha256=ETHl-O38xK9JOQC2x8IZVJRCQrR1cokk2Vb4vpGMvb8,1204
13
13
  lumibot/backtesting/interactive_brokers_rest_backtesting.py,sha256=5HJ_sPX0uOUg-rbfOKDjwYVCLiXevlwtdK_3BcUwqXc,6602
14
14
  lumibot/backtesting/pandas_backtesting.py,sha256=m-NvT4o-wFQjaZft6TXULzeZBrskO_7Z-jfy9AIkyAY,388
15
15
  lumibot/backtesting/polygon_backtesting.py,sha256=u9kif_2_7k0P4-KDvbHhaMfSoBVejUUX7fh9H3PCVE0,12350
16
16
  lumibot/backtesting/thetadata_backtesting.py,sha256=Xcz5f-4zTkKgWWcktNzItH2vrr8CysIMQWKKqLwugbA,345
17
- lumibot/backtesting/thetadata_backtesting_pandas.py,sha256=3ltwnxqAw4xXG2VOk14G_vpVHAVdnGl10SsveoMoJF0,51844
17
+ lumibot/backtesting/thetadata_backtesting_pandas.py,sha256=4jIxziBASbmCe5MCmjmvQBeo-5EQ90UTmblRpHrAaDE,51829
18
18
  lumibot/backtesting/yahoo_backtesting.py,sha256=LT2524mGlrUSq1YSRnUqGW4-Xcq4USgRv2EhnV_zfs4,502
19
19
  lumibot/brokers/__init__.py,sha256=MGWKHeH3mqseYRL7u-KX1Jp2x9EaFO4Ol8sfNSxzu1M,404
20
20
  lumibot/brokers/alpaca.py,sha256=VQ17idfqiEFb2JCqqdMGmbvF789L7_PpsCbudiFRzmg,61595
@@ -34,7 +34,7 @@ lumibot/components/configs_helper.py,sha256=e4-2jBmjsIDeMQ9fbQ9j2U6WlHCe2z9bIQtJ
34
34
  lumibot/components/drift_rebalancer_logic.py,sha256=KL1S9Su5gxibGzl2fw_DH8StzXvjFfFl23sOFGHgIZI,28653
35
35
  lumibot/components/grok_helper.py,sha256=vgyPAs63VoNsfZojMoHYRLckb-CFiZkHIlY8HSYZLQs,15382
36
36
  lumibot/components/grok_news_helper.py,sha256=7GFtSuRkBo4-3IcUmC6qhuf7iekoVyyP7q5jyUjAWTc,11007
37
- lumibot/components/options_helper.py,sha256=pUihHFEv5w17pXACE3xw0Tq6tHb9s05fzttlg4hmD7E,64931
37
+ lumibot/components/options_helper.py,sha256=UTMJUrTX9AOq-SPkZR7IFOFc65JQRZDSSrzy6a7ptZY,67371
38
38
  lumibot/components/perplexity_helper.py,sha256=0dhAIXunvUnTKIFFdYbFt8adnSIavKRqXkQ91fjsDPI,31432
39
39
  lumibot/components/quiver_helper.py,sha256=s0Y-tL1y2S1mYlbtlRTcL5R4WV0HYOgiSJ9hT41Hzrw,14380
40
40
  lumibot/components/vix_helper.py,sha256=tsWHFQzOJCjdmSGgmnYIty0K5ua-iAgBiRlyRIpGli0,61940
@@ -104,17 +104,17 @@ lumibot/example_strategies/test_broker_functions.py,sha256=wnVS-M_OtzMgaXVBgshVE
104
104
  lumibot/resources/ThetaTerminal.jar,sha256=K6GeeFcN8-gvyL2x5iq5pzD79KfPJvMK8iiezi3TmNQ,11834389
105
105
  lumibot/resources/conf.yaml,sha256=rjB9-10JP7saZ_edjX5bQDGfuc3amOQTUUUr-UiMpNA,597
106
106
  lumibot/strategies/__init__.py,sha256=jEZ95K5hG0f595EXYKWwL2_UsnWWk5Pug361PK2My2E,79
107
- lumibot/strategies/_strategy.py,sha256=jfKU4NpYiGJexTEeHz-pi-8NtSdcX1BXBTcjr7wsUuY,110825
107
+ lumibot/strategies/_strategy.py,sha256=3Z2MPz3jYgJdAphaFyoEI5OUs_6ClAJgRCrLWy-b2sg,111718
108
108
  lumibot/strategies/session_manager.py,sha256=Nze6UYNSPlCsf-tyHvtFqUeL44WSNHjwsKrIepvsyCY,12956
109
109
  lumibot/strategies/strategy.py,sha256=toPeL5oIVWmCxBNcfXqIuTCF_EeCfIVj425PrSYImCo,170021
110
- lumibot/strategies/strategy_executor.py,sha256=IrHwDOu5s3gG65dz7FL-0kllWy7COci7JFyB4iiPUrg,70801
110
+ lumibot/strategies/strategy_executor.py,sha256=AnmXlKD2eMgKXs3TrD1u8T_Zsn_8GnG5KRcM_Pq-JBQ,70749
111
111
  lumibot/tools/__init__.py,sha256=oRRoK2NBkfnc0kueAfY0HrWVKgzRBO1hlglVMR4jr5M,1501
112
112
  lumibot/tools/alpaca_helpers.py,sha256=nhBS-sv28lZfIQ85szC9El8VHLrCw5a5KbsGOOEjm6w,3147
113
113
  lumibot/tools/backtest_cache.py,sha256=A-Juzu0swZI_FP4U7cd7ruYTgJYgV8BPf_WJDI-NtrM,9556
114
114
  lumibot/tools/bitunix_helpers.py,sha256=-UzrN3w_Y-Ckvhl7ZBoAcx7sgb6tH0KcpVph1Ovm3gw,25780
115
115
  lumibot/tools/black_scholes.py,sha256=TBjJuDTudvqsbwqSb7-zb4gXsJBCStQFaym8xvePAjw,25428
116
- lumibot/tools/ccxt_data_store.py,sha256=VXLSs0sWcwjRPZzbuEeVPS-3V6D10YnYMfIyoTPTG0U,21225
117
- lumibot/tools/databento_helper.py,sha256=6jCIh1GlEpdU_copJSNTYmvcIKMXCucaAONwmqQjxCE,43466
116
+ lumibot/tools/ccxt_data_store.py,sha256=PlP3MHPHZP7GisEZsk1OUxeWijoPXwiQbsOBTr7jkQI,21227
117
+ lumibot/tools/databento_helper.py,sha256=WBuQCm9Z7WFpde8qdsJEabtpU0ThGsjjPeK7BI2pUVA,43681
118
118
  lumibot/tools/databento_helper_polars.py,sha256=FXvvES_Y-E-IzAmVBtquh1UtQ-eN6i6BEoflcP7y8s0,48674
119
119
  lumibot/tools/databento_roll.py,sha256=48HAw3h6OngCK4UTl9ifpjo-ki8qmB6OoJUrHp0gRmE,6767
120
120
  lumibot/tools/debugers.py,sha256=ga6npFsS9cpKtTXaygh9t2_txCElg3bfzfeqDBvSL8k,485
@@ -132,7 +132,7 @@ lumibot/tools/polygon_helper_async.py,sha256=YHDXa9kmkkn8jh7hToY6GP5etyXS9Tj-uky
132
132
  lumibot/tools/polygon_helper_polars_optimized.py,sha256=NaIZ-5Av-G2McPEKHyJ-x65W72W_Agnz4lRgvXfQp8c,30415
133
133
  lumibot/tools/projectx_helpers.py,sha256=EIemLfbG923T_RBV_i6s6A9xgs7dt0et0oCnhFwdWfA,58299
134
134
  lumibot/tools/schwab_helper.py,sha256=CXnYhgsXOIb5MgmIYOp86aLxsBF9oeVrMGrjwl_GEv0,11768
135
- lumibot/tools/thetadata_helper.py,sha256=-FJm_NXSBJoyYLcdNQXGytMbmr-wx7F1gItnRnBUWf0,80072
135
+ lumibot/tools/thetadata_helper.py,sha256=QXuFzfHi22L9sqzwL_mXZGhU2m9ortbE9U1-Kt7jSew,86743
136
136
  lumibot/tools/types.py,sha256=x-aQBeC6ZTN2-pUyxyo69Q0j5e0c_swdfe06kfrWSVc,1978
137
137
  lumibot/tools/yahoo_helper.py,sha256=htcKKkuktatIckVKfLc_ms0X75mXColysQhrZW244z8,19497
138
138
  lumibot/tools/yahoo_helper_polars_optimized.py,sha256=g9xBN-ReHSW4Aj9EMU_OncBXVS1HpfL8LTHit9ZxFY4,7417
@@ -142,7 +142,7 @@ lumibot/traders/trader.py,sha256=KMif3WoZtnSxA0BzoK3kvkTITNELrDFIortx1BYBv8s,967
142
142
  lumibot/trading_builtins/__init__.py,sha256=vH2QL5zLjL3slfEV1YW-BvQHtEYLCFkIWTZDfh3y8LE,87
143
143
  lumibot/trading_builtins/custom_stream.py,sha256=8_XiPT0JzyXrgnXCXoovGGUrWEfnG4ohIYMPfB_Nook,5264
144
144
  lumibot/trading_builtins/safe_list.py,sha256=IIjZOHSiZYK25A4WBts0oJaZNOJDsjZL65MOSHhE3Ig,1975
145
- lumibot-4.2.4.dist-info/licenses/LICENSE,sha256=fYhGIyxjyNXACgpNQS3xxpxDOaVOWRVxZMCRbsDv8k0,35130
145
+ lumibot-4.2.7.dist-info/licenses/LICENSE,sha256=fYhGIyxjyNXACgpNQS3xxpxDOaVOWRVxZMCRbsDv8k0,35130
146
146
  tests/__init__.py,sha256=3-VoT-nAuqMfwufd4ceN6fXaHl_zCfDCSXJOTp1ywYQ,393
147
147
  tests/conftest.py,sha256=UBw_2fx7r6TZPKus2b1Qxrzmd4bg8EEBnX1vCHUuSVA,3311
148
148
  tests/fixtures.py,sha256=wOHQsh1SGHnXe_PGi6kDWI30CS_Righi7Ig7vwSEKT4,9082
@@ -163,7 +163,7 @@ tests/test_backtesting_broker_await_close.py,sha256=WbehY7E4Qet3_Mo7lpfgjmhtI9pn
163
163
  tests/test_backtesting_broker_time_advance.py,sha256=FCv0nKG8BQlEjNft7kmQYm9M2CsLIZ0b7mWCllOHQxc,6378
164
164
  tests/test_backtesting_crypto_cash_unit.py,sha256=4EO9jVajdZNV0M7zSyp4gpR_msZFoM4x5tb-6g-mHO8,11399
165
165
  tests/test_backtesting_data_source_env.py,sha256=ZzpF42-tMc8qqETuy_nf43UsSZMbHtS_ivH93ZqV5P0,12460
166
- tests/test_backtesting_datetime_normalization.py,sha256=8NjxiZbSIOzE4I2qDwEqkN3M7jnIdTfjPPE2Qp_0ySE,2984
166
+ tests/test_backtesting_datetime_normalization.py,sha256=n1ObwObTI_V4uQOQw_WIii7Ph_OgPZYIZGbx5Jv_19U,3245
167
167
  tests/test_backtesting_flow_control.py,sha256=pBqW-fa-HnZq0apUBltalGMM-vNJ_2A5W2SoJzMK8Mg,7208
168
168
  tests/test_backtesting_multileg_unit.py,sha256=h1DPfVuYXXx-uq6KtUjr6_nasZuXPm_5gFat1XxCKIo,6456
169
169
  tests/test_backtesting_quiet_logs_complete.py,sha256=x-GfOiqkiUu8pYKCzB0UUacn13Nx_cPRth7_jmPY2Y8,14155
@@ -208,7 +208,7 @@ tests/test_lumibot_logger.py,sha256=76e5yl86NWk4OYtM_be-7yaHnPA-1bO2rBNH-piX8Dk,
208
208
  tests/test_market_infinite_loop_bug.py,sha256=p8Wiq2sdNFYOH54Ij9MkDFWBLTC8V3vbJEphcPq6T8g,16181
209
209
  tests/test_mes_symbols.py,sha256=Av5AM-pogp1STfWZ4A3bA2NmbaA3E9b0Dg449mfk-Ss,3106
210
210
  tests/test_momentum.py,sha256=oakKjgnz5pNQkw0CagbbKKSux0HkOxP_iLW8-Yk0XOo,8492
211
- tests/test_options_helper.py,sha256=NizH8E9ZnzVWsShsHp5X6qBE8FWJ3sS2eE9bCko7y9U,27352
211
+ tests/test_options_helper.py,sha256=wVXafXP4KfL3T-u9dFjJZbGI9YOHApohW-NfQ36yTuk,29185
212
212
  tests/test_options_helper_enhancements.py,sha256=3Ei-vy9IQfg1AnlBqofLNc-kuiQ64vouhJyIuVj3KIg,7324
213
213
  tests/test_order.py,sha256=qN_sqsItl8L-WqSQTF0PUN0s2nxm4HKYOA6gYQ6EBJA,12110
214
214
  tests/test_order_serialization.py,sha256=zcX5fgcYeo0jm6dlIQnUfuH56svTnKRFPtYCSI7xCJw,3710
@@ -226,7 +226,7 @@ tests/test_projectx_helpers.py,sha256=mKFcWZ7C2ve6UXk2LaJWo1E6QwhU09dkIYht1LElQW
226
226
  tests/test_projectx_lifecycle.py,sha256=F7H08wg2b5lYh0vNzKNa5AkQnhCuTrbpPm0YvOZHN6A,3179
227
227
  tests/test_projectx_lifecycle_unit.py,sha256=XqktdtFMS4s5RIF2ay9uZNW9Ij1AQL46OUbgStA9rhA,21926
228
228
  tests/test_projectx_live_flow.py,sha256=vKnb_VDKCOAbmOdQ5TlyQroCNtNE8UFjHKcd-YpIjXg,412
229
- tests/test_projectx_timestep_alias.py,sha256=vMf80illnTrwgl09wzGQ99CiTAVgIt2jSfHZELxxEEA,2042
229
+ tests/test_projectx_timestep_alias.py,sha256=Kcq-q9-pB9h9mBSol_qb_VCztVdLItY8xClroGNf3zo,2043
230
230
  tests/test_projectx_url_mappings.py,sha256=1E0xZe_cZtGiU-fPIfohbvsrrPjawkblR8F2Mvu_Ct8,10039
231
231
  tests/test_quiet_logs_buy_and_hold.py,sha256=GjXGeHBcNrRJoHGWLuc_LkrzcpNVzshiiBkQACczIxI,2681
232
232
  tests/test_quiet_logs_comprehensive.py,sha256=QVkZWLAUnPEb04Ec8qKXvLzdDUkp8alez2mU_Y1Umm0,6068
@@ -234,8 +234,9 @@ tests/test_quiet_logs_functionality.py,sha256=MlOBUICuTy1OXCDifOW05uD7hjnZRsQ2xx
234
234
  tests/test_quiet_logs_requirements.py,sha256=YoUooSVLrFL8TlWPfxEiqxvSj4d8z6-qg58ja4dtOc0,7856
235
235
  tests/test_session_manager.py,sha256=1qygN3aQ2Xe2uh4BMPm0E3V8KXLFNGq5qdL8KkZjef4,11632
236
236
  tests/test_strategy_methods.py,sha256=j9Mhr6nnG1fkiVQXnx7gLjzGbeQmwt0UbJr_4plD36o,12539
237
+ tests/test_strategy_price_guard.py,sha256=3GJdlfROwx6-adsSi8ZBrWaLOy9e-0N6V1eqpikj8e4,1540
237
238
  tests/test_thetadata_backwards_compat.py,sha256=RzNLhNZNJZ2hPkEDyG-T_4mRRXh5XqavK6r-OjfRASQ,3306
238
- tests/test_thetadata_helper.py,sha256=l77ksu70knZBudfrMAOwT9zJ91AxX5UgazqvYSrotqM,59346
239
+ tests/test_thetadata_helper.py,sha256=RQ-q7OfKc_2aO7-dInaVRTUrrI5iTh9oteYsdmfn4Lg,63735
239
240
  tests/test_thetadata_pandas_verification.py,sha256=MWUecqBY6FGFslWLRo_C5blGbom_unmXCZikAfZXLks,6553
240
241
  tests/test_tradier.py,sha256=iCEM2FTxJSzJ2oLNaRqSx05XaX_DCiMzLx1aEYPANko,33280
241
242
  tests/test_tradier_data.py,sha256=1jTxDzQtzaC42CQJVXMRMElBwExy1mVci3NFfKjjVH0,13363
@@ -281,7 +282,7 @@ tests/backtest/test_thetadata.py,sha256=xWYfC9C4EhbMDb29qyZWHO3sSWaLIPzzvcMbHCt5
281
282
  tests/backtest/test_thetadata_comprehensive.py,sha256=-gN3xLJcJtlB-k4vlaK82DCZDGDmr0LNZZDzn-aN3l4,26120
282
283
  tests/backtest/test_thetadata_vs_polygon.py,sha256=dZqsrOx3u3cz-1onIO6o5BDRjI1ey7U9vIkZupfXoig,22831
283
284
  tests/backtest/test_yahoo.py,sha256=2FguUTUMC9_A20eqxnZ17rN3tT9n6hyvJHaL98QKpqY,3443
284
- lumibot-4.2.4.dist-info/METADATA,sha256=tTX4FY9DKXc8JjeCMImHK3NgzlR1h_U9dSvQqyOnQ28,12092
285
- lumibot-4.2.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
286
- lumibot-4.2.4.dist-info/top_level.txt,sha256=otUnUjDFVASauEDiTiAzNgMyqQ1B6jjS3QqqP-WSx38,14
287
- lumibot-4.2.4.dist-info/RECORD,,
285
+ lumibot-4.2.7.dist-info/METADATA,sha256=cCEnxjhYBqtO5Uvd2pEJ90ULy2OlCiX1FHSaJPEpXAI,12093
286
+ lumibot-4.2.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
287
+ lumibot-4.2.7.dist-info/top_level.txt,sha256=otUnUjDFVASauEDiTiAzNgMyqQ1B6jjS3QqqP-WSx38,14
288
+ lumibot-4.2.7.dist-info/RECORD,,
@@ -3,6 +3,7 @@ from unittest.mock import patch
3
3
 
4
4
  import pytest
5
5
 
6
+ from lumibot.constants import LUMIBOT_DEFAULT_PYTZ
6
7
  from lumibot.strategies import Strategy
7
8
  from lumibot.strategies._strategy import _Strategy
8
9
 
@@ -88,3 +89,6 @@ def test_run_backtest_normalizes_mixed_timezones():
88
89
 
89
90
  assert "start" in captured and captured["start"].tzinfo is not None
90
91
  assert "end" in captured and captured["end"].tzinfo is not None
92
+ assert captured["start"].tzinfo.zone == LUMIBOT_DEFAULT_PYTZ.zone
93
+ assert captured["end"].tzinfo.zone == LUMIBOT_DEFAULT_PYTZ.zone
94
+ assert captured["start"].tzinfo.zone == captured["end"].tzinfo.zone
@@ -11,7 +11,7 @@ import pytest
11
11
  # Add the lumibot path
12
12
  sys.path.insert(0, '/Users/robertgrzesik/Documents/Development/lumivest_bot_server/strategies/lumibot')
13
13
 
14
- from lumibot.components.options_helper import OptionsHelper
14
+ from lumibot.components.options_helper import OptionsHelper, OptionMarketEvaluation
15
15
  from lumibot.entities import Asset
16
16
  from lumibot.entities.chains import OptionsDataFormatError, normalize_option_chains
17
17
  from lumibot.brokers.broker import Broker
@@ -311,7 +311,7 @@ class TestOptionsHelper(unittest.TestCase):
311
311
  result = self.options_helper.get_expiration_on_or_after_date(target, expiries, "call")
312
312
  self.assertEqual(result, _date(2024, 1, 9))
313
313
 
314
- def test_get_expiration_on_or_after_date_uses_latest_when_needed(self):
314
+ def test_get_expiration_on_or_after_date_returns_none_when_no_future_available(self):
315
315
  from datetime import date as _date
316
316
 
317
317
  expiries = {
@@ -325,7 +325,7 @@ class TestOptionsHelper(unittest.TestCase):
325
325
 
326
326
  target = _date(2024, 2, 1)
327
327
  result = self.options_helper.get_expiration_on_or_after_date(target, expiries, "call")
328
- self.assertEqual(result, _date(2024, 1, 9))
328
+ self.assertIsNone(result)
329
329
 
330
330
  def test_chains_backward_compatibility_string_access(self):
331
331
  """Test that existing code using string keys still works."""
@@ -676,6 +676,48 @@ class TestOptionsHelper(unittest.TestCase):
676
676
  self.assertIsNone(evaluation.sell_price)
677
677
  self.assertFalse(evaluation.used_last_price_fallback)
678
678
 
679
+ def test_evaluate_option_market_rejects_non_finite_quotes(self):
680
+ """NaN or infinite quotes are treated as missing to prevent crashes."""
681
+ option_asset = Asset(
682
+ "TEST",
683
+ asset_type=Asset.AssetType.OPTION,
684
+ expiration=date.today() + timedelta(days=7),
685
+ strike=200,
686
+ right="call",
687
+ underlying_asset=Asset("TEST", asset_type=Asset.AssetType.STOCK),
688
+ )
689
+
690
+ self.mock_strategy.get_quote.return_value = Mock(bid=float("nan"), ask=float("inf"))
691
+ self.mock_strategy.get_last_price.return_value = float("nan")
692
+ self.mock_strategy.broker.data_source.option_quote_fallback_allowed = True
693
+
694
+ evaluation = self.options_helper.evaluate_option_market(option_asset, max_spread_pct=0.25)
695
+
696
+ self.assertIsNone(evaluation.buy_price)
697
+ self.assertIn("bid_non_finite", evaluation.data_quality_flags)
698
+ self.assertIn("ask_non_finite", evaluation.data_quality_flags)
699
+ self.assertTrue(evaluation.missing_bid_ask)
700
+ self.assertFalse(OptionsHelper.has_actionable_price(evaluation))
701
+
702
+ def test_has_actionable_price_requires_positive_finite_value(self):
703
+ """has_actionable_price returns False for zero or negative values."""
704
+ evaluation = OptionMarketEvaluation(
705
+ bid=0.5,
706
+ ask=0.6,
707
+ last_price=0.55,
708
+ spread_pct=0.18,
709
+ has_bid_ask=True,
710
+ spread_too_wide=False,
711
+ missing_bid_ask=False,
712
+ missing_last_price=False,
713
+ buy_price=0.0,
714
+ sell_price=0.4,
715
+ used_last_price_fallback=False,
716
+ max_spread_pct=0.25,
717
+ data_quality_flags=["buy_price_non_positive"],
718
+ )
719
+ self.assertFalse(OptionsHelper.has_actionable_price(evaluation))
720
+
679
721
  if __name__ == "__main__":
680
722
  print("🧪 Running enhanced options helper tests...")
681
723
  unittest.main(verbosity=2)
@@ -9,7 +9,7 @@ from lumibot.data_sources.projectx_data import ProjectXData
9
9
  class DummyClient:
10
10
  def history_retrieve_bars(self, contract_id, start_datetime, end_datetime, unit, unit_number, limit, include_partial_bar, live, is_est):
11
11
  # Return a minimal DataFrame resembling expected structure
12
- idx = pd.date_range(end=end_datetime, periods=unit_number, freq='T')
12
+ idx = pd.date_range(end=end_datetime, periods=unit_number, freq='min')
13
13
  data = {
14
14
  'open': [1.0]*len(idx),
15
15
  'high': [1.1]*len(idx),
@@ -51,4 +51,3 @@ def test_projectx_get_bars_accepts_timestep_alias(projectx):
51
51
  bars_map2 = projectx.get_bars(asset, 1, timestep='minute')
52
52
  bars2 = list(bars_map2.values())[0]
53
53
  assert bars2 is not None and not bars2.df.empty
54
-
@@ -0,0 +1,50 @@
1
+ import math
2
+ from lumibot.components.options_helper import OptionsHelper, OptionMarketEvaluation
3
+
4
+
5
+ class _GuardedStrategy:
6
+ def __init__(self):
7
+ self.logged = []
8
+ self.options_helper = OptionsHelper(self)
9
+
10
+ def log_message(self, message, color="white"):
11
+ self.logged.append((color, message))
12
+
13
+ def get_cash(self):
14
+ return 10_000.0
15
+
16
+ def size_position(self, evaluation: OptionMarketEvaluation):
17
+ if evaluation.spread_too_wide or not self.options_helper.has_actionable_price(evaluation):
18
+ self.log_message(
19
+ f"Skipping trade due to invalid quotes (flags={evaluation.data_quality_flags}).",
20
+ color="yellow",
21
+ )
22
+ return 0
23
+
24
+ buy_price = float(evaluation.buy_price)
25
+ budget = self.get_cash() * 0.1
26
+ return math.floor(budget / (buy_price * 100.0))
27
+
28
+
29
+ def test_strategy_guard_skips_non_finite_prices():
30
+ strategy = _GuardedStrategy()
31
+ evaluation = OptionMarketEvaluation(
32
+ bid=None,
33
+ ask=None,
34
+ last_price=None,
35
+ spread_pct=None,
36
+ has_bid_ask=False,
37
+ spread_too_wide=False,
38
+ missing_bid_ask=True,
39
+ missing_last_price=True,
40
+ buy_price=float("nan"),
41
+ sell_price=None,
42
+ used_last_price_fallback=False,
43
+ max_spread_pct=None,
44
+ data_quality_flags=["buy_price_non_finite"],
45
+ )
46
+
47
+ contracts = strategy.size_position(evaluation)
48
+
49
+ assert contracts == 0
50
+ assert any("invalid quotes" in msg for _, msg in strategy.logged)