lumibot 4.2.5__py3-none-any.whl → 4.2.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lumibot might be problematic. Click here for more details.

@@ -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