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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.141
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
- RETRYABLE = (RemoteDisconnected, ConnectionError, Timeout, RequestException)
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.get('opt_type', 'C')
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.get('opt_type', 'C')
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.get('opt_type', 'C')
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
- current_price = (intrinsic_value / (100 * contracts)) if contracts > 0 else 0
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
- strategy_type = self.config.get('strategy_type', 'STRADDLE')
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 close_position(self, position_id, exit_date, exit_price,
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
- if debuginfo >= 1:
18270
- print(f" Error: {e}")
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
- _gap_detection_cache[_cache_key] = {
19090
- 'stock_df': stock_df,
19091
- 'verified_at': time.time(),
19092
- }
19093
- _gap_elapsed = time.time() - start_time
19094
- _rich_print(f" 💾 [CACHE SAVE] Gap detection for {symbol} cached "
19095
- f"(took {_gap_elapsed:.2f}s, will be skipped next time)")
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
- if debuginfo >= 1:
19902
- print(f" Error fetching {params.get('from_')} {params.get('cp')}: {e}")
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
- if debuginfo >= 1:
21840
- print(f" fut-opts-by-param "
21841
- f"{params.get('from_')}→{params.get('to')} "
21842
- f"{params.get('cp')}: {e}")
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
- if debuginfo >= 1:
21959
- added = set(df.columns) - set(existing_cols)
21960
- removed = set(existing_cols) - set(df.columns)
21961
- print(f" 🔄 Schema drift in {table}; recreating "
21962
- f"(+{sorted(added)} −{sorted(removed)})")
21963
- duck_conn.execute(f"DROP TABLE {table}")
21964
- tables.remove(table)
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
- duck_conn.execute(f"INSERT INTO {table} SELECT * FROM df_for_db")
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
- _gap_detection_cache[cache_key] = {
22472
- 'stock_df': stock_df,
22473
- 'meta': meta,
22474
- 'verified_at': _time.time(),
22475
- }
22476
- elapsed = _time.time() - start_time
22477
- print(f"\n 💾 [CACHE SAVE] Gap detection done in {elapsed:.2f}s "
22478
- f"(next call: fast-path)")
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
- return chunk_mgr.get_options_for_date(date)
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 options_df[options_df['date'] == date].copy()
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.get('opt_type', 'put')
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.get('opt_type', 'put')
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.141
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.141"
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)