lumibot 4.2.5__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.

@@ -410,6 +410,7 @@ class DataBentoDataBacktestingPandas(PandasData):
410
410
  # OPTIMIZATION: Check cache first
411
411
  self._check_and_clear_cache()
412
412
  current_dt = self.get_datetime()
413
+ current_dt_aware = to_datetime_aware(current_dt)
413
414
 
414
415
  # Try to get data from our cached pandas_data first
415
416
  search_asset = asset
@@ -435,8 +436,6 @@ class DataBentoDataBacktestingPandas(PandasData):
435
436
 
436
437
  if not df.empty and 'close' in df.columns:
437
438
  # Ensure current_dt is timezone-aware for comparison
438
- current_dt_aware = to_datetime_aware(current_dt)
439
-
440
439
  # Step back one bar so only fully closed bars are visible
441
440
  bar_delta = timedelta(minutes=1)
442
441
  if asset_data.timestep == "hour":
@@ -454,19 +453,45 @@ class DataBentoDataBacktestingPandas(PandasData):
454
453
  filtered_df = df[df.index <= current_dt_aware]
455
454
 
456
455
  if not filtered_df.empty:
457
- last_price = filtered_df['close'].iloc[-1]
458
- if not pd.isna(last_price):
459
- price = float(last_price)
456
+ valid_closes = filtered_df['close'].dropna()
457
+ if not valid_closes.empty:
458
+ price = float(valid_closes.iloc[-1])
460
459
  # OPTIMIZATION: Cache the result
461
460
  self._last_price_cache[cache_key] = price
462
461
  return price
463
462
 
464
- # If no cached data, try to get recent data
463
+ # If no cached data, try to load it for the backtest window
464
+ try:
465
+ fetched_bars = self.get_historical_prices(
466
+ asset_separated,
467
+ length=1,
468
+ quote=quote_asset,
469
+ timestep="minute",
470
+ )
471
+ if fetched_bars is not None:
472
+ asset_data = self.pandas_data.get(search_asset)
473
+ if asset_data is not None:
474
+ df = asset_data.df
475
+ if not df.empty and 'close' in df.columns:
476
+ valid_closes = df[df.index <= current_dt_aware]['close'].dropna()
477
+ if not valid_closes.empty:
478
+ price = float(valid_closes.iloc[-1])
479
+ self._last_price_cache[cache_key] = price
480
+ return price
481
+ except Exception as exc:
482
+ logger.debug(
483
+ "Attempted to hydrate Databento cache for %s but hit error: %s",
484
+ asset.symbol,
485
+ exc,
486
+ )
487
+
488
+ # If still no data, fall back to direct fetch (live-style)
465
489
  logger.warning(f"No cached data for {asset.symbol}, attempting direct fetch")
466
490
  return databento_helper.get_last_price_from_databento(
467
491
  api_key=self._api_key,
468
492
  asset=asset_separated,
469
- venue=exchange
493
+ venue=exchange,
494
+ reference_date=current_dt_aware
470
495
  )
471
496
 
472
497
  except DataBentoAuthenticationError as e:
@@ -771,7 +771,7 @@ class ThetaDataBacktestingPandas(PandasData):
771
771
  quote_columns = ['bid', 'ask', 'bid_size', 'ask_size', 'bid_condition', 'ask_condition', 'bid_exchange', 'ask_exchange']
772
772
  existing_quote_cols = [col for col in quote_columns if col in df.columns]
773
773
  if existing_quote_cols:
774
- df[existing_quote_cols] = df[existing_quote_cols].fillna(method='ffill')
774
+ df[existing_quote_cols] = df[existing_quote_cols].ffill()
775
775
 
776
776
  # Log how much forward filling occurred
777
777
  if 'bid' in df.columns and 'ask' in df.columns:
@@ -1,5 +1,7 @@
1
1
  from dataclasses import dataclass
2
2
  from datetime import date, datetime, timedelta
3
+ from decimal import Decimal
4
+ import math
3
5
  from typing import Any, Dict, List, Optional, Tuple, Union
4
6
  import warnings
5
7
 
@@ -23,6 +25,7 @@ class OptionMarketEvaluation:
23
25
  sell_price: Optional[float]
24
26
  used_last_price_fallback: bool
25
27
  max_spread_pct: Optional[float]
28
+ data_quality_flags: List[str]
26
29
 
27
30
 
28
31
  class OptionsHelper:
@@ -58,6 +61,54 @@ class OptionsHelper:
58
61
  self._liquidity_deprecation_warned = False
