lumibot 4.2.4__py3-none-any.whl → 4.2.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of lumibot might be problematic. Click here for more details.
- lumibot/backtesting/databento_backtesting_pandas.py +32 -7
- lumibot/backtesting/thetadata_backtesting_pandas.py +1 -1
- lumibot/components/options_helper.py +86 -23
- lumibot/strategies/_strategy.py +24 -4
- lumibot/strategies/strategy_executor.py +1 -3
- lumibot/tools/ccxt_data_store.py +1 -1
- lumibot/tools/databento_helper.py +17 -9
- lumibot/tools/thetadata_helper.py +255 -59
- {lumibot-4.2.4.dist-info → lumibot-4.2.7.dist-info}/METADATA +2 -2
- {lumibot-4.2.4.dist-info → lumibot-4.2.7.dist-info}/RECORD +18 -17
- tests/test_backtesting_datetime_normalization.py +4 -0
- tests/test_options_helper.py +45 -3
- tests/test_projectx_timestep_alias.py +1 -2
- tests/test_strategy_price_guard.py +50 -0
- tests/test_thetadata_helper.py +187 -63
- {lumibot-4.2.4.dist-info → lumibot-4.2.7.dist-info}/WHEEL +0 -0
- {lumibot-4.2.4.dist-info → lumibot-4.2.7.dist-info}/licenses/LICENSE +0 -0
- {lumibot-4.2.4.dist-info → lumibot-4.2.7.dist-info}/top_level.txt +0 -0
|
@@ -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.
|
|
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.
|
|
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
|
|
1881
|
-
"""
|
|
1882
|
-
|
|
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
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1906
|
-
|
|
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
|
-
|
|
1909
|
-
|
|
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
|
-
|
|
1912
|
-
|
|
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
|
-
|
|
1915
|
-
|
|
1984
|
+
chains: Dict[str, Dict[str, List[float]]] = {"CALL": {}, "PUT": {}}
|
|
1985
|
+
expirations_added = 0
|
|
1986
|
+
consecutive_misses = 0
|
|
1916
1987
|
|
|
1917
|
-
|
|
1918
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2062
|
-
|
|
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
|
-
|
|
2065
|
-
|
|
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
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
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.
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
117
|
-
lumibot/tools/databento_helper.py,sha256=
|
|
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
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
285
|
-
lumibot-4.2.
|
|
286
|
-
lumibot-4.2.
|
|
287
|
-
lumibot-4.2.
|
|
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
|
tests/test_options_helper.py
CHANGED
|
@@ -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
|
|
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.
|
|
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='
|
|
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)
|