ivolatility-backtesting 2.143__tar.gz → 2.145__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.143
3
+ Version: 2.145
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
@@ -15,8 +15,8 @@ Classifier: License :: OSI Approved :: MIT License
15
15
  Classifier: Operating System :: OS Independent
16
16
  Requires-Python: >=3.8
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: pandas>=1.5.0
19
- Requires-Dist: numpy>=1.21.0
18
+ Requires-Dist: pandas<3.0,>=1.5.0
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
22
  Requires-Dist: ivolatility>=1.8.2
@@ -179,8 +179,29 @@ _API_LOG_FILE = None # Track API log file path for summary printing
179
179
  # ============================================================
180
180
  from typing import Optional, Dict, Any, Tuple
181
181
 
182
+ _NUMERIC_COERCE_WARNED = False
183
+
184
+
185
+ def _as_data_row(source):
186
+ """Normalize a stock/data row to a single record.
187
+
188
+ Callers legitimately pass either a row (``df.iloc[0]``) or a one-row slice
189
+ (``df[df['date'] == d]``). Only a 2-D object needs ``.iloc[0]``: applying it
190
+ to a Series would collapse it to its first cell.
191
+ """
192
+ if source is None:
193
+ return None
194
+ try:
195
+ if getattr(source, 'ndim', 1) > 1:
196
+ return source.iloc[0] if len(source) > 0 else None
197
+ except Exception:
198
+ return source
199
+ return source
200
+
201
+
182
202
  def _first_valid_numeric_value(source, columns) -> Optional[float]:
183
203
  """Return the first usable numeric value from source[column] in priority order."""
204
+ source = _as_data_row(source)
184
205
  if source is None:
185
206
  return None
186
207
  for col in columns:
@@ -196,7 +217,16 @@ def _first_valid_numeric_value(source, columns) -> Optional[float]:
196
217
  if value is None or pd.isna(value):
197
218
  continue
198
219
  return float(value)
199
- except Exception:
220
+ except Exception as exc:
221
+ # Shape errors here used to vanish silently and surface later as a
222
+ # wrong number (fallback instead of the real price). Warn once per
223
+ # run so the cause is visible without flooding the notebook output.
224
+ global _NUMERIC_COERCE_WARNED
225
+ if not _NUMERIC_COERCE_WARNED:
226
+ _NUMERIC_COERCE_WARNED = True
227
+ print(f"\u26a0\ufe0f Could not read numeric field '{col}' from the data row "
228
+ f"({type(exc).__name__}: {exc}). Falling back to defaults — "
229
+ f"check that stock_row is a row or a one-row slice.")
200
230
  continue
201
231
  return None
202
232
 