59
62
  self.strategy.log_message("OptionsHelper initialized.", color="blue")
60
63
 
64
+ @staticmethod
65
+ def _coerce_price(value: Any, field_name: str, flags: List[str], notes: List[str]) -> Optional[float]:
66
+ """Normalize quote values and record data quality issues."""
67
+ raw_value = value
68
+
69
+ if value is None:
70
+ flags.append(f"{field_name}_missing")
71
+ return None
72
+
73
+ try:
74
+ if isinstance(value, Decimal):
75
+ value = float(value)
76
+ else:
77
+ value = float(value) # type: ignore[arg-type]
78
+ except (TypeError, ValueError):
79
+ flags.append(f"{field_name}_non_numeric")
80
+ notes.append(f"{field_name} value {raw_value!r} is non-numeric; dropping.")
81
+ return None
82
+
83
+ if math.isnan(value) or math.isinf(value):
84
+ flags.append(f"{field_name}_non_finite")
85
+ notes.append(f"{field_name} value {value!r} is not finite; dropping.")
86
+ return None
87
+
88
+ if value <= 0:
89
+ flags.append(f"{field_name}_non_positive")
90
+ notes.append(f"{field_name} value {value!r} is non-positive; dropping.")
91
+ return None
92
+
93
+ return value
94
+
95
+ @staticmethod
96
+ def has_actionable_price(evaluation: Optional["OptionMarketEvaluation"]) -> bool:
97
+ """Return True when the evaluation contains a usable buy price."""
98
+ if evaluation is None:
99
+ return False
100
+
101
+ price = evaluation.buy_price
102
+ if price is None:
103
+ return False
104
+
105
+ try:
106
+ price = float(price)
107
+ except (TypeError, ValueError):
108
+ return False
109
+
110
+ return math.isfinite(price) and price > 0 and not evaluation.spread_too_wide
111
+
61
112
  # ============================================================
62
113
  # Basic Utility Functions
63
114
  # ============================================================
@@ -467,6 +518,9 @@ class OptionsHelper:
467
518
  buy_price: Optional[float] = None
468
519
  sell_price: Optional[float] = None
469
520
 
521
+ data_quality_flags: List[str] = []
522
+ sanitization_notes: List[str] = []
523
+
470
524
  # Attempt to get quotes first
471
525
  quote = None
472
526
  try:
@@ -478,24 +532,20 @@ class OptionsHelper:
478
532
  )
479
533
 
480
534
  if quote and quote.bid is not None and quote.ask is not None:
481
- try:
482
- bid = float(quote.bid)
483
- ask = float(quote.ask)
484
- except (TypeError, ValueError):
485
- bid = quote.bid
486
- ask = quote.ask
535
+ bid = self._coerce_price(quote.bid, "bid", data_quality_flags, sanitization_notes)
536
+ ask = self._coerce_price(quote.ask, "ask", data_quality_flags, sanitization_notes)
487
537
  has_bid_ask = bid is not None and ask is not None
488
538
 
489
539
  if has_bid_ask and bid is not None and ask is not None:
490
540
  buy_price = ask
491
541
  sell_price = bid
492
- mid = (ask + bid) / 2 if (ask is not None and bid is not None) else None
493
- if mid and mid > 0:
542
+ mid = (ask + bid) / 2
543
+ if not math.isfinite(mid) or mid <= 0:
544
+ spread_pct = None
545
+ else:
494
546
  spread_pct = (ask - bid) / mid
495
547
  if max_spread_pct is not None:
496
548
  spread_too_wide = spread_pct > max_spread_pct
497
- else:
498
- spread_pct = None
499
549
  else:
500
550
  missing_bid_ask = True
501
551
 
@@ -510,6 +560,10 @@ class OptionsHelper:
510
560
 
511
561
  if last_price is None:
512
562
  missing_last_price = True
563
+ else:
564
+ last_price = self._coerce_price(last_price, "last_price", data_quality_flags, sanitization_notes)
565
+ if last_price is None:
566
+ missing_last_price = True
513
567
 
514
568
  if not has_bid_ask and allow_fallback and last_price is not None:
515
569
  buy_price = last_price
@@ -519,6 +573,14 @@ class OptionsHelper:
519
573
  f"Using last-price fallback for {option_asset} due to missing bid/ask quotes.",
520
574
  color="yellow",
521
575
  )
