ivolatility-backtesting 2.144__tar.gz → 2.146__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.144
3
+ Version: 2.146
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
@@ -1,7 +1,7 @@
1
1
  from .ivolatility_backtesting import (
2
2
  BacktestResults, BacktestAnalyzer, ResultsReporter,
3
3
  ChartGenerator, ResultsExporter, run_backtest, run_backtest_with_stoploss,
4
- run_backtest_notebook, print_signals_table,
4
+ run_backtest_notebook, print_signals_table, enable_output_logging,
5
5
  init_api, api_call, APIHelper, APIManager,
6
6
  ResourceMonitor, create_progress_bar, update_progress, format_time,
7
7
  StopLossManager, PositionManager, StopLossConfig,
@@ -33,7 +33,7 @@ from .ivolatility_backtesting import (
33
33
  __all__ = [
34
34
  'BacktestResults', 'BacktestAnalyzer', 'ResultsReporter',
35
35
  'ChartGenerator', 'ResultsExporter', 'run_backtest', 'run_backtest_with_stoploss',
36
- 'run_backtest_notebook', 'print_signals_table',
36
+ 'run_backtest_notebook', 'print_signals_table', 'enable_output_logging',
37
37
  'init_api', 'api_call', 'APIHelper', 'APIManager',
38
38
  'ResourceMonitor', 'create_progress_bar', 'update_progress', 'format_time',
39
39
  'StopLossManager', 'PositionManager', 'StopLossConfig',
@@ -179,8 +179,29 @@ _API_LOG_FILE = None # Track API log file path for summary printing
179
179
  # ============================================================
180
180
  from typing import Optional, Dict, Any, Tuple
181
181
 
182
+ _NUMERIC_COERCE_WARNED = False
183
+
184
+
185
+ def _as_data_row(source):
186
+ """Normalize a stock/data row to a single record.
187
+
188
+ Callers legitimately pass either a row (``df.iloc[0]``) or a one-row slice
189
+ (``df[df['date'] == d]``). Only a 2-D object needs ``.iloc[0]``: applying it
190
+ to a Series would collapse it to its first cell.
191
+ """
192
+ if source is None:
193
+ return None
194
+ try:
195
+ if getattr(source, 'ndim', 1) > 1:
196
+ return source.iloc[0] if len(source) > 0 else None
197
+ except Exception:
198
+ return source
199
+ return source
200
+
201
+
182
202
  def _first_valid_numeric_value(source, columns) -> Optional[float]:
183
203
  """Return the first usable numeric value from source[column] in priority order."""
204
+ source = _as_data_row(source)
184
205
  if source is None:
185
206
  return None
186
207
  for col in columns:
@@ -196,7 +217,16 @@ def _first_valid_numeric_value(source, columns) -> Optional[float]:
196
217
  if value is None or pd.isna(value):
197
218
  continue
198
219
  return float(value)
199
- except Exception:
220
+ except Exception as exc:
221
+ # Shape errors here used to vanish silently and surface later as a
222
+ # wrong number (fallback instead of the real price). Warn once per
223
+ # run so the cause is visible without flooding the notebook output.
224
+ global _NUMERIC_COERCE_WARNED
225
+ if not _NUMERIC_COERCE_WARNED:
226
+ _NUMERIC_COERCE_WARNED = True
227
+ print(f"\u26a0\ufe0f Could not read numeric field '{col}' from the data row "
228
+ f"({type(exc).__name__}: {exc}). Falling back to defaults — "
229
+ f"check that stock_row is a row or a one-row slice.")
200
230
  continue
201
231
  return None
202
232
 
@@ -266,14 +296,7 @@ def _resolve_runtime_underlying_price(stock_price=None, stock_row=None, options_
266
296
  Resolve the best option-aligned underlying price for runtime P&L / SL logic.
267
297
  Priority: stock_row runtime fields -> options underlying_price -> passed scalar.
268
298
  """
269
- row_obj = None
270
- try:
271
- if stock_row is not None and hasattr(stock_row, 'iloc') and len(stock_row) > 0:
272
- row_obj = stock_row.iloc[0]
273
- elif stock_row is not None:
274
- row_obj = stock_row
275
- except Exception:
276
- row_obj = stock_row
299
+ row_obj = _as_data_row(stock_row)
277
300
 
278
301
  value = _first_valid_numeric_value(
279
302
  row_obj,
@@ -297,14 +320,7 @@ def _resolve_runtime_underlying_price(stock_price=None, stock_row=None, options_
297
320
 
298
321
  def _resolve_runtime_stock_range(stock_row=None, fallback_price=None) -> Tuple[Optional[float], Optional[float]]:
299
322
  """Resolve runtime high/low in the same coordinate system as runtime price."""
300
- row_obj = None
301
- try:
302
- if stock_row is not None and hasattr(stock_row, 'iloc') and len(stock_row) > 0:
303
- row_obj = stock_row.iloc[0]
304
- elif stock_row is not None:
305
- row_obj = stock_row
306
- except Exception:
307
- row_obj = stock_row
323
+ row_obj = _as_data_row(stock_row)
308
324
 
309
325
  high = _first_valid_numeric_value(row_obj, ('runtime_high', 'high'))
310
326
  low = _first_valid_numeric_value(row_obj, ('runtime_low', 'low'))
@@ -1515,6 +1531,7 @@ def report_results_folder(
1515
1531
  return {"folder": folder, "strategies": strategies}
1516
1532
 
1517
1533
 
1534
+
1518
1535
  def run_backtest_notebook(
1519
1536
  base_config: dict,
1520
1537
  strategy_fn,
@@ -12125,12 +12142,59 @@ def _is_am_settled_position(position):
12125
12142
  if style == 'PM':
12126
12143
  return False
12127
12144
  root = str(position.get('opt_root') or position.get('symbol') or '').upper()
12145
+ if root == 'VIX':
12146
+ # Every VIX expiration (Wednesday, or Tue/Thu when shifted) is cash-settled
12147
+ # on the morning VRO print; the open is only the fallback proxy.
12148
+ return True
12128
12149
  if root not in _AM_SETTLED_INDEX_ROOTS:
12129
12150
  return False
12130
12151
  exp = _as_date_obj(position.get('expiration'))
12131
12152
  return exp is not None and exp.weekday() == 4 and 15 <= exp.day <= 21
12132
12153
 
12133
12154
 
12155
+ # Max calendar gap between a VIX expiration date and the VRO print that settles it.
12156
+ # Normal case: same day; an unscheduled exchange closure moves the print to the next trading day.
12157
+ _VRO_MAX_SHIFT_DAYS = 5
12158
+
12159
+
12160
+ def _vro_settlement_for(config, expiration):
12161
+ """VRO settlement value for a VIX expiration, or None when unavailable.
12162
+
12163
+ The settling print is the first VRO row dated >= expiration (VIX expiry
12164
+ dates in the option data already carry the Tue/Wed shift; only unscheduled
12165
+ closures move the print to a later day). Returns None when config has no
12166
+ '_preloaded_vro' frame, the date is unparsable, or the nearest print is
12167
+ more than _VRO_MAX_SHIFT_DAYS away.
12168
+ """
12169
+ exp = _as_date_obj(expiration)
12170
+ if exp is None or config is None:
12171
+ return None
12172
+ vro = config.get('_preloaded_vro')
12173
+ if vro is None:
12174
+ # Safety net for strategies that never went through preload_data_universal:
12175
+ # fetch once and keep it on the shared config (empty frame on failure).
12176
+ try:
12177
+ vro = _load_vro_series(config.get('start_date'), config.get('end_date'), config)
12178
+ except Exception:
12179
+ vro = pd.DataFrame(columns=['date', 'vro'])
12180
+ config['_preloaded_vro'] = vro
12181
+ if getattr(vro, 'empty', True):
12182
+ return None
12183
+ try:
12184
+ dates = pd.to_datetime(vro['date']).dt.date
12185
+ mask = dates >= exp
12186
+ if not mask.any():
12187
+ return None
12188
+ first_idx = dates[mask].idxmin() # earliest print on/after expiration
12189
+ first_date = dates.loc[first_idx]
12190
+ if (first_date - exp).days > _VRO_MAX_SHIFT_DAYS:
12191
+ return None
12192
+ value = float(vro.loc[first_idx, 'vro'])
12193
+ return value if value > 0 else None
12194
+ except Exception:
12195
+ return None
12196
+
12197
+
12134
12198
  class PositionManager:
12135
12199
  """Universal Position Manager with automatic mode detection"""
12136
12200
 
@@ -12139,6 +12203,7 @@ class PositionManager:
12139
12203
  self.closed_trades = []
12140
12204
  self.config = config
12141
12205
  self.debug = debug
12206
+ self._vro_missing_warned = set() # VIX expirations already reported as settled without VRO
12142
12207
  self.debuginfo = config.get('debuginfo', 0) # Add debuginfo level
12143
12208
 
12144
12209
  # AUTO-DETECT strategy_type if missing
@@ -12936,10 +13001,29 @@ class PositionManager:
12936
13001
  position.get('underlying_entry_price', 0)
12937
13002
  )
12938
13003
 
12939
- # AM-settled index monthlies settle on the MORNING open
12940
- # (SOQ), not the close; expiration-day open is the proxy
12941
- if (underlying_price and self.config.get('am_settlement_open', True)
13004
+ # Settlement reference, in priority order:
13005
+ # 1. exact settlement print (VIX -> VRO), set by build_price_data
13006
+ # 2. AM-settled index (SPX/NDX/... 3rd Friday, VIX any date):
13007
+ # expiration-day OPEN as the SOQ proxy
13008
+ # 3. close (everything else)
13009
+ settlement_source = 'close'
13010
+ _settle_px = None
13011
+ if position_id in price_data and isinstance(price_data[position_id], dict):
13012
+ _settle_px = price_data[position_id].get('settlement_price')
13013
+ if _settle_px and _settle_px > 0 and self.config.get('am_settlement_open', True):
13014
+ if self.debug and underlying_price and abs(_settle_px - underlying_price) > 1e-9:
13015
+ print(f"[SETTLEMENT] {position_id}: using settlement print "
13016
+ f"{_settle_px:.2f} (VRO) instead of close {underlying_price:.2f}")
13017
+ underlying_price = _settle_px
13018
+ settlement_source = 'vro'
13019
+ elif (underlying_price and self.config.get('am_settlement_open', True)
12942
13020
  and _is_am_settled_position(position)):
13021
+ if str(position.get('opt_root') or position.get('symbol') or '').upper() == 'VIX':
13022
+ _exp_key = str(position.get('expiration'))[:10]
13023
+ if _exp_key not in self._vro_missing_warned:
13024
+ self._vro_missing_warned.add(_exp_key)
13025
+ print(f"WARNING: no VRO settlement print for VIX expiration {_exp_key} - "
13026
+ f"settling on the expiration-day open (SOQ proxy)")
12943
13027
  _open_px = None
12944
13028
  if position_id in price_data and isinstance(price_data[position_id], dict):
12945
13029
  _open_px = price_data[position_id].get('underlying_open')
@@ -12948,6 +13032,7 @@ class PositionManager:
12948
13032
  print(f"[AM SETTLEMENT] {position_id}: using expiration-day "
12949
13033
  f"OPEN {_open_px:.2f} (SOQ proxy) instead of close {underlying_price:.2f}")
12950
13034
  underlying_price = _open_px
13035
+ settlement_source = 'open'
12951
13036
 
12952
13037
  strike = position.get('strike', 0)
12953
13038
  strategy_type = position.get('strategy_type', '')
@@ -13049,7 +13134,11 @@ class PositionManager:
13049
13134
  'pnl': current_pnl,
13050
13135
  'pnl_pct': current_pnl_pct,
13051
13136
  'settlement_type': 'intrinsic' if used_intrinsic else 'market',
13052
- **close_kwargs # Include leg exit data automatically
13137
+ **close_kwargs, # Include leg exit data automatically
13138
+ # after close_kwargs: a custom strategy's generic kwargs must not
13139
+ # overwrite what the settlement actually used
13140
+ 'settlement_source': settlement_source,
13141
+ 'settlement_price': underlying_price if used_intrinsic else None,
13053
13142
  }
13054
13143
 
13055
13144
  to_close.append(stop_info)
@@ -13715,19 +13804,23 @@ class PositionManager:
13715
13804
  if stock_row is not None:
13716
13805
  # runtime_open = split-aligned open (REFERENCE §4a) — the
13717
13806
  # AM-settlement reference must not use the adjusted series
13718
- _stock_open = None
13719
- for _oc in ('runtime_open', 'open'):
13720
- try:
13721
- _ov = stock_row.get(_oc) if hasattr(stock_row, 'get') else stock_row[_oc]
13722
- except (KeyError, IndexError, TypeError):
13723
- _ov = None
13724
- if _ov is not None and not pd.isna(_ov) and float(_ov) > 0:
13725
- _stock_open = float(_ov)
13726
- break
13807
+ # stock_row may arrive as a single-row slice (stock_df[mask]) —
13808
+ # take the row itself, otherwise .get() yields a Series and any
13809
+ # boolean/float use of it raises ValueError.
13810
+ _stock_open = _first_valid_numeric_value(stock_row, ('runtime_open', 'open'))
13811
+ if _stock_open is not None and _stock_open <= 0:
13812
+ _stock_open = None
13813
+ _settle_px = None
13814
+ if str(position.get('opt_root') or position.get('symbol') or '').upper() == 'VIX':
13815
+ _exp_d = _as_date_obj(position.get('expiration'))
13816
+ _cur_d = _as_date_obj(current_date)
13817
+ if _exp_d is not None and _cur_d is not None and _cur_d >= _exp_d:
13818
+ _settle_px = _vro_settlement_for(self.config, position.get('expiration'))
13727
13819
  price_data[pos_id].update({
13728
13820
  'underlying_high': stock_high,
13729
13821
  'underlying_low': stock_low,
13730
13822
  'underlying_open': _stock_open,
13823
+ 'settlement_price': _settle_px,
13731
13824
  'underlying_entry_price': position.get('underlying_entry_price', stock_price),
13732
13825
  'current_date': current_date,
13733
13826
  'symbol': position.get('symbol')
@@ -15265,6 +15358,24 @@ class BacktestAnalyzer:
15265
15358
  self.metrics['avg_loss'] = losing['pnl'].mean() if len(losing) > 0 else 0
15266
15359
  self.metrics['best_trade'] = trades_df['pnl'].max()
15267
15360
  self.metrics['worst_trade'] = trades_df['pnl'].min()
15361
+
15362
+ # Requested vs realized tenor. A wide dte_tolerance (or a symbol with only
15363
+ # weekly expirations) can silently turn "1 DTE" into a ~6 DTE test.
15364
+ _dte_target = (getattr(getattr(self, 'results', None), 'config', None) or {}).get('dte_target')
15365
+ if 'entry_dte' in trades_df.columns:
15366
+ _dte = pd.to_numeric(trades_df['entry_dte'], errors='coerce').dropna()
15367
+ if len(_dte) > 0:
15368
+ self.metrics['dte_target'] = _dte_target
15369
+ self.metrics['dte_realized_median'] = float(_dte.median())
15370
+ self.metrics['dte_realized_min'] = int(_dte.min())
15371
+ self.metrics['dte_realized_max'] = int(_dte.max())
15372
+ try:
15373
+ _t = float(_dte_target)
15374
+ # dte_target 0 means "nearest expiration after the exit" (earnings
15375
+ # pattern) - realized DTE is expected to float there, no check.
15376
+ self.metrics['dte_mismatch'] = _t > 0 and abs(float(_dte.median()) - _t) > max(1.0, _t)
15377
+ except (TypeError, ValueError):
15378
+ self.metrics['dte_mismatch'] = False
15268
15379
 
15269
15380
  if len(winning) > 0 and len(losing) > 0 and self.metrics['avg_loss'] != 0:
15270
15381
  self.metrics['avg_win_loss_ratio'] = abs(self.metrics['avg_win'] / self.metrics['avg_loss'])
@@ -15708,6 +15819,25 @@ class ResultsReporter:
15708
15819
  print("TRADING STATISTICS")
15709
15820
  print("-"*80)
15710
15821
  print(f"Total Trades: {m['total_trades']:>15}")
15822
+ if m.get('dte_realized_median') is not None:
15823
+ _tgt = m.get('dte_target')
15824
+ try:
15825
+ _tgt_s = f"{int(float(_tgt))}"
15826
+ except (TypeError, ValueError):
15827
+ _tgt_s = "n/a"
15828
+ print(f"DTE Target / Realized: {_tgt_s:>6} / median {m['dte_realized_median']:.0f}"
15829
+ f" (range {m['dte_realized_min']}-{m['dte_realized_max']} days at entry)")
15830
+ if m.get('dte_mismatch'):
15831
+ print("WARNING: realized DTE differs from the requested dte_target - the tenor actually")
15832
+ print(" traded is not the one asked for (check dte_tolerance / expiration calendar)")
15833
+ if not m.get('total_trades'):
15834
+ # 0 trades is almost never a real result — it usually means the entry
15835
+ # signal never fired (unread config key, indicators not computed).
15836
+ print("\u26a0\ufe0f 0 trades: the entry signal never fired — this is not a "
15837
+ "result, it is a run that never entered. Check that indicators were "
15838
+ "computed (look for 'Processing N cached indicators' — 0 means the "
15839
+ "signal could not be evaluated) and that the data loaded (a locked "
15840
+ "DuckDB cache or a failed fetch also ends in 0 trades).")
15711
15841
  print(f"Winning Trades: {m['winning_trades']:>15}")
15712
15842
  print(f"Losing Trades: {m['losing_trades']:>15}")
15713
15843
  print(f"Win Rate: {m['win_rate']:>15.2f}% (% profitable trades)")
@@ -16755,6 +16885,17 @@ class ResultsExporter:
16755
16885
  f.write(f"Sharpe: {m['sharpe']:.2f}\n")
16756
16886
  f.write(f"Max DD: {m['max_drawdown']:.2f}%\n")
16757
16887
  f.write(f"Trades: {m['total_trades']}\n")
16888
+ if m.get('dte_realized_median') is not None:
16889
+ _tgt = m.get('dte_target')
16890
+ try:
16891
+ _tgt_s = f"{int(float(_tgt))}"
16892
+ except (TypeError, ValueError):
16893
+ _tgt_s = "n/a"
16894
+ f.write(f"DTE Target / Realized: {_tgt_s} / median {m['dte_realized_median']:.0f}"
16895
+ f" (range {m['dte_realized_min']}-{m['dte_realized_max']} days at entry)\n")
16896
+ if m.get('dte_mismatch'):
16897
+ f.write("WARNING: realized DTE differs from the requested dte_target - "
16898
+ "the tenor actually traded is not the one asked for\n")
16758
16899
 
16759
16900
  exported_files.append((f'{prefix}_summary.txt', ""))
16760
16901
  if not silent:
@@ -18932,6 +19073,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
18932
19073
  }
18933
19074
 
18934
19075
  _load_futures_for_hedge(config, preloaded)
19076
+ _attach_vro_settlement(config, preloaded)
18935
19077
  return preloaded
18936
19078
  else:
18937
19079
  _rich_print(f" ℹ️ Legacy mode — running full gap detection")
@@ -19473,6 +19615,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
19473
19615
 
19474
19616
  # DON'T close conn - ChunkManager needs it!
19475
19617
  _load_futures_for_hedge(config, preloaded)
19618
+ _attach_vro_settlement(config, preloaded)
19476
19619
  return preloaded
19477
19620
 
19478
19621
  else:
@@ -19615,6 +19758,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
19615
19758
  }
19616
19759
 
19617
19760
  _load_futures_for_hedge(config, preloaded)
19761
+ _attach_vro_settlement(config, preloaded)
19618
19762
  return preloaded
19619
19763
 
19620
19764
  except Exception as e:
@@ -19993,6 +20137,7 @@ def _preload_duckdb_SSD_storage(config, cache_config):
19993
20137
  }
19994
20138
 
19995
20139
  _load_futures_for_hedge(config, preloaded)
20140
+ _attach_vro_settlement(config, preloaded)
19996
20141
  return preloaded
19997
20142
 
19998
20143
 
@@ -20887,6 +21032,7 @@ def _try_duckdb_preload(config, cache_config, debug=False):
20887
21032
  }
20888
21033
 
20889
21034
  _load_futures_for_hedge(config, preloaded)
21035
+ _attach_vro_settlement(config, preloaded)
20890
21036
  return preloaded
20891
21037
 
20892
21038
 
@@ -23098,6 +23244,48 @@ def _preload_futures_to_duckdb(config, cache_config):
23098
23244
  # ============================================================
23099
23245
  # UNIVERSAL DATA PRELOADER V2 (NEW!)
23100
23246
  # ============================================================
23247
+ def _vro_window_end(end_date):
23248
+ """end_date + _VRO_MAX_SHIFT_DAYS as 'YYYY-MM-DD' (a print shifted past the last backtest day must still load)."""
23249
+ d = _as_date_obj(end_date)
23250
+ if d is None:
23251
+ return str(end_date)[:10]
23252
+ import datetime as _d
23253
+ return (d + _d.timedelta(days=_VRO_MAX_SHIFT_DAYS)).strftime('%Y-%m-%d')
23254
+
23255
+
23256
+ def _load_vro_series(start_date, end_date, config=None):
23257
+ """VRO (VIX settlement print) as a DataFrame[date, vro]; empty frame on any failure."""
23258
+ if not start_date or not end_date:
23259
+ return pd.DataFrame(columns=['date', 'vro'])
23260
+ try:
23261
+ raw = get_api_data(api_call(MarketDataManager.ENDPOINT_STOCK_EOD,
23262
+ cache_config=(config or {}).get('cache_config'),
23263
+ symbol='VRO', from_=str(start_date)[:10], to=_vro_window_end(end_date)))
23264
+ if raw is None or raw.empty or 'date' not in raw.columns or 'close' not in raw.columns:
23265
+ print("WARNING: VRO settlement series unavailable - VIX expirations will settle on the open (SOQ proxy)")
23266
+ return pd.DataFrame(columns=['date', 'vro'])
23267
+ out = raw[['date', 'close']].rename(columns={'close': 'vro'}).copy()
23268
+ out['date'] = pd.to_datetime(out['date'])
23269
+ out = out.sort_values('date').drop_duplicates('date').reset_index(drop=True)
23270
+ print(f"VRO settlement series loaded: {len(out)} prints ({out['date'].min().date()} .. {out['date'].max().date()})")
23271
+ return out
23272
+ except Exception as e:
23273
+ print(f"WARNING: VRO settlement series failed to load ({e}) - VIX expirations will settle on the open (SOQ proxy)")
23274
+ return pd.DataFrame(columns=['date', 'vro'])
23275
+
23276
+
23277
+ def _attach_vro_settlement(config, preloaded):
23278
+ """VIX only: put the VRO settlement series into preloaded['_preloaded_vro'] (idempotent)."""
23279
+ try:
23280
+ if str((config or {}).get('symbol', '')).upper() != 'VIX':
23281
+ return
23282
+ if '_preloaded_vro' in preloaded or '_preloaded_vro' in config:
23283
+ return
23284
+ preloaded['_preloaded_vro'] = _load_vro_series(config.get('start_date'), config.get('end_date'), config)
23285
+ except Exception as e:
23286
+ print(f"WARNING: VRO settlement series not attached ({e})")
23287
+
23288
+
23101
23289
  def preload_data_universal(config, data_requests=None, debug=False):
23102
23290
  """
23103
23291
  🚀 TRULY UNIVERSAL DATA PRELOADER - Works with ANY API endpoint!
@@ -23279,6 +23467,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
23279
23467
  set_option_patch_context(OptionPatchContext(symbol, _fetch_opt_intraday))
23280
23468
 
23281
23469
  _activate_runtime_stock_shim(config, config)
23470
+ _attach_vro_settlement(config, config)
23282
23471
  return config
23283
23472
 
23284
23473
  # Start timing for data loading
@@ -24113,6 +24302,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
24113
24302
 
24114
24303
  _activate_runtime_stock_shim(config, preloaded)
24115
24304
  _load_futures_for_hedge(config, preloaded)
24305
+ _attach_vro_settlement(config, preloaded)
24116
24306
  return preloaded
24117
24307
 
24118
24308
 
@@ -25550,7 +25740,7 @@ def print_signals_table(config, indicator_cache, stock_df, save_path=None, silen
25550
25740
  expected_ivx_key = ('iv_lean_zscore_ivx', (_cfg_dte, int(_cfg_lb))) if _cfg_lb is not None else None
25551
25741
 
25552
25742
  if expected_raw_key is not None and expected_raw_key not in indicator_cache and should_print:
25553
- present_lbs = sorted({k[1][0] for k in indicator_cache if k[0] == 'iv_lean_zscore' and isinstance(k[1], tuple)})
25743
+ present_lbs = sorted({k[1][0] for k in indicator_cache if k[0] == 'iv_lean_zscore' and isinstance(k[1], tuple) and k[1]})
25554
25744
  print(f"⚠️ print_signals_table: no iv_lean_zscore cache for lookback={_cfg_lb} "
25555
25745
  f"(cache has: {present_lbs}). Table will be empty for active config.")
25556
25746
 
@@ -26803,6 +26993,7 @@ def run_optimization(base_config, param_grid, strategy_function,
26803
26993
  (combined_results_df, baselines_dict, results_folder)
26804
26994
  baselines_dict: {symbol: metrics_dict}
26805
26995
  """
26996
+
26806
26997
  import copy as _copy
26807
26998
 
26808
26999
  if symbols is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.144
3
+ Version: 2.146
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
@@ -9,4 +9,5 @@ ivolatility_backtesting.egg-info/dependency_links.txt
9
9
  ivolatility_backtesting.egg-info/requires.txt
10
10
  ivolatility_backtesting.egg-info/top_level.txt
11
11
  tests/test_2142_fixes.py
12
- tests/test_2144_duckdb_dedup.py
12
+ tests/test_2144_duckdb_dedup.py
13
+ tests/test_2146_vix_vro.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ivolatility_backtesting"
7
- version = "2.144"
7
+ version = "2.146"
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,205 @@
1
+ # Tests for 2.146: VIX options settle against VRO (Cboe settlement print),
2
+ # not the VIX close; report shows requested vs realized DTE.
3
+ #
4
+ # Runs standalone (no pytest needed): python3 tests/test_2146_vix_vro.py
5
+ # Also pytest-compatible for CI: pytest tests/test_2146_vix_vro.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 _vro_frame():
21
+ # Real VRO prints (restapi stock-prices symbol=VRO): Wed 2024-12-18, the
22
+ # holiday-shifted Thu 2018-12-06 (expiration was Wed 2018-12-05).
23
+ return pd.DataFrame({
24
+ 'date': pd.to_datetime(['2018-12-06', '2024-12-11', '2024-12-18', '2024-12-24']),
25
+ 'vro': [24.60, 13.20, 15.66, 14.10],
26
+ })
27
+
28
+
29
+ def _open_short_vix_call(pm, pid='p1', strike=17.0, credit=6.0, exp=date(2024, 12, 18)):
30
+ # 1 contract sold at $0.06 -> total_cost 6.0 (credit); entry_price 0 marks SHORT
31
+ pm.open_position(position_id=pid, strategy_type='CALL', symbol='VIX',
32
+ entry_date=date(2024, 12, 16), entry_price=0.0,
33
+ quantity=100, contracts=1, total_cost=credit,
34
+ strike=strike, expiration=exp,
35
+ underlying_entry_price=14.69, opt_type='call')
36
+
37
+
38
+ def test_vix_is_am_settled_any_date():
39
+ assert lib._is_am_settled_position({'symbol': 'VIX', 'expiration': date(2024, 12, 18)}) is True # Wed
40
+ assert lib._is_am_settled_position({'symbol': 'VIX', 'expiration': date(2024, 12, 24)}) is True # Tue
41
+ assert lib._is_am_settled_position({'opt_root': 'VIX', 'expiration': date(2018, 12, 5)}) is True
42
+ assert lib._is_am_settled_position({'symbol': 'VIX', 'expiration': date(2024, 12, 18),
43
+ 'settlement_style': 'PM'}) is False
44
+ # unchanged for the existing roots
45
+ assert lib._is_am_settled_position({'symbol': 'SPX', 'expiration': date(2025, 7, 25)}) is False
46
+
47
+
48
+ def test_vro_lookup_same_day_and_holiday_shift():
49
+ cfg = {'_preloaded_vro': _vro_frame()}
50
+ assert lib._vro_settlement_for(cfg, date(2024, 12, 18)) == 15.66
51
+ assert lib._vro_settlement_for(cfg, '2024-12-18') == 15.66
52
+ assert lib._vro_settlement_for(cfg, pd.Timestamp('2024-12-24')) == 14.10
53
+ # expiration on an unscheduled closure -> next print
54
+ assert lib._vro_settlement_for(cfg, date(2018, 12, 5)) == 24.60
55
+ # too far from any print -> None (no silent fallback to a wrong week)
56
+ assert lib._vro_settlement_for(cfg, date(2024, 11, 1)) is None
57
+ assert lib._vro_settlement_for(cfg, date(2025, 1, 1)) is None
58
+ assert lib._vro_settlement_for({}, date(2024, 12, 18)) is None
59
+ assert lib._vro_settlement_for({'_preloaded_vro': pd.DataFrame(columns=['date', 'vro'])},
60
+ date(2024, 12, 18)) is None
61
+
62
+
63
+ def test_vro_lookup_unsorted_frame():
64
+ vro = pd.DataFrame({'date': pd.to_datetime(['2024-12-24', '2024-12-18', '2024-12-11']),
65
+ 'vro': [14.10, 15.66, 13.20]})
66
+ assert lib._vro_settlement_for({'_preloaded_vro': vro}, date(2024, 12, 18)) == 15.66
67
+ assert lib._vro_settlement_for({'_preloaded_vro': vro}, date(2024, 12, 13)) == 15.66 # 5-day shift allowed
68
+ assert lib._vro_settlement_for({'_preloaded_vro': vro}, date(2024, 12, 12)) is None # 6 days: too far
69
+
70
+
71
+ def test_am_settlement_open_false_disables_vro_too():
72
+ pm = lib.PositionManager({'strategy_type': 'CALL', 'symbol': 'VIX', 'am_settlement_open': False}, debug=False)
73
+ _open_short_vix_call(pm)
74
+ pd_data = {'p1': {'underlying_price': 27.62, 'underlying_open': 15.57, 'settlement_price': 15.66}}
75
+ si = pm.check_positions(date(2024, 12, 18), pd_data)[0]
76
+ assert abs(si['pnl'] - (6.0 - (27.62 - 17.0) * 100)) < 1e-6, si['pnl']
77
+ assert si['settlement_source'] == 'close'
78
+
79
+
80
+ def test_settlement_uses_vro_over_close_and_open():
81
+ pm = lib.PositionManager({'strategy_type': 'CALL', 'symbol': 'VIX'}, debug=False)
82
+ _open_short_vix_call(pm)
83
+ # 2024-12-18: VIX open 15.57, close 27.62, VRO 15.66 -> strike 17 expires worthless
84
+ pd_data = {'p1': {'underlying_price': 27.62, 'underlying_open': 15.57, 'settlement_price': 15.66}}
85
+ si = pm.check_positions(date(2024, 12, 18), pd_data)[0]
86
+ assert abs(si['pnl'] - 6.0) < 1e-6, si['pnl']
87
+ assert si['settlement_source'] == 'vro', si.get('settlement_source')
88
+
89
+
90
+ def test_settlement_vro_in_the_money():
91
+ pm = lib.PositionManager({'strategy_type': 'CALL', 'symbol': 'VIX'}, debug=False)
92
+ _open_short_vix_call(pm, strike=19.0, credit=10.0, exp=date(2018, 12, 5))
93
+ # settled Thu 2018-12-06 at VRO 24.60 (close was 21.19): loss = 10 - (24.60-19)*100 = -550
94
+ pd_data = {'p1': {'underlying_price': 21.19, 'underlying_open': 23.53, 'settlement_price': 24.60}}
95
+ si = pm.check_positions(date(2018, 12, 6), pd_data)[0]
96
+ assert abs(si['pnl'] - (-550.0)) < 1e-6, si['pnl']
97
+ assert si['settlement_source'] == 'vro'
98
+
99
+
100
+ def test_settlement_falls_back_to_open_without_vro():
101
+ import io, contextlib
102
+ pm = lib.PositionManager({'strategy_type': 'CALL', 'symbol': 'VIX'}, debug=False)
103
+ _open_short_vix_call(pm)
104
+ pd_data = {'p1': {'underlying_price': 27.62, 'underlying_open': 15.57, 'settlement_price': None}}
105
+ buf = io.StringIO()
106
+ with contextlib.redirect_stdout(buf):
107
+ si = pm.check_positions(date(2024, 12, 18), pd_data)[0]
108
+ assert abs(si['pnl'] - 6.0) < 1e-6, si['pnl']
109
+ assert si['settlement_source'] == 'open'
110
+ assert 'no VRO settlement print for VIX expiration 2024-12-18' in buf.getvalue(), buf.getvalue()
111
+
112
+
113
+ def test_settlement_close_for_equity_unchanged():
114
+ pm = lib.PositionManager({'strategy_type': 'CALL', 'symbol': 'AAPL'}, debug=False)
115
+ pm.open_position(position_id='p1', strategy_type='CALL', symbol='AAPL',
116
+ entry_date=date(2025, 5, 2), entry_price=1000.0,
117
+ quantity=100, contracts=1, total_cost=1000.0,
118
+ strike=200.0, expiration=date(2025, 7, 18),
119
+ underlying_entry_price=201.0, opt_type='call')
120
+ pd_data = {'p1': {'underlying_price': 230.0, 'underlying_open': 225.0}}
121
+ si = pm.check_positions(date(2025, 7, 18), pd_data)[0]
122
+ assert abs(si['pnl'] - ((230.0 - 200.0) * 100 - 1000.0)) < 1.0, si['pnl']
123
+ assert si['settlement_source'] == 'close'
124
+
125
+
126
+ def test_build_price_data_attaches_vro_for_vix():
127
+ cfg = {'strategy_type': 'CALL', 'symbol': 'VIX', '_preloaded_vro': _vro_frame()}
128
+ pm = lib.PositionManager(cfg, debug=False)
129
+ _open_short_vix_call(pm)
130
+ stock_row = pd.Series({'date': pd.Timestamp('2024-12-18'), 'open': 15.57, 'high': 28.32,
131
+ 'low': 15.0, 'close': 27.62})
132
+ options_df = pd.DataFrame({'strike': [17.0], 'expiration': [pd.Timestamp('2024-12-18')],
133
+ 'opt_type': ['C'], 'bid': [10.5], 'ask': [10.7], 'price': [10.6]})
134
+
135
+ def _get_opt(*a, **k):
136
+ return {'bid': 10.5, 'ask': 10.7, 'price': 10.6, 'mid': 10.6}
137
+
138
+ pdata = pm.build_price_data(date(2024, 12, 18), 27.62, options_df, _get_opt, stock_row=stock_row)
139
+ assert pdata['p1'].get('settlement_price') == 15.66, pdata['p1'].get('settlement_price')
140
+ assert pdata['p1'].get('underlying_open') == 15.57
141
+ # before expiration the print is not exposed (no look-ahead field on live bars)
142
+ pre = pm.build_price_data(date(2024, 12, 17), 14.7, options_df, _get_opt, stock_row=stock_row)
143
+ assert pre['p1'].get('settlement_price') is None
144
+
145
+
146
+ def test_enable_output_logging_is_exported():
147
+ # the master prompt tells generated scripts to `from ivolatility_backtesting import enable_output_logging`
148
+ from ivolatility_backtesting import enable_output_logging # noqa: F401
149
+ assert callable(enable_output_logging)
150
+
151
+
152
+ def test_vro_window_end_extends_past_end_date():
153
+ assert lib._vro_window_end(date(2018, 12, 5)) == '2018-12-10'
154
+ assert lib._vro_window_end('2018-12-05') == '2018-12-10'
155
+
156
+
157
+ def test_dte_realized_metrics_and_warning():
158
+ trades = [
159
+ {'pnl': 10.0, 'entry_dte': 6, 'entry_date': date(2024, 1, 4), 'exit_date': date(2024, 1, 10)},
160
+ {'pnl': -5.0, 'entry_dte': 6, 'entry_date': date(2024, 1, 11), 'exit_date': date(2024, 1, 17)},
161
+ {'pnl': 8.0, 'entry_dte': 1, 'entry_date': date(2024, 1, 23), 'exit_date': date(2024, 1, 24)},
162
+ ]
163
+ res = lib.BacktestResults(equity_curve=[100000, 100010, 100005, 100013],
164
+ equity_dates=[date(2024, 1, 2), date(2024, 1, 10), date(2024, 1, 17), date(2024, 1, 24)],
165
+ trades=trades, initial_capital=100000,
166
+ config={'dte_target': 1, 'dte_tolerance': 7})
167
+ an = lib.BacktestAnalyzer(res)
168
+ an.calculate_all_metrics()
169
+ m = an.metrics
170
+ assert m['dte_target'] == 1 and m['dte_realized_median'] == 6 and m['dte_realized_min'] == 1 \
171
+ and m['dte_realized_max'] == 6, m
172
+ assert m['dte_mismatch'] is True
173
+
174
+ res2 = lib.BacktestResults(equity_curve=[100000, 100010], equity_dates=[date(2024, 1, 2), date(2024, 1, 10)],
175
+ trades=[{'pnl': 1.0, 'entry_dte': 30, 'entry_date': date(2024, 1, 2), 'exit_date': date(2024, 1, 10)}],
176
+ initial_capital=100000, config={'dte_target': 30})
177
+ an2 = lib.BacktestAnalyzer(res2)
178
+ an2.calculate_all_metrics()
179
+ assert an2.metrics['dte_mismatch'] is False
180
+
181
+ # earnings pattern: dte_target 0 = nearest expiration after exit -> never a mismatch
182
+ res3 = lib.BacktestResults(equity_curve=[100000, 100010], equity_dates=[date(2024, 1, 2), date(2024, 1, 10)],
183
+ trades=[{'pnl': 1.0, 'entry_dte': 12, 'entry_date': date(2024, 1, 2), 'exit_date': date(2024, 1, 10)}],
184
+ initial_capital=100000, config={'dte_target': 0, 'dte_tolerance': 29})
185
+ an3 = lib.BacktestAnalyzer(res3)
186
+ an3.calculate_all_metrics()
187
+ assert an3.metrics['dte_mismatch'] is False and an3.metrics['dte_realized_median'] == 12
188
+
189
+
190
+ # ---------------------------------------------------------------- runner
191
+
192
+ if __name__ == '__main__':
193
+ tests = [(n, f) for n, f in sorted(globals().items())
194
+ if n.startswith('test_') and callable(f)]
195
+ failed = []
196
+ for name, fn in tests:
197
+ try:
198
+ fn()
199
+ print(f"PASS {name}")
200
+ except Exception:
201
+ failed.append(name)
202
+ print(f"FAIL {name}")
203
+ traceback.print_exc()
204
+ print(f"\n{len(tests) - len(failed)}/{len(tests)} passed")
205
+ sys.exit(1 if failed else 0)