ivolatility-backtesting 2.149__tar.gz → 2.150__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.

Potentially problematic release.


This version of ivolatility-backtesting might be problematic. Click here for more details.

Files changed (18) hide show
  1. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/PKG-INFO +1 -1
  2. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting/__init__.py +2 -0
  3. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting/ivolatility_backtesting.py +68 -33
  4. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/PKG-INFO +1 -1
  5. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/SOURCES.txt +2 -1
  6. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/pyproject.toml +1 -1
  7. ivolatility_backtesting-2.150/tests/test_2150_prompt_patterns.py +356 -0
  8. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/README.md +0 -0
  9. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
  10. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/requires.txt +0 -0
  11. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
  12. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/setup.cfg +0 -0
  13. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2142_fixes.py +0 -0
  14. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2144_duckdb_dedup.py +0 -0
  15. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2146_vix_vro.py +0 -0
  16. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2147_cents_multiplier.py +0 -0
  17. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2148_loader_memory.py +0 -0
  18. {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2149_client_errors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.149
3
+ Version: 2.150
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
@@ -28,6 +28,7 @@ from .ivolatility_backtesting import (
28
28
  FUTURES_HEDGE_MAP, _load_futures_for_hedge,
29
29
  set_project_root, get_project_root,
30
30
  get_position_multiplier, get_hedge_multiplier,
31
+ parse_earnings_calendar,
31
32
  )
32
33
 
33
34
  __all__ = [
@@ -60,4 +61,5 @@ __all__ = [
60
61
  'FUTURES_HEDGE_MAP', '_load_futures_for_hedge',
61
62
  'set_project_root', 'get_project_root',
62
63
  'get_position_multiplier', 'get_hedge_multiplier',
64
+ 'parse_earnings_calendar',
63
65
  ]
@@ -758,6 +758,12 @@ def _get_options_eod_table(config_like: Optional[Dict[str, Any]]) -> str:
758
758
  return 'options_eod_1545' if snap == '1545' else 'options_eod_close'
759
759
 
760
760
 
761
+ def _complete_option_dates_sql(table: str) -> str:
762
+ """Dates holding both calls and puts; a date with a single type is treated as not loaded."""
763
+ return (f"SELECT date FROM {table} WHERE symbol = ? "
764
+ f"GROUP BY date HAVING COUNT(DISTINCT UPPER(SUBSTR(CAST(type AS VARCHAR), 1, 1))) >= 2")
765
+
766
+
761
767
  def _get_legacy_1545_duckdb_path(config_like: Optional[Dict[str, Any]]) -> str:
762
768
  """Legacy pre-refactor 15:45 DuckDB path kept for one-time migration."""
763
769
  cache_dir = _resolve_path((config_like or {}).get('cache_dir', 'cache'))
@@ -3740,6 +3746,11 @@ STRATEGIES = {
3740
3746
  },
3741
3747
  }
3742
3748
 
3749
+ STRATEGIES['DIRECTIONAL_LONG'] = {**STRATEGIES['LONG_STOCK'], 'name': 'Directional Long (stock)'}
3750
+ STRATEGIES['DIRECTIONAL_SHORT'] = {**STRATEGIES['SHORT_STOCK'], 'name': 'Directional Short (stock)'}
3751
+ STOCK_STRATEGY_TYPES = ('LONG_STOCK', 'SHORT_STOCK', 'DIRECTIONAL_LONG', 'DIRECTIONAL_SHORT')
3752
+ SHORT_STOCK_TYPES = ('SHORT_STOCK', 'DIRECTIONAL_SHORT')
3753
+
3743
3754
  # Backwards compatibility mapping (lowercase to UPPERCASE)
3744
3755
  _STRATEGY_NAME_MAP = {
3745
3756
  'iron_condor': 'IRON_CONDOR',
@@ -6152,7 +6163,7 @@ class StrategyRegistry:
6152
6163
  call_premium = position.get('call_premium', abs(credit))
6153
6164
  capital_at_risk = max(stock_value - call_premium, stock_value * 0.8)
6154
6165
  locked_capital = stock_value
6155
- elif position_type in ('LONG_STOCK', 'SHORT_STOCK'):
6166
+ elif position_type in STOCK_STRATEGY_TYPES:
6156
6167
  stock_value = underlying_price * contracts * um
6157
6168
  capital_at_risk = stock_value
6158
6169
  locked_capital = stock_value
@@ -6424,13 +6435,13 @@ class StrategyRegistry:
6424
6435
  # Universal CREDIT detection (works for ANY strategy)
6425
6436
  is_credit = cls.is_credit_strategy(strategy_type=strategy_type, position=position)
6426
6437
 
6427
- # ⚠️ CRITICAL: For NEUTRAL strategies (STRADDLE, STRANGLE) without 'short' prefix
6428
- # If ALL legs don't have 'short' prefix AND strategy is CREDIT → treat ALL as SHORT
6429
- if is_credit and not is_short_leg and category == 'NEUTRAL':
6438
+ # ⚠️ CRITICAL: NEUTRAL (STRADDLE, STRANGLE) and single-leg CREDIT (SHORT_PUT, SHORT_CALL)
6439
+ # legs carry no 'short' prefix. If ALL legs lack it AND position is CREDIT → treat ALL as SHORT
6440
+ if is_credit and not is_short_leg and category in ('NEUTRAL', 'CREDIT'):
6430
6441
  # Check if NO legs have 'short' prefix (simple STRADDLE/STRANGLE)
6431
6442
  has_short_prefix = any('short' in l['name'].lower() for l in legs)
6432
6443
  if not has_short_prefix:
6433
- # Simple STRADDLE/STRANGLE: all legs are SHORT for CREDIT
6444
+ # STRADDLE/STRANGLE, SHORT_PUT/SHORT_CALL: all legs are SHORT for CREDIT
6434
6445
  is_short_leg = True
6435
6446
 
6436
6447
  # ⚠️ FIX: For DEBIT strategies used as CREDIT (e.g. DCS SELL, calendar SELL)
@@ -6634,7 +6645,7 @@ class StrategyRegistry:
6634
6645
  # total_cost is negative (credit from selling call), so adding it reduces risk
6635
6646
  return (stock_price * um * contracts) + total_cost # subtract call premium
6636
6647
 
6637
- elif strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
6648
+ elif strategy_type in STOCK_STRATEGY_TYPES:
6638
6649
  stock_price = position_params.get('stock_price', position_params.get('underlying_entry_price', 0))
6639
6650
  return stock_price * um * contracts
6640
6651
 
@@ -6833,13 +6844,13 @@ class StrategyRegistry:
6833
6844
  # doubling the call bid while completely missing the stock value.
6834
6845
  # Fix: compute P&L directly using underlying_price (available here) + call leg.
6835
6846
  # ── LONG_STOCK / SHORT_STOCK — pure stock position, no option legs ──
6836
- if strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
6847
+ if strategy_type in STOCK_STRATEGY_TYPES:
6837
6848
  stock_exit_value = underlying_price * contracts * um
6838
- if strategy_type == 'SHORT_STOCK':
6849
+ if strategy_type in SHORT_STOCK_TYPES:
6839
6850
  pnl = entry_cost - stock_exit_value # short: profit when price drops
6840
6851
  else:
6841
6852
  pnl = stock_exit_value - entry_cost # long: profit when price rises
6842
- is_credit = (strategy_type == 'SHORT_STOCK')
6853
+ is_credit = (strategy_type in SHORT_STOCK_TYPES)
6843
6854
  close_cost = stock_exit_value
6844
6855
  # ── COVERED_CALL / COVERED_PUT — stock + option leg ──────────
6845
6856
  elif (strategy_type in ('COVERED_CALL', 'COVERED_CALL_ATM', 'COVERED_CALL_25D', 'COVERED_PUT', 'COVERED_PUT_ATM', 'COVERED_PUT_25D')
@@ -9774,7 +9785,7 @@ class StopLossManager:
9774
9785
 
9775
9786
  # --- Stock-based strategies (no option legs for intraday SL) ---
9776
9787
 
9777
- elif strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
9788
+ elif strategy_type in STOCK_STRATEGY_TYPES:
9778
9789
  pass
9779
9790
 
9780
9791
  elif strategy_type in ('COVERED_CALL', 'COVERED_CALL_ATM', 'COVERED_CALL_25D', 'COVERED_PUT', 'COVERED_PUT_ATM', 'COVERED_PUT_25D') or 'COVERED_CALL' in strategy_type.upper() or 'COVERED_PUT' in strategy_type.upper():
@@ -10474,10 +10485,9 @@ class StopLossManager:
10474
10485
  pnl_from_baseline = current_value - baseline_close_cost # change in close cost
10475
10486
  denom = abs(baseline_close_cost)
10476
10487
  else:
10477
- # Fallback: old behavior (no baseline close cost stored)
10478
- original_credit_fb = original_credit or entry_value
10479
- position_value_now = original_credit_fb + current_value
10480
- pnl_from_baseline = position_value_now - entry_value
10488
+ # Simple credit (straddle, single leg): entry_value is the 09:30 close cost
10489
+ # (or the credit before any reset); current_value is -close cost now
10490
+ pnl_from_baseline = abs(entry_value) + current_value
10481
10491
  denom = abs(entry_value)
10482
10492
  intraday_pnl_pct = (pnl_from_baseline / denom) * 100 if denom != 0 else 0
10483
10493
  else:
@@ -11345,6 +11355,18 @@ class StopLossManager:
11345
11355
 
11346
11356
  def _check_pl_loss_stop(self, pos, kwargs):
11347
11357
  """Stop-loss based on actual P&L"""
11358
+ # Stock positions: entry_price may be per share or a whole-position dollar value, so measure the
11359
+ # per-share move from underlying_entry_price (per share in both conventions)
11360
+ if pos.get('sl_baseline_reset') and pos.get('strategy_type') in STOCK_STRATEGY_TYPES:
11361
+ base_px = pos.get('underlying_entry_price')
11362
+ px = kwargs.get('underlying_price')
11363
+ if base_px and px:
11364
+ move_pct = (px - base_px) / base_px * 100
11365
+ is_short = pos.get('is_short_bias') or pos.get('strategy_type') in SHORT_STOCK_TYPES
11366
+ stock_pnl_pct = -move_pct if is_short else move_pct
11367
+ stop_level = -pos['stop_value'] * 100
11368
+ return stock_pnl_pct <= stop_level, stop_level, 'pl_loss'
11369
+
11348
11370
  pnl_pct = kwargs.get('pnl_pct')
11349
11371
 
11350
11372
  # [EARNINGS SL FIX] Force recalculation when baseline was reset
@@ -12708,7 +12730,7 @@ class PositionManager:
12708
12730
  call_intrinsic = max(0, underlying_price - call_strike)
12709
12731
  intrinsic_value = call_intrinsic * unit_multiplier * contracts # Only call obligation
12710
12732
 
12711
- elif strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
12733
+ elif strategy_type in STOCK_STRATEGY_TYPES:
12712
12734
  intrinsic_value = underlying_price * unit_multiplier * contracts
12713
12735
  if self.debug:
12714
12736
  print(f"[INTRINSIC SETTLEMENT] {position_id} - {strategy_type}:")
@@ -12847,7 +12869,16 @@ class PositionManager:
12847
12869
  # BACKWARD COMPATIBILITY: If 'type' passed but 'strategy_type' not, copy type → strategy_type
12848
12870
  if 'strategy_type' not in kwargs and 'type' in kwargs:
12849
12871
  kwargs['strategy_type'] = kwargs['type']
12850
-
12872
+
12873
+ # DIRECTIONAL_* pass shares in `quantity` with contracts=0; stock P&L paths use contracts × unit_multiplier
12874
+ if kwargs.get('strategy_type') in ('DIRECTIONAL_LONG', 'DIRECTIONAL_SHORT'):
12875
+ if not kwargs.get('contracts'):
12876
+ kwargs['contracts'] = quantity
12877
+ kwargs.setdefault('unit_multiplier', 1)
12878
+ if not kwargs.get('total_cost'):
12879
+ entry_px = kwargs.get('underlying_entry_price') or entry_price
12880
+ kwargs['total_cost'] = entry_px * kwargs['contracts'] * kwargs['unit_multiplier']
12881
+
12851
12882
  # AUTO-DETECT position_type if not provided
12852
12883
  if position_type is None:
12853
12884
  strategy_type = kwargs.get('strategy_type')
@@ -13506,7 +13537,7 @@ class PositionManager:
13506
13537
  _is_stock_position = (
13507
13538
  'COVERED_CALL' in strategy_type.upper()
13508
13539
  or 'COVERED_PUT' in strategy_type.upper()
13509
- or strategy_type in ('LONG_STOCK', 'SHORT_STOCK')
13540
+ or strategy_type in STOCK_STRATEGY_TYPES
13510
13541
  )
13511
13542
  if _is_stock_position:
13512
13543
  # entry_value/current_value are full position values
@@ -13521,10 +13552,16 @@ class PositionManager:
13521
13552
  is_credit = StrategyRegistry.is_credit_strategy(
13522
13553
  strategy_type=strategy_type, position=position
13523
13554
  )
13555
+ # entry_value may be the reset 09:30 baseline; the trade is booked against its cost
13556
+ _orig_cost = abs(position.get('total_cost') or entry_value)
13524
13557
  if is_credit:
13525
- intraday_pnl = entry_value + current_value
13558
+ intraday_pnl = _orig_cost + current_value
13559
+ _pnl_denom = position.get('entry_max_risk') or position.get('max_risk') or _orig_cost
13526
13560
  else:
13527
- intraday_pnl = current_value - entry_value
13561
+ intraday_pnl = current_value - _orig_cost
13562
+ _pnl_denom = _orig_cost
13563
+ if _pnl_denom:
13564
+ intraday_pnl_pct = (intraday_pnl / _pnl_denom) * 100
13528
13565
 
13529
13566
  if self.debug:
13530
13567
  print(f" [Stop Loss] Using intraday P&L: {intraday_pnl_pct:.2f}% (${intraday_pnl:.2f}) instead of EOD: {current_pnl_pct:.2f}% (${current_pnl:.2f})")
@@ -13841,11 +13878,11 @@ class PositionManager:
13841
13878
 
13842
13879
  # LONG_STOCK / SHORT_STOCK: no option legs → compute P&L directly from stock price
13843
13880
  strategy_type = position.get('strategy_type', self.config.get('strategy_type', 'STRADDLE'))
13844
- if leg_data is None and strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
13881
+ if leg_data is None and strategy_type in STOCK_STRATEGY_TYPES:
13845
13882
  entry_cost = abs(position.get('total_cost', 0))
13846
13883
  contracts = position.get('contracts', 1)
13847
13884
  stock_exit_value = stock_price * contracts * _pos_unit_multiplier(position)
13848
- if strategy_type == 'SHORT_STOCK':
13885
+ if strategy_type in SHORT_STOCK_TYPES:
13849
13886
  pnl = entry_cost - stock_exit_value
13850
13887
  else:
13851
13888
  pnl = stock_exit_value - entry_cost
@@ -14888,7 +14925,7 @@ class PositionManager:
14888
14925
  # ========================================
14889
14926
  # LONG STOCK (pure stock position)
14890
14927
  # ========================================
14891
- elif position_type in ('LONG_STOCK', 'SHORT_STOCK'):
14928
+ elif position_type in STOCK_STRATEGY_TYPES:
14892
14929
  contracts = position.get('contracts', 1)
14893
14930
  underlying_price = position.get('underlying_entry_price', 0)
14894
14931
  stock_value = underlying_price * contracts * _pos_unit_multiplier(position)
@@ -19300,9 +19337,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
19300
19337
  _rich_print(f" 📋 DB DTE: 0-{int(db_max_dte)}")
19301
19338
 
19302
19339
  # Get dates that have options data
19303
- options_dates_result = db.execute_read(f"""
19304
- SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?
19305
- """, [symbol]).fetchdf()
19340
+ options_dates_result = db.execute_read(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
19306
19341
  options_dates = set(pd.to_datetime(options_dates_result['date']).dt.strftime('%Y-%m-%d'))
19307
19342
 
19308
19343
  _rich_print(f" 🔍 Need: {extended_start} → {end_date}, DTE 0-{required_max_dte}")
@@ -19458,9 +19493,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
19458
19493
  if trading_days:
19459
19494
  try:
19460
19495
  db.refresh_conn()
19461
- _post_df = db.execute_read(
19462
- f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
19463
- ).fetchdf()
19496
+ _post_df = db.execute_read(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
19464
19497
  _post_dates = set(pd.to_datetime(_post_df['date']).dt.strftime('%Y-%m-%d'))
19465
19498
  _req_min = pd.to_datetime(extended_start)
19466
19499
  _req_max = pd.to_datetime(end_date)
@@ -20085,9 +20118,7 @@ def _preload_duckdb_SSD_storage(config, cache_config):
20085
20118
  # Cold-path completeness: every backtest trading day must exist in
20086
20119
  # the options table; misses are loud + flag _failed_chunks
20087
20120
  try:
20088
- _cc_df = conn.execute(
20089
- f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
20090
- ).fetchdf()
20121
+ _cc_df = conn.execute(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
20091
20122
  _cc_have = set(pd.to_datetime(_cc_df['date']).dt.strftime('%Y-%m-%d'))
20092
20123
  _cc_need = {pd.Timestamp(d).strftime('%Y-%m-%d') for d in backtest_trading_days}
20093
20124
  _cc_missing = sorted(_cc_need - _cc_have)
@@ -23707,6 +23738,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
23707
23738
  # and _try_read_from_duckdb_storage). Setting the old per-row lazy
23708
23739
  # patch here would be redundant and wasteful (extra intraday API calls
23709
23740
  # for data that is already correct in DuckDB).
23741
+ _activate_runtime_stock_shim(config, result)
23710
23742
  _timing.end_stage() # End "Loading Data" timing
23711
23743
  return result
23712
23744
  # Check if lock was the reason for failure
@@ -23745,6 +23777,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
23745
23777
  return None
23746
23778
  if config.get('bar_exit') == '16:00':
23747
23779
  set_option_patch_context(OptionPatchContext(_sym, _fetch_opt_intraday_duckdb_ram))
23780
+ _activate_runtime_stock_shim(config, result)
23748
23781
  _timing.end_stage() # End "Loading Data" timing
23749
23782
  return result
23750
23783
  print(" ⚠️ DuckDB RAM reader: No cached Parquet data, falling back to API...")
@@ -24992,7 +25025,7 @@ def parse_earnings_calendar(earnings_df):
24992
25025
 
24993
25026
  Returns:
24994
25027
  list: [{'date': datetime.date, 'estimate': float, 'reported': float,
24995
- 'time_of_day': str}, ...]
25028
+ 'time_of_day': str, 'time_of_day_code': str}, ...]
24996
25029
  Sorted by date ascending.
24997
25030
 
24998
25031
  Usage in strategy:
@@ -25020,11 +25053,13 @@ def parse_earnings_calendar(earnings_df):
25020
25053
  else:
25021
25054
  date_obj = earning_date
25022
25055
 
25056
+ time_of_day = record.get('time_of_day_code', 'UNK')
25023
25057
  earnings_events.append({
25024
25058
  'date': date_obj,
25025
25059
  'estimate': record.get('estimate'),
25026
25060
  'reported': record.get('reported_earning'),
25027
- 'time_of_day': record.get('time_of_day_code', 'UNK')
25061
+ 'time_of_day': time_of_day,
25062
+ 'time_of_day_code': time_of_day,
25028
25063
  })
25029
25064
 
25030
25065
  return sorted(earnings_events, key=lambda x: x['date'])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.149
3
+ Version: 2.150
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
@@ -13,4 +13,5 @@ tests/test_2144_duckdb_dedup.py
13
13
  tests/test_2146_vix_vro.py
14
14
  tests/test_2147_cents_multiplier.py
15
15
  tests/test_2148_loader_memory.py
16
- tests/test_2149_client_errors.py
16
+ tests/test_2149_client_errors.py
17
+ tests/test_2150_prompt_patterns.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ivolatility_backtesting"
7
- version = "2.149"
7
+ version = "2.150"
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,356 @@
1
+ # Patterns that backtest_master_prompt.md teaches the agent, checked against known answers.
2
+ # Run: python3 tests/test_2150_prompt_patterns.py (or pytest). Offline.
3
+ import datetime as dt
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import traceback
8
+
9
+ import matplotlib
10
+ matplotlib.use('Agg')
11
+
12
+ import pandas as pd
13
+
14
+ _REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
+ sys.path.insert(0, os.path.join(_REPO, 'ivolatility_backtesting'))
16
+ import ivolatility_backtesting as lib # noqa: E402
17
+
18
+ EXP = pd.Timestamp('2024-02-16')
19
+ DAY = dt.date(2024, 1, 5)
20
+ SL20 = {'type': 'pl_loss', 'min_days_before_check': 0, 'pl_loss_settings': {'pl_loss': 0.20},
21
+ 'intraday_settings': {'intraday_mode': 'disabled'}}
22
+
23
+
24
+ def _chain(put_bid, put_ask, call_bid, call_ask, underlying=460.0):
25
+ rows = []
26
+ for typ, bid, ask, delta in (('P', put_bid, put_ask, -0.5), ('C', call_bid, call_ask, 0.5)):
27
+ rows.append({'date': pd.Timestamp(DAY), 'expiration': EXP, 'strike': 470.0, 'type': typ,
28
+ 'bid': bid, 'ask': ask, 'delta': delta, 'iv': 0.2, 'price': (bid + ask) / 2,
29
+ 'dte': 42, 'underlying_price': underlying})
30
+ return pd.DataFrame(rows)
31
+
32
+
33
+ def _stock_row(price):
34
+ return pd.DataFrame([{'date': pd.Timestamp(DAY), 'close': price, 'open': price, 'high': price, 'low': price}])
35
+
36
+
37
+ def _pm(strategy_type, sl=SL20):
38
+ return lib.PositionManager({'symbol': 'SPY', 'debuginfo': 0, 'strategy_type': strategy_type,
39
+ 'stop_loss_enabled': True, 'stop_loss_config': sl}, debug=False)
40
+
41
+
42
+ def _price_data(pm, stock_price, chain):
43
+ return pm.build_price_data(DAY, stock_price, chain,
44
+ lambda s, e, t: lib.get_option_by_strike_exp(chain, s, e, t),
45
+ stock_row=_stock_row(stock_price))
46
+
47
+
48
+ def _short_option(strategy_type, chain):
49
+ pm = _pm(strategy_type)
50
+ pm.open_position(position_id='P1', symbol='SPY', entry_date=dt.date(2024, 1, 2), entry_price=0,
51
+ quantity=100, strategy_type=strategy_type, contracts=1, total_cost=-500.0,
52
+ is_short_bias=True, entry_max_risk=500.0, strike=470.0, expiration=EXP,
53
+ underlying_entry_price=470.0)
54
+ pdx = _price_data(pm, 460.0, chain)
55
+ return pm, pdx
56
+
57
+
58
+ def test_short_single_leg_buys_back_at_ask():
59
+ # credit 500, buy back at ask 10.00 x 100 = 1000 -> P&L -500
60
+ put_rich = _chain(9.9, 10.0, 1.0, 1.1)
61
+ call_rich = _chain(1.0, 1.1, 9.9, 10.0)
62
+ for strategy_type, chain in (('SHORT_PUT', put_rich), ('PUT_ATM', put_rich),
63
+ ('SHORT_CALL', call_rich), ('CALL', call_rich)):
64
+ pm, pdx = _short_option(strategy_type, chain)
65
+ assert round(pdx['P1']['pnl']) == -500, (strategy_type, pdx['P1']['pnl'])
66
+ hits = pm.check_positions(DAY, pdx, underlying_price=460.0)
67
+ assert [h['exit_reason'] for h in hits] == ['stop_loss_pl_loss'], (strategy_type, hits)
68
+
69
+
70
+ def test_short_single_leg_profit_when_option_decays():
71
+ put_cheap = _chain(0.9, 1.0, 9.9, 10.0)
72
+ pm, pdx = _short_option('SHORT_PUT', put_cheap)
73
+ assert round(pdx['P1']['pnl']) == 400, pdx['P1']['pnl']
74
+
75
+
76
+ def test_long_call_unchanged():
77
+ chain = _chain(1.0, 1.1, 9.9, 10.0)
78
+ pm = _pm('CALL')
79
+ pm.open_position(position_id='P1', symbol='SPY', entry_date=dt.date(2024, 1, 2), entry_price=5.0,
80
+ quantity=100, strategy_type='CALL', contracts=1, total_cost=500.0,
81
+ entry_max_risk=500.0, strike=470.0, expiration=EXP, underlying_entry_price=470.0)
82
+ pdx = _price_data(pm, 460.0, chain)
83
+ assert round(pdx['P1']['pnl']) == 490, pdx['P1']['pnl']
84
+
85
+
86
+ def test_multi_leg_close_cost_unchanged():
87
+ expected = {
88
+ 'IRON_CONDOR': (-320.0, 320.0), 'IRON_BUTTERFLY': (-320.0, 320.0),
89
+ 'BEAR_CALL_SPREAD': (-160.0, 160.0), 'BULL_PUT_SPREAD': (-160.0, 160.0),
90
+ 'BULL_CALL_SPREAD': (240.0, 160.0), 'STRADDLE': (680.0, 600.0), 'STRANGLE': (680.0, 600.0),
91
+ 'CALENDAR_SPREAD': (680.0, 600.0), 'COVERED_CALL': (680.0, 600.0),
92
+ }
93
+ reg = lib.StrategyRegistry
94
+ for strategy_type, (credit_cost, debit_cost) in expected.items():
95
+ legs = [leg.get('field_prefix', leg['name']) for leg in reg.get(strategy_type)['legs']]
96
+ leg_data = {p: {'bid': 1.0 + i, 'ask': 1.2 + i} for i, p in enumerate(legs)}
97
+ got = []
98
+ for credit in (True, False):
99
+ pos = {'contracts': 2, 'unit_multiplier': 100, 'entry_price': 0 if credit else 3.0,
100
+ 'total_cost': -300.0 if credit else 300.0, 'is_short_bias': credit}
101
+ got.append(round(reg.calculate_close_cost(strategy_type, pos, leg_data), 6))
102
+ assert tuple(got) == (credit_cost, debit_cost), (strategy_type, got)
103
+
104
+
105
+ def _directional(strategy_type, side, entry, now, sl_pct=0.03, with_total_cost=True):
106
+ sl = dict(SL20, pl_loss_settings={'pl_loss': sl_pct})
107
+ pm = _pm(strategy_type, sl=sl)
108
+ shares = 10
109
+ extra = {'total_cost': entry * shares} if with_total_cost else {}
110
+ pm.open_position(position_id='D1', strategy_type=strategy_type, symbol='SPY',
111
+ entry_date=dt.date(2024, 1, 2), entry_price=entry, entry_max_risk=entry * shares,
112
+ quantity=shares, contracts=0, is_short_bias=(side == 'SHORT'), position_type=side,
113
+ underlying_entry_price=entry, **extra)
114
+ pdx = _price_data(pm, now, pd.DataFrame())
115
+ return pm, pdx
116
+
117
+
118
+ def test_directional_stock_positions_have_pnl_and_stop():
119
+ for strategy_type, side, now in (('DIRECTIONAL_LONG', 'LONG', 90.0), ('DIRECTIONAL_SHORT', 'SHORT', 110.0)):
120
+ pm, pdx = _directional(strategy_type, side, 100.0, now)
121
+ assert 'D1' in pdx, strategy_type
122
+ assert round(pdx['D1']['pnl']) == -100, (strategy_type, pdx['D1'])
123
+ hits = pm.check_positions(DAY, pdx, underlying_price=now)
124
+ assert [h['exit_reason'] for h in hits] == ['stop_loss_pl_loss'], (strategy_type, hits)
125
+
126
+
127
+ def test_directional_without_total_cost_uses_entry_value():
128
+ for strategy_type, side in (('DIRECTIONAL_LONG', 'LONG'), ('DIRECTIONAL_SHORT', 'SHORT')):
129
+ pm, pdx = _directional(strategy_type, side, 100.0, 100.0, sl_pct=0.5, with_total_cost=False)
130
+ assert round(pdx['D1']['pnl']) == 0, (strategy_type, pdx['D1'])
131
+ pm, pdx = _directional(strategy_type, side, 100.0, 90.0 if side == 'LONG' else 110.0, sl_pct=0.5, with_total_cost=False)
132
+ assert round(pdx['D1']['pnl']) == -100, (strategy_type, pdx['D1'])
133
+
134
+
135
+ def test_directional_signal_close_keeps_pnl_and_label():
136
+ pm, pdx = _directional('DIRECTIONAL_LONG', 'LONG', 100.0, 110.0, sl_pct=0.5)
137
+ pnl = pm.close_position_by_signal('D1', DAY, pdx, 'z_score_exit')
138
+ assert round(pnl) == 100, pnl
139
+ trade = pm.get_closed_trades()[-1]
140
+ assert round(trade['pnl']) == 100, trade
141
+ assert trade.get('strategy_type') == 'DIRECTIONAL_LONG', trade.get('strategy_type')
142
+
143
+
144
+ def _stock_100(strategy_type, sl, now):
145
+ pm = _pm(strategy_type, sl=dict(sl, min_days_before_check=0))
146
+ short = strategy_type in ('SHORT_STOCK', 'DIRECTIONAL_SHORT')
147
+ pm.open_position(position_id='S1', strategy_type=strategy_type, symbol='SPY', entry_date=dt.date(2024, 1, 2),
148
+ entry_price=100.0, quantity=100, contracts=100, unit_multiplier=1, total_cost=10000.0,
149
+ entry_max_risk=10000.0, is_short_bias=short, position_type='SHORT' if short else 'LONG',
150
+ underlying_entry_price=100.0)
151
+ pdx = _price_data(pm, now, pd.DataFrame())
152
+ return [h['exit_reason'] for h in pm.check_positions(DAY, pdx, underlying_price=now)]
153
+
154
+
155
+ def test_stock_pl_loss_with_baseline_reset_uses_per_share_move():
156
+ sl = {'type': 'pl_loss', 'pl_loss_settings': {'pl_loss': 0.03}, 'reset_baseline_on_sl_start': True,
157
+ 'intraday_settings': {'intraday_mode': 'disabled'}}
158
+ for strategy_type, small, big in (('DIRECTIONAL_SHORT', 100.3, 103.5), ('SHORT_STOCK', 100.3, 103.5),
159
+ ('DIRECTIONAL_LONG', 99.7, 96.5), ('LONG_STOCK', 99.7, 96.5)):
160
+ assert _stock_100(strategy_type, sl, small) == [], (strategy_type, small)
161
+ assert _stock_100(strategy_type, sl, big) == ['stop_loss_pl_loss'], (strategy_type, big)
162
+
163
+
164
+ def test_stock_pl_loss_reset_with_dollar_entry_price():
165
+ sl = {'type': 'pl_loss', 'pl_loss_settings': {'pl_loss': 0.50}, 'reset_baseline_on_sl_start': True,
166
+ 'min_days_before_check': 0, 'intraday_settings': {'intraday_mode': 'disabled'}}
167
+
168
+ def run(strategy_type, now):
169
+ pm = _pm(strategy_type, sl=sl)
170
+ short = strategy_type == 'SHORT_STOCK'
171
+ pm.open_position(position_id='G1', strategy_type=strategy_type, symbol='SPY', entry_date=dt.date(2024, 1, 2),
172
+ entry_price=10000.0, quantity=100, contracts=1, total_cost=10000.0, entry_max_risk=10000.0,
173
+ is_short_bias=short, position_type='SHORT' if short else 'LONG', underlying_entry_price=100.0)
174
+ pdx = _price_data(pm, now, pd.DataFrame())
175
+ return [h['exit_reason'] for h in pm.check_positions(DAY, pdx, underlying_price=now)]
176
+
177
+ assert run('LONG_STOCK', 100.5) == []
178
+ assert run('LONG_STOCK', 49.0) == ['stop_loss_pl_loss']
179
+ assert run('SHORT_STOCK', 160.0) == ['stop_loss_pl_loss']
180
+
181
+
182
+ def test_long_stock_price_stops_do_not_fire_on_gains():
183
+ for sl in ({'type': 'fixed_pct', 'value': 0.03}, {'type': 'trailing', 'value': 0.03, 'trailing_distance': 0.03}):
184
+ for strategy_type in ('LONG_STOCK', 'DIRECTIONAL_LONG'):
185
+ assert _stock_100(strategy_type, sl, 103.0) == [], (strategy_type, sl['type'])
186
+
187
+
188
+ def test_long_short_stock_unchanged():
189
+ for strategy_type, now in (('LONG_STOCK', 90.0), ('SHORT_STOCK', 110.0)):
190
+ pm = _pm(strategy_type, sl=dict(SL20, pl_loss_settings={'pl_loss': 0.03}))
191
+ pm.open_position(position_id='S1', strategy_type=strategy_type, symbol='SPY',
192
+ entry_date=dt.date(2024, 1, 2), entry_price=100.0, quantity=10, contracts=10,
193
+ unit_multiplier=1, total_cost=1000.0, entry_max_risk=1000.0,
194
+ is_short_bias=(strategy_type == 'SHORT_STOCK'), underlying_entry_price=100.0)
195
+ pdx = _price_data(pm, now, pd.DataFrame())
196
+ assert round(pdx['S1']['pnl']) == -100, (strategy_type, pdx['S1'])
197
+
198
+
199
+ def test_earnings_calendar_keeps_time_of_day_code():
200
+ df = pd.DataFrame([{'earning_date': '2024-01-12', 'estimate': 3.3, 'reported_earning': 3.9, 'time_of_day_code': 'BMO'},
201
+ {'earning_date': '2024-04-12', 'estimate': 4.1, 'reported_earning': 4.4, 'time_of_day_code': 'AMC'}])
202
+ events = lib.parse_earnings_calendar(df)
203
+ assert [e['time_of_day_code'] for e in events] == ['BMO', 'AMC'], events
204
+ assert [e['time_of_day'] for e in events] == ['BMO', 'AMC'], events
205
+
206
+
207
+ def test_package_exports_parse_earnings_calendar():
208
+ r = subprocess.run([sys.executable, '-c', 'from ivolatility_backtesting import parse_earnings_calendar'],
209
+ cwd=_REPO, capture_output=True, text=True)
210
+ assert r.returncode == 0, r.stderr[-400:]
211
+
212
+
213
+ def test_duckdb_preload_returns_option_aligned_stock():
214
+ adjusted = pd.DataFrame({'date': pd.to_datetime(['2024-01-02', '2024-01-03']),
215
+ 'close': [48.17, 47.57], 'unadjusted_close': [481.68, 475.69],
216
+ 'runtime_close': [481.68, 475.69], 'split_factor': [10.0, 10.0]})
217
+ canned = {'_preloaded_stock': adjusted.copy(),
218
+ '_preloaded_options': pd.DataFrame({'date': pd.to_datetime(['2024-01-02'])})}
219
+ saved_ssd, saved_ram = lib._preload_duckdb_SSD_storage, lib._try_duckdb_preload
220
+ try:
221
+ lib._preload_duckdb_SSD_storage = lambda config, cache_config: {k: v.copy() for k, v in canned.items()}
222
+ lib._try_duckdb_preload = lambda config, cache_config, debug=False: {k: v.copy() for k, v in canned.items()}
223
+ for mode in ('duckdb_SSD_storage', 'duckdb_RAM_reader'):
224
+ cfg = {'symbol': 'NVDA', 'start_date': '2024-01-02', 'end_date': '2024-01-03', 'debuginfo': 0,
225
+ 'strategy_type': 'STRADDLE', 'cache_config': {'cache_mode': mode}}
226
+ out = lib.preload_data_universal(cfg)
227
+ assert list(out['_preloaded_stock']['close'].round(2)) == [481.68, 475.69], (mode, out['_preloaded_stock'])
228
+ assert list(out['_preloaded_stock_adjusted']['close'].round(2)) == [48.17, 47.57], mode
229
+ finally:
230
+ lib._preload_duckdb_SSD_storage, lib._try_duckdb_preload = saved_ssd, saved_ram
231
+
232
+
233
+ # --- event-day stop (reset_baseline_on_sl_start) with intraday bars -------------------------------
234
+
235
+ def _event_day_stop(strategy_type, pl_loss, bars_by_time, eod_chain, **open_kwargs):
236
+ sl = {'type': 'pl_loss', 'min_days_before_check': 0, 'pl_loss_settings': {'pl_loss': pl_loss},
237
+ 'reset_baseline_on_sl_start': True,
238
+ 'intraday_settings': {'intraday_mode': 'auto', 'minute_interval': 'MINUTE_5',
239
+ 'reset_baseline_on_sl_start': True}}
240
+ pm = _pm(strategy_type, sl)
241
+ pm.sl_manager._get_legs_intraday_data = lambda *a, **k: (bars_by_time, sorted(bars_by_time))
242
+ pm.open_position(position_id='P1', symbol='SPY', entry_date=dt.date(2024, 1, 2), quantity=100,
243
+ strategy_type=strategy_type, contracts=1, strike=470.0, expiration=EXP,
244
+ underlying_entry_price=460.0, **open_kwargs)
245
+ pdx = _price_data(pm, 460.0, eod_chain)
246
+ return pm.check_positions(DAY, pdx, underlying_price=460.0)
247
+
248
+
249
+ def _put_bars(asks):
250
+ return {t: [{'strike': 470.0, 'type': 'P', 'expiration': EXP, 'bid': a - 0.05, 'ask': a}]
251
+ for t, a in asks.items()}
252
+
253
+
254
+ SHORT_500 = dict(entry_price=0, total_cost=-500.0, is_short_bias=True, entry_max_risk=500.0)
255
+
256
+
257
+ def test_event_day_short_put_no_stop_without_a_real_loss():
258
+ for label, asks, thr in (('flat', {'09:30': 5.0, '09:35': 5.0, '15:55': 5.0}, 1.0),
259
+ ('adverse then recovers', {'09:30': 5.0, '09:35': 5.05, '15:55': 4.9}, 1.0),
260
+ ('favorable', {'09:30': 5.0, '09:35': 4.0, '15:55': 4.0}, 0.4)):
261
+ eod_ask = asks['15:55']
262
+ hits = _event_day_stop('SHORT_PUT', thr, _put_bars(asks),
263
+ _chain(eod_ask - 0.05, eod_ask, 1.0, 1.1), **SHORT_500)
264
+ assert hits == [], (label, hits)
265
+
266
+
267
+ def test_event_day_short_put_real_loss_books_pnl_from_premium():
268
+ hits = _event_day_stop('SHORT_PUT', 1.0, _put_bars({'09:30': 5.0, '09:35': 11.0, '15:55': 11.0}),
269
+ _chain(10.95, 11.0, 1.0, 1.1), **SHORT_500)
270
+ assert len(hits) == 1 and hits[0]['options_stop_trigger_time'] == '09:35', hits
271
+ assert round(hits[0]['pnl']) == -600 and round(hits[0]['pnl_pct']) == -120, hits[0]
272
+ hits = _event_day_stop('SHORT_PUT', 0.4, _put_bars({'09:30': 5.0, '09:35': 7.5, '15:55': 7.5}),
273
+ _chain(7.45, 7.5, 1.0, 1.1), **SHORT_500)
274
+ assert len(hits) == 1 and hits[0]['options_stop_trigger_time'] == '09:35', hits
275
+ assert round(hits[0]['pnl']) == -250 and round(hits[0]['pnl_pct']) == -50, hits[0]
276
+
277
+
278
+ def test_event_day_long_call_books_pnl_from_premium_not_from_0930():
279
+ bars = {t: [{'strike': 470.0, 'type': 'C', 'expiration': EXP, 'bid': b, 'ask': b + 0.05}]
280
+ for t, b in {'09:30': 3.0, '09:35': 1.5, '15:55': 1.5}.items()}
281
+ hits = _event_day_stop('CALL', 0.4, bars, _chain(1.0, 1.1, 1.5, 1.55),
282
+ entry_price=500.0, total_cost=500.0, call_entry_bid=5.0)
283
+ assert len(hits) == 1 and hits[0]['options_stop_trigger_time'] == '09:35', hits
284
+ assert round(hits[0]['pnl']) == -350, hits[0]
285
+
286
+
287
+ def _cache_gap_probe(puts_missing_on):
288
+ import contextlib, io, tempfile
289
+ tmp = tempfile.mkdtemp(prefix='ivb_cache_')
290
+ cc = lib._normalize_cache_config({'cache_mode': 'duckdb_SSD_storage', 'cache_dir': tmp, 'disk_enabled': True})
291
+ conn = lib._get_duckdb_storage_conn(cc)
292
+ for table in ('stock_eod', 'options_eod_1545'):
293
+ conn.execute(f"DELETE FROM {table} WHERE symbol = 'AAPL'")
294
+ days = [d.date() for d in pd.bdate_range('2024-01-02', '2024-01-31')]
295
+ exp = dt.date(2024, 2, 16)
296
+ stock = pd.DataFrame({'symbol': 'AAPL', 'date': days, 'open': 180.0, 'high': 182.0, 'low': 179.0, 'close': 181.0,
297
+ 'volume': 1e6, 'bid': 180.9, 'ask': 181.1, 'unadjusted_close': 181.0, 'source_endpoint': 't'})
298
+ conn.execute("INSERT INTO stock_eod SELECT * FROM stock")
299
+ rows = [dict(option_symbol=f'AAPL_{exp}_{k}_{t}', date=d, symbol='AAPL', expiration=exp, strike=k, type=t,
300
+ bid=1.0, ask=1.2, price=1.1, underlying_price=181.0, iv=0.25, delta=0.5 if t == 'C' else -0.5,
301
+ gamma=0.01, theta=-0.05, vega=0.1, rho=0.01, dte=float((exp - d).days), volume=10.0,
302
+ open_interest=100.0, source_endpoint='t', bid_eod=1.0, ask_eod=1.2)
303
+ for d in days for t in ('C', 'P') for k in (180.0, 185.0)
304
+ if not (t == 'P' and puts_missing_on(d))]
305
+ opts = pd.DataFrame(rows)
306
+ conn.execute("INSERT INTO options_eod_1545 SELECT * FROM opts")
307
+ config = {'symbol': 'AAPL', 'start_date': '2024-01-08', 'end_date': '2024-01-26', 'dte_target': 35,
308
+ 'dte_tolerance': 10, 'options_snapshot_mode': '1545', 'use_duckdb_indicators': True,
309
+ 'cache_config': cc, 'debuginfo': 0}
310
+ fetched = []
311
+ saved = {n: getattr(lib, n) for n in ('_fetch_date_range_data', '_load_stock_to_duckdb', '_load_ivx_to_duckdb',
312
+ '_patch_eod_prices_to_1545', '_load_futures_for_hedge', '_attach_vro_settlement')}
313
+ try:
314
+ lib._fetch_date_range_data = lambda cfg, c, sym, s, e, dfrom, dto: (fetched.append((s, e)), 0)[1]
315
+ lib._load_stock_to_duckdb = lambda *a, **k: None
316
+ lib._load_ivx_to_duckdb = lambda *a, **k: None
317
+ lib._patch_eod_prices_to_1545 = lambda *a, **k: {}
318
+ lib._load_futures_for_hedge = lambda *a, **k: None
319
+ lib._attach_vro_settlement = lambda *a, **k: None
320
+ lib._clear_gap_detection_cache()
321
+ with contextlib.redirect_stdout(io.StringIO()):
322
+ result = lib._try_read_from_duckdb_storage(config, cc, 'AAPL', '2024-01-02', '2024-01-31', '2024-01-08')
323
+ finally:
324
+ for n, f in saved.items():
325
+ setattr(lib, n, f)
326
+ return result is None, fetched
327
+
328
+
329
+ def test_duckdb_cache_days_with_only_calls_are_refetched():
330
+ rejected, fetched = _cache_gap_probe(lambda d: d.day in (10, 11, 12))
331
+ assert not rejected and fetched, 'calls-only days were accepted as complete'
332
+ assert fetched[0][0] <= '2024-01-10' and fetched[-1][1] >= '2024-01-12', fetched
333
+
334
+
335
+ def test_duckdb_cache_all_days_calls_only_forces_full_reload():
336
+ rejected, fetched = _cache_gap_probe(lambda d: True)
337
+ assert rejected and fetched == []
338
+
339
+
340
+ def test_duckdb_cache_complete_days_are_not_refetched():
341
+ rejected, fetched = _cache_gap_probe(lambda d: False)
342
+ assert not rejected and fetched == []
343
+
344
+
345
+ if __name__ == '__main__':
346
+ failed = 0
347
+ for name, fn in list(globals().items()):
348
+ if name.startswith('test_') and callable(fn):
349
+ try:
350
+ fn()
351
+ print(f'PASS {name}')
352
+ except Exception:
353
+ failed += 1
354
+ print(f'FAIL {name}')
355
+ traceback.print_exc()
356
+ sys.exit(1 if failed else 0)