576
+ elif not has_bid_ask and allow_fallback and last_price is None:
577
+ data_quality_flags.append("last_price_unusable")
578
+
579
+ if buy_price is not None and (not math.isfinite(buy_price) or buy_price <= 0):
580
+ sanitization_notes.append(f"buy_price {buy_price!r} is not actionable; clearing.")
581
+ data_quality_flags.append("buy_price_non_finite")
582
+ buy_price = None
583
+ sell_price = None
522
584
 
523
585
  # Compose log message
524
586
  spread_str = f"{spread_pct:.2%}" if spread_pct is not None else "None"
@@ -526,6 +588,12 @@ class OptionsHelper:
526
588
  log_color = "red" if spread_too_wide else (
527
589
  "yellow" if (missing_bid_ask or missing_last_price or used_last_price_fallback) else "blue"
528
590
  )
591
+ if sanitization_notes:
592
+ note_summary = "; ".join(sanitization_notes)
593
+ self.strategy.log_message(
594
+ f"Option data sanitization for {option_asset}: {note_summary}",
595
+ color="yellow",
596
+ )
529
597
  self.strategy.log_message(
530
598
  (
531
599
  f"Option market evaluation for {option_asset}: "
@@ -533,7 +601,8 @@ class OptionsHelper:
533
601
  f"max_spread={max_spread_str}, missing_bid_ask={missing_bid_ask}, "
534
602
  f"missing_last_price={missing_last_price}, spread_too_wide={spread_too_wide}, "
535
603
  f"used_last_price_fallback={used_last_price_fallback}, "
536
- f"buy_price={buy_price}, sell_price={sell_price}"
604
+ f"buy_price={buy_price}, sell_price={sell_price}, "
605
+ f"data_quality_flags={data_quality_flags}"
537
606
  ),
538
607
  color=log_color,
539
608
  )
@@ -551,6 +620,7 @@ class OptionsHelper:
551
620
  sell_price=sell_price,
552
621
  used_last_price_fallback=used_last_price_fallback,
553
622
  max_spread_pct=max_spread_pct,
623
+ data_quality_flags=data_quality_flags,
554
624
  )
555
625
 
556
626
  def check_option_liquidity(self, option_asset: Asset, max_spread_pct: float) -> bool:
@@ -721,18 +791,11 @@ class OptionsHelper:
721
791
  self.strategy.log_message(f"Cannot validate data without underlying symbol, returning {exp_date}", color="yellow")
722
792
  return exp_date
723
793
 
724
- # No future expirations with valid data; log and check last available
725
- if expiration_dates:
726
- # Check the last available expiry for data
727
- for exp_str, exp_date in reversed(expiration_dates):
728
- strikes = specific_chain.get(exp_str)
729
- if strikes and len(strikes) > 0:
730
- self.strategy.log_message(
731
- f"No valid expirations on or after {dt}; using latest available {exp_date} for {call_or_put_caps}.",
732
- color="yellow",
733
- )
734
- return exp_date
735
-
794
+ # No future expirations with tradeable data; let the caller skip entries gracefully.
795
+ self.strategy.log_message(
796
+ f"No valid expirations on or after {dt} with tradeable data for {call_or_put_caps}; skipping.",
797
+ color="yellow",
798
+ )
736
799
  return None
737
800
 
738
801
  # ============================================================
@@ -126,14 +126,20 @@ class Vars:
126
126
  class _Strategy:
127
127
  @staticmethod
128
128
  def _normalize_backtest_datetime(value):
129
- """Convert backtest boundary datetimes to the LumiBot default timezone."""
129
+ """Ensure backtest boundary datetimes are timezone-aware.
130
+
131
+ Naive datetimes are localized to the LumiBot default timezone; timezone-aware
132
+ inputs are returned unchanged so their original offsets are preserved.
133
+ """
130
134
  if value is None:
131
135
  return None
132
- aware = to_datetime_aware(value)
133
- tzinfo = getattr(aware, "tzinfo", None)
134
- if tzinfo is not None and tzinfo != LUMIBOT_DEFAULT_PYTZ:
135
- return aware.astimezone(LUMIBOT_DEFAULT_PYTZ)
136
- return aware
136
+ if isinstance(value, datetime.datetime):
137
+ tzinfo = value.tzinfo
138
+ if tzinfo is None or tzinfo.utcoffset(value) is None:
139
+ return to_datetime_aware(value)
140
+ if not hasattr(tzinfo, "zone"):
141
+ return value.astimezone(LUMIBOT_DEFAULT_PYTZ)
142
+ return value
137
143
 
