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.
- 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 +12 -6
- lumibot/tools/ccxt_data_store.py +1 -1
- lumibot/tools/databento_helper.py +17 -9
- lumibot/tools/thetadata_helper.py +348 -95
- {lumibot-4.2.5.dist-info → lumibot-4.2.9.dist-info}/METADATA +2 -2
- {lumibot-4.2.5.dist-info → lumibot-4.2.9.dist-info}/RECORD +16 -15
- 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 +260 -63
- {lumibot-4.2.5.dist-info → lumibot-4.2.9.dist-info}/WHEEL +0 -0
- {lumibot-4.2.5.dist-info → lumibot-4.2.9.dist-info}/licenses/LICENSE +0 -0
- {lumibot-4.2.5.dist-info → lumibot-4.2.9.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
|
|
458
|
-
if not
|
|
459
|
-
price = float(
|
|
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
|
|
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].
|
|
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
|
-
|
|
482
|
-
|
|
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
|
|
493
|
-
if mid
|
|
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
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
# ============================================================
|
lumibot/strategies/_strategy.py
CHANGED
|
@@ -126,14 +126,20 @@ class Vars:
|
|
|
126
126
|
class _Strategy:
|
|
127
127
|
@staticmethod
|
|
128
128
|
def _normalize_backtest_datetime(value):
|
|
129
|
-
"""
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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:
|
lumibot/tools/ccxt_data_store.py
CHANGED
|
@@ -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="
|
|
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
|
-
#
|
|
982
|
-
resolved_symbol =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1053
|
-
if
|
|
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
|
|