ivolatility-backtesting 2.145__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.145
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',
@@ -12142,12 +12142,59 @@ def _is_am_settled_position(position):
12142
12142
  if style == 'PM':
12143
12143
  return False
12144
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
12145
12149
  if root not in _AM_SETTLED_INDEX_ROOTS:
12146
12150
  return False
12147
12151
  exp = _as_date_obj(position.get('expiration'))
12148
12152
  return exp is not None and exp.weekday() == 4 and 15 <= exp.day <= 21
12149
12153
 
12150
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
+
12151
12198
  class PositionManager:
12152
12199
  """Universal Position Manager with automatic mode detection"""
12153
12200
 
@@ -12156,6 +12203,7 @@ class PositionManager:
12156
12203
  self.closed_trades = []
12157
12204
  self.config = config
12158
12205
  self.debug = debug
12206
+ self._vro_missing_warned = set() # VIX expirations already reported as settled without VRO
12159
12207
  self.debuginfo = config.get('debuginfo', 0) # Add debuginfo level
12160
12208
 
12161
12209
  # AUTO-DETECT strategy_type if missing
@@ -12953,10 +13001,29 @@ class PositionManager:
12953
13001
  position.get('underlying_entry_price', 0)
12954
13002
  )
12955
13003
 
12956
- # AM-settled index monthlies settle on the MORNING open
12957
- # (SOQ), not the close; expiration-day open is the proxy
12958
- 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)
12959
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)")
12960
13027
  _open_px = None
12961
13028
  if position_id in price_data and isinstance(price_data[position_id], dict):
12962
13029
  _open_px = price_data[position_id].get('underlying_open')
@@ -12965,6 +13032,7 @@ class PositionManager:
12965
13032
  print(f"[AM SETTLEMENT] {position_id}: using expiration-day "
12966
13033
  f"OPEN {_open_px:.2f} (SOQ proxy) instead of close {underlying_price:.2f}")
12967
13034
  underlying_price = _open_px
13035
+ settlement_source = 'open'
12968
13036
 
12969
13037
  strike = position.get('strike', 0)
12970
13038
  strategy_type = position.get('strategy_type', '')
@@ -13066,7 +13134,11 @@ class PositionManager:
13066
13134
  'pnl': current_pnl,
13067
13135
  'pnl_pct': current_pnl_pct,
13068
13136
  'settlement_type': 'intrinsic' if used_intrinsic else 'market',
13069
- **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,
13070
13142
  }
13071
13143
 
13072
13144
  to_close.append(stop_info)
@@ -13738,10 +13810,17 @@ class PositionManager:
13738
13810
  _stock_open = _first_valid_numeric_value(stock_row, ('runtime_open', 'open'))
13739
13811
  if _stock_open is not None and _stock_open <= 0:
13740
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'))
13741
13819
  price_data[pos_id].update({
13742
13820
  'underlying_high': stock_high,
13743
13821
  'underlying_low': stock_low,
13744
13822
  'underlying_open': _stock_open,
13823
+ 'settlement_price': _settle_px,
13745
13824
  'underlying_entry_price': position.get('underlying_entry_price', stock_price),
13746
13825
  'current_date': current_date,
13747
13826
  'symbol': position.get('symbol')
@@ -15279,6 +15358,24 @@ class BacktestAnalyzer:
15279
15358
  self.metrics['avg_loss'] = losing['pnl'].mean() if len(losing) > 0 else 0
15280
15359
  self.metrics['best_trade'] = trades_df['pnl'].max()
15281
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
15282
15379
 
15283
15380
  if len(winning) > 0 and len(losing) > 0 and self.metrics['avg_loss'] != 0:
15284
15381
  self.metrics['avg_win_loss_ratio'] = abs(self.metrics['avg_win'] / self.metrics['avg_loss'])
@@ -15722,6 +15819,17 @@ class ResultsReporter:
15722
15819
  print("TRADING STATISTICS")
15723
15820
  print("-"*80)
15724
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)")
15725
15833
  if not m.get('total_trades'):
15726
15834
  # 0 trades is almost never a real result — it usually means the entry
15727
15835
  # signal never fired (unread config key, indicators not computed).
@@ -16777,6 +16885,17 @@ class ResultsExporter:
16777
16885
  f.write(f"Sharpe: {m['sharpe']:.2f}\n")
16778
16886
  f.write(f"Max DD: {m['max_drawdown']:.2f}%\n")
16779
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")
16780
16899
 
16781
16900
  exported_files.append((f'{prefix}_summary.txt', ""))
16782
16901
  if not silent:
@@ -18954,6 +19073,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
18954
19073
  }
18955
19074
 
18956
19075
  _load_futures_for_hedge(config, preloaded)
19076
+ _attach_vro_settlement(config, preloaded)
18957
19077
  return preloaded
18958
19078
  else:
18959
19079
  _rich_print(f" ℹ️ Legacy mode — running full gap detection")
@@ -19495,6 +19615,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
19495
19615
 
19496
19616
  # DON'T close conn - ChunkManager needs it!
19497
19617
  _load_futures_for_hedge(config, preloaded)
19618
+ _attach_vro_settlement(config, preloaded)
19498
19619
  return preloaded
19499
19620
 
19500
19621
  else:
@@ -19637,6 +19758,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
19637
19758
  }
19638
19759
 
19639
19760
  _load_futures_for_hedge(config, preloaded)
19761
+ _attach_vro_settlement(config, preloaded)
19640
19762
  return preloaded
19641
19763
 
19642
19764
  except Exception as e:
@@ -20015,6 +20137,7 @@ def _preload_duckdb_SSD_storage(config, cache_config):
20015
20137
  }
20016
20138
 
20017
20139
  _load_futures_for_hedge(config, preloaded)
20140
+ _attach_vro_settlement(config, preloaded)
20018
20141
  return preloaded
20019
20142
 
20020
20143
 
@@ -20909,6 +21032,7 @@ def _try_duckdb_preload(config, cache_config, debug=False):
20909
21032
  }
20910
21033
 
20911
21034
  _load_futures_for_hedge(config, preloaded)
21035
+ _attach_vro_settlement(config, preloaded)
20912
21036
  return preloaded
20913
21037
 
20914
21038
 
@@ -23120,6 +23244,48 @@ def _preload_futures_to_duckdb(config, cache_config):
23120
23244
  # ============================================================
23121
23245
  # UNIVERSAL DATA PRELOADER V2 (NEW!)
23122
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
+
23123
23289
  def preload_data_universal(config, data_requests=None, debug=False):
23124
23290
  """
23125
23291
  🚀 TRULY UNIVERSAL DATA PRELOADER - Works with ANY API endpoint!
@@ -23301,6 +23467,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
23301
23467
  set_option_patch_context(OptionPatchContext(symbol, _fetch_opt_intraday))
23302
23468
 
23303
23469
  _activate_runtime_stock_shim(config, config)
23470
+ _attach_vro_settlement(config, config)
23304
23471
  return config
23305
23472
 
23306
23473
  # Start timing for data loading
@@ -24135,6 +24302,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
24135
24302
 
24136
24303
  _activate_runtime_stock_shim(config, preloaded)
24137
24304
  _load_futures_for_hedge(config, preloaded)
24305
+ _attach_vro_settlement(config, preloaded)
24138
24306
  return preloaded
24139
24307
 
24140
24308
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.145
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.145"
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)