138
144
  @property
139
145
  def is_backtesting(self) -> bool:
@@ -445,7 +445,7 @@ class CcxtCacheDB:
445
445
  if freq == "1d":
446
446
  dt_range = pd.date_range(start=df.index.min(), end=df.index.max(), freq="D")
447
447
  else:
448
- dt_range = pd.date_range(start=df.index.min(), end=df.index.max(), freq="T")
448
+ dt_range = pd.date_range(start=df.index.min(), end=df.index.max(), freq="min")
449
449
 
450
450
  df_complete = df.reindex(dt_range).ffill()
451
451
  df_complete['missing'] = np.where(df_complete.index.isin(df.index), 0, 1)
@@ -947,6 +947,7 @@ def get_last_price_from_databento(
947
947
  api_key: str,
948
948
  asset: Asset,
949
949
  venue: Optional[str] = None,
950
+ reference_date: Optional[datetime] = None,
950
951
  **kwargs
951
952
  ) -> Optional[Union[float, Decimal]]:
952
953
  """
@@ -978,12 +979,14 @@ def get_last_price_from_databento(
978
979
 
979
980
  # For continuous futures, resolve to the current active contract
980
981
  if asset.asset_type == Asset.AssetType.CONT_FUTURE:
981
- # Use Asset class method to resolve continuous futures to actual contract (returns string)
982
- resolved_symbol = asset.resolve_continuous_futures_contract(year_digits=1)
982
+ # Resolve based on reference date when backtesting so we match the contract in use
983
+ resolved_symbol = _format_futures_symbol_for_databento(
984
+ asset,
985
+ reference_date=reference_date,
986
+ )
983
987
  if resolved_symbol is None:
984
988
  logger.error(f"Could not resolve continuous futures contract for {asset.symbol}")
985
989
  return None
986
- # Generate the correct DataBento symbol format (should be single result)
987
990
  symbols_to_try = _generate_databento_symbol_alternatives(asset.symbol, resolved_symbol)
988
991
  logger.info(f"Resolved continuous future {asset.symbol} to specific contract: {resolved_symbol}")
989
992
  logger.info(f"DataBento symbol format for last price: {symbols_to_try[0]}")
@@ -1000,12 +1003,17 @@ def get_last_price_from_databento(
1000
1003
  if hasattr(range_result, 'end') and range_result.end:
1001
1004
  if hasattr(range_result.end, 'tz_localize'):
1002
1005
  # Already a pandas Timestamp
1003
- available_end = range_result.end if range_result.end.tz else range_result.end.tz_localize('UTC')
1006
+ if range_result.end.tz is not None:
1007
+ available_end = range_result.end.tz_convert('UTC')
1008
+ else:
1009
+ available_end = range_result.end.tz_localize('UTC')
1004
1010
  else:
1005
1011
  # Convert to pandas Timestamp
1006
- available_end = pd.to_datetime(range_result.end).tz_localize('UTC')
1012
+ ts = pd.to_datetime(range_result.end)
1013
+ available_end = ts if ts.tz is not None else ts.tz_localize('UTC')
1007
1014
  elif isinstance(range_result, dict) and 'end' in range_result:
1008
- available_end = pd.to_datetime(range_result['end']).tz_localize('UTC')
1015
+ ts = pd.to_datetime(range_result['end'])
1016
+ available_end = ts if ts.tz is not None else ts.tz_localize('UTC')
1009
1017
  else:
1010
1018
  logger.warning(f"Could not parse dataset range for {dataset}: {range_result}")
1011
1019
  # Fallback: use a recent date that's likely to have data
@@ -1047,10 +1055,10 @@ def get_last_price_from_databento(
1047
1055
  df = pd.DataFrame(data)
1048
1056
 
1049
1057
  if not df.empty:
1050
- # Get the last available price (close price of most recent bar)
1051
1058
  if 'close' in df.columns:
1052
- price = df['close'].iloc[-1]
1053
- if pd.notna(price):
1059
+ closes = df['close'].dropna()
1060
+ if not closes.empty:
1061
+ price = closes.iloc[-1]
1054
1062
  logger.info(f"✓ SUCCESS: Got last price for {symbol_to_use}: {price}")
1055
1063
  return float(price)
1056
1064
 
@@ -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
@@ -1883,55 +1884,242 @@ def get_historical_data(asset: Asset, start_dt: datetime, end_dt: datetime, ivl:
1883
1884
  return df
1884
1885
 
1885
1886
 
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
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
1889
1891
 
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
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]}"
1898
1901
 
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"
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
1910
+
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)
1906
1930
 
1907
- querystring = {"root": ticker}
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")
1908
1954
 
1909
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
+ )
1910
1963
 
1911
- # Send the request
1912
- 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
1913
1971
 
1914
- # Convert to pandas dataframe
1915
- 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
1916
1980
 
1917
- # Convert df to a list of the first (and only) column
1918
- 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"))
1919
1983
 
1920
- # Convert after_date to a number
1921
- 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
1922
1987
 
1923
- # Filter out any dates before after_date
1924
- 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
2010
+
2011
+ expiration_iso = _normalize_expiration_value(exp_value)
2012
+ if not expiration_iso:
2013
+ continue
1925
2014
 
1926
- # Convert from "YYYYMMDD" (an int) to "YYYY-MM-DD" (a string)
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]
1927
2119
  expirations_final = []
1928
2120
  for expiration in expirations:
1929
2121
  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
-
2122
+ expirations_final.append(f"{expiration_str[:4]}-{expiration_str[4:6]}-{expiration_str[6:]}")
1935
2123
  return expirations_final
1936
2124
 
1937
2125
 
@@ -2017,8 +2205,6 @@ def get_chains_cached(
2017
2205
  }
2018
2206
  }
2019
2207
  """
