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.
- 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 +247 -57
- {lumibot-4.2.5.dist-info → lumibot-4.2.7.dist-info}/METADATA +2 -2
- {lumibot-4.2.5.dist-info → lumibot-4.2.7.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 +187 -63
- {lumibot-4.2.5.dist-info → lumibot-4.2.7.dist-info}/WHEEL +0 -0
- {lumibot-4.2.5.dist-info → lumibot-4.2.7.dist-info}/licenses/LICENSE +0 -0
- {lumibot-4.2.5.dist-info → lumibot-4.2.7.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
|
|
|
@@ -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
|
|
1887
|
-
"""
|
|
1888
|
-
|
|
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
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1912
|
-
|
|
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
|
-
|
|
1915
|
-
|
|
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
|
-
|
|
1918
|
-
|
|
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
|
-
|
|
1921
|
-
|
|
1984
|
+
chains: Dict[str, Dict[str, List[float]]] = {"CALL": {}, "PUT": {}}
|
|
1985
|
+
expirations_added = 0
|
|
1986
|
+
consecutive_misses = 0
|
|
1922
1987
|
|
|
1923
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2068
|
-
|
|
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
|
-
|
|
2071
|
-
|
|
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
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
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.
|
|
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,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=
|
|
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=
|
|
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
|
|
@@ -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,,
|
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)
|
tests/test_thetadata_helper.py
CHANGED
|
@@ -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(
|
|
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="
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
1343
|
-
|
|
1355
|
+
def fake_builder(**kwargs):
|
|
1356
|
+
calls.append(kwargs)
|
|
1357
|
+
return sample_chain
|
|
1344
1358
|
|
|
1345
|
-
|
|
1359
|
+
monkeypatch.setattr(thetadata_helper, "build_historical_chain", fake_builder)
|
|
1360
|
+
monkeypatch.setattr(thetadata_helper, "LUMIBOT_CACHE_FOLDER", str(tmp_path))
|
|
1346
1361
|
|
|
1347
|
-
|
|
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
|
-
|
|
1356
|
-
assert len(
|
|
1357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
-
|
|
1368
|
-
password = os.environ.get("THETADATA_PASSWORD")
|
|
1380
|
+
call_count = {"total": 0}
|
|
1369
1381
|
|
|
1370
|
-
|
|
1371
|
-
|
|
1382
|
+
def fake_builder(**kwargs):
|
|
1383
|
+
call_count["total"] += 1
|
|
1384
|
+
return sample_chain
|
|
1372
1385
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1405
|
-
|
|
1529
|
+
with caplog.at_level(logging.WARNING):
|
|
1530
|
+
result = thetadata_helper.build_historical_chain("user", "pass", asset, as_of_date)
|
|
1406
1531
|
|
|
1407
|
-
|
|
1408
|
-
|
|
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():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|