ivolatility-backtesting 2.148__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.148 → ivolatility_backtesting-2.150}/PKG-INFO +2 -2
  2. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting/__init__.py +2 -0
  3. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting/ivolatility_backtesting.py +100 -40
  4. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/PKG-INFO +2 -2
  5. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/SOURCES.txt +3 -1
  6. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/requires.txt +1 -1
  7. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/pyproject.toml +2 -2
  8. ivolatility_backtesting-2.150/tests/test_2149_client_errors.py +131 -0
  9. ivolatility_backtesting-2.150/tests/test_2150_prompt_patterns.py +356 -0
  10. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/README.md +0 -0
  11. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
  12. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
  13. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/setup.cfg +0 -0
  14. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2142_fixes.py +0 -0
  15. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2144_duckdb_dedup.py +0 -0
  16. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2146_vix_vro.py +0 -0
  17. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2147_cents_multiplier.py +0 -0
  18. {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2148_loader_memory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.148
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
@@ -19,7 +19,7 @@ Requires-Dist: pandas<3.0,>=1.5.0
19
19
  Requires-Dist: numpy<3.0,>=1.21.0
20
20
  Requires-Dist: matplotlib>=3.5.0
21
21
  Requires-Dist: seaborn>=0.11.0
22
- Requires-Dist: ivolatility>=1.8.2
22
+ Requires-Dist: ivolatility>=1.9.6
23
23
  Requires-Dist: psutil>=7.1.0
24
24
 
25
25
  # IVolatility Backtesting Framework v1.32
@@ -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
  ]
@@ -40,13 +40,24 @@ import os
40
40
  # Distinctive User-Agent on ALL REST traffic: lets the gateway/Graylog tell
41
41
  # framework calls apart from hand-written scripts, and workspace runs from
42
42
  # local pip installs (CODER_AGENT_URL is set inside Coder workspaces only).
43
+ def _ivb_client():
44
+ if os.getenv('IVOL_CLIENT'):
45
+ return os.getenv('IVOL_CLIENT')
46
+ if os.getenv('CODER_AGENT_URL'):
47
+ return 'workspace'
48
+ # venv created by the IVolAI plugin (ivolai-dist setup_env.sh); pods use /home/coder/.venv
49
+ if os.path.normpath(__import__('sys').prefix).endswith(os.path.join('ivolatility', '.venv')):
50
+ return 'plugin'
51
+ return 'local'
52
+
53
+
43
54
  def _ivb_user_agent():
44
55
  try:
45
56
  from importlib.metadata import version as _pkg_version
46
57
  _ver = _pkg_version('ivolatility_backtesting')
47
58
  except Exception:
48
59
  _ver = 'dev'
49
- _env = 'workspace' if os.getenv('CODER_AGENT_URL') else 'local'
60
+ _env = _ivb_client()
50
61
  try:
51
62
  import requests as _rq
52
63
  _rq_ver = _rq.__version__
@@ -747,6 +758,12 @@ def _get_options_eod_table(config_like: Optional[Dict[str, Any]]) -> str:
747
758
  return 'options_eod_1545' if snap == '1545' else 'options_eod_close'
748
759
 
749
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
+
750
767
  def _get_legacy_1545_duckdb_path(config_like: Optional[Dict[str, Any]]) -> str:
751
768
  """Legacy pre-refactor 15:45 DuckDB path kept for one-time migration."""
752
769
  cache_dir = _resolve_path((config_like or {}).get('cache_dir', 'cache'))
@@ -1793,8 +1810,10 @@ def _create_sdk_like_session():
1793
1810
  from urllib3.util.retry import Retry
1794
1811
  from requests.adapters import HTTPAdapter
1795
1812
  s = _req.Session()
1796
- retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504],
1797
- allowed_methods=["GET", "POST", "DELETE"])
1813
+ # SDK >= 1.9.6 pauses before the first retry; reuse it so thread sessions behave the same
1814
+ retry = getattr(_ivol_inner, '_retry_strategy', None) or Retry(
1815
+ total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504],
1816
+ allowed_methods=["GET", "POST", "DELETE"])
1798
1817
  adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10, max_retries=retry)
1799
1818
  s.mount("https://", adapter)
1800
1819
  s.mount("http://", adapter)
@@ -3727,6 +3746,11 @@ STRATEGIES = {
3727
3746
  },
3728
3747
  }
3729
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
+
3730
3754
  # Backwards compatibility mapping (lowercase to UPPERCASE)
3731
3755
  _STRATEGY_NAME_MAP = {
3732
3756
  'iron_condor': 'IRON_CONDOR',
@@ -6139,7 +6163,7 @@ class StrategyRegistry:
6139
6163
  call_premium = position.get('call_premium', abs(credit))
6140
6164
  capital_at_risk = max(stock_value - call_premium, stock_value * 0.8)
6141
6165
  locked_capital = stock_value
6142
- elif position_type in ('LONG_STOCK', 'SHORT_STOCK'):
6166
+ elif position_type in STOCK_STRATEGY_TYPES:
6143
6167
  stock_value = underlying_price * contracts * um
6144
6168
  capital_at_risk = stock_value
6145
6169
  locked_capital = stock_value
@@ -6411,13 +6435,13 @@ class StrategyRegistry:
6411
6435
  # Universal CREDIT detection (works for ANY strategy)
6412
6436
  is_credit = cls.is_credit_strategy(strategy_type=strategy_type, position=position)
6413
6437
 
6414
- # ⚠️ CRITICAL: For NEUTRAL strategies (STRADDLE, STRANGLE) without 'short' prefix
6415
- # If ALL legs don't have 'short' prefix AND strategy is CREDIT → treat ALL as SHORT
6416
- 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'):
6417
6441
  # Check if NO legs have 'short' prefix (simple STRADDLE/STRANGLE)
6418
6442
  has_short_prefix = any('short' in l['name'].lower() for l in legs)
6419
6443
  if not has_short_prefix:
6420
- # Simple STRADDLE/STRANGLE: all legs are SHORT for CREDIT
6444
+ # STRADDLE/STRANGLE, SHORT_PUT/SHORT_CALL: all legs are SHORT for CREDIT
6421
6445
  is_short_leg = True
6422
6446
 
6423
6447
  # ⚠️ FIX: For DEBIT strategies used as CREDIT (e.g. DCS SELL, calendar SELL)
@@ -6621,7 +6645,7 @@ class StrategyRegistry:
6621
6645
  # total_cost is negative (credit from selling call), so adding it reduces risk
6622
6646
  return (stock_price * um * contracts) + total_cost # subtract call premium
6623
6647
 
6624
- elif strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
6648
+ elif strategy_type in STOCK_STRATEGY_TYPES:
6625
6649
  stock_price = position_params.get('stock_price', position_params.get('underlying_entry_price', 0))
6626
6650
  return stock_price * um * contracts
6627
6651
 
@@ -6820,13 +6844,13 @@ class StrategyRegistry:
6820
6844
  # doubling the call bid while completely missing the stock value.
6821
6845
  # Fix: compute P&L directly using underlying_price (available here) + call leg.
6822
6846
  # ── LONG_STOCK / SHORT_STOCK — pure stock position, no option legs ──
6823
- if strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
6847
+ if strategy_type in STOCK_STRATEGY_TYPES:
6824
6848
  stock_exit_value = underlying_price * contracts * um
6825
- if strategy_type == 'SHORT_STOCK':
6849
+ if strategy_type in SHORT_STOCK_TYPES:
6826
6850
  pnl = entry_cost - stock_exit_value # short: profit when price drops
6827
6851
  else:
6828
6852
  pnl = stock_exit_value - entry_cost # long: profit when price rises
6829
- is_credit = (strategy_type == 'SHORT_STOCK')
6853
+ is_credit = (strategy_type in SHORT_STOCK_TYPES)
6830
6854
  close_cost = stock_exit_value
6831
6855
  # ── COVERED_CALL / COVERED_PUT — stock + option leg ──────────
6832
6856
  elif (strategy_type in ('COVERED_CALL', 'COVERED_CALL_ATM', 'COVERED_CALL_25D', 'COVERED_PUT', 'COVERED_PUT_ATM', 'COVERED_PUT_25D')
@@ -7822,8 +7846,9 @@ def _api_call_direct(endpoint: str, max_retries: int = 3, **kwargs) -> Optional[
7822
7846
  return None
7823
7847
  df = data if isinstance(data, pd.DataFrame) else pd.DataFrame(data)
7824
7848
  return df if not df.empty else None
7825
- except RETRYABLE:
7826
- if attempt == max_retries - 1:
7849
+ except RETRYABLE as e:
7850
+ if _is_client_error(e) or attempt == max_retries - 1:
7851
+ _safe_print(f" ⚠️ {endpoint}: {e}")
7827
7852
  return None
7828
7853
  continue
7829
7854
  except Exception:
@@ -7831,6 +7856,12 @@ def _api_call_direct(endpoint: str, max_retries: int = 3, **kwargs) -> Optional[
7831
7856
  return None
7832
7857
 
7833
7858
 
7859
+ def _is_client_error(exc):
7860
+ """4xx other than 429: the same request cannot succeed on retry."""
7861
+ status = getattr(getattr(exc, 'response', None), 'status_code', None)
7862
+ return status is not None and 400 <= status < 500 and status != 429
7863
+
7864
+
7834
7865
  def api_call(endpoint, cache_config=None, debug=False, max_retries=3, skip_parquet_cache=False,
7835
7866
  _chunk_info=None, **kwargs):
7836
7867
  """
@@ -7927,6 +7958,8 @@ def api_call(endpoint, cache_config=None, debug=False, max_retries=3, skip_parqu
7927
7958
 
7928
7959
  except RETRYABLE_ERRORS as e:
7929
7960
  last_exception = e
7961
+ if _is_client_error(e):
7962
+ raise
7930
7963
  if debug_level >= 1:
7931
7964
  print(f"[RETRY] Network error on attempt {attempt + 1}/{max_retries}: {type(e).__name__}: {e}")
7932
7965
 
@@ -9752,7 +9785,7 @@ class StopLossManager:
9752
9785
 
9753
9786
  # --- Stock-based strategies (no option legs for intraday SL) ---
9754
9787
 
9755
- elif strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
9788
+ elif strategy_type in STOCK_STRATEGY_TYPES:
9756
9789
  pass
9757
9790
 
9758
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():
@@ -10452,10 +10485,9 @@ class StopLossManager:
10452
10485
  pnl_from_baseline = current_value - baseline_close_cost # change in close cost
10453
10486
  denom = abs(baseline_close_cost)
10454
10487
  else:
10455
- # Fallback: old behavior (no baseline close cost stored)
10456
- original_credit_fb = original_credit or entry_value
10457
- position_value_now = original_credit_fb + current_value
10458
- 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
10459
10491
  denom = abs(entry_value)
10460
10492
  intraday_pnl_pct = (pnl_from_baseline / denom) * 100 if denom != 0 else 0
10461
10493
  else:
@@ -11323,6 +11355,18 @@ class StopLossManager:
11323
11355
 
11324
11356
  def _check_pl_loss_stop(self, pos, kwargs):
11325
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
+
11326
11370
  pnl_pct = kwargs.get('pnl_pct')
11327
11371
 
11328
11372
  # [EARNINGS SL FIX] Force recalculation when baseline was reset
@@ -12686,7 +12730,7 @@ class PositionManager:
12686
12730
  call_intrinsic = max(0, underlying_price - call_strike)
12687
12731
  intrinsic_value = call_intrinsic * unit_multiplier * contracts # Only call obligation
12688
12732
 
12689
- elif strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
12733
+ elif strategy_type in STOCK_STRATEGY_TYPES:
12690
12734
  intrinsic_value = underlying_price * unit_multiplier * contracts
12691
12735
  if self.debug:
12692
12736
  print(f"[INTRINSIC SETTLEMENT] {position_id} - {strategy_type}:")
@@ -12825,7 +12869,16 @@ class PositionManager:
12825
12869
  # BACKWARD COMPATIBILITY: If 'type' passed but 'strategy_type' not, copy type → strategy_type
12826
12870
  if 'strategy_type' not in kwargs and 'type' in kwargs:
12827
12871
  kwargs['strategy_type'] = kwargs['type']
12828
-
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
+
12829
12882
  # AUTO-DETECT position_type if not provided
12830
12883
  if position_type is None:
12831
12884
  strategy_type = kwargs.get('strategy_type')
@@ -13484,7 +13537,7 @@ class PositionManager:
13484
13537
  _is_stock_position = (
13485
13538
  'COVERED_CALL' in strategy_type.upper()
13486
13539
  or 'COVERED_PUT' in strategy_type.upper()
13487
- or strategy_type in ('LONG_STOCK', 'SHORT_STOCK')
13540
+ or strategy_type in STOCK_STRATEGY_TYPES
13488
13541
  )
13489
13542
  if _is_stock_position:
13490
13543
  # entry_value/current_value are full position values
@@ -13499,10 +13552,16 @@ class PositionManager:
13499
13552
  is_credit = StrategyRegistry.is_credit_strategy(
13500
13553
  strategy_type=strategy_type, position=position
13501
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)
13502
13557
  if is_credit:
13503
- 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
13504
13560
  else:
13505
- 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
13506
13565
 
13507
13566
  if self.debug:
13508
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})")
@@ -13819,11 +13878,11 @@ class PositionManager:
13819
13878
 
13820
13879
  # LONG_STOCK / SHORT_STOCK: no option legs → compute P&L directly from stock price
13821
13880
  strategy_type = position.get('strategy_type', self.config.get('strategy_type', 'STRADDLE'))
13822
- 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:
13823
13882
  entry_cost = abs(position.get('total_cost', 0))
13824
13883
  contracts = position.get('contracts', 1)
13825
13884
  stock_exit_value = stock_price * contracts * _pos_unit_multiplier(position)
13826
- if strategy_type == 'SHORT_STOCK':
13885
+ if strategy_type in SHORT_STOCK_TYPES:
13827
13886
  pnl = entry_cost - stock_exit_value
13828
13887
  else:
13829
13888
  pnl = stock_exit_value - entry_cost
@@ -14866,7 +14925,7 @@ class PositionManager:
14866
14925
  # ========================================
14867
14926
  # LONG STOCK (pure stock position)
14868
14927
  # ========================================
14869
- elif position_type in ('LONG_STOCK', 'SHORT_STOCK'):
14928
+ elif position_type in STOCK_STRATEGY_TYPES:
14870
14929
  contracts = position.get('contracts', 1)
14871
14930
  underlying_price = position.get('underlying_entry_price', 0)
14872
14931
  stock_value = underlying_price * contracts * _pos_unit_multiplier(position)
@@ -19278,9 +19337,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
19278
19337
  _rich_print(f" 📋 DB DTE: 0-{int(db_max_dte)}")
19279
19338
 
19280
19339
  # Get dates that have options data
19281
- options_dates_result = db.execute_read(f"""
19282
- SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?
19283
- """, [symbol]).fetchdf()
19340
+ options_dates_result = db.execute_read(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
19284
19341
  options_dates = set(pd.to_datetime(options_dates_result['date']).dt.strftime('%Y-%m-%d'))
19285
19342
 
19286
19343
  _rich_print(f" 🔍 Need: {extended_start} → {end_date}, DTE 0-{required_max_dte}")
@@ -19436,9 +19493,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
19436
19493
  if trading_days:
19437
19494
  try:
19438
19495
  db.refresh_conn()
19439
- _post_df = db.execute_read(
19440
- f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
19441
- ).fetchdf()
19496
+ _post_df = db.execute_read(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
19442
19497
  _post_dates = set(pd.to_datetime(_post_df['date']).dt.strftime('%Y-%m-%d'))
19443
19498
  _req_min = pd.to_datetime(extended_start)
19444
19499
  _req_max = pd.to_datetime(end_date)
@@ -20063,9 +20118,7 @@ def _preload_duckdb_SSD_storage(config, cache_config):
20063
20118
  # Cold-path completeness: every backtest trading day must exist in
20064
20119
  # the options table; misses are loud + flag _failed_chunks
20065
20120
  try:
20066
- _cc_df = conn.execute(
20067
- f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
20068
- ).fetchdf()
20121
+ _cc_df = conn.execute(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
20069
20122
  _cc_have = set(pd.to_datetime(_cc_df['date']).dt.strftime('%Y-%m-%d'))
20070
20123
  _cc_need = {pd.Timestamp(d).strftime('%Y-%m-%d') for d in backtest_trading_days}
20071
20124
  _cc_missing = sorted(_cc_need - _cc_have)
@@ -21853,8 +21906,11 @@ def _load_futures_for_hedge(config, preloaded):
21853
21906
  if r.status_code == 200:
21854
21907
  data = r.json().get('data', [])
21855
21908
  all_rows.extend(data)
21856
- except Exception:
21857
- pass
21909
+ else:
21910
+ print(f" ⚠️ futures/eod/prices {fut_root} {current:%Y-%m-%d}..{chunk_end:%Y-%m-%d}: "
21911
+ f"HTTP {r.status_code} {r.text[:300]}")
21912
+ except Exception as e:
21913
+ print(f" ⚠️ futures/eod/prices {fut_root} {current:%Y-%m-%d}: {e}")
21858
21914
  current = chunk_end + _td(days=1)
21859
21915
 
21860
21916
  if all_rows:
@@ -23682,6 +23738,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
23682
23738
  # and _try_read_from_duckdb_storage). Setting the old per-row lazy
23683
23739
  # patch here would be redundant and wasteful (extra intraday API calls
23684
23740
  # for data that is already correct in DuckDB).
23741
+ _activate_runtime_stock_shim(config, result)
23685
23742
  _timing.end_stage() # End "Loading Data" timing
23686
23743
  return result
23687
23744
  # Check if lock was the reason for failure
@@ -23720,6 +23777,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
23720
23777
  return None
23721
23778
  if config.get('bar_exit') == '16:00':
23722
23779
  set_option_patch_context(OptionPatchContext(_sym, _fetch_opt_intraday_duckdb_ram))
23780
+ _activate_runtime_stock_shim(config, result)
23723
23781
  _timing.end_stage() # End "Loading Data" timing
23724
23782
  return result
23725
23783
  print(" ⚠️ DuckDB RAM reader: No cached Parquet data, falling back to API...")
@@ -24967,7 +25025,7 @@ def parse_earnings_calendar(earnings_df):
24967
25025
 
24968
25026
  Returns:
24969
25027
  list: [{'date': datetime.date, 'estimate': float, 'reported': float,
24970
- 'time_of_day': str}, ...]
25028
+ 'time_of_day': str, 'time_of_day_code': str}, ...]
24971
25029
  Sorted by date ascending.
24972
25030
 
24973
25031
  Usage in strategy:
@@ -24995,11 +25053,13 @@ def parse_earnings_calendar(earnings_df):
24995
25053
  else:
24996
25054
  date_obj = earning_date
24997
25055
 
25056
+ time_of_day = record.get('time_of_day_code', 'UNK')
24998
25057
  earnings_events.append({
24999
25058
  'date': date_obj,
25000
25059
  'estimate': record.get('estimate'),
25001
25060
  'reported': record.get('reported_earning'),
25002
- '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,
25003
25063
  })
25004
25064
 
25005
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.148
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
@@ -19,7 +19,7 @@ Requires-Dist: pandas<3.0,>=1.5.0
19
19
  Requires-Dist: numpy<3.0,>=1.21.0
20
20
  Requires-Dist: matplotlib>=3.5.0
21
21
  Requires-Dist: seaborn>=0.11.0
22
- Requires-Dist: ivolatility>=1.8.2
22
+ Requires-Dist: ivolatility>=1.9.6
23
23
  Requires-Dist: psutil>=7.1.0
24
24
 
25
25
  # IVolatility Backtesting Framework v1.32
@@ -12,4 +12,6 @@ tests/test_2142_fixes.py
12
12
  tests/test_2144_duckdb_dedup.py
13
13
  tests/test_2146_vix_vro.py
14
14
  tests/test_2147_cents_multiplier.py
15
- tests/test_2148_loader_memory.py
15
+ tests/test_2148_loader_memory.py
16
+ tests/test_2149_client_errors.py
17
+ tests/test_2150_prompt_patterns.py
@@ -2,5 +2,5 @@ pandas<3.0,>=1.5.0
2
2
  numpy<3.0,>=1.21.0
3
3
  matplotlib>=3.5.0
4
4
  seaborn>=0.11.0
5
- ivolatility>=1.8.2
5
+ ivolatility>=1.9.6
6
6
  psutil>=7.1.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ivolatility_backtesting"
7
- version = "2.148"
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 = [
@@ -17,7 +17,7 @@ dependencies = [
17
17
  "numpy>=1.21.0,<3.0",
18
18
  "matplotlib>=3.5.0",
19
19
  "seaborn>=0.11.0",
20
- "ivolatility>=1.8.2",
20
+ "ivolatility>=1.9.6",
21
21
  "psutil>=7.1.0",
22
22
  ]
23
23
  keywords = ["backtesting", "finance", "trading", "ivolatility"]
@@ -0,0 +1,131 @@
1
+ # No retry on 4xx, pause before retrying a 429, client tag in User-Agent.
2
+ # Run: python3 tests/test_2149_client_errors.py (or pytest). No network: a local server stands in for the API.
3
+ import json
4
+ import os
5
+ import sys
6
+ import threading
7
+ import time
8
+ import traceback
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+
11
+ import matplotlib
12
+ matplotlib.use('Agg')
13
+
14
+ HITS = {}
15
+
16
+
17
+ class _Api(BaseHTTPRequestHandler):
18
+ def log_message(self, *args):
19
+ pass
20
+
21
+ def _reply(self, code, body):
22
+ data = json.dumps(body).encode()
23
+ self.send_response(code)
24
+ self.send_header('Content-Type', 'application/json')
25
+ self.send_header('Content-Length', str(len(data)))
26
+ self.end_headers()
27
+ self.wfile.write(data)
28
+
29
+ def do_GET(self):
30
+ path = self.path.split('?')[0]
31
+ HITS[path] = HITS.get(path, 0) + 1
32
+ if path == '/busy' and HITS[path] == 1:
33
+ self._reply(429, {'code': 429, 'message': 'per-second rate limit'})
34
+ elif path == '/equities/eod/stock-prices':
35
+ self._reply(403, {'code': 403, 'name': 'Forbidden',
36
+ 'message': 'Trial records limit exhausted. To continue, please upgrade to a paid plan'})
37
+ else:
38
+ self._reply(200, {'ok': True})
39
+
40
+
41
+ # CI runners set HTTP_PROXY; the local stand-in API must bypass it
42
+ for _var in ('NO_PROXY', 'no_proxy'):
43
+ os.environ[_var] = ','.join(filter(None, [os.environ.get(_var), '127.0.0.1', 'localhost']))
44
+
45
+ _SERVER = ThreadingHTTPServer(('127.0.0.1', 0), _Api)
46
+ threading.Thread(target=_SERVER.serve_forever, daemon=True).start()
47
+ _BASE = f'http://127.0.0.1:{_SERVER.server_address[1]}'
48
+ os.environ['IVOL_API_BASE_URL'] = _BASE
49
+ os.environ['API_KEY'] = 'TESTKEY000000000'
50
+
51
+ _REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
52
+ sys.path.insert(0, os.path.join(_REPO, 'ivolatility_backtesting'))
53
+ import ivolatility_backtesting as lib # noqa: E402
54
+ import requests # noqa: E402
55
+
56
+
57
+ def _sdk_version():
58
+ return tuple(int(x) for x in lib.ivol.__version__.split('.')[:3])
59
+
60
+
61
+ def test_client_error_not_retried():
62
+ HITS.pop('/equities/eod/stock-prices', None)
63
+ try:
64
+ lib.api_call('/equities/eod/stock-prices', symbol='AAPL', from_='2024-01-02', to='2024-01-03')
65
+ raise AssertionError('403 must raise')
66
+ except requests.HTTPError as e:
67
+ assert e.response.status_code == 403
68
+ if _sdk_version() >= (1, 9, 6):
69
+ assert 'Trial records limit' in str(e)
70
+ assert HITS['/equities/eod/stock-prices'] == 1
71
+
72
+
73
+ def test_direct_call_not_retried_on_client_error():
74
+ HITS.pop('/equities/eod/stock-prices', None)
75
+ assert lib._api_call_direct('/equities/eod/stock-prices', symbol='AAPL',
76
+ from_='2024-01-02', to='2024-01-03') is None
77
+ assert HITS['/equities/eod/stock-prices'] == 1
78
+
79
+
80
+ def test_thread_session_pauses_before_retrying_429():
81
+ if _sdk_version() < (1, 9, 6):
82
+ return
83
+ HITS.pop('/busy', None)
84
+ t0 = time.time()
85
+ r = lib._create_sdk_like_session().get(f'{_BASE}/busy', timeout=10)
86
+ assert r.status_code == 200 and HITS['/busy'] == 2
87
+ assert time.time() - t0 >= 1.0
88
+
89
+
90
+ def test_is_client_error():
91
+ class E(Exception):
92
+ def __init__(self, code):
93
+ self.response = type('R', (), {'status_code': code})()
94
+ assert lib._is_client_error(E(403)) and lib._is_client_error(E(400))
95
+ assert not lib._is_client_error(E(429)) and not lib._is_client_error(E(500))
96
+ assert not lib._is_client_error(ValueError())
97
+
98
+
99
+ def test_client_tag():
100
+ saved = {k: os.environ.pop(k, None) for k in ('IVOL_CLIENT', 'CODER_AGENT_URL')}
101
+ saved_prefix = sys.prefix
102
+ try:
103
+ sys.prefix = '/opt/venv'
104
+ assert lib._ivb_client() == 'local'
105
+ sys.prefix = os.path.join('/Users/x', 'ivolatility', '.venv')
106
+ assert lib._ivb_client() == 'plugin'
107
+ os.environ['CODER_AGENT_URL'] = 'http://agent'
108
+ assert lib._ivb_client() == 'workspace'
109
+ os.environ['IVOL_CLIENT'] = 'chat'
110
+ assert lib._ivb_client() == 'chat'
111
+ finally:
112
+ sys.prefix = saved_prefix
113
+ for k, v in saved.items():
114
+ os.environ.pop(k, None)
115
+ if v is not None:
116
+ os.environ[k] = v
117
+
118
+
119
+ if __name__ == '__main__':
120
+ failed = 0
121
+ for name, fn in list(globals().items()):
122
+ if name.startswith('test_') and callable(fn):
123
+ try:
124
+ fn()
125
+ print(f'PASS {name}')
126
+ except Exception:
127
+ failed += 1
128
+ print(f'FAIL {name}')
129
+ traceback.print_exc()
130
+ _SERVER.shutdown()
131
+ sys.exit(1 if failed else 0)
@@ -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)