ivolatility-backtesting 2.141__tar.gz → 2.142__tar.gz
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.
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/PKG-INFO +1 -1
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/ivolatility_backtesting/ivolatility_backtesting.py +420 -60
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/ivolatility_backtesting.egg-info/PKG-INFO +1 -1
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/ivolatility_backtesting.egg-info/SOURCES.txt +2 -1
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/pyproject.toml +1 -1
- ivolatility_backtesting-2.142/tests/test_2142_fixes.py +383 -0
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/README.md +0 -0
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/ivolatility_backtesting/__init__.py +0 -0
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/ivolatility_backtesting.egg-info/requires.txt +0 -0
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
- {ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ivolatility_backtesting
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.142
|
|
4
4
|
Summary: A universal backtesting framework for financial strategies using the IVolatility API.
|
|
5
5
|
Author-email: IVolatility <support@ivolatility.com>
|
|
6
6
|
Project-URL: Homepage, https://ivolatility.com
|
|
@@ -7720,8 +7720,12 @@ def _api_call_direct(endpoint: str, max_retries: int = 3, **kwargs) -> Optional[
|
|
|
7720
7720
|
import time
|
|
7721
7721
|
from http.client import RemoteDisconnected
|
|
7722
7722
|
from requests.exceptions import ConnectionError, Timeout, RequestException
|
|
7723
|
+
from pandas.errors import EmptyDataError
|
|
7723
7724
|
|
|
7724
|
-
|
|
7725
|
+
# EmptyDataError = HTTP 200 with empty async-CSV body; a re-request
|
|
7726
|
+
# regenerates the file server-side, so it is retryable like a network error
|
|
7727
|
+
RETRYABLE = (RemoteDisconnected, ConnectionError, Timeout, RequestException,
|
|
7728
|
+
EmptyDataError)
|
|
7725
7729
|
|
|
7726
7730
|
for attempt in range(max_retries):
|
|
7727
7731
|
try:
|
|
@@ -7772,8 +7776,12 @@ def api_call(endpoint, cache_config=None, debug=False, max_retries=3, skip_parqu
|
|
|
7772
7776
|
# 0 = silent, 1 = basic, 2 = detailed (URLs), 3 = verbose timing
|
|
7773
7777
|
debug_level = debug if isinstance(debug, int) else (1 if debug else 0)
|
|
7774
7778
|
|
|
7775
|
-
# Network errors that should trigger retry
|
|
7779
|
+
# Network errors that should trigger retry.
|
|
7780
|
+
# EmptyDataError = 200 with empty async-CSV body: the SDK's download retries
|
|
7781
|
+
# don't cover the pd.read_csv step; a re-request regenerates the file
|
|
7782
|
+
from pandas.errors import EmptyDataError
|
|
7776
7783
|
RETRYABLE_ERRORS = (
|
|
7784
|
+
EmptyDataError,
|
|
7777
7785
|
RemoteDisconnected,
|
|
7778
7786
|
ConnectionError,
|
|
7779
7787
|
Timeout,
|
|
@@ -8314,7 +8322,12 @@ def _api_call_internal(endpoint, cache_config, debug, debug_level, skip_parquet_
|
|
|
8314
8322
|
# the server without benefit — so let it fall through to return None.
|
|
8315
8323
|
from http.client import RemoteDisconnected
|
|
8316
8324
|
from requests.exceptions import ConnectionError, Timeout, RequestException, ChunkedEncodingError
|
|
8325
|
+
from pandas.errors import EmptyDataError
|
|
8317
8326
|
|
|
8327
|
+
# Unlike ChunkedEncodingError, EmptyDataError NEEDS a new outer request
|
|
8328
|
+
# (server regenerates the file) — re-raise into the retry loop
|
|
8329
|
+
if isinstance(e, EmptyDataError):
|
|
8330
|
+
raise
|
|
8318
8331
|
if isinstance(e, (RemoteDisconnected, ConnectionError, Timeout, RequestException)) \
|
|
8319
8332
|
and not isinstance(e, ChunkedEncodingError):
|
|
8320
8333
|
raise # Let retry logic handle it
|
|
@@ -12052,9 +12065,73 @@ def _auto_detect_strategy_type(config):
|
|
|
12052
12065
|
return 'STRADDLE'
|
|
12053
12066
|
|
|
12054
12067
|
|
|
12068
|
+
def _norm_opt_type_char(position, default='C'):
|
|
12069
|
+
"""Normalize opt_type to 'C'/'P'. Clients pass 'call'/'CALL'/'c'/'C' —
|
|
12070
|
+
never compare position['opt_type'] to 'C'/'P' directly, use this.
|
|
12071
|
+
Absent opt_type derives from strategy_type, then `default`."""
|
|
12072
|
+
raw = position.get('opt_type')
|
|
12073
|
+
if raw:
|
|
12074
|
+
s = str(raw).strip().upper()
|
|
12075
|
+
if s.startswith('C'):
|
|
12076
|
+
return 'C'
|
|
12077
|
+
if s.startswith('P'):
|
|
12078
|
+
return 'P'
|
|
12079
|
+
st = str(position.get('strategy_type', '')).upper()
|
|
12080
|
+
if 'PUT' in st:
|
|
12081
|
+
return 'P'
|
|
12082
|
+
if 'CALL' in st:
|
|
12083
|
+
return 'C'
|
|
12084
|
+
return default
|
|
12085
|
+
|
|
12086
|
+
|
|
12087
|
+
def _as_date_obj(value):
|
|
12088
|
+
"""Best-effort normalize date-ish value (date/datetime/Timestamp/'YYYY-MM-DD') to datetime.date; None if impossible."""
|
|
12089
|
+
if value is None:
|
|
12090
|
+
return None
|
|
12091
|
+
if hasattr(value, 'date') and not isinstance(value, str):
|
|
12092
|
+
try:
|
|
12093
|
+
return value.date()
|
|
12094
|
+
except TypeError:
|
|
12095
|
+
pass
|
|
12096
|
+
if isinstance(value, str):
|
|
12097
|
+
from datetime import datetime as _dt
|
|
12098
|
+
try:
|
|
12099
|
+
return _dt.strptime(value[:10], '%Y-%m-%d').date()
|
|
12100
|
+
except ValueError:
|
|
12101
|
+
return None
|
|
12102
|
+
import datetime as _d
|
|
12103
|
+
return value if isinstance(value, _d.date) else None
|
|
12104
|
+
|
|
12105
|
+
|
|
12106
|
+
# AM-settled cash index roots (CBOE): standard 3rd-Friday monthlies settle on
|
|
12107
|
+
# the OPENING print (SOQ). Their weekly series (SPXW/NDXP/RUTW/…) are never
|
|
12108
|
+
# listed ON the 3rd Friday, so root+3rd-Friday identifies AM exactly.
|
|
12109
|
+
_AM_SETTLED_INDEX_ROOTS = {'SPX', 'NDX', 'RUT', 'DJX', 'XSP'}
|
|
12110
|
+
|
|
12111
|
+
|
|
12112
|
+
def _is_am_settled_position(position):
|
|
12113
|
+
"""True when the position's expiration settles on the MORNING open (SOQ).
|
|
12114
|
+
|
|
12115
|
+
Explicit position['settlement_style'] ('AM'/'PM') always wins; otherwise
|
|
12116
|
+
AM = (opt_root or symbol) in _AM_SETTLED_INDEX_ROOTS and expiration is a
|
|
12117
|
+
standard 3rd Friday. Everything else (equities, ETFs, weeklys, futures)
|
|
12118
|
+
is PM/close-settled.
|
|
12119
|
+
"""
|
|
12120
|
+
style = str(position.get('settlement_style') or '').upper()
|
|
12121
|
+
if style == 'AM':
|
|
12122
|
+
return True
|
|
12123
|
+
if style == 'PM':
|
|
12124
|
+
return False
|
|
12125
|
+
root = str(position.get('opt_root') or position.get('symbol') or '').upper()
|
|
12126
|
+
if root not in _AM_SETTLED_INDEX_ROOTS:
|
|
12127
|
+
return False
|
|
12128
|
+
exp = _as_date_obj(position.get('expiration'))
|
|
12129
|
+
return exp is not None and exp.weekday() == 4 and 15 <= exp.day <= 21
|
|
12130
|
+
|
|
12131
|
+
|
|
12055
12132
|
class PositionManager:
|
|
12056
12133
|
"""Universal Position Manager with automatic mode detection"""
|
|
12057
|
-
|
|
12134
|
+
|
|
12058
12135
|
def __init__(self, config, debug=False):
|
|
12059
12136
|
self.positions = {}
|
|
12060
12137
|
self.closed_trades = []
|
|
@@ -12427,7 +12504,7 @@ class PositionManager:
|
|
|
12427
12504
|
# ========================================================
|
|
12428
12505
|
short_strike = position.get('short_strike', strike)
|
|
12429
12506
|
long_strike = position.get('long_strike', strike)
|
|
12430
|
-
opt_type = position
|
|
12507
|
+
opt_type = _norm_opt_type_char(position)
|
|
12431
12508
|
|
|
12432
12509
|
if opt_type == 'C':
|
|
12433
12510
|
short_intrinsic = max(0, underlying_price - short_strike)
|
|
@@ -12457,7 +12534,7 @@ class PositionManager:
|
|
|
12457
12534
|
|
|
12458
12535
|
# Approximation: Only front month intrinsic (back has time value)
|
|
12459
12536
|
front_strike = position.get('front_strike', position.get('strike', strike))
|
|
12460
|
-
opt_type = position
|
|
12537
|
+
opt_type = _norm_opt_type_char(position)
|
|
12461
12538
|
|
|
12462
12539
|
if opt_type == 'C':
|
|
12463
12540
|
intrinsic_value = max(0, underlying_price - front_strike) * unit_multiplier * contracts
|
|
@@ -12491,7 +12568,7 @@ class PositionManager:
|
|
|
12491
12568
|
# ========================================================
|
|
12492
12569
|
# FALLBACK: Single option or unknown strategy
|
|
12493
12570
|
# ========================================================
|
|
12494
|
-
opt_type = position
|
|
12571
|
+
opt_type = _norm_opt_type_char(position)
|
|
12495
12572
|
if opt_type == 'C':
|
|
12496
12573
|
intrinsic_value = max(0, underlying_price - strike) * unit_multiplier * contracts
|
|
12497
12574
|
else:
|
|
@@ -12857,6 +12934,19 @@ class PositionManager:
|
|
|
12857
12934
|
position.get('underlying_entry_price', 0)
|
|
12858
12935
|
)
|
|
12859
12936
|
|
|
12937
|
+
# AM-settled index monthlies settle on the MORNING open
|
|
12938
|
+
# (SOQ), not the close; expiration-day open is the proxy
|
|
12939
|
+
if (underlying_price and self.config.get('am_settlement_open', True)
|
|
12940
|
+
and _is_am_settled_position(position)):
|
|
12941
|
+
_open_px = None
|
|
12942
|
+
if position_id in price_data and isinstance(price_data[position_id], dict):
|
|
12943
|
+
_open_px = price_data[position_id].get('underlying_open')
|
|
12944
|
+
if _open_px and _open_px > 0:
|
|
12945
|
+
if self.debug and abs(_open_px - underlying_price) > 1e-9:
|
|
12946
|
+
print(f"[AM SETTLEMENT] {position_id}: using expiration-day "
|
|
12947
|
+
f"OPEN {_open_px:.2f} (SOQ proxy) instead of close {underlying_price:.2f}")
|
|
12948
|
+
underlying_price = _open_px
|
|
12949
|
+
|
|
12860
12950
|
strike = position.get('strike', 0)
|
|
12861
12951
|
strategy_type = position.get('strategy_type', '')
|
|
12862
12952
|
contracts = position.get('contracts', 1)
|
|
@@ -12881,7 +12971,8 @@ class PositionManager:
|
|
|
12881
12971
|
current_pnl = entry_premium - intrinsic_value
|
|
12882
12972
|
else:
|
|
12883
12973
|
current_pnl = intrinsic_value - entry_premium
|
|
12884
|
-
|
|
12974
|
+
_um = position.get('unit_multiplier', 100)
|
|
12975
|
+
current_price = (intrinsic_value / (_um * contracts)) if contracts > 0 else 0
|
|
12885
12976
|
|
|
12886
12977
|
# P&L %
|
|
12887
12978
|
max_risk = position.get('entry_max_risk', entry_premium)
|
|
@@ -13620,9 +13711,21 @@ class PositionManager:
|
|
|
13620
13711
|
|
|
13621
13712
|
# Add directional stop fields from stock_row (NEW simple approach)
|
|
13622
13713
|
if stock_row is not None:
|
|
13714
|
+
# runtime_open = split-aligned open (REFERENCE §4a) — the
|
|
13715
|
+
# AM-settlement reference must not use the adjusted series
|
|
13716
|
+
_stock_open = None
|
|
13717
|
+
for _oc in ('runtime_open', 'open'):
|
|
13718
|
+
try:
|
|
13719
|
+
_ov = stock_row.get(_oc) if hasattr(stock_row, 'get') else stock_row[_oc]
|
|
13720
|
+
except (KeyError, IndexError, TypeError):
|
|
13721
|
+
_ov = None
|
|
13722
|
+
if _ov is not None and not pd.isna(_ov) and float(_ov) > 0:
|
|
13723
|
+
_stock_open = float(_ov)
|
|
13724
|
+
break
|
|
13623
13725
|
price_data[pos_id].update({
|
|
13624
13726
|
'underlying_high': stock_high,
|
|
13625
13727
|
'underlying_low': stock_low,
|
|
13728
|
+
'underlying_open': _stock_open,
|
|
13626
13729
|
'underlying_entry_price': position.get('underlying_entry_price', stock_price),
|
|
13627
13730
|
'current_date': current_date,
|
|
13628
13731
|
'symbol': position.get('symbol')
|
|
@@ -13705,13 +13808,16 @@ class PositionManager:
|
|
|
13705
13808
|
pos_data = price_data.get(position_id, {})
|
|
13706
13809
|
pnl = pos_data.get('pnl', 0)
|
|
13707
13810
|
pnl_pct = pos_data.get('pnl_pct', 0)
|
|
13708
|
-
|
|
13709
|
-
# Generate close kwargs automatically
|
|
13710
|
-
|
|
13811
|
+
|
|
13812
|
+
# Generate close kwargs automatically — use the POSITION's strategy_type;
|
|
13813
|
+
# self.config may hold a placeholder type (mixed CALL/PUT scripts), which
|
|
13814
|
+
# would generate kwargs for the wrong legs and drop exit data
|
|
13815
|
+
strategy_type = ((self.positions.get(position_id) or {}).get('strategy_type')
|
|
13816
|
+
or self.config.get('strategy_type', 'STRADDLE'))
|
|
13711
13817
|
kwargs = StrategyRegistry.generate_close_position_kwargs(strategy_type, pos_data)
|
|
13712
|
-
|
|
13818
|
+
|
|
13713
13819
|
# Close position
|
|
13714
|
-
self.close_position(
|
|
13820
|
+
trade = self.close_position(
|
|
13715
13821
|
position_id=position_id,
|
|
13716
13822
|
exit_date=exit_date,
|
|
13717
13823
|
exit_price=0.0,
|
|
@@ -13721,7 +13827,12 @@ class PositionManager:
|
|
|
13721
13827
|
stat_key='signal_exits', # For consistency with other exits
|
|
13722
13828
|
**kwargs
|
|
13723
13829
|
)
|
|
13724
|
-
|
|
13830
|
+
|
|
13831
|
+
# Return the FINAL pnl: the intrinsic guard inside close_position may
|
|
13832
|
+
# have re-settled a dead-quote expiration close, and clients do
|
|
13833
|
+
# `capital += pnl` with this return value
|
|
13834
|
+
if trade is not None and 'pnl' in trade:
|
|
13835
|
+
return trade['pnl']
|
|
13725
13836
|
return pnl
|
|
13726
13837
|
|
|
13727
13838
|
def check_profit_target(self, current_date, stock_price, options_df, get_option_func, stock_row=None):
|
|
@@ -13780,6 +13891,14 @@ class PositionManager:
|
|
|
13780
13891
|
trading_days = self.config.get('_trading_days')
|
|
13781
13892
|
|
|
13782
13893
|
for position_id, position in self.positions.items():
|
|
13894
|
+
# No PT on/after expiration: zero-quote chain rows fake a +100%
|
|
13895
|
+
# CREDIT buyback at $0; expiration settles via intrinsic instead
|
|
13896
|
+
_pt_exp = _as_date_obj(position.get('expiration'))
|
|
13897
|
+
_pt_now = _as_date_obj(current_date)
|
|
13898
|
+
if _pt_exp is not None and _pt_now is not None and _pt_now >= _pt_exp:
|
|
13899
|
+
if self.debuginfo >= 2:
|
|
13900
|
+
print(f"[PT] SKIP {position_id}: at/after expiration {_pt_exp} — settled by expiration path")
|
|
13901
|
+
continue
|
|
13783
13902
|
# ========================================================
|
|
13784
13903
|
# Check min_days_before_check for profit target (trading days)
|
|
13785
13904
|
# ========================================================
|
|
@@ -13904,7 +14023,57 @@ class PositionManager:
|
|
|
13904
14023
|
|
|
13905
14024
|
return to_close
|
|
13906
14025
|
|
|
13907
|
-
def
|
|
14026
|
+
def _intrinsic_guard_pnl(self, position, position_id, exit_date, kwargs):
|
|
14027
|
+
"""Return (pnl, pnl_pct) via intrinsic settlement when closing at/after
|
|
14028
|
+
expiration with ALL leg exit quotes dead (zero-stub chain rows book a
|
|
14029
|
+
fake -100%/+100% off market quotes otherwise); None = guard not applicable."""
|
|
14030
|
+
if kwargs.get('settlement_type') == 'intrinsic':
|
|
14031
|
+
return None # already settled by check_positions
|
|
14032
|
+
exp_d = _as_date_obj(position.get('expiration'))
|
|
14033
|
+
exit_d = _as_date_obj(exit_date)
|
|
14034
|
+
if exp_d is None or exit_d is None or exit_d < exp_d:
|
|
14035
|
+
return None
|
|
14036
|
+
quote_keys = [k for k in kwargs
|
|
14037
|
+
if k.endswith('_exit_bid') or k.endswith('_exit_ask')]
|
|
14038
|
+
if not quote_keys:
|
|
14039
|
+
return None # nothing to judge — trust the caller's pnl
|
|
14040
|
+
try:
|
|
14041
|
+
if any(float(kwargs.get(k) or 0) > 0 for k in quote_keys):
|
|
14042
|
+
return None # live quotes → market close is legitimate
|
|
14043
|
+
except (TypeError, ValueError):
|
|
14044
|
+
return None
|
|
14045
|
+
underlying_price = (kwargs.get('underlying_exit_price')
|
|
14046
|
+
or kwargs.get('underlying_price')
|
|
14047
|
+
or self._current_underlying_price
|
|
14048
|
+
or position.get('underlying_exit_price')
|
|
14049
|
+
or 0)
|
|
14050
|
+
try:
|
|
14051
|
+
underlying_price = float(underlying_price)
|
|
14052
|
+
except (TypeError, ValueError):
|
|
14053
|
+
return None
|
|
14054
|
+
if underlying_price <= 0:
|
|
14055
|
+
return None
|
|
14056
|
+
strategy_type = position.get('strategy_type') or self.config.get('strategy_type', '')
|
|
14057
|
+
contracts = position.get('contracts', 1)
|
|
14058
|
+
entry_premium = abs(position.get('total_cost', 0))
|
|
14059
|
+
intrinsic_value = self._calculate_intrinsic_value(
|
|
14060
|
+
position=position,
|
|
14061
|
+
position_id=position_id,
|
|
14062
|
+
strategy_type=strategy_type,
|
|
14063
|
+
underlying_price=underlying_price,
|
|
14064
|
+
strike=position.get('strike', 0),
|
|
14065
|
+
contracts=contracts,
|
|
14066
|
+
)
|
|
14067
|
+
is_sell = 'SELL' in str(strategy_type).upper() or position.get('entry_price', 0) == 0
|
|
14068
|
+
pnl = (entry_premium - intrinsic_value) if is_sell else (intrinsic_value - entry_premium)
|
|
14069
|
+
max_risk = position.get('entry_max_risk') or entry_premium
|
|
14070
|
+
pnl_pct = (pnl / max_risk * 100) if max_risk > 0 else 0.0
|
|
14071
|
+
if self.debug:
|
|
14072
|
+
print(f"[INTRINSIC GUARD] {exit_date} {position_id}: dead quotes at/after expiration "
|
|
14073
|
+
f"→ intrinsic=${intrinsic_value:.2f} pnl=${pnl:.2f} ({pnl_pct:.1f}%)")
|
|
14074
|
+
return pnl, pnl_pct
|
|
14075
|
+
|
|
14076
|
+
def close_position(self, position_id, exit_date, exit_price,
|
|
13908
14077
|
pnl=None, pnl_pct=None,
|
|
13909
14078
|
portfolio_state_data=None, **kwargs):
|
|
13910
14079
|
"""
|
|
@@ -13928,7 +14097,14 @@ class PositionManager:
|
|
|
13928
14097
|
|
|
13929
14098
|
position = self.positions.pop(position_id)
|
|
13930
14099
|
self._leg_price_cache.pop(position_id, None)
|
|
13931
|
-
|
|
14100
|
+
|
|
14101
|
+
_guard = self._intrinsic_guard_pnl(position, position_id, exit_date, kwargs)
|
|
14102
|
+
if _guard is not None:
|
|
14103
|
+
pnl, pnl_pct = _guard
|
|
14104
|
+
kwargs.pop('pnl', None)
|
|
14105
|
+
kwargs.pop('pnl_pct', None)
|
|
14106
|
+
kwargs['settlement_type'] = 'intrinsic_guard'
|
|
14107
|
+
|
|
13932
14108
|
# Check if pnl provided in kwargs (takes priority over calculated)
|
|
13933
14109
|
if pnl is None:
|
|
13934
14110
|
pnl = kwargs.get('pnl', None)
|
|
@@ -18131,6 +18307,22 @@ def _apply_duckdb_tuning(conn):
|
|
|
18131
18307
|
pass
|
|
18132
18308
|
|
|
18133
18309
|
|
|
18310
|
+
def _report_failed_chunks(stage, failed_chunks, config=None):
|
|
18311
|
+
"""Loud summary of dropped chunks; flags config['_failed_chunks'] so
|
|
18312
|
+
gap-detection caches are not stamped "verified" over a holey load."""
|
|
18313
|
+
_safe_print(f" ⚠️ {stage}: {len(failed_chunks)} chunk(s) DROPPED after retries — "
|
|
18314
|
+
f"cache has HOLES in these ranges:")
|
|
18315
|
+
for start, end, cp, err in failed_chunks[:20]:
|
|
18316
|
+
_safe_print(f" • {start} → {end} cp={cp}: {err}")
|
|
18317
|
+
if len(failed_chunks) > 20:
|
|
18318
|
+
_safe_print(f" … and {len(failed_chunks) - 20} more")
|
|
18319
|
+
_safe_print(f" ⚠️ Results on this cache are INCOMPLETE for those ranges — "
|
|
18320
|
+
f"re-run preload to refetch.")
|
|
18321
|
+
if isinstance(config, dict):
|
|
18322
|
+
config.setdefault('_failed_chunks', []).extend(
|
|
18323
|
+
(stage, s, e, c) for s, e, c, _ in failed_chunks)
|
|
18324
|
+
|
|
18325
|
+
|
|
18134
18326
|
def _fetch_date_range_data(config, cache_config, symbol, start_date, end_date, dte_from, dte_to):
|
|
18135
18327
|
"""
|
|
18136
18328
|
Fetch data for specific date and DTE range from API.
|
|
@@ -18266,16 +18458,18 @@ def _fetch_date_range_data(config, cache_config, symbol, start_date, end_date, d
|
|
|
18266
18458
|
)
|
|
18267
18459
|
return df
|
|
18268
18460
|
except Exception as e:
|
|
18269
|
-
|
|
18270
|
-
|
|
18461
|
+
_safe_print(f" ❌ CHUNK DROPPED [{chunk_idx}/{total_requests}] "
|
|
18462
|
+
f"{chunk_info['start']} → {chunk_info['end']} cp={params.get('cp','?')}: {e}")
|
|
18463
|
+
failed_chunks.append((chunk_info['start'], chunk_info['end'], params.get('cp', '?'), str(e)))
|
|
18271
18464
|
return None
|
|
18272
|
-
|
|
18465
|
+
|
|
18273
18466
|
# MEMFIX(gap-path): раньше весь диапазон копился в all_data и склеивался одним
|
|
18274
18467
|
# DataFrame — на 15-летнем gap-fill это весь датасет в RAM и OOM 3GB-пода
|
|
18275
18468
|
# (сценарий Fprater39: кэш с 3-летних прогонов есть -> идём этой веткой).
|
|
18276
18469
|
# Теперь каждый чанк пишется в DuckDB сразу и освобождается; возвращаем
|
|
18277
18470
|
# число сохранённых строк (int), а не DataFrame — оба вызывающих обновлены.
|
|
18278
18471
|
rows_streamed = 0
|
|
18472
|
+
failed_chunks = [] # 2.142: (start, end, cp, error) of every dropped chunk
|
|
18279
18473
|
_gap_save_ep = _get_options_endpoints(_get_options_snapshot_mode(config))['filtered']
|
|
18280
18474
|
|
|
18281
18475
|
def _stream_one(df):
|
|
@@ -18314,7 +18508,10 @@ def _fetch_date_range_data(config, cache_config, symbol, start_date, end_date, d
|
|
|
18314
18508
|
df = fetch_chunk((params, i + 1))
|
|
18315
18509
|
_stream_one(df)
|
|
18316
18510
|
df = None
|
|
18317
|
-
|
|
18511
|
+
|
|
18512
|
+
if failed_chunks:
|
|
18513
|
+
_report_failed_chunks('gap-fill', failed_chunks, config)
|
|
18514
|
+
|
|
18318
18515
|
return rows_streamed
|
|
18319
18516
|
|
|
18320
18517
|
|
|
@@ -19011,6 +19208,33 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
|
|
|
19011
19208
|
if total_rows_saved_fallback > 0:
|
|
19012
19209
|
_rich_print(f" 💾 Total saved: {total_rows_saved_fallback:,} rows (streamed)")
|
|
19013
19210
|
|
|
19211
|
+
# Post-fill re-verify: every relevant stock trading day must exist
|
|
19212
|
+
# in the options table; misses are loud + flag _failed_chunks
|
|
19213
|
+
if trading_days:
|
|
19214
|
+
try:
|
|
19215
|
+
db.refresh_conn()
|
|
19216
|
+
_post_df = db.execute_read(
|
|
19217
|
+
f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
|
|
19218
|
+
).fetchdf()
|
|
19219
|
+
_post_dates = set(pd.to_datetime(_post_df['date']).dt.strftime('%Y-%m-%d'))
|
|
19220
|
+
_req_min = pd.to_datetime(extended_start)
|
|
19221
|
+
_req_max = pd.to_datetime(end_date)
|
|
19222
|
+
_relevant = {d for d in trading_days
|
|
19223
|
+
if _req_min <= pd.to_datetime(d) <= _req_max}
|
|
19224
|
+
_still_missing = sorted(_relevant - _post_dates)
|
|
19225
|
+
except Exception as _ce:
|
|
19226
|
+
_still_missing = None
|
|
19227
|
+
_rich_print(f" ⚠️ Completeness check could not run: {_ce}")
|
|
19228
|
+
if _still_missing:
|
|
19229
|
+
_rich_print(f" 🕳️ COMPLETENESS: {len(_still_missing)} trading day(s) "
|
|
19230
|
+
f"STILL missing in options after gap-fill: {_still_missing[:10]}"
|
|
19231
|
+
+ (' …' if len(_still_missing) > 10 else ''))
|
|
19232
|
+
config.setdefault('_failed_chunks', []).extend(
|
|
19233
|
+
('completeness', d, d, '*') for d in _still_missing)
|
|
19234
|
+
elif _still_missing is not None:
|
|
19235
|
+
_rich_print(f" ✅ COMPLETENESS: options cover all "
|
|
19236
|
+
f"{len(_relevant)} relevant trading days")
|
|
19237
|
+
|
|
19014
19238
|
# IMPORTANT: Smart gap fill above only fetches OPTIONS (stock-opts-by-param).
|
|
19015
19239
|
# If the DB was just force-cleared (tables emptied) or IVX was never loaded,
|
|
19016
19240
|
# IVX-based indicators (Z(ivx), IV Rank, IV Percentile) will silently disappear
|
|
@@ -19086,13 +19310,21 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
|
|
|
19086
19310
|
# ========================================================
|
|
19087
19311
|
# SAVE TO CACHE: Coverage verified — skip gap detection next time
|
|
19088
19312
|
# ========================================================
|
|
19089
|
-
|
|
19090
|
-
|
|
19091
|
-
|
|
19092
|
-
|
|
19093
|
-
|
|
19094
|
-
|
|
19095
|
-
|
|
19313
|
+
# 2.142: NEVER stamp "verified" when this run dropped chunks — a stamped
|
|
19314
|
+
# failure would silence gap detection for the whole kernel session and
|
|
19315
|
+
# freeze the holes in place until restart
|
|
19316
|
+
if config.get('_failed_chunks'):
|
|
19317
|
+
_rich_print(f" ⚠️ [CACHE SKIP] Gap-detection NOT cached: "
|
|
19318
|
+
f"{len(config['_failed_chunks'])} chunk(s) were dropped this run — "
|
|
19319
|
+
f"next call will re-verify and refetch")
|
|
19320
|
+
else:
|
|
19321
|
+
_gap_detection_cache[_cache_key] = {
|
|
19322
|
+
'stock_df': stock_df,
|
|
19323
|
+
'verified_at': time.time(),
|
|
19324
|
+
}
|
|
19325
|
+
_gap_elapsed = time.time() - start_time
|
|
19326
|
+
_rich_print(f" 💾 [CACHE SAVE] Gap detection for {symbol} cached "
|
|
19327
|
+
f"(took {_gap_elapsed:.2f}s, will be skipped next time)")
|
|
19096
19328
|
|
|
19097
19329
|
# ========================================================
|
|
19098
19330
|
# 5. LOAD DATA FROM DB (RAM-efficient via ChunkManager when use_duckdb_indicators=True)
|
|
@@ -19111,17 +19343,43 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
|
|
|
19111
19343
|
|
|
19112
19344
|
# Stock already loaded above
|
|
19113
19345
|
_rich_print(f" ✅ Stock: {len(stock_df):,} rows")
|
|
19114
|
-
|
|
19346
|
+
|
|
19115
19347
|
# Extract backtest trading days from stock data (needed for chunk planning!)
|
|
19116
19348
|
backtest_trading_days = None
|
|
19117
19349
|
if stock_df is not None and not stock_df.empty and 'date' in stock_df.columns:
|
|
19118
19350
|
backtest_dates = stock_df[
|
|
19119
|
-
(stock_df['date'] >= pd.Timestamp(start_date)) &
|
|
19351
|
+
(stock_df['date'] >= pd.Timestamp(start_date)) &
|
|
19120
19352
|
(stock_df['date'] <= pd.Timestamp(end_date))
|
|
19121
19353
|
]['date'].unique()
|
|
19122
19354
|
backtest_trading_days = sorted(backtest_dates)
|
|
19123
19355
|
_rich_print(f" 📅 Found {len(backtest_trading_days)} trading days in backtest period")
|
|
19124
|
-
|
|
19356
|
+
|
|
19357
|
+
# Cold path loads options BEFORE stock — this is the first moment the
|
|
19358
|
+
# trading-day calendar exists, so completeness is checked here
|
|
19359
|
+
if backtest_trading_days:
|
|
19360
|
+
try:
|
|
19361
|
+
_cc_tbl = _get_target_table(
|
|
19362
|
+
_get_options_endpoints(_get_options_snapshot_mode(config))['filtered'])
|
|
19363
|
+
_cc_conn = _get_duckdb_storage_conn(cache_config)
|
|
19364
|
+
_cc_df = _cc_conn.execute(
|
|
19365
|
+
f"SELECT DISTINCT date FROM {_cc_tbl} WHERE symbol = ?", [symbol]
|
|
19366
|
+
).fetchdf()
|
|
19367
|
+
_cc_have = set(pd.to_datetime(_cc_df['date']).dt.strftime('%Y-%m-%d'))
|
|
19368
|
+
_cc_need = {pd.Timestamp(d).strftime('%Y-%m-%d') for d in backtest_trading_days}
|
|
19369
|
+
_cc_missing = sorted(_cc_need - _cc_have)
|
|
19370
|
+
except Exception as _ce:
|
|
19371
|
+
_cc_missing = None
|
|
19372
|
+
_rich_print(f" ⚠️ Completeness check could not run: {_ce}")
|
|
19373
|
+
if _cc_missing:
|
|
19374
|
+
_rich_print(f" 🕳️ COMPLETENESS: {len(_cc_missing)} trading day(s) missing "
|
|
19375
|
+
f"in options after initial load: {_cc_missing[:10]}"
|
|
19376
|
+
+ (' …' if len(_cc_missing) > 10 else ''))
|
|
19377
|
+
config.setdefault('_failed_chunks', []).extend(
|
|
19378
|
+
('cold-completeness', d, d, '*') for d in _cc_missing)
|
|
19379
|
+
elif _cc_missing is not None:
|
|
19380
|
+
_rich_print(f" ✅ COMPLETENESS: options cover all "
|
|
19381
|
+
f"{len(_cc_need)} backtest trading days")
|
|
19382
|
+
|
|
19125
19383
|
if use_duckdb_indicators:
|
|
19126
19384
|
# ════════════════════════════════════════════════════════════
|
|
19127
19385
|
# TRUE RAM-EFFICIENT MODE: Don't load full table!
|
|
@@ -19898,13 +20156,15 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
|
|
|
19898
20156
|
_mf_inflight.acquire() # ждём, пока главный поток освободит слот
|
|
19899
20157
|
return df
|
|
19900
20158
|
except Exception as e:
|
|
19901
|
-
|
|
19902
|
-
|
|
20159
|
+
_safe_print(f" ❌ CHUNK DROPPED [{chunk_idx}/{total_requests}] "
|
|
20160
|
+
f"{chunk_info['start']} → {chunk_info['end']} cp={params.get('cp','?')}: {e}")
|
|
20161
|
+
failed_chunks.append((chunk_info['start'], chunk_info['end'], params.get('cp', '?'), str(e)))
|
|
19903
20162
|
return None
|
|
19904
|
-
|
|
20163
|
+
|
|
19905
20164
|
# MEMORY-EFFICIENT: Insert to DuckDB immediately after each request
|
|
19906
20165
|
rows_saved_total = 0
|
|
19907
|
-
|
|
20166
|
+
failed_chunks = [] # 2.142: (start, end, cp, error) of every dropped chunk
|
|
20167
|
+
|
|
19908
20168
|
if use_parallel:
|
|
19909
20169
|
# ========================================================
|
|
19910
20170
|
# PARALLEL MODE - Stream to DuckDB
|
|
@@ -20095,7 +20355,10 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
|
|
|
20095
20355
|
print(f" 📦 Request {i + 1}/{total_requests}: {total_rows:,} rows so far")
|
|
20096
20356
|
|
|
20097
20357
|
print(f" 💾 Saved {rows_saved_total:,} rows to DuckDB (streamed, no concat!)")
|
|
20098
|
-
|
|
20358
|
+
|
|
20359
|
+
if failed_chunks:
|
|
20360
|
+
_report_failed_chunks('options-load', failed_chunks, config)
|
|
20361
|
+
|
|
20099
20362
|
# ========================================================
|
|
20100
20363
|
# CRITICAL CHECK: Did data actually save to DuckDB?
|
|
20101
20364
|
# ========================================================
|
|
@@ -21416,7 +21679,7 @@ def _normalize_futures_options_df(df, root_symbol, root_to_category=None):
|
|
|
21416
21679
|
# (JSON path returns numbers natively; pd.to_numeric is a no-op there.)
|
|
21417
21680
|
for col in ('strike', 'bid', 'ask', 'price', 'price_open', 'price_high',
|
|
21418
21681
|
'price_low', 'iv', 'iv_interpolated', 'delta', 'gamma', 'theta',
|
|
21419
|
-
'vega', 'underlying_price', 'calc_OTM',
|
|
21682
|
+
'vega', 'underlying_price', 'calc_OTM', 'settle',
|
|
21420
21683
|
'dte', 'volume', 'open_interest'):
|
|
21421
21684
|
if col in df.columns:
|
|
21422
21685
|
df[col] = pd.to_numeric(df[col], errors='coerce')
|
|
@@ -21836,12 +22099,14 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
|
|
|
21836
22099
|
_mf_fut_sem.acquire() # MEMFIX: backpressure — ждём разгрузки очереди
|
|
21837
22100
|
return _df
|
|
21838
22101
|
except Exception as e:
|
|
21839
|
-
|
|
21840
|
-
|
|
21841
|
-
|
|
21842
|
-
|
|
22102
|
+
_safe_print(f" ❌ CHUNK DROPPED [{chunk_idx}/{total}] fut-opts-by-param "
|
|
22103
|
+
f"{params.get('from_')} → {params.get('to')} cp={params.get('cp','?')}: {e}")
|
|
22104
|
+
failed_chunks.append((params.get('from_', '?'), params.get('to', '?'),
|
|
22105
|
+
params.get('cp', '?'), str(e)))
|
|
21843
22106
|
return None
|
|
21844
22107
|
|
|
22108
|
+
failed_chunks = [] # 2.142: (start, end, cp, error) of every dropped chunk
|
|
22109
|
+
|
|
21845
22110
|
# Resolve allowed categories from policy framework (see OPT_CATEGORY_POLICIES).
|
|
21846
22111
|
# `None` means no category filtering ('all' policy).
|
|
21847
22112
|
allowed_categories = resolve_opt_category_policy(config)
|
|
@@ -21955,17 +22220,35 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
|
|
|
21955
22220
|
existing_cols = [c[1] for c in duck_conn.execute(
|
|
21956
22221
|
f"PRAGMA table_info('{table}')").fetchall()]
|
|
21957
22222
|
if set(existing_cols) != set(df.columns):
|
|
21958
|
-
|
|
21959
|
-
|
|
21960
|
-
|
|
21961
|
-
|
|
21962
|
-
|
|
21963
|
-
|
|
21964
|
-
|
|
22223
|
+
added = set(df.columns) - set(existing_cols)
|
|
22224
|
+
removed = set(existing_cols) - set(df.columns)
|
|
22225
|
+
# Additive drift → ALTER ADD: DROP would nuke rows of ALL
|
|
22226
|
+
# symbols while only the current window gets re-fetched.
|
|
22227
|
+
if not removed:
|
|
22228
|
+
if debuginfo >= 1:
|
|
22229
|
+
print(f" 🔄 Schema drift in {table}; ALTER ADD "
|
|
22230
|
+
f"{sorted(added)} (rows preserved)")
|
|
22231
|
+
for _new_col in sorted(added):
|
|
22232
|
+
_dtype = 'DOUBLE' if pd.api.types.is_numeric_dtype(df[_new_col]) else 'VARCHAR'
|
|
22233
|
+
duck_conn.execute(
|
|
22234
|
+
f'ALTER TABLE {table} ADD COLUMN "{_new_col}" {_dtype}')
|
|
22235
|
+
else:
|
|
22236
|
+
if debuginfo >= 1:
|
|
22237
|
+
print(f" 🔄 Schema drift in {table}; recreating "
|
|
22238
|
+
f"(+{sorted(added)} −{sorted(removed)})")
|
|
22239
|
+
duck_conn.execute(f"DROP TABLE {table}")
|
|
22240
|
+
tables.remove(table)
|
|
21965
22241
|
if table not in tables:
|
|
21966
22242
|
duck_conn.execute(
|
|
21967
22243
|
f"CREATE TABLE {table} AS SELECT * FROM df_for_db WHERE 1=0")
|
|
21968
|
-
|
|
22244
|
+
# Name-aligned INSERT: after ALTER ADD the table's column ORDER
|
|
22245
|
+
# differs from the df — positional `SELECT *` would misalign silently
|
|
22246
|
+
_ins_cols = ', '.join(
|
|
22247
|
+
f'"{c}"' for c in df.columns
|
|
22248
|
+
if c in {ci[1] for ci in duck_conn.execute(
|
|
22249
|
+
f"PRAGMA table_info('{table}')").fetchall()})
|
|
22250
|
+
duck_conn.execute(
|
|
22251
|
+
f"INSERT INTO {table} ({_ins_cols}) SELECT {_ins_cols} FROM df_for_db")
|
|
21969
22252
|
rows_saved_total += len(df)
|
|
21970
22253
|
# Per-chunk insert log — same format as the equity path
|
|
21971
22254
|
# (_save_to_duckdb_storage debug=True), so futures runs aren't
|
|
@@ -22142,6 +22425,9 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
|
|
|
22142
22425
|
_release_freed_memory()
|
|
22143
22426
|
|
|
22144
22427
|
print(f" 💾 Saved {rows_saved_total:,} futures-options rows to DuckDB ({table})")
|
|
22428
|
+
|
|
22429
|
+
if failed_chunks:
|
|
22430
|
+
_report_failed_chunks('futures-options-load', failed_chunks, config)
|
|
22145
22431
|
if rows_filtered_total > 0:
|
|
22146
22432
|
policy_name = config.get('opt_root_policy', 'tradable')
|
|
22147
22433
|
if filtered_by_category:
|
|
@@ -22467,15 +22753,43 @@ def _try_read_futures_from_duckdb_storage(config, cache_config, symbol,
|
|
|
22467
22753
|
else:
|
|
22468
22754
|
print(f"\n ⏭️ [4/5] No gaps — skipping API")
|
|
22469
22755
|
|
|
22756
|
+
# Post-fill re-verify: every relevant trading day must exist in the
|
|
22757
|
+
# futures-options table; misses are loud + flag _failed_chunks
|
|
22758
|
+
try:
|
|
22759
|
+
_post_conn = _get_duckdb_storage_conn(cache_config)
|
|
22760
|
+
_post_df = _post_conn.execute(
|
|
22761
|
+
f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
|
|
22762
|
+
).fetchdf()
|
|
22763
|
+
_post_dates = set(pd.to_datetime(_post_df['date']).dt.strftime('%Y-%m-%d'))
|
|
22764
|
+
_still_missing = sorted(relevant - _post_dates)
|
|
22765
|
+
except Exception as _ce:
|
|
22766
|
+
_still_missing = None
|
|
22767
|
+
print(f" ⚠️ Completeness check could not run: {_ce}")
|
|
22768
|
+
if _still_missing:
|
|
22769
|
+
print(f" 🕳️ COMPLETENESS: {len(_still_missing)} trading day(s) STILL missing "
|
|
22770
|
+
f"in futures options after gap-fill: {_still_missing[:10]}"
|
|
22771
|
+
+ (' …' if len(_still_missing) > 10 else ''))
|
|
22772
|
+
config.setdefault('_failed_chunks', []).extend(
|
|
22773
|
+
('fut-completeness', d, d, '*') for d in _still_missing)
|
|
22774
|
+
elif _still_missing is not None:
|
|
22775
|
+
print(f" ✅ COMPLETENESS: futures options cover all "
|
|
22776
|
+
f"{len(relevant)} relevant trading days")
|
|
22777
|
+
|
|
22470
22778
|
# Save to gap-detection cache (avoids re-running steps 1-4 next time)
|
|
22471
|
-
|
|
22472
|
-
|
|
22473
|
-
|
|
22474
|
-
|
|
22475
|
-
|
|
22476
|
-
|
|
22477
|
-
|
|
22478
|
-
|
|
22779
|
+
# 2.142: skip the stamp when chunks were dropped this run (see equity path)
|
|
22780
|
+
if config.get('_failed_chunks'):
|
|
22781
|
+
print(f"\n ⚠️ [CACHE SKIP] Gap-detection NOT cached: "
|
|
22782
|
+
f"{len(config['_failed_chunks'])} chunk(s) dropped this run — "
|
|
22783
|
+
f"next call will re-verify and refetch")
|
|
22784
|
+
else:
|
|
22785
|
+
_gap_detection_cache[cache_key] = {
|
|
22786
|
+
'stock_df': stock_df,
|
|
22787
|
+
'meta': meta,
|
|
22788
|
+
'verified_at': _time.time(),
|
|
22789
|
+
}
|
|
22790
|
+
elapsed = _time.time() - start_time
|
|
22791
|
+
print(f"\n 💾 [CACHE SAVE] Gap detection done in {elapsed:.2f}s "
|
|
22792
|
+
f"(next call: fast-path)")
|
|
22479
22793
|
|
|
22480
22794
|
# ── [5/5] Build preloaded dict ──────────────────────────────────────
|
|
22481
22795
|
print(f"\n 📊 [5/5] Building ChunkManager...")
|
|
@@ -23513,6 +23827,10 @@ def preload_data_universal(config, data_requests=None, debug=False):
|
|
|
23513
23827
|
'end': chunk_end.strftime('%Y-%m-%d')
|
|
23514
23828
|
}
|
|
23515
23829
|
response = api_call(endpoint, cache_config, debug=debuginfo, _chunk_info=chunk_info, **params)
|
|
23830
|
+
if not (response and 'data' in response):
|
|
23831
|
+
# 2.142: sequential path now warns like the parallel one
|
|
23832
|
+
_safe_print(f" ⚠️ WARNING: chunk {request_num}/{total_requests} "
|
|
23833
|
+
f"{chunk_info['start']} → {chunk_info['end']} returned no data — skipped")
|
|
23516
23834
|
if response and 'data' in response:
|
|
23517
23835
|
data = response['data']
|
|
23518
23836
|
# Copy DataFrame from cache to avoid modifying original
|
|
@@ -23547,6 +23865,9 @@ def preload_data_universal(config, data_requests=None, debug=False):
|
|
|
23547
23865
|
params = base_params.copy()
|
|
23548
23866
|
chunk_info = {'chunk': 1, 'total': 1, 'start': request_start_date, 'end': request_end_date}
|
|
23549
23867
|
response = api_call(endpoint, cache_config, debug=debuginfo, _chunk_info=chunk_info, **params)
|
|
23868
|
+
if not (response and 'data' in response):
|
|
23869
|
+
_safe_print(f" ⚠️ WARNING: single request {request_start_date} → "
|
|
23870
|
+
f"{request_end_date} returned no data")
|
|
23550
23871
|
if response and 'data' in response:
|
|
23551
23872
|
data = response['data']
|
|
23552
23873
|
# Copy DataFrame from cache to avoid modifying original
|
|
@@ -24120,6 +24441,21 @@ def get_option_by_strike_exp(options_df, strike, expiration, opt_type):
|
|
|
24120
24441
|
|
|
24121
24442
|
if len(filtered) > 0:
|
|
24122
24443
|
row = filtered.iloc[0].to_dict()
|
|
24444
|
+
# 2.142: settlement-price fallback (futures options). Illiquid strikes
|
|
24445
|
+
# carry bid=ask=0 while the exchange settlement is present (EC: 93% of
|
|
24446
|
+
# rows) — the engine then valued positions at $0 (phantom -100%/+100%).
|
|
24447
|
+
# When both quotes are dead and `settle` is alive, price off settle.
|
|
24448
|
+
# Equity chains have no `settle` column → strict no-op there.
|
|
24449
|
+
_settle = row.get('settle')
|
|
24450
|
+
if _settle is not None and pd.notna(_settle):
|
|
24451
|
+
try:
|
|
24452
|
+
_settle = float(_settle)
|
|
24453
|
+
except (TypeError, ValueError):
|
|
24454
|
+
_settle = 0.0
|
|
24455
|
+
if _settle > 0 and (row.get('bid') or 0) <= 0 and (row.get('ask') or 0) <= 0:
|
|
24456
|
+
row['bid'] = _settle
|
|
24457
|
+
row['ask'] = _settle
|
|
24458
|
+
row['_priced_from_settle'] = True
|
|
24123
24459
|
if _option_patch_ctx is not None:
|
|
24124
24460
|
row = _apply_option_1600_patch(row, _option_patch_ctx)
|
|
24125
24461
|
return row
|
|
@@ -30035,20 +30371,44 @@ def get_options_for_date(config, date) -> pd.DataFrame:
|
|
|
30035
30371
|
# Try chunk manager first (RAM-efficient mode)
|
|
30036
30372
|
chunk_mgr = config.get('_options_chunk_manager')
|
|
30037
30373
|
if chunk_mgr is not None:
|
|
30038
|
-
|
|
30039
|
-
|
|
30374
|
+
df = chunk_mgr.get_options_for_date(date)
|
|
30375
|
+
return _apply_fut_opt_pricing_mode(df, config)
|
|
30376
|
+
|
|
30040
30377
|
# Fall back to preloaded DataFrame (legacy mode)
|
|
30041
30378
|
options_df = config.get('_preloaded_options')
|
|
30042
30379
|
if options_df is not None and not options_df.empty:
|
|
30043
30380
|
# Normalize date for comparison
|
|
30044
30381
|
if isinstance(date, str):
|
|
30045
30382
|
date = pd.Timestamp(date)
|
|
30046
|
-
return
|
|
30383
|
+
return _apply_fut_opt_pricing_mode(
|
|
30384
|
+
options_df[options_df['date'] == date].copy(), config)
|
|
30047
30385
|
|
|
30048
30386
|
# No data: preserve options schema so callers can do df['expiration'] safely
|
|
30049
30387
|
return pd.DataFrame(columns=_get_table_columns('options_eod_close'))
|
|
30050
30388
|
|
|
30051
30389
|
|
|
30390
|
+
def _apply_fut_opt_pricing_mode(df, config):
|
|
30391
|
+
"""config['fut_opt_pricing']='settlement': price every row with a live
|
|
30392
|
+
`settle` at bid=ask=settle (CME-style marking, spread=0). Default
|
|
30393
|
+
'market' = no-op; the dead-quote fallback in get_option_by_strike_exp
|
|
30394
|
+
still applies either way."""
|
|
30395
|
+
if df is None or not isinstance(df, pd.DataFrame) or df.empty:
|
|
30396
|
+
return df
|
|
30397
|
+
if config.get('fut_opt_pricing', 'market') != 'settlement':
|
|
30398
|
+
return df
|
|
30399
|
+
if 'settle' not in df.columns:
|
|
30400
|
+
return df
|
|
30401
|
+
_s = pd.to_numeric(df['settle'], errors='coerce')
|
|
30402
|
+
_mask = _s.notna() & (_s > 0)
|
|
30403
|
+
if _mask.any():
|
|
30404
|
+
df = df.copy()
|
|
30405
|
+
df.loc[_mask, 'bid'] = _s[_mask]
|
|
30406
|
+
df.loc[_mask, 'ask'] = _s[_mask]
|
|
30407
|
+
if 'mid' in df.columns:
|
|
30408
|
+
df.loc[_mask, 'mid'] = _s[_mask]
|
|
30409
|
+
return df
|
|
30410
|
+
|
|
30411
|
+
|
|
30052
30412
|
class DuckDBCacheManager:
|
|
30053
30413
|
"""
|
|
30054
30414
|
High-performance cache manager using DuckDB for direct Parquet queries.
|
|
@@ -31396,7 +31756,7 @@ def check_portfolio_delta_hedge(position_managers, options_today, get_option_fun
|
|
|
31396
31756
|
|
|
31397
31757
|
strike = pos.get('strike')
|
|
31398
31758
|
expiration = pos.get('expiration')
|
|
31399
|
-
opt_type = pos
|
|
31759
|
+
opt_type = _norm_opt_type_char(pos, default='P')
|
|
31400
31760
|
if strike is None or expiration is None:
|
|
31401
31761
|
continue
|
|
31402
31762
|
|
|
@@ -31550,7 +31910,7 @@ def check_portfolio_stop_loss(position_managers, options_today, get_option_func,
|
|
|
31550
31910
|
contracts = pos.get('contracts', 0)
|
|
31551
31911
|
strike = pos.get('strike')
|
|
31552
31912
|
expiration = pos.get('expiration')
|
|
31553
|
-
opt_type = pos
|
|
31913
|
+
opt_type = _norm_opt_type_char(pos, default='P')
|
|
31554
31914
|
if strike is not None and expiration is not None and contracts > 0:
|
|
31555
31915
|
option_data = get_option_func(strike, expiration, opt_type)
|
|
31556
31916
|
if option_data:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ivolatility_backtesting
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.142
|
|
4
4
|
Summary: A universal backtesting framework for financial strategies using the IVolatility API.
|
|
5
5
|
Author-email: IVolatility <support@ivolatility.com>
|
|
6
6
|
Project-URL: Homepage, https://ivolatility.com
|
|
@@ -7,4 +7,5 @@ ivolatility_backtesting.egg-info/PKG-INFO
|
|
|
7
7
|
ivolatility_backtesting.egg-info/SOURCES.txt
|
|
8
8
|
ivolatility_backtesting.egg-info/dependency_links.txt
|
|
9
9
|
ivolatility_backtesting.egg-info/requires.txt
|
|
10
|
-
ivolatility_backtesting.egg-info/top_level.txt
|
|
10
|
+
ivolatility_backtesting.egg-info/top_level.txt
|
|
11
|
+
tests/test_2142_fixes.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ivolatility_backtesting"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.142"
|
|
8
8
|
description = "A universal backtesting framework for financial strategies using the IVolatility API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# Tests for 2.142 fix set: W1 intrinsic settlement, W2 loader resilience,
|
|
2
|
+
# W3 futures settle pricing, W4 AM settlement reference.
|
|
3
|
+
#
|
|
4
|
+
# Runs standalone (no pytest needed): python3 tests/test_2142_fixes.py
|
|
5
|
+
# Also pytest-compatible for CI: pytest tests/test_2142_fixes.py
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import traceback
|
|
9
|
+
from datetime import date
|
|
10
|
+
|
|
11
|
+
import matplotlib
|
|
12
|
+
matplotlib.use('Agg')
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
sys.path.insert(0, os.path.join(_REPO, 'ivolatility_backtesting'))
|
|
17
|
+
import ivolatility_backtesting as lib # noqa: E402
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _mk_pm(strategy_type='CALL', **cfg_extra):
|
|
21
|
+
cfg = {'strategy_type': strategy_type, 'symbol': 'SPX'}
|
|
22
|
+
cfg.update(cfg_extra)
|
|
23
|
+
return lib.PositionManager(cfg, debug=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _open_long_call(pm, pid='p1', opt_type='call', symbol='SPX',
|
|
27
|
+
strike=5685.0, premium=23000.0, exp=date(2025, 7, 18)):
|
|
28
|
+
pm.open_position(position_id=pid, strategy_type='CALL', symbol=symbol,
|
|
29
|
+
entry_date=date(2025, 5, 2), entry_price=premium,
|
|
30
|
+
quantity=100, contracts=1, total_cost=premium,
|
|
31
|
+
strike=strike, expiration=exp,
|
|
32
|
+
underlying_entry_price=5686.67, opt_type=opt_type)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------- W1a
|
|
36
|
+
|
|
37
|
+
def test_w1a_lowercase_call_settles_as_call():
|
|
38
|
+
pm = _mk_pm()
|
|
39
|
+
_open_long_call(pm, opt_type='call')
|
|
40
|
+
si = pm.check_positions(date(2025, 7, 18), {'p1': {'underlying_price': 6296.79}})[0]
|
|
41
|
+
assert abs(si['pnl'] - 38179.0) < 1.0, si['pnl']
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_w1a_all_variants_normalize():
|
|
45
|
+
for variant in ('call', 'CALL', 'Call', 'c', 'C'):
|
|
46
|
+
pm = _mk_pm()
|
|
47
|
+
_open_long_call(pm, opt_type=variant)
|
|
48
|
+
si = pm.check_positions(date(2025, 7, 18), {'p1': {'underlying_price': 6296.79}})[0]
|
|
49
|
+
assert abs(si['pnl'] - 38179.0) < 1.0, (variant, si['pnl'])
|
|
50
|
+
for variant in ('put', 'PUT', 'P'):
|
|
51
|
+
pm = _mk_pm('PUT')
|
|
52
|
+
pm.open_position(position_id='p1', strategy_type='PUT', symbol='SPX',
|
|
53
|
+
entry_date=date(2025, 5, 2), entry_price=10000.0,
|
|
54
|
+
quantity=100, contracts=1, total_cost=10000.0,
|
|
55
|
+
strike=5685.0, expiration=date(2025, 7, 18),
|
|
56
|
+
underlying_entry_price=5686.67, opt_type=variant)
|
|
57
|
+
si = pm.check_positions(date(2025, 7, 18), {'p1': {'underlying_price': 5000.0}})[0]
|
|
58
|
+
assert abs(si['pnl'] - 58500.0) < 1.0, (variant, si['pnl'])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_w1a_missing_opt_type_derives_from_strategy():
|
|
62
|
+
pm = _mk_pm('PUT')
|
|
63
|
+
pm.open_position(position_id='p1', strategy_type='PUT', symbol='SPX',
|
|
64
|
+
entry_date=date(2025, 5, 2), entry_price=10000.0,
|
|
65
|
+
quantity=100, contracts=1, total_cost=10000.0,
|
|
66
|
+
strike=5685.0, expiration=date(2025, 7, 18),
|
|
67
|
+
underlying_entry_price=5686.67) # no opt_type at all
|
|
68
|
+
si = pm.check_positions(date(2025, 7, 18), {'p1': {'underlying_price': 5000.0}})[0]
|
|
69
|
+
assert abs(si['pnl'] - 58500.0) < 1.0, si['pnl']
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------- W1b
|
|
73
|
+
|
|
74
|
+
_DEAD_STUB = {'pnl': -23000.0, 'pnl_pct': -100.0, 'underlying_price': 6296.79,
|
|
75
|
+
'underlying_exit_price': 6296.79,
|
|
76
|
+
'call_exit_bid': 0.0, 'call_exit_ask': 0.0, 'call_iv_exit': -1.0}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_w1b_by_signal_guard_debit():
|
|
80
|
+
pm = _mk_pm()
|
|
81
|
+
_open_long_call(pm)
|
|
82
|
+
ret = pm.close_position_by_signal('p1', date(2025, 7, 18), {'p1': dict(_DEAD_STUB)}, 'expiration')
|
|
83
|
+
tr = pm.closed_trades[-1]
|
|
84
|
+
assert abs(ret - 38179.0) < 1.0, ret
|
|
85
|
+
assert abs(tr['pnl'] - 38179.0) < 1.0, tr['pnl']
|
|
86
|
+
assert tr.get('settlement_type') == 'intrinsic_guard'
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_w1b_by_signal_guard_credit_pin():
|
|
90
|
+
pm = _mk_pm('STRADDLE')
|
|
91
|
+
pm.open_position(position_id='p4', strategy_type='SELL_STRADDLE', symbol='SPX',
|
|
92
|
+
entry_date=date(2025, 6, 1), entry_price=0.0, quantity=100,
|
|
93
|
+
contracts=1, total_cost=-5000.0, strike=6300.0,
|
|
94
|
+
expiration=date(2025, 7, 18), entry_max_risk=20000.0,
|
|
95
|
+
underlying_entry_price=6290.0)
|
|
96
|
+
stub = {'p4': {'pnl': 5000.0, 'pnl_pct': 100.0, 'underlying_price': 6300.0,
|
|
97
|
+
'underlying_exit_price': 6300.0,
|
|
98
|
+
'call_exit_bid': 0.0, 'call_exit_ask': 0.0,
|
|
99
|
+
'put_exit_bid': 0.0, 'put_exit_ask': 0.0}}
|
|
100
|
+
ret = pm.close_position_by_signal('p4', date(2025, 7, 18), stub, 'expiration')
|
|
101
|
+
assert abs(ret - 5000.0) < 1.0, ret # premium kept, intrinsic 0 at the pin
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_w1b_guard_skips_live_quotes():
|
|
105
|
+
pm = _mk_pm()
|
|
106
|
+
_open_long_call(pm)
|
|
107
|
+
live = {'p1': {'pnl': 39000.0, 'pnl_pct': 169.6, 'underlying_price': 6296.79,
|
|
108
|
+
'underlying_exit_price': 6296.79,
|
|
109
|
+
'call_exit_bid': 620.0, 'call_exit_ask': 622.0}}
|
|
110
|
+
ret = pm.close_position_by_signal('p1', date(2025, 7, 18), live, 'expiration')
|
|
111
|
+
tr = pm.closed_trades[-1]
|
|
112
|
+
assert abs(ret - 39000.0) < 1.0, ret # market close honored
|
|
113
|
+
assert tr.get('settlement_type') != 'intrinsic_guard'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_w1b_guard_skips_before_expiration():
|
|
117
|
+
pm = _mk_pm()
|
|
118
|
+
_open_long_call(pm)
|
|
119
|
+
dead_early = {'p1': dict(_DEAD_STUB, pnl=-8000.0, pnl_pct=-34.8)}
|
|
120
|
+
ret = pm.close_position_by_signal('p1', date(2025, 6, 20), dead_early, 'signal_exit')
|
|
121
|
+
assert abs(ret - (-8000.0)) < 1.0, ret # pre-expiration: caller's pnl kept
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_w1b_strategy_type_taken_from_position():
|
|
125
|
+
# config says CALL, position is PUT: kwargs must be generated for PUT legs
|
|
126
|
+
pm = _mk_pm('CALL')
|
|
127
|
+
pm.open_position(position_id='pp', strategy_type='PUT', symbol='SPX',
|
|
128
|
+
entry_date=date(2025, 5, 2), entry_price=9000.0,
|
|
129
|
+
quantity=100, contracts=1, total_cost=9000.0,
|
|
130
|
+
strike=6400.0, expiration=date(2025, 9, 19),
|
|
131
|
+
underlying_entry_price=6300.0, opt_type='put')
|
|
132
|
+
stub = {'pp': {'pnl': 1500.0, 'pnl_pct': 16.7,
|
|
133
|
+
'put_exit_bid': 105.0, 'put_exit_ask': 106.0}}
|
|
134
|
+
pm.close_position_by_signal('pp', date(2025, 6, 20), stub, 'signal_exit')
|
|
135
|
+
tr = pm.closed_trades[-1]
|
|
136
|
+
assert tr.get('put_exit_bid') == 105.0, tr.get('put_exit_bid')
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_w1b_profit_target_skipped_at_expiration():
|
|
140
|
+
pm = _mk_pm('CALL', profit_target_config={'enabled': True, 'target_pct': 50})
|
|
141
|
+
_open_long_call(pm)
|
|
142
|
+
chain = pd.DataFrame({'strike': [5685.0], 'expiration': ['2025-07-18'],
|
|
143
|
+
'type': ['C'], 'bid': [0.0], 'ask': [0.0]})
|
|
144
|
+
to_close = pm.check_profit_target(
|
|
145
|
+
date(2025, 7, 18), 6296.79, chain,
|
|
146
|
+
lambda s, e, t: lib.get_option_by_strike_exp(chain, s, e, t))
|
|
147
|
+
assert to_close == [], to_close # no phantom PT on expiration day
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------- W2
|
|
151
|
+
|
|
152
|
+
def test_w2_api_call_retries_empty_data_error():
|
|
153
|
+
calls = {'n': 0}
|
|
154
|
+
|
|
155
|
+
def fake_internal(endpoint, cache_config, debug, debug_level,
|
|
156
|
+
skip_parquet_cache=False, _chunk_info=None, **kwargs):
|
|
157
|
+
calls['n'] += 1
|
|
158
|
+
if calls['n'] < 3:
|
|
159
|
+
raise pd.errors.EmptyDataError('No columns to parse from file')
|
|
160
|
+
return {'data': [{'x': 1}]}
|
|
161
|
+
|
|
162
|
+
orig_internal = lib._api_call_internal
|
|
163
|
+
orig_sleep = lib.time.sleep if hasattr(lib, 'time') else None
|
|
164
|
+
import time as _t
|
|
165
|
+
orig_t_sleep = _t.sleep
|
|
166
|
+
_t.sleep = lambda *_a, **_k: None
|
|
167
|
+
lib._api_call_internal = fake_internal
|
|
168
|
+
try:
|
|
169
|
+
resp = lib.api_call('/fake/endpoint', None, debug=0, max_retries=3)
|
|
170
|
+
finally:
|
|
171
|
+
lib._api_call_internal = orig_internal
|
|
172
|
+
_t.sleep = orig_t_sleep
|
|
173
|
+
assert calls['n'] == 3, calls['n']
|
|
174
|
+
assert resp and resp['data'] == [{'x': 1}]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_w2_api_call_gives_up_after_retries():
|
|
178
|
+
calls = {'n': 0}
|
|
179
|
+
|
|
180
|
+
def fake_internal(*a, **k):
|
|
181
|
+
calls['n'] += 1
|
|
182
|
+
raise pd.errors.EmptyDataError('No columns to parse from file')
|
|
183
|
+
|
|
184
|
+
import time as _t
|
|
185
|
+
orig_internal, orig_sleep = lib._api_call_internal, _t.sleep
|
|
186
|
+
_t.sleep = lambda *_a, **_k: None
|
|
187
|
+
lib._api_call_internal = fake_internal
|
|
188
|
+
try:
|
|
189
|
+
raised = False
|
|
190
|
+
try:
|
|
191
|
+
lib.api_call('/fake/endpoint', None, debug=0, max_retries=3)
|
|
192
|
+
except pd.errors.EmptyDataError:
|
|
193
|
+
raised = True
|
|
194
|
+
finally:
|
|
195
|
+
lib._api_call_internal = orig_internal
|
|
196
|
+
_t.sleep = orig_sleep
|
|
197
|
+
assert calls['n'] == 3, calls['n']
|
|
198
|
+
assert raised # surfaces to fetch_chunk → loud drop, not a silent None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_w2_api_call_direct_retries_empty():
|
|
202
|
+
calls = {'n': 0}
|
|
203
|
+
|
|
204
|
+
class FakeAPIManager:
|
|
205
|
+
@staticmethod
|
|
206
|
+
def get_method(endpoint):
|
|
207
|
+
def m(**kwargs):
|
|
208
|
+
calls['n'] += 1
|
|
209
|
+
if calls['n'] < 2:
|
|
210
|
+
raise pd.errors.EmptyDataError('No columns to parse from file')
|
|
211
|
+
return pd.DataFrame({'x': [1]})
|
|
212
|
+
return m
|
|
213
|
+
|
|
214
|
+
class FakeHelper:
|
|
215
|
+
@staticmethod
|
|
216
|
+
def normalize_response(response, debug=False):
|
|
217
|
+
return {'data': response}
|
|
218
|
+
|
|
219
|
+
import time as _t
|
|
220
|
+
orig_mgr, orig_helper, orig_sleep = lib.APIManager, lib.APIHelper, _t.sleep
|
|
221
|
+
_t.sleep = lambda *_a, **_k: None
|
|
222
|
+
lib.APIManager, lib.APIHelper = FakeAPIManager, FakeHelper
|
|
223
|
+
try:
|
|
224
|
+
df = lib._api_call_direct('/fake', max_retries=3)
|
|
225
|
+
finally:
|
|
226
|
+
lib.APIManager, lib.APIHelper = orig_mgr, orig_helper
|
|
227
|
+
_t.sleep = orig_sleep
|
|
228
|
+
assert calls['n'] == 2 and df is not None and len(df) == 1
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------- W3
|
|
232
|
+
|
|
233
|
+
def _chain_row(bid, ask, settle=None, extra=None):
|
|
234
|
+
data = {'strike': [5000.0], 'expiration': ['2026-09-14'], 'type': ['C'],
|
|
235
|
+
'bid': [bid], 'ask': [ask]}
|
|
236
|
+
if settle is not None:
|
|
237
|
+
data['settle'] = [settle]
|
|
238
|
+
if extra:
|
|
239
|
+
data.update(extra)
|
|
240
|
+
return pd.DataFrame(data)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_w3_settle_fallback_dead_quotes():
|
|
244
|
+
row = lib.get_option_by_strike_exp(_chain_row(0.0, 0.0, settle=12.5),
|
|
245
|
+
5000.0, '2026-09-14', 'C')
|
|
246
|
+
assert row['bid'] == 12.5 and row['ask'] == 12.5
|
|
247
|
+
assert row.get('_priced_from_settle') is True
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_w3_settle_fallback_ignores_live_quotes():
|
|
251
|
+
row = lib.get_option_by_strike_exp(_chain_row(1.0, 1.2, settle=12.5),
|
|
252
|
+
5000.0, '2026-09-14', 'C')
|
|
253
|
+
assert row['bid'] == 1.0 and row['ask'] == 1.2
|
|
254
|
+
assert '_priced_from_settle' not in row
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_w3_no_settle_column_noop():
|
|
258
|
+
row = lib.get_option_by_strike_exp(_chain_row(0.0, 0.0),
|
|
259
|
+
5000.0, '2026-09-14', 'C')
|
|
260
|
+
assert row['bid'] == 0.0 and row['ask'] == 0.0
|
|
261
|
+
assert '_priced_from_settle' not in row
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_w3_settle_varchar_from_old_cache():
|
|
265
|
+
df = _chain_row(0.0, 0.0)
|
|
266
|
+
df['settle'] = ['12.5'] # string, as a pre-fix VARCHAR cache would give
|
|
267
|
+
row = lib.get_option_by_strike_exp(df, 5000.0, '2026-09-14', 'C')
|
|
268
|
+
assert row['bid'] == 12.5, row['bid']
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_w3_settlement_mode_marks_all_rows():
|
|
272
|
+
df = pd.DataFrame({'strike': [1.0, 2.0], 'expiration': ['e', 'e'],
|
|
273
|
+
'type': ['C', 'C'], 'bid': [0.5, 0.0], 'ask': [0.7, 0.0],
|
|
274
|
+
'mid': [0.6, 0.0], 'settle': [0.55, 0.33]})
|
|
275
|
+
out = lib._apply_fut_opt_pricing_mode(df, {'fut_opt_pricing': 'settlement'})
|
|
276
|
+
assert list(out['bid']) == [0.55, 0.33]
|
|
277
|
+
assert list(out['ask']) == [0.55, 0.33]
|
|
278
|
+
# default mode: untouched
|
|
279
|
+
out2 = lib._apply_fut_opt_pricing_mode(df, {})
|
|
280
|
+
assert list(out2['bid']) == [0.5, 0.0]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_w3_normalize_casts_settle_numeric():
|
|
284
|
+
raw = pd.DataFrame({'opt_symbol': ['EC/26U 1.14C.CME'], 'strike': ['1.14'],
|
|
285
|
+
'bid': ['0'], 'ask': ['0'], 'settle': ['0.0047'],
|
|
286
|
+
'expiration': ['2026-09-14'], 'date': ['2026-07-24'],
|
|
287
|
+
'type': ['C']})
|
|
288
|
+
norm = lib._normalize_futures_options_df(raw.copy(), 'EC')
|
|
289
|
+
assert norm['settle'].dtype.kind == 'f', norm['settle'].dtype
|
|
290
|
+
assert abs(float(norm['settle'].iloc[0]) - 0.0047) < 1e-9
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_w3_duckdb_alter_add_preserves_other_symbols():
|
|
294
|
+
# simulate the migration semantics on a real tmp DuckDB the same way
|
|
295
|
+
# _save_chunk does: ALTER ADD for additive drift + name-aligned INSERT
|
|
296
|
+
import duckdb
|
|
297
|
+
import tempfile
|
|
298
|
+
with tempfile.TemporaryDirectory() as td:
|
|
299
|
+
con = duckdb.connect(os.path.join(td, 't.duckdb'))
|
|
300
|
+
con.execute("CREATE TABLE futures_options_eod_close AS "
|
|
301
|
+
"SELECT 'ES' AS symbol, '2026-07-01' AS date, 1.0 AS bid, 2.0 AS ask")
|
|
302
|
+
# incoming frame has a NEW column settle → additive drift
|
|
303
|
+
df_new = pd.DataFrame({'symbol': ['EC'], 'date': ['2026-07-24'],
|
|
304
|
+
'bid': [0.0], 'ask': [0.0], 'settle': [0.0047]})
|
|
305
|
+
existing = [c[1] for c in con.execute(
|
|
306
|
+
"PRAGMA table_info('futures_options_eod_close')").fetchall()]
|
|
307
|
+
added = set(df_new.columns) - set(existing)
|
|
308
|
+
removed = set(existing) - set(df_new.columns)
|
|
309
|
+
assert added == {'settle'} and not removed
|
|
310
|
+
for c in sorted(added):
|
|
311
|
+
con.execute(f'ALTER TABLE futures_options_eod_close ADD COLUMN "{c}" DOUBLE')
|
|
312
|
+
cols = ', '.join(f'"{c}"' for c in df_new.columns)
|
|
313
|
+
con.register('df_for_db', df_new)
|
|
314
|
+
con.execute(f"INSERT INTO futures_options_eod_close ({cols}) SELECT {cols} FROM df_for_db")
|
|
315
|
+
rows = con.execute("SELECT symbol, settle FROM futures_options_eod_close ORDER BY symbol").fetchall()
|
|
316
|
+
assert len(rows) == 2, rows # ES row SURVIVED
|
|
317
|
+
assert rows[0][0] == 'EC' and abs(rows[0][1] - 0.0047) < 1e-9
|
|
318
|
+
assert rows[1][0] == 'ES' and rows[1][1] is None # old row, NULL settle
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------- W4
|
|
322
|
+
|
|
323
|
+
def test_w4_am_detection():
|
|
324
|
+
am = {'symbol': 'SPX', 'expiration': date(2025, 7, 18)} # 3rd Friday
|
|
325
|
+
assert lib._is_am_settled_position(am) is True
|
|
326
|
+
weekly = {'symbol': 'SPX', 'expiration': date(2025, 7, 25)} # 4th Friday
|
|
327
|
+
assert lib._is_am_settled_position(weekly) is False
|
|
328
|
+
equity = {'symbol': 'AAPL', 'expiration': date(2025, 7, 18)}
|
|
329
|
+
assert lib._is_am_settled_position(equity) is False
|
|
330
|
+
explicit = {'symbol': 'AAPL', 'expiration': date(2025, 7, 18),
|
|
331
|
+
'settlement_style': 'AM'}
|
|
332
|
+
assert lib._is_am_settled_position(explicit) is True
|
|
333
|
+
forced_pm = {'symbol': 'SPX', 'expiration': date(2025, 7, 18),
|
|
334
|
+
'settlement_style': 'PM'}
|
|
335
|
+
assert lib._is_am_settled_position(forced_pm) is False
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_w4_settlement_uses_open_for_am():
|
|
339
|
+
pm = _mk_pm()
|
|
340
|
+
_open_long_call(pm) # SPX, exp 2025-07-18 = 3rd Friday → AM
|
|
341
|
+
pd_data = {'p1': {'underlying_price': 6296.79, 'underlying_open': 6250.00}}
|
|
342
|
+
si = pm.check_positions(date(2025, 7, 18), pd_data)[0]
|
|
343
|
+
expected = (6250.00 - 5685.0) * 100 - 23000.0
|
|
344
|
+
assert abs(si['pnl'] - expected) < 1.0, (si['pnl'], expected)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_w4_settlement_keeps_close_when_disabled():
|
|
348
|
+
pm = _mk_pm(am_settlement_open=False)
|
|
349
|
+
_open_long_call(pm)
|
|
350
|
+
pd_data = {'p1': {'underlying_price': 6296.79, 'underlying_open': 6250.00}}
|
|
351
|
+
si = pm.check_positions(date(2025, 7, 18), pd_data)[0]
|
|
352
|
+
assert abs(si['pnl'] - 38179.0) < 1.0, si['pnl']
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_w4_settlement_keeps_close_for_non_am():
|
|
356
|
+
pm = lib.PositionManager({'strategy_type': 'CALL', 'symbol': 'AAPL'}, debug=False)
|
|
357
|
+
pm.open_position(position_id='p1', strategy_type='CALL', symbol='AAPL',
|
|
358
|
+
entry_date=date(2025, 5, 2), entry_price=1000.0,
|
|
359
|
+
quantity=100, contracts=1, total_cost=1000.0,
|
|
360
|
+
strike=200.0, expiration=date(2025, 7, 18),
|
|
361
|
+
underlying_entry_price=201.0, opt_type='call')
|
|
362
|
+
pd_data = {'p1': {'underlying_price': 230.0, 'underlying_open': 225.0}}
|
|
363
|
+
si = pm.check_positions(date(2025, 7, 18), pd_data)[0]
|
|
364
|
+
expected = (230.0 - 200.0) * 100 - 1000.0
|
|
365
|
+
assert abs(si['pnl'] - expected) < 1.0, si['pnl']
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------- runner
|
|
369
|
+
|
|
370
|
+
if __name__ == '__main__':
|
|
371
|
+
tests = [(n, f) for n, f in sorted(globals().items())
|
|
372
|
+
if n.startswith('test_') and callable(f)]
|
|
373
|
+
failed = []
|
|
374
|
+
for name, fn in tests:
|
|
375
|
+
try:
|
|
376
|
+
fn()
|
|
377
|
+
print(f"PASS {name}")
|
|
378
|
+
except Exception:
|
|
379
|
+
failed.append(name)
|
|
380
|
+
print(f"FAIL {name}")
|
|
381
|
+
traceback.print_exc()
|
|
382
|
+
print(f"\n{len(tests) - len(failed)}/{len(tests)} passed")
|
|
383
|
+
sys.exit(1 if failed else 0)
|
|
File without changes
|
{ivolatility_backtesting-2.141 → ivolatility_backtesting-2.142}/ivolatility_backtesting/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|