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.
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/PKG-INFO +1 -1
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/ivolatility_backtesting/__init__.py +2 -2
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/ivolatility_backtesting/ivolatility_backtesting.py +172 -4
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/ivolatility_backtesting.egg-info/PKG-INFO +1 -1
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/ivolatility_backtesting.egg-info/SOURCES.txt +2 -1
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/pyproject.toml +1 -1
- ivolatility_backtesting-2.146/tests/test_2146_vix_vro.py +205 -0
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/README.md +0 -0
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/ivolatility_backtesting.egg-info/requires.txt +0 -0
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/setup.cfg +0 -0
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/tests/test_2142_fixes.py +0 -0
- {ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/tests/test_2144_duckdb_dedup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ivolatility_backtesting
|
|
3
|
-
Version: 2.
|
|
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
|
{ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/ivolatility_backtesting/__init__.py
RENAMED
|
@@ -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
|
-
#
|
|
12957
|
-
#
|
|
12958
|
-
|
|
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.
|
|
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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ivolatility_backtesting-2.145 → ivolatility_backtesting-2.146}/tests/test_2144_duckdb_dedup.py
RENAMED
|
File without changes
|