2020
- from collections import defaultdict
2021
-
2022
2208
  logger.debug(f"get_chains_cached called for {asset.symbol} on {current_date}")
2023
2209
 
2024
2210
  # 1) If current_date is None => bail out
@@ -2063,28 +2249,32 @@ def get_chains_cached(
2063
2249
 
2064
2250
  return data
2065
2251
 
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.")
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
+ )
2069
2259
 
2070
- # Get expirations and strikes using existing functions
2071
- 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
+ )
2072
2266
 
2073
- chains_dict = {
2074
- "Multiplier": 100,
2075
- "Exchange": "SMART",
2076
- "Chains": {
2077
- "CALL": defaultdict(list),
2078
- "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": {}},
2079
2277
  }
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
2278
 
2089
2279
  # 5) Save to cache file for future reuse
2090
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.5
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,7 +104,7 @@ 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=wWx2te98kwMTuusioKR24KsKW8Djs3yhOb2nNmvgbiU,111467
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
110
  lumibot/strategies/strategy_executor.py,sha256=AnmXlKD2eMgKXs3TrD1u8T_Zsn_8GnG5KRcM_Pq-JBQ,70749
@@ -113,8 +113,8 @@ lumibot/tools/alpaca_helpers.py,sha256=nhBS-sv28lZfIQ85szC9El8VHLrCw5a5KbsGOOEjm
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=Guutp_2QAZe22_r6pftCojpTb3DpDp4Ul_szf6N7X1I,80152
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.5.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
@@ -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.5.dist-info/METADATA,sha256=lK1f_O0TcWv79IUVPehgpO2s_dQE1jrsj69_LCqeKM8,12092
285
- lumibot-4.2.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
286
- lumibot-4.2.5.dist-info/top_level.txt,sha256=otUnUjDFVASauEDiTiAzNgMyqQ1B6jjS3QqqP-WSx38,14
287
- lumibot-4.2.5.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,,
@@ -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)
@@ -138,7 +138,11 @@ def test_get_price_data_partial_cache_hit(mock_build_cache_filename, mock_load_c
138
138
  assert df is not None
139
139
  assert len(df) == 10 # Combined cached and fetched data
140
140
  mock_get_historical_data.assert_called_once()
141
- pd.testing.assert_frame_equal(df, updated_data.drop(columns="missing"))
141
+ pd.testing.assert_frame_equal(
142
+ df,
143
+ updated_data.drop(columns="missing"),
144
+ check_dtype=False,
145
+ )
142
146
  mock_update_cache.assert_called_once()
143
147
 
144
148
 
@@ -452,7 +456,7 @@ def test_get_price_data_invokes_remote_cache_manager(tmp_path, monkeypatch):
452
456
 
453
457
  df = pd.DataFrame(
454
458
  {
455
- "datetime": pd.date_range("2024-01-01 09:30:00", periods=2, freq="T", tz=pytz.UTC),
459
+ "datetime": pd.date_range("2024-01-01 09:30:00", periods=2, freq="min", tz=pytz.UTC),
456
460
  "open": [100.0, 101.0],
457
461
  "high": [101.0, 102.0],
458
462
  "low": [99.5, 100.5],
@@ -1330,83 +1334,203 @@ class TestThetaDataProcessHealthCheck:
1330
1334
  assert thetadata_helper.is_process_alive() is True, "New process should be alive"
1331
1335
 
1332
1336
 
1333
- @pytest.mark.apitest
1334
1337
  class TestThetaDataChainsCaching:
1335
- """Test option chain caching matches Polygon pattern - ZERO TOLERANCE."""
1338
+ """Unit coverage for historical chain caching and normalization."""
1339
+
1340
+ def test_chains_cached_basic_structure(self, tmp_path, monkeypatch):
1341
+ asset = Asset("TEST", asset_type="stock")
1342
+ test_date = date(2024, 11, 7)
1343
+
1344
+ sample_chain = {
1345
+ "Multiplier": 100,
1346
+ "Exchange": "SMART",
1347
+ "Chains": {
1348
+ "CALL": {"2024-11-15": [100.0, 105.0]},
1349
+ "PUT": {"2024-11-15": [90.0, 95.0]},
1350
+ },
1351
+ }
1336
1352
 
1337
- def test_chains_cached_basic_structure(self):
1338
- """Test chain caching returns correct structure."""
1339
- username = os.environ.get("THETADATA_USERNAME")
1340
- password = os.environ.get("THETADATA_PASSWORD")
1353
+ calls = []
1341
1354
 
1342
- asset = Asset("SPY", asset_type="stock")
1343
- test_date = date(2025, 9, 15)
1355
+ def fake_builder(**kwargs):
1356
+ calls.append(kwargs)
1357
+ return sample_chain
1344
1358
 
1345
- chains = thetadata_helper.get_chains_cached(username, password, asset, test_date)
1359
+ monkeypatch.setattr(thetadata_helper, "build_historical_chain", fake_builder)
1360
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
1346
1361
 
1347
- assert chains is not None, "Chains should not be None"
1348
- assert "Multiplier" in chains, "Missing Multiplier"
1349
- assert chains["Multiplier"] == 100, f"Multiplier should be 100, got {chains['Multiplier']}"
1350
- assert "Exchange" in chains, "Missing Exchange"
1351
- assert "Chains" in chains, "Missing Chains"
1352
- assert "CALL" in chains["Chains"], "Missing CALL chains"
1353
- assert "PUT" in chains["Chains"], "Missing PUT chains"
1362
+ result = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
1354
1363
 
1355
- # Verify at least one expiration exists
1356
- assert len(chains["Chains"]["CALL"]) > 0, "Should have at least one CALL expiration"
1357
- assert len(chains["Chains"]["PUT"]) > 0, "Should have at least one PUT expiration"
1364
+ assert result == sample_chain
1365
+ assert len(calls) == 1
1366
+ builder_call = calls[0]
1367
+ assert builder_call["asset"] == asset
1368
+ assert builder_call["as_of_date"] == test_date
1358
1369
 
1359
- print(f"✓ Chain structure valid: {len(chains['Chains']['CALL'])} expirations")
1370
+ def test_chains_cache_reuse(self, tmp_path, monkeypatch):
1371
+ asset = Asset("REUSE", asset_type="stock")
1372
+ test_date = date(2024, 11, 8)
1360
1373
 
1361
- def test_chains_cache_reuse(self):
1362
- """Test that second call reuses cached data (no API call)."""
1363
- import time
1364
- from pathlib import Path
1365
- from lumibot.constants import LUMIBOT_CACHE_FOLDER
1374
+ sample_chain = {
1375
+ "Multiplier": 100,
1376
+ "Exchange": "SMART",
1377
+ "Chains": {"CALL": {"2024-11-22": [110.0]}, "PUT": {"2024-11-22": [95.0]}},
1378
+ }
1366
1379
 
1367
- username = os.environ.get("THETADATA_USERNAME")
1368
- password = os.environ.get("THETADATA_PASSWORD")
1380
+ call_count = {"total": 0}
1369
1381
 
1370
- asset = Asset("AAPL", asset_type="stock")
1371
- test_date = date(2025, 9, 15)
1382
+ def fake_builder(**kwargs):
1383
+ call_count["total"] += 1
1384
+ return sample_chain
1372
1385
 
1373
- # CLEAR CACHE to ensure first call downloads fresh data
1374
- # This prevents cache pollution from previous tests in the suite
1375
- # Chains are stored in: LUMIBOT_CACHE_FOLDER / "thetadata" / "option" / "option_chains"
1376
- chain_folder = Path(LUMIBOT_CACHE_FOLDER) / "thetadata" / "option" / "option_chains"
1377
- if chain_folder.exists():
1378
- # Delete all AAPL chain cache files
1379
- for cache_file in chain_folder.glob("AAPL_*.parquet"):
1380
- try:
1381
- cache_file.unlink()
1382
- except Exception:
1383
- pass
1384
-
1385
- # Restart ThetaData Terminal to ensure fresh connection after cache clearing
1386
- # This is necessary because cache clearing may interfere with active connections
1387
- thetadata_helper.start_theta_data_client(username, password)
1388
- time.sleep(3) # Give Terminal time to fully connect
1386
+ monkeypatch.setattr(thetadata_helper, "build_historical_chain", fake_builder)
1387
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
1388
+
1389
+ first = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
1390
+ second = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
1391
+
1392
+ assert first == sample_chain
1393
+ assert second == sample_chain
1394
+ assert call_count["total"] == 1, "Builder should only run once due to cache reuse"
1395
+
1396
+ def test_chain_cache_respects_recent_file(self, tmp_path, monkeypatch):
1397
+ asset = Asset("RECENT", asset_type="stock")
1398
+ test_date = date(2024, 11, 30)
1399
+
1400
+ sample_chain = {
1401
+ "Multiplier": 100,
1402
+ "Exchange": "SMART",
1403
+ "Chains": {"CALL": {"2024-12-06": [120.0]}, "PUT": {"2024-12-06": [80.0]}},
1404
+ }
1405
+
1406
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
1407
+
1408
+ cache_folder = Path(tmp_path) / "thetadata" / "stock" / "option_chains"
1409
+ cache_folder.mkdir(parents=True, exist_ok=True)
1410
+
1411
+ cache_file = cache_folder / f"{asset.symbol}_{test_date.isoformat()}.parquet"
1412
+ pd.DataFrame({"data": [sample_chain]}).to_parquet(cache_file, compression="snappy", engine="pyarrow")
1413
+
1414
+ # Builder should not be invoked because cache hit satisfies tolerance window
1415
+ def fail_builder(**kwargs):
1416
+ raise AssertionError("build_historical_chain should not be called when cache is fresh")
1417
+
1418
+ monkeypatch.setattr(thetadata_helper, "build_historical_chain", fail_builder)
1419
+
1420
+ result = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
1421
+ assert result == sample_chain
1422
+
1423
+ def test_chains_cached_handles_none_builder(self, tmp_path, monkeypatch, caplog):
1424
+ asset = Asset("NONE", asset_type="stock")
1425
+ test_date = date(2024, 11, 28)
1426
+
1427
+ monkeypatch.setattr(thetadata_helper, "build_historical_chain", lambda **kwargs: None)
1428
+ monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
1429
+
1430
+ with caplog.at_level(logging.WARNING):
1431
+ result = thetadata_helper.get_chains_cached("user", "pass", asset, test_date)
1432
+
1433
+ cache_folder = Path(tmp_path) / "thetadata" / "stock" / "option_chains"
1434
+ assert not cache_folder.exists() or not list(cache_folder.glob("*.parquet"))
1435
+
1436
+ assert result == {
1437
+ "Multiplier": 100,
1438
+ "Exchange": "SMART",
1439
+ "Chains": {"CALL": {}, "PUT": {}},
1440
+ }
1441
+ assert "ThetaData returned no option data" in caplog.text
1442
+
1443
+
1444
+ def test_build_historical_chain_parses_quote_payload(monkeypatch):
1445
+ asset = Asset("CVNA", asset_type="stock")
1446
+ as_of_date = date(2024, 11, 7)
1447
+ as_of_int = int(as_of_date.strftime("%Y%m%d"))
1448
+
1449
+ def fake_get_request(url, headers, querystring, username, password):
1450
+ if url.endswith("/v2/list/expirations"):
1451
+ return {
1452
+ "header": {"format": ["date"]},
1453
+ "response": [[20241115], [20241205], [20250124]],
1454
+ }
1455
+ if url.endswith("/v2/list/strikes"):
1456
+ exp = querystring["exp"]
1457
+ if exp == "20241115":
1458
+ return {
1459
+ "header": {"format": ["strike"]},
1460
+ "response": [[100000], [105000]],
1461
+ }
1462
+ if exp == "20241205":
1463
+ return {
1464
+ "header": {"format": ["strike"]},
1465
+ "response": [[110000]],
1466
+ }
1467
+ return {
1468
+ "header": {"format": ["strike"]},
1469
+ "response": [[120000]],
1470
+ }
1471
+ if url.endswith("/list/dates/option/quote"):
1472
+ exp = querystring["exp"]
1473
+ if exp == "20241115":
1474
+ return {
1475
+ "header": {"format": None, "error_type": "null"},
1476
+ "response": [as_of_int, as_of_int + 1],
1477
+ }
1478
+ return {
1479
+ "header": {"format": None, "error_type": "NO_DATA"},
1480
+ "response": [],
1481
+ }
1482
+ raise AssertionError(f"Unexpected URL {url}")
1483
+
1484
+ monkeypatch.setattr(thetadata_helper, "get_request", fake_get_request)
1485
+
1486
+ result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
1487
+
1488
+ assert result["Multiplier"] == 100
1489
+ assert set(result["Chains"].keys()) == {"CALL", "PUT"}
1490
+ assert list(result["Chains"]["CALL"].keys()) == ["2024-11-15"]
1491
+ assert result["Chains"]["CALL"]["2024-11-15"] == [100.0, 105.0]
1492
+ assert result["Chains"]["PUT"]["2024-11-15"] == [100.0, 105.0]
1493
+
1494
+
1495
+ def test_build_historical_chain_returns_none_when_no_dates(monkeypatch, caplog):
1496
+ asset = Asset("NONE", asset_type="stock")
1497
+ as_of_date = date(2024, 11, 28)
1498
+
1499
+ as_of_int = int(as_of_date.strftime("%Y%m%d"))
1500
+
1501
+ def fake_get_request(url, headers, querystring, username, password):
1502
+ if url.endswith("/v2/list/expirations"):
1503
+ return {"header": {"format": ["date"]}, "response": [[20241129], [20241206]]}
1504
+ if url.endswith("/v2/list/strikes"):
1505
+ return {"header": {"format": ["strike"]}, "response": [[150000], [155000]]}
1506
+ if url.endswith("/list/dates/option/quote"):
1507
+ return {"header": {"format": None, "error_type": "NO_DATA"}, "response": []}
1508
+ raise AssertionError(f"Unexpected URL {url}")
1509
+
1510
+ monkeypatch.setattr(thetadata_helper, "get_request", fake_get_request)
1511
+
1512
+ with caplog.at_level(logging.WARNING):
1513
+ result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
1514
+
1515
+ assert result is None
1516
+ assert f"No expirations with data found for {asset.symbol}" in caplog.text
1389
1517
 
1390
- # Verify connection is established
1391
- _, connected = thetadata_helper.check_connection(username, password)
1392
- assert connected, "ThetaData Terminal failed to connect"
1518
+ def test_build_historical_chain_empty_response(monkeypatch, caplog):
1519
+ asset = Asset("EMPTY", asset_type="stock")
1520
+ as_of_date = date(2024, 11, 9)
1393
1521
 
1394
- # First call - downloads (now guaranteed to be fresh)
1395
- start1 = time.time()
1396
- chains1 = thetadata_helper.get_chains_cached(username, password, asset, test_date)
1397
- time1 = time.time() - start1
1522
+ def fake_get_request(url, headers, querystring, username, password):
1523
+ if url.endswith("/v2/list/expirations"):
1524
+ return {"header": {"format": ["date"]}, "response": []}
1525
+ raise AssertionError("Unexpected call after empty expirations")
1398
1526
 
1399
- # Second call - should use cache
1400
- start2 = time.time()
1401
- chains2 = thetadata_helper.get_chains_cached(username, password, asset, test_date)
1402
- time2 = time.time() - start2
1527
+ monkeypatch.setattr(thetadata_helper, "get_request", fake_get_request)
1403
1528
 
1404
- # Verify same data
1405
- assert chains1 == chains2, "Cached chains should match original"
1529
+ with caplog.at_level(logging.WARNING):
1530
+ result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
1406
1531
 
1407
- # Second call should be MUCH faster (cached)
1408
- assert time2 < time1 * 0.1, f"Cache not working: time1={time1:.2f}s, time2={time2:.2f}s (should be 10x faster)"
1409
- print(f"✓ Cache speedup: {time1/time2:.1f}x faster ({time1:.2f}s -> {time2:.4f}s)")
1532
+ assert result is None
1533
+ assert "returned no expirations" in caplog.text
1410
1534
 
1411
1535
 
1412
1536
  def test_finalize_day_frame_handles_dst_fallback():