@@ -266,14 +296,7 @@ def _resolve_runtime_underlying_price(stock_price=None, stock_row=None, options_
266
296
  Resolve the best option-aligned underlying price for runtime P&L / SL logic.
267
297
  Priority: stock_row runtime fields -> options underlying_price -> passed scalar.
268
298
  """
269
- row_obj = None
270
- try:
271
- if stock_row is not None and hasattr(stock_row, 'iloc') and len(stock_row) > 0:
272
- row_obj = stock_row.iloc[0]
273
- elif stock_row is not None:
274
- row_obj = stock_row
275
- except Exception:
276
- row_obj = stock_row
299
+ row_obj = _as_data_row(stock_row)
277
300
 
278
301
  value = _first_valid_numeric_value(
279
302
  row_obj,
@@ -297,14 +320,7 @@ def _resolve_runtime_underlying_price(stock_price=None, stock_row=None, options_
297
320
 
298
321
  def _resolve_runtime_stock_range(stock_row=None, fallback_price=None) -> Tuple[Optional[float], Optional[float]]:
299
322
  """Resolve runtime high/low in the same coordinate system as runtime price."""
300
- row_obj = None
301
- try:
302
- if stock_row is not None and hasattr(stock_row, 'iloc') and len(stock_row) > 0:
303
- row_obj = stock_row.iloc[0]
304
- elif stock_row is not None:
305
- row_obj = stock_row
306
- except Exception:
307
- row_obj = stock_row
323
+ row_obj = _as_data_row(stock_row)
308
324
 
309
325
  high = _first_valid_numeric_value(row_obj, ('runtime_high', 'high'))
310
326
  low = _first_valid_numeric_value(row_obj, ('runtime_low', 'low'))
@@ -1515,6 +1531,7 @@ def report_results_folder(
1515
1531
  return {"folder": folder, "strategies": strategies}
1516
1532
 
1517
1533
 
1534
+
1518
1535
  def run_backtest_notebook(
1519
1536
  base_config: dict,
1520
1537
  strategy_fn,
@@ -2019,6 +2036,8 @@ def _enable_api_logging(log_file='logs/ivolatility_api.log', level=2):
2019
2036
  # Uses ThreadPoolExecutor for parallel api_call (preserves caching!)
2020
2037
  # For I/O bound tasks (HTTP), threads work well despite GIL
2021
2038
  from concurrent.futures import ThreadPoolExecutor, as_completed
2039
+ # On py<3.11 futures raise their own TimeoutError, not the builtin
2040
+ from concurrent.futures import TimeoutError as _FuturesTimeoutError
2022
2041
  _PARALLEL_AVAILABLE = True
2023
2042
 
2024
2043
  # ============================================================
@@ -13713,15 +13732,12 @@ class PositionManager:
13713
13732
  if stock_row is not None:
13714
13733
  # runtime_open = split-aligned open (REFERENCE §4a) — the
13715
13734
  # AM-settlement reference must not use the adjusted series
13716
- _stock_open = None
13717
- for _oc in ('runtime_open', 'open'):
13718
- try:
13719
- _ov = stock_row.get(_oc) if hasattr(stock_row, 'get') else stock_row[_oc]
13720
- except (KeyError, IndexError, TypeError):
13721
- _ov = None
13722
- if _ov is not None and not pd.isna(_ov) and float(_ov) > 0:
13723
- _stock_open = float(_ov)
13724
- break
13735
+ # stock_row may arrive as a single-row slice (stock_df[mask]) —
13736
+ # take the row itself, otherwise .get() yields a Series and any
13737
+ # boolean/float use of it raises ValueError.
13738
+ _stock_open = _first_valid_numeric_value(stock_row, ('runtime_open', 'open'))
13739
+ if _stock_open is not None and _stock_open <= 0:
13740
+ _stock_open = None
13725
13741
  price_data[pos_id].update({
13726
13742
  'underlying_high': stock_high,
13727
13743
  'underlying_low': stock_low,
@@ -15706,6 +15722,14 @@ class ResultsReporter:
15706
15722
  print("TRADING STATISTICS")
15707
15723
  print("-"*80)
15708
15724
  print(f"Total Trades: {m['total_trades']:>15}")
15725
+ if not m.get('total_trades'):
15726
+ # 0 trades is almost never a real result — it usually means the entry
15727
+ # signal never fired (unread config key, indicators not computed).
15728
+ print("\u26a0\ufe0f 0 trades: the entry signal never fired — this is not a "
15729
+ "result, it is a run that never entered. Check that indicators were "
15730
+ "computed (look for 'Processing N cached indicators' — 0 means the "
15731
+ "signal could not be evaluated) and that the data loaded (a locked "
15732
+ "DuckDB cache or a failed fetch also ends in 0 trades).")
15709
15733
  print(f"Winning Trades: {m['winning_trades']:>15}")
15710
15734
  print(f"Losing Trades: {m['losing_trades']:>15}")
15711
15735
  print(f"Win Rate: {m['win_rate']:>15.2f}% (% profitable trades)")
@@ -18471,6 +18495,7 @@ def _fetch_date_range_data(config, cache_config, symbol, start_date, end_date, d
18471
18495
  rows_streamed = 0
18472
18496
  failed_chunks = [] # 2.142: (start, end, cp, error) of every dropped chunk
18473
18497
  _gap_save_ep = _get_options_endpoints(_get_options_snapshot_mode(config))['filtered']
18498
+ _GAP_GLOBAL_TIMEOUT = 1800 # same safety net as the main parallel loader
18474
18499
 
18475
18500
  def _stream_one(df):
18476
18501
  nonlocal rows_streamed, total_rows
@@ -18486,23 +18511,37 @@ def _fetch_date_range_data(config, cache_config, symbol, start_date, end_date, d
18486
18511
  completed = 0
18487
18512
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as executor:
18488
18513
  futures = {executor.submit(fetch_chunk, (params, i+1)): i for i, params in enumerate(all_requests)}
18489
-
18490
- for future in as_completed(futures):
18491
- completed += 1
18492
- try:
18493
- df = future.result(timeout=120)
18494
- except Exception:
18514
+
18515
+ try:
18516
+ # Without the timeout a single hung HTTP call blocks as_completed
18517
+ # forever — result(timeout) never gets a chance to fire.
18518
+ for future in as_completed(futures, timeout=_GAP_GLOBAL_TIMEOUT):
18519
+ completed += 1
18520
+ try:
18521
+ df = future.result(timeout=120)
18522
+ except Exception:
18523
+ df = None
18524
+
18525
+ _stream_one(df)
18526
+ try:
18527
+ future._result = None
18528
+ except Exception:
18529
+ pass
18495
18530
  df = None
18496
-
18497
- _stream_one(df)
18498
- try:
18499
- future._result = None
18500
- except Exception:
18501
- pass
18502
- df = None
18503
-
18504
- if debuginfo >= 1 and (completed % 10 == 0 or completed == total_requests):
18505
- _safe_print(f" ⚡ Progress: {completed}/{total_requests}, {total_rows:,} rows streamed")
18531
+
18532
+ if debuginfo >= 1 and (completed % 10 == 0 or completed == total_requests):
18533
+ _safe_print(f" ⚡ Progress: {completed}/{total_requests}, {total_rows:,} rows streamed")
18534
+ except (TimeoutError, _FuturesTimeoutError):
18535
+ _pending = [i + 1 for f, i in futures.items() if not f.done()]
18536
+ _safe_print(f" ❌ [GAP TIMEOUT] as_completed timed out after "
18537
+ f"{_GAP_GLOBAL_TIMEOUT}s — dropping pending chunks {_pending}")
18538
+ for f, i in futures.items():
18539
+ if not f.done():
18540
+ f.cancel()
18541
+ _p = all_requests[i]
18542
+ failed_chunks.append((_p.get('from_', _p.get('startDate', '?')),
18543
+ _p.get('to', _p.get('endDate', '?')),
18544
+ _p.get('cp', '?'), 'gap global timeout'))
18506
18545
  else:
18507
18546
  for i, params in enumerate(all_requests):
18508
18547
  df = fetch_chunk((params, i + 1))
@@ -20241,7 +20280,7 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
20241
20280
  _bt_logger.info(f"[DIAG] MAIN LOOP future ready completed={completed}/{total_requests} chunk_id={_chunk_id}")
20242
20281
  try:
20243
20282
  df = future.result(timeout=120)
20244
- except TimeoutError:
20283
+ except (TimeoutError, _FuturesTimeoutError):
20245
20284
  _safe_print(f" ⏱️ [TIMEOUT] future.result() timed out after 120s (chunk {completed}/{total_requests})")
20246
20285
  _bt_logger.info(f"[DIAG] FUTURE TIMEOUT chunk={_chunk_id} completed={completed}/{total_requests}")
20247
20286
  df = None
@@ -20332,7 +20371,7 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
20332
20371
  if completed % 10 == 0 or completed == total_requests:
20333
20372
  _wall = _time_mod.time() - _parallel_start
20334
20373
  _safe_print(f" ⚡ Progress: {completed}/{total_requests} requests, {total_rows:,} rows, wall={_wall:.1f}s")
20335
- except TimeoutError:
20374
+ except (TimeoutError, _FuturesTimeoutError):
20336
20375
  _wall = _time_mod.time() - _parallel_start
20337
20376
  _pending = [cid for f, cid in futures.items() if not f.done()]
20338
20377
  _safe_print(f" ❌ [GLOBAL TIMEOUT] as_completed timed out after {_PARALLEL_GLOBAL_TIMEOUT}s (wall={_wall:.1f}s)")
@@ -21810,10 +21849,18 @@ def _load_futures_prices_to_duckdb(config, cache_config, symbol,
21810
21849
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as exe:
21811
21850
  futures = {exe.submit(_fetch, cs, ce, i + 1): (cs, ce)
21812
21851
  for i, (cs, ce) in enumerate(chunks)}
21813
- for f in as_completed(futures):
21814
- part = f.result()
21815
- if part is not None and not part.empty:
21816
- parts.append(part)
21852
+ try:
21853
+ # a hung HTTP call blocks as_completed forever without this
21854
+ for f in as_completed(futures, timeout=1800):
21855
+ part = f.result()
21856
+ if part is not None and not part.empty:
21857
+ parts.append(part)
21858
+ except (TimeoutError, _FuturesTimeoutError):
21859
+ _hung = [rng for f, rng in futures.items() if not f.done()]
21860
+ print(f" ❌ [TIMEOUT] futures_eod load: dropping hung chunks {_hung}")
21861
+ for f in futures:
21862
+ if not f.done():
21863
+ f.cancel()
21817
21864
  else:
21818
21865
  for i, (cs, ce) in enumerate(chunks):
21819
21866
  part = _fetch(cs, ce, i + 1)
@@ -22342,24 +22389,34 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
22342
22389
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as exe:
22343
22390
  futs = {exe.submit(_fetch, p, i + 1): p
22344
22391
  for i, p in enumerate(all_requests)}
22345
- for f in as_completed(futs):
22346
- completed += 1
22347
- _fdf = f.result()
22348
- _save_chunk(_fdf)
22349
- if _fdf is not None and not getattr(_fdf, 'empty', True):
22392
+ try:
22393
+ # a hung HTTP call blocks as_completed forever without this
22394
+ for f in as_completed(futs, timeout=1800):
22395
+ completed += 1
22396
+ _fdf = f.result()
22397
+ _save_chunk(_fdf)
22398
+ if _fdf is not None and not getattr(_fdf, 'empty', True):
22399
+ try:
22400
+ _mf_fut_sem.release()
22401
+ except Exception:
22402
+ pass
22350
22403
  try:
22351
- _mf_fut_sem.release()
22404
+ f._result = None
22352
22405
  except Exception:
22353
22406
  pass
22354
- try:
22355
- f._result = None
22356
- except Exception:
22357
- pass
22358
- _fdf = None
22359
- _maybe_checkpoint()
22360
- if completed % 20 == 0 or completed == total:
22361
- print(f" ⚡ {completed}/{total} requests, "
22362
- f"{rows_saved_total:,} rows so far")
22407
+ _fdf = None
22408
+ _maybe_checkpoint()
22409
+ if completed % 20 == 0 or completed == total:
22410
+ print(f" ⚡ {completed}/{total} requests, "
22411
+ f"{rows_saved_total:,} rows so far")
22412
+ except (TimeoutError, _FuturesTimeoutError):
22413
+ print(f" ❌ [TIMEOUT] futures-options load: as_completed "
22414
+ f"timed out after 1800s — dropping hung chunks")
22415
+ for f, p in futs.items():
22416
+ if not f.done():
22417
+ f.cancel()
22418
+ failed_chunks.append((p.get('from_', '?'), p.get('to', '?'),
22419
+ p.get('cp', '?'), 'global timeout'))
22363
22420
  else:
22364
22421
  for i, params in enumerate(all_requests, 1):
22365
22422
  completed = i
@@ -23758,23 +23815,34 @@ def preload_data_universal(config, data_requests=None, debug=False):
23758
23815
 
23759
23816
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as executor:
23760
23817
  future_to_idx = {
23761
- executor.submit(fetch_chunk, req_data): i
23818
+ executor.submit(fetch_chunk, req_data): i
23762
23819
  for i, req_data in enumerate(all_request_data)
23763
23820
  }
23764
-
23765
- for future in as_completed(future_to_idx):
23766
- idx = future_to_idx[future]
23767
- completed += 1
23768
-
23769
- if completed % max(1, total_requests // 5) == 0 or completed == total_requests:
23770
- _safe_print(f" ⚡ Progress: {completed}/{total_requests}...")
23771
-
23772
- try:
23773
- result = future.result(timeout=120)
23774
- results.append((idx, result))
23775
- except Exception as e:
23776
- req_data = all_request_data[idx]
23777
- results.append((idx, (None, req_data[1], req_data[2], req_data[3], e)))
23821
+
23822
+ try:
23823
+ # a hung HTTP call blocks as_completed forever without this
23824
+ for future in as_completed(future_to_idx, timeout=1800):
23825
+ idx = future_to_idx[future]
23826
+ completed += 1
23827
+
23828
+ if completed % max(1, total_requests // 5) == 0 or completed == total_requests:
23829
+ _safe_print(f" ⚡ Progress: {completed}/{total_requests}...")
23830
+
23831
+ try:
23832
+ result = future.result(timeout=120)
23833
+ results.append((idx, result))
23834
+ except Exception as e:
23835
+ req_data = all_request_data[idx]
23836
+ results.append((idx, (None, req_data[1], req_data[2], req_data[3], e)))
23837
+ except (TimeoutError, _FuturesTimeoutError):
23838
+ _safe_print(f" ❌ [TIMEOUT] as_completed timed out after 1800s — "
23839
+ f"dropping hung chunks")
23840
+ for future, idx in future_to_idx.items():
23841
+ if not future.done():
23842
+ future.cancel()
23843
+ req_data = all_request_data[idx]
23844
+ results.append((idx, (None, req_data[1], req_data[2], req_data[3],
23845
+ TimeoutError('global timeout'))))
23778
23846
 
23779
23847
  # Sort by original index to maintain order
23780
23848
  results.sort(key=lambda x: x[0])
@@ -25504,7 +25572,7 @@ def print_signals_table(config, indicator_cache, stock_df, save_path=None, silen
25504
25572
  expected_ivx_key = ('iv_lean_zscore_ivx', (_cfg_dte, int(_cfg_lb))) if _cfg_lb is not None else None
25505
25573
 
25506
25574
  if expected_raw_key is not None and expected_raw_key not in indicator_cache and should_print:
25507
- present_lbs = sorted({k[1][0] for k in indicator_cache if k[0] == 'iv_lean_zscore' and isinstance(k[1], tuple)})
25575
+ present_lbs = sorted({k[1][0] for k in indicator_cache if k[0] == 'iv_lean_zscore' and isinstance(k[1], tuple) and k[1]})
25508
25576
  print(f"⚠️ print_signals_table: no iv_lean_zscore cache for lookback={_cfg_lb} "
25509
25577
  f"(cache has: {present_lbs}). Table will be empty for active config.")
25510
25578
 
@@ -26757,6 +26825,7 @@ def run_optimization(base_config, param_grid, strategy_function,
26757
26825
  (combined_results_df, baselines_dict, results_folder)
26758
26826
  baselines_dict: {symbol: metrics_dict}
26759
26827
  """
26828
+
26760
26829
  import copy as _copy
26761
26830
 
26762
26831
  if symbols is None:
@@ -28614,6 +28683,8 @@ def _init_duckdb_storage_tables(conn):
28614
28683
  print(f"[DUCKDB] ⚠️ Legacy migration warning: {_mig_err}", flush=True)
28615
28684
 
28616
28685
  # OPTIONS_EOD_CLOSE - EOD options from stock-opts-by-param and options-rawiv (close prices)
28686
+ # No PK: a PK's ART index is RAM-resident in DuckDB and OOMs small containers on
28687
+ # multi-million-row tables; dedup lives in _duckdb_insert_dedup(). Do not re-add.
28617
28688
  _OPTIONS_EOD_SCHEMA = """(
28618
28689
  option_symbol VARCHAR,
28619
28690
  date DATE,
@@ -28636,26 +28707,32 @@ def _init_duckdb_storage_tables(conn):
28636
28707
  open_interest DOUBLE,
28637
28708
  source_endpoint VARCHAR,
28638
28709
  bid_eod DOUBLE,
28639
- ask_eod DOUBLE,
28640
- PRIMARY KEY (option_symbol, date)
28710
+ ask_eod DOUBLE
28641
28711
  )"""
28712
+ # Rebuild legacy caches that still carry the PK, preserving rows
28713
+ for _opt_tbl_mig in ('options_eod_close', 'options_eod_1545'):
28714
+ try:
28715
+ _existing_tbls = [t[0] for t in conn.execute("SHOW TABLES").fetchall()]
28716
+ if f'{_opt_tbl_mig}__nopk' in _existing_tbls:
28717
+ conn.execute(f"DROP TABLE {_opt_tbl_mig}__nopk")
28718
+ if _opt_tbl_mig not in _existing_tbls:
28719
+ continue
28720
+ _has_pk = conn.execute(
28721
+ "SELECT 1 FROM information_schema.table_constraints "
28722
+ "WHERE table_name = ? AND constraint_type = 'PRIMARY KEY'",
28723
+ [_opt_tbl_mig]).fetchall()
28724
+ if _has_pk:
28725
+ _mig_rows = conn.execute(f"SELECT COUNT(*) FROM {_opt_tbl_mig}").fetchone()[0]
28726
+ conn.execute(f"CREATE TABLE {_opt_tbl_mig}__nopk AS SELECT * FROM {_opt_tbl_mig}")
28727
+ conn.execute(f"DROP TABLE {_opt_tbl_mig}")
28728
+ conn.execute(f"ALTER TABLE {_opt_tbl_mig}__nopk RENAME TO {_opt_tbl_mig}")
28729
+ print(f"[DUCKDB] 🔄 Rebuilt {_opt_tbl_mig} without PRIMARY KEY "
28730
+ f"({_mig_rows:,} rows preserved)", flush=True)
28731
+ except Exception as _pk_err:
28732
+ print(f"[DUCKDB] ⚠️ PK removal migration for {_opt_tbl_mig}: {_pk_err}", flush=True)
28642
28733
  conn.execute(f"CREATE TABLE IF NOT EXISTS options_eod_close {_OPTIONS_EOD_SCHEMA}")
28643
-
28734
+
28644
28735
  # OPTIONS_EOD_1545 - 15:45 snapshot options (same schema, isolated table)
28645
- # Migration: recreate if table exists but lacks PRIMARY KEY (created by older code)
28646
- try:
28647
- _1545_exists = any(t[0] == 'options_eod_1545' for t in conn.execute("SHOW TABLES").fetchall())
28648
- if _1545_exists:
28649
- _1545_constraints = conn.execute(
28650
- "SELECT constraint_type FROM information_schema.table_constraints "
28651
- "WHERE table_name = 'options_eod_1545' AND constraint_type = 'PRIMARY KEY'"
28652
- ).fetchall()
28653
- if not _1545_constraints:
28654
- _1545_rows = conn.execute("SELECT COUNT(*) FROM options_eod_1545").fetchone()[0]
28655
- conn.execute("DROP TABLE options_eod_1545")
28656
- print(f"[DUCKDB] 🔄 Recreating options_eod_1545 with PRIMARY KEY (had {_1545_rows} rows, no PK)", flush=True)
28657
- except Exception as _pk_err:
28658
- print(f"[DUCKDB] ⚠️ PK migration check for options_eod_1545: {_pk_err}", flush=True)
28659
28736
  conn.execute(f"CREATE TABLE IF NOT EXISTS options_eod_1545 {_OPTIONS_EOD_SCHEMA}")
28660
28737
 
28661
28738
  # OPTIONS_INTRADAY - Minute-level options data
@@ -28820,6 +28897,37 @@ def _get_target_table(endpoint: str) -> str:
28820
28897
  return None
28821
28898
 
28822
28899
 
28900
+ _ANTIJOIN_DEDUP_TABLES = ('options_eod_close', 'options_eod_1545')
28901
+
28902
+
28903
+ def _duckdb_insert_dedup(conn, table_name, df_normalized):
28904
+ """Insert df_normalized skipping existing keys.
28905
+
28906
+ options_eod_* have no PK (see _OPTIONS_EOD_SCHEMA): dedup = anti-join on
28907
+ (option_symbol, date) scoped to the batch's date range; in-batch duplicates
28908
+ collapse via QUALIFY. Other tables keep PK + INSERT OR IGNORE."""
28909
+ if table_name in _ANTIJOIN_DEDUP_TABLES:
28910
+ _dmin = df_normalized['date'].min()
28911
+ _dmax = df_normalized['date'].max()
28912
+ conn.execute(f"""
28913
+ INSERT INTO {table_name}
28914
+ SELECT * FROM (
28915
+ SELECT * FROM df_normalized
28916
+ QUALIFY ROW_NUMBER() OVER (PARTITION BY option_symbol, date) = 1
28917
+ ) d
28918
+ WHERE NOT EXISTS (
28919
+ SELECT 1 FROM {table_name} t
28920
+ WHERE t.date >= ? AND t.date <= ?
28921
+ AND t.option_symbol = d.option_symbol AND t.date = d.date
28922
+ )
28923
+ """, [_dmin, _dmax])
28924
+ else:
28925
+ conn.execute(f"""
28926
+ INSERT OR IGNORE INTO {table_name}
28927
+ SELECT * FROM df_normalized
28928
+ """)
28929
+
28930
+
28823
28931
  def _save_to_duckdb_storage(df, endpoint: str, cache_config: Dict[str, Any], debug: bool = False):
28824
28932
  """
28825
28933
  Save DataFrame to DuckDB storage with deduplication.
@@ -28922,10 +29030,15 @@ def _save_to_duckdb_storage(df, endpoint: str, cache_config: Dict[str, Any], deb
28922
29030
  count_before = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]
28923
29031
 
28924
29032
  # Insert with conflict handling (skip duplicates)
28925
- conn.execute(f"""
28926
- INSERT OR IGNORE INTO {table_name}
28927
- SELECT * FROM df_normalized
28928
- """)
29033
+ try:
29034
+ _duckdb_insert_dedup(conn, table_name, df_normalized)
29035
+ except Exception:
29036
+ # CHECKPOINT frees dirty buffer pages; retry once before failing loud
29037
+ try:
29038
+ conn.execute("CHECKPOINT")
29039
+ except Exception:
29040
+ pass
29041
+ _duckdb_insert_dedup(conn, table_name, df_normalized)
28929
29042
 
28930
29043
  # Get count after insert
28931
29044
  count_after = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]
@@ -28984,10 +29097,14 @@ def _save_to_duckdb_storage_batched(df, endpoint: str, cache_config: Dict[str, A
28984
29097
  batch_normalized = _normalize_for_duckdb_storage(batch, endpoint, table_name)
28985
29098
 
28986
29099
  # Insert batch
28987
- conn.execute(f"""
28988
- INSERT OR IGNORE INTO {table_name}
28989
- SELECT * FROM batch_normalized
28990
- """)
29100
+ try:
29101
+ _duckdb_insert_dedup(conn, table_name, batch_normalized)
29102
+ except Exception:
29103
+ try:
29104
+ conn.execute("CHECKPOINT")
29105
+ except Exception:
29106
+ pass
29107
+ _duckdb_insert_dedup(conn, table_name, batch_normalized)
28991
29108
 
28992
29109
  # Progress every 5 batches
28993
29110
  if debug and (i // batch_size) % 5 == 0:
@@ -31287,21 +31404,29 @@ class MarketDataManager:
31287
31404
  completed = 0
31288
31405
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as pool:
31289
31406
  futures = {pool.submit(_fetch, req): req for req in requests_list}
31290
- for fut in as_completed(futures):
31291
- completed += 1
31292
- try:
31293
- ep, df = fut.result(timeout=120)
31294
- except Exception:
31295
- ep = futures[fut][0]
31296
- df = None
31297
- if df is not None and not df.empty:
31298
- if 'symbol' not in df.columns:
31299
- df['symbol'] = symbol
31300
- df = self._normalizer.normalize(df, ep)
31301
- collected[ep].append(df)
31302
- self._cache_write(ep, df, cache_key=None)
31303
- if completed % max(1, total // 5) == 0 or completed == total:
31304
- _safe_print(f"[MDM] progress: {completed}/{total}")
31407
+ try:
31408
+ # a hung HTTP call blocks as_completed forever without this
31409
+ for fut in as_completed(futures, timeout=1800):
31410
+ completed += 1
31411
+ try:
31412
+ ep, df = fut.result(timeout=120)
31413
+ except Exception:
31414
+ ep = futures[fut][0]
31415
+ df = None
31416
+ if df is not None and not df.empty:
31417
+ if 'symbol' not in df.columns:
31418
+ df['symbol'] = symbol
31419
+ df = self._normalizer.normalize(df, ep)
31420
+ collected[ep].append(df)
31421
+ self._cache_write(ep, df, cache_key=None)
31422
+ if completed % max(1, total // 5) == 0 or completed == total:
31423
+ _safe_print(f"[MDM] progress: {completed}/{total}")
31424
+ except (TimeoutError, _FuturesTimeoutError):
31425
+ _safe_print(f"[MDM] ❌ [TIMEOUT] as_completed timed out after 1800s — "
31426
+ f"dropping hung requests")
31427
+ for fut in futures:
31428
+ if not fut.done():
31429
+ fut.cancel()
31305
31430
  else:
31306
31431
  for req in requests_list:
31307
31432
  ep, df = _fetch(req)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.143
3
+ Version: 2.145
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
@@ -15,8 +15,8 @@ Classifier: License :: OSI Approved :: MIT License
15
15
  Classifier: Operating System :: OS Independent
16
16
  Requires-Python: >=3.8
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: pandas>=1.5.0
19
- Requires-Dist: numpy>=1.21.0
18
+ Requires-Dist: pandas<3.0,>=1.5.0
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
22
  Requires-Dist: ivolatility>=1.8.2
@@ -8,4 +8,5 @@ ivolatility_backtesting.egg-info/SOURCES.txt
8
8
  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
- tests/test_2142_fixes.py
11
+ tests/test_2142_fixes.py
12
+ tests/test_2144_duckdb_dedup.py
@@ -1,5 +1,5 @@
1
- pandas>=1.5.0
2
- numpy>=1.21.0
1
+ pandas<3.0,>=1.5.0
2
+ numpy<3.0,>=1.21.0
3
3
  matplotlib>=3.5.0
4
4
  seaborn>=0.11.0
5
5
  ivolatility>=1.8.2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ivolatility_backtesting"
7
- version = "2.143"
7
+ version = "2.145"
8
8
  description = "A universal backtesting framework for financial strategies using the IVolatility API."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -13,8 +13,8 @@ authors = [
13
13
  license = { file = "LICENSE" }
14
14
  requires-python = ">=3.8"
15
15
  dependencies = [
16
- "pandas>=1.5.0",
17
- "numpy>=1.21.0",
16
+ "pandas>=1.5.0,<3.0",
17
+ "numpy>=1.21.0,<3.0",
18
18
  "matplotlib>=3.5.0",
19
19
  "seaborn>=0.11.0",
20
20
  "ivolatility>=1.8.2",
@@ -0,0 +1,191 @@
1
+ # Tests for 2.144: options_eod_* without PK — anti-join dedup in
2
+ # _duckdb_insert_dedup() + PK-removal migration of legacy caches.
3
+ #
4
+ # Runs standalone (no pytest needed): python3 tests/test_2144_duckdb_dedup.py
5
+ # Also pytest-compatible for CI: pytest tests/test_2144_duckdb_dedup.py
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import traceback
10
+ from datetime import date
11
+
12
+ import duckdb
13
+ import matplotlib
14
+ matplotlib.use('Agg')
15
+ import pandas as pd
16
+
17
+ _REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
+ sys.path.insert(0, os.path.join(_REPO, 'ivolatility_backtesting'))
19
+ import ivolatility_backtesting as lib # noqa: E402
20
+
21
+
22
+ def _tbl_has_pk(con, tbl):
23
+ return bool(con.execute(
24
+ "SELECT 1 FROM information_schema.table_constraints "
25
+ "WHERE table_name = ? AND constraint_type = 'PRIMARY KEY'", [tbl]).fetchall())
26
+
27
+
28
+ def _opts_row(option_symbol, d):
29
+ return {
30
+ 'option_symbol': option_symbol, 'date': d, 'symbol': 'SPX',
31
+ 'expiration': date(2026, 3, 20), 'strike': 5000.0, 'type': 'C',
32
+ 'bid': 1.0, 'ask': 2.0, 'price': 1.5, 'underlying_price': 5100.0,
33
+ 'iv': 0.2, 'delta': 0.5, 'gamma': 0.01, 'theta': -0.5, 'vega': 3.0,
34
+ 'rho': 0.1, 'dte': 74.0, 'volume': 10.0, 'open_interest': 100.0,
35
+ 'source_endpoint': 'test', 'bid_eod': 1.0, 'ask_eod': 2.0,
36
+ }
37
+
38
+
39
+ def _opts_df(rows):
40
+ return pd.DataFrame([_opts_row(sym, d) for sym, d in rows])
41
+
42
+
43
+ def _count(con, tbl='options_eod_1545'):
44
+ return con.execute(f'SELECT COUNT(*) FROM {tbl}').fetchone()[0]
45
+
46
+
47
+ # ---------------------------------------------------------------- schema
48
+
49
+ def test_fresh_options_tables_have_no_pk_and_stock_keeps_pk():
50
+ with tempfile.TemporaryDirectory() as td:
51
+ con = duckdb.connect(os.path.join(td, 't.duckdb'))
52
+ lib._init_duckdb_storage_tables(con)
53
+ assert not _tbl_has_pk(con, 'options_eod_close')
54
+ assert not _tbl_has_pk(con, 'options_eod_1545')
55
+ assert _tbl_has_pk(con, 'stock_eod')
56
+
57
+
58
+ def test_pk_migration_preserves_rows():
59
+ with tempfile.TemporaryDirectory() as td:
60
+ con = duckdb.connect(os.path.join(td, 't.duckdb'))
61
+ con.execute("CREATE TABLE options_eod_1545 (option_symbol VARCHAR, date DATE, "
62
+ "bid DOUBLE, PRIMARY KEY (option_symbol, date))")
63
+ con.execute("INSERT INTO options_eod_1545 VALUES ('A', '2026-01-05', 1.0), "
64
+ "('B', '2026-01-06', 2.0)")
65
+ lib._init_duckdb_storage_tables(con)
66
+ assert not _tbl_has_pk(con, 'options_eod_1545')
67
+ assert _count(con) == 2
68
+ # columns of the migrated table survive as-is (additive drift handled elsewhere)
69
+ cols = [r[0] for r in con.execute("DESCRIBE options_eod_1545").fetchall()]
70
+ assert cols == ['option_symbol', 'date', 'bid']
71
+
72
+
73
+ def test_pk_migration_drops_stale_nopk_leftover():
74
+ with tempfile.TemporaryDirectory() as td:
75
+ con = duckdb.connect(os.path.join(td, 't.duckdb'))
76
+ con.execute("CREATE TABLE options_eod_close__nopk (x INTEGER)")
77
+ con.execute("CREATE TABLE options_eod_close (option_symbol VARCHAR, date DATE, "
78
+ "PRIMARY KEY (option_symbol, date))")
79
+ con.execute("INSERT INTO options_eod_close VALUES ('A', '2026-01-05')")
80
+ lib._init_duckdb_storage_tables(con)
81
+ assert not _tbl_has_pk(con, 'options_eod_close')
82
+ assert _count(con, 'options_eod_close') == 1
83
+ tables = [t[0] for t in con.execute('SHOW TABLES').fetchall()]
84
+ assert 'options_eod_close__nopk' not in tables
85
+
86
+
87
+ def test_init_idempotent_second_run():
88
+ with tempfile.TemporaryDirectory() as td:
89
+ con = duckdb.connect(os.path.join(td, 't.duckdb'))
90
+ lib._init_duckdb_storage_tables(con)
91
+ df = _opts_df([('SPX_C5000_1', date(2026, 1, 5))])
92
+ lib._duckdb_insert_dedup(con, 'options_eod_1545', df)
93
+ lib._init_duckdb_storage_tables(con)
94
+ assert _count(con) == 1
95
+
96
+
97
+ # ---------------------------------------------------------------- dedup
98
+
99
+ def test_reinsert_same_batch_adds_zero():
100
+ with tempfile.TemporaryDirectory() as td:
101
+ con = duckdb.connect(os.path.join(td, 't.duckdb'))
102
+ lib._init_duckdb_storage_tables(con)
103
+ df = _opts_df([('SPX_C5000_1', date(2026, 1, 5)),
104
+ ('SPX_C5000_2', date(2026, 1, 5)),
105
+ ('SPX_C5000_1', date(2026, 1, 6))])
106
+ lib._duckdb_insert_dedup(con, 'options_eod_1545', df)
107
+ assert _count(con) == 3
108
+ lib._duckdb_insert_dedup(con, 'options_eod_1545', df)
109
+ assert _count(con) == 3
110
+
111
+
112
+ def test_partial_overlap_inserts_only_new_keys():
113
+ with tempfile.TemporaryDirectory() as td:
114
+ con = duckdb.connect(os.path.join(td, 't.duckdb'))
115
+ lib._init_duckdb_storage_tables(con)
116
+ lib._duckdb_insert_dedup(con, 'options_eod_1545',
117
+ _opts_df([('A', date(2026, 1, 5))]))
118
+ df2 = _opts_df([('A', date(2026, 1, 5)), # duplicate key
119
+ ('B', date(2026, 1, 5)), # new
120
+ ('A', date(2026, 1, 6))]) # same symbol, new date
121
+ lib._duckdb_insert_dedup(con, 'options_eod_1545', df2)
122
+ assert _count(con) == 3
123
+ rows = con.execute("SELECT option_symbol, date FROM options_eod_1545 "
124
+ "ORDER BY option_symbol, date").fetchall()
125
+ assert rows == [('A', date(2026, 1, 5)), ('A', date(2026, 1, 6)),
126
+ ('B', date(2026, 1, 5))]
127
+
128
+
129
+ def test_inbatch_duplicate_key_collapsed_to_one():
130
+ with tempfile.TemporaryDirectory() as td:
131
+ con = duckdb.connect(os.path.join(td, 't.duckdb'))
132
+ lib._init_duckdb_storage_tables(con)
133
+ df = _opts_df([('A', date(2026, 1, 5)), ('A', date(2026, 1, 5))])
134
+ lib._duckdb_insert_dedup(con, 'options_eod_1545', df)
135
+ assert _count(con) == 1
136
+
137
+
138
+ def test_non_options_table_keeps_or_ignore_path():
139
+ with tempfile.TemporaryDirectory() as td:
140
+ con = duckdb.connect(os.path.join(td, 't.duckdb'))
141
+ lib._init_duckdb_storage_tables(con)
142
+ cols = [r[0] for r in con.execute('DESCRIBE stock_eod').fetchall()]
143
+ row = {c: None for c in cols}
144
+ row.update({'symbol': 'SPX', 'date': date(2026, 1, 5)})
145
+ df_stock = pd.DataFrame([row])
146
+ lib._duckdb_insert_dedup(con, 'stock_eod', df_stock)
147
+ lib._duckdb_insert_dedup(con, 'stock_eod', df_stock)
148
+ assert _count(con, 'stock_eod') == 1
149
+
150
+
151
+ # ---------------------------------------------------------------- timeouts
152
+
153
+ def test_futures_timeout_caught_by_except_tuple():
154
+ # every as_completed() in the lib has a global timeout caught as
155
+ # (TimeoutError, _FuturesTimeoutError) — verify the tuple catches the
156
+ # class as_completed actually raises on this interpreter
157
+ import time as _t
158
+ from concurrent.futures import ThreadPoolExecutor, as_completed
159
+ caught = False
160
+ with ThreadPoolExecutor(max_workers=1) as ex:
161
+ f = ex.submit(_t.sleep, 3)
162
+ try:
163
+ for _ in as_completed({f: 1}, timeout=0.3):
164
+ pass
165
+ except (TimeoutError, lib._FuturesTimeoutError):
166
+ caught = True
167
+ f.cancel()
168
+ assert caught
169
+
170
+
171
+ # ---------------------------------------------------------------- runner
172
+
173
+ def main():
174
+ tests = [(n, f) for n, f in sorted(globals().items())
175
+ if n.startswith('test_') and callable(f)]
176
+ passed = failed = 0
177
+ for name, fn in tests:
178
+ try:
179
+ fn()
180
+ print(f' ✅ {name}')
181
+ passed += 1
182
+ except Exception:
183
+ print(f' ❌ {name}')
184
+ traceback.print_exc()
185
+ failed += 1
186
+ print(f'\n{passed} passed, {failed} failed of {len(tests)}')
187
+ return 1 if failed else 0
188
+
189
+
190
+ if __name__ == '__main__':
191
+ sys.exit(main())