ivolatility-backtesting 2.142__tar.gz → 2.144__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.142
3
+ Version: 2.144
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
@@ -2019,6 +2019,8 @@ def _enable_api_logging(log_file='logs/ivolatility_api.log', level=2):
2019
2019
  # Uses ThreadPoolExecutor for parallel api_call (preserves caching!)
2020
2020
  # For I/O bound tasks (HTTP), threads work well despite GIL
2021
2021
  from concurrent.futures import ThreadPoolExecutor, as_completed
2022
+ # On py<3.11 futures raise their own TimeoutError, not the builtin
2023
+ from concurrent.futures import TimeoutError as _FuturesTimeoutError
2022
2024
  _PARALLEL_AVAILABLE = True
2023
2025
 
2024
2026
  # ============================================================
@@ -18471,6 +18473,7 @@ def _fetch_date_range_data(config, cache_config, symbol, start_date, end_date, d
18471
18473
  rows_streamed = 0
18472
18474
  failed_chunks = [] # 2.142: (start, end, cp, error) of every dropped chunk
18473
18475
  _gap_save_ep = _get_options_endpoints(_get_options_snapshot_mode(config))['filtered']
18476
+ _GAP_GLOBAL_TIMEOUT = 1800 # same safety net as the main parallel loader
18474
18477
 
18475
18478
  def _stream_one(df):
18476
18479
  nonlocal rows_streamed, total_rows
@@ -18486,23 +18489,37 @@ def _fetch_date_range_data(config, cache_config, symbol, start_date, end_date, d
18486
18489
  completed = 0
18487
18490
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as executor:
18488
18491
  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:
18492
+
18493
+ try:
18494
+ # Without the timeout a single hung HTTP call blocks as_completed
18495
+ # forever — result(timeout) never gets a chance to fire.
18496
+ for future in as_completed(futures, timeout=_GAP_GLOBAL_TIMEOUT):
18497
+ completed += 1
18498
+ try:
18499
+ df = future.result(timeout=120)
18500
+ except Exception:
18501
+ df = None
18502
+
18503
+ _stream_one(df)
18504
+ try:
18505
+ future._result = None
18506
+ except Exception:
18507
+ pass
18495
18508
  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")
18509
+
18510
+ if debuginfo >= 1 and (completed % 10 == 0 or completed == total_requests):
18511
+ _safe_print(f" ⚡ Progress: {completed}/{total_requests}, {total_rows:,} rows streamed")
18512
+ except (TimeoutError, _FuturesTimeoutError):
18513
+ _pending = [i + 1 for f, i in futures.items() if not f.done()]
18514
+ _safe_print(f" ❌ [GAP TIMEOUT] as_completed timed out after "
18515
+ f"{_GAP_GLOBAL_TIMEOUT}s dropping pending chunks {_pending}")
18516
+ for f, i in futures.items():
18517
+ if not f.done():
18518
+ f.cancel()
18519
+ _p = all_requests[i]
18520
+ failed_chunks.append((_p.get('from_', _p.get('startDate', '?')),
18521
+ _p.get('to', _p.get('endDate', '?')),
18522
+ _p.get('cp', '?'), 'gap global timeout'))
18506
18523
  else:
18507
18524
  for i, params in enumerate(all_requests):
18508
18525
  df = fetch_chunk((params, i + 1))
@@ -19832,8 +19849,30 @@ def _preload_duckdb_SSD_storage(config, cache_config):
19832
19849
 
19833
19850
  # Get connection for ChunkManager
19834
19851
  conn = _get_duckdb_storage_conn(cache_config)
19835
-
19852
+
19836
19853
  _opt_tbl = _get_options_eod_table(config)
19854
+
19855
+ # Cold-path completeness: every backtest trading day must exist in
19856
+ # the options table; misses are loud + flag _failed_chunks
19857
+ try:
19858
+ _cc_df = conn.execute(
19859
+ f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
19860
+ ).fetchdf()
19861
+ _cc_have = set(pd.to_datetime(_cc_df['date']).dt.strftime('%Y-%m-%d'))
19862
+ _cc_need = {pd.Timestamp(d).strftime('%Y-%m-%d') for d in backtest_trading_days}
19863
+ _cc_missing = sorted(_cc_need - _cc_have)
19864
+ except Exception as _ce:
19865
+ _cc_missing = None
19866
+ print(f" ⚠️ Completeness check could not run: {_ce}")
19867
+ if _cc_missing:
19868
+ print(f" 🕳️ COMPLETENESS: {len(_cc_missing)} trading day(s) missing "
19869
+ f"in options after initial load: {_cc_missing[:10]}"
19870
+ + (' …' if len(_cc_missing) > 10 else ''))
19871
+ config.setdefault('_failed_chunks', []).extend(
19872
+ ('cold-completeness', d, d, '*') for d in _cc_missing)
19873
+ elif _cc_missing is not None:
19874
+ print(f" ✅ COMPLETENESS: options cover all {len(_cc_need)} backtest trading days")
19875
+
19837
19876
  chunk_mgr = OptionsChunkManager(
19838
19877
  db_path=db_path,
19839
19878
  symbol=symbol,
@@ -20219,7 +20258,7 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
20219
20258
  _bt_logger.info(f"[DIAG] MAIN LOOP future ready completed={completed}/{total_requests} chunk_id={_chunk_id}")
20220
20259
  try:
20221
20260
  df = future.result(timeout=120)
20222
- except TimeoutError:
20261
+ except (TimeoutError, _FuturesTimeoutError):
20223
20262
  _safe_print(f" ⏱️ [TIMEOUT] future.result() timed out after 120s (chunk {completed}/{total_requests})")
20224
20263
  _bt_logger.info(f"[DIAG] FUTURE TIMEOUT chunk={_chunk_id} completed={completed}/{total_requests}")
20225
20264
  df = None
@@ -20310,7 +20349,7 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
20310
20349
  if completed % 10 == 0 or completed == total_requests:
20311
20350
  _wall = _time_mod.time() - _parallel_start
20312
20351
  _safe_print(f" ⚡ Progress: {completed}/{total_requests} requests, {total_rows:,} rows, wall={_wall:.1f}s")
20313
- except TimeoutError:
20352
+ except (TimeoutError, _FuturesTimeoutError):
20314
20353
  _wall = _time_mod.time() - _parallel_start
20315
20354
  _pending = [cid for f, cid in futures.items() if not f.done()]
20316
20355
  _safe_print(f" ❌ [GLOBAL TIMEOUT] as_completed timed out after {_PARALLEL_GLOBAL_TIMEOUT}s (wall={_wall:.1f}s)")
@@ -21788,10 +21827,18 @@ def _load_futures_prices_to_duckdb(config, cache_config, symbol,
21788
21827
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as exe:
21789
21828
  futures = {exe.submit(_fetch, cs, ce, i + 1): (cs, ce)
21790
21829
  for i, (cs, ce) in enumerate(chunks)}
21791
- for f in as_completed(futures):
21792
- part = f.result()
21793
- if part is not None and not part.empty:
21794
- parts.append(part)
21830
+ try:
21831
+ # a hung HTTP call blocks as_completed forever without this
21832
+ for f in as_completed(futures, timeout=1800):
21833
+ part = f.result()
21834
+ if part is not None and not part.empty:
21835
+ parts.append(part)
21836
+ except (TimeoutError, _FuturesTimeoutError):
21837
+ _hung = [rng for f, rng in futures.items() if not f.done()]
21838
+ print(f" ❌ [TIMEOUT] futures_eod load: dropping hung chunks {_hung}")
21839
+ for f in futures:
21840
+ if not f.done():
21841
+ f.cancel()
21795
21842
  else:
21796
21843
  for i, (cs, ce) in enumerate(chunks):
21797
21844
  part = _fetch(cs, ce, i + 1)
@@ -22320,24 +22367,34 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
22320
22367
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as exe:
22321
22368
  futs = {exe.submit(_fetch, p, i + 1): p
22322
22369
  for i, p in enumerate(all_requests)}
22323
- for f in as_completed(futs):
22324
- completed += 1
22325
- _fdf = f.result()
22326
- _save_chunk(_fdf)
22327
- if _fdf is not None and not getattr(_fdf, 'empty', True):
22370
+ try:
22371
+ # a hung HTTP call blocks as_completed forever without this
22372
+ for f in as_completed(futs, timeout=1800):
22373
+ completed += 1
22374
+ _fdf = f.result()
22375
+ _save_chunk(_fdf)
22376
+ if _fdf is not None and not getattr(_fdf, 'empty', True):
22377
+ try:
22378
+ _mf_fut_sem.release()
22379
+ except Exception:
22380
+ pass
22328
22381
  try:
22329
- _mf_fut_sem.release()
22382
+ f._result = None
22330
22383
  except Exception:
22331
22384
  pass
22332
- try:
22333
- f._result = None
22334
- except Exception:
22335
- pass
22336
- _fdf = None
22337
- _maybe_checkpoint()
22338
- if completed % 20 == 0 or completed == total:
22339
- print(f" {completed}/{total} requests, "
22340
- f"{rows_saved_total:,} rows so far")
22385
+ _fdf = None
22386
+ _maybe_checkpoint()
22387
+ if completed % 20 == 0 or completed == total:
22388
+ print(f" ⚡ {completed}/{total} requests, "
22389
+ f"{rows_saved_total:,} rows so far")
22390
+ except (TimeoutError, _FuturesTimeoutError):
22391
+ print(f" ❌ [TIMEOUT] futures-options load: as_completed "
22392
+ f"timed out after 1800s — dropping hung chunks")
22393
+ for f, p in futs.items():
22394
+ if not f.done():
22395
+ f.cancel()
22396
+ failed_chunks.append((p.get('from_', '?'), p.get('to', '?'),
22397
+ p.get('cp', '?'), 'global timeout'))
22341
22398
  else:
22342
22399
  for i, params in enumerate(all_requests, 1):
22343
22400
  completed = i
@@ -23736,23 +23793,34 @@ def preload_data_universal(config, data_requests=None, debug=False):
23736
23793
 
23737
23794
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as executor:
23738
23795
  future_to_idx = {
23739
- executor.submit(fetch_chunk, req_data): i
23796
+ executor.submit(fetch_chunk, req_data): i
23740
23797
  for i, req_data in enumerate(all_request_data)
23741
23798
  }
23742
-
23743
- for future in as_completed(future_to_idx):
23744
- idx = future_to_idx[future]
23745
- completed += 1
23746
-
23747
- if completed % max(1, total_requests // 5) == 0 or completed == total_requests:
23748
- _safe_print(f" ⚡ Progress: {completed}/{total_requests}...")
23749
-
23750
- try:
23751
- result = future.result(timeout=120)
23752
- results.append((idx, result))
23753
- except Exception as e:
23754
- req_data = all_request_data[idx]
23755
- results.append((idx, (None, req_data[1], req_data[2], req_data[3], e)))
23799
+
23800
+ try:
23801
+ # a hung HTTP call blocks as_completed forever without this
23802
+ for future in as_completed(future_to_idx, timeout=1800):
23803
+ idx = future_to_idx[future]
23804
+ completed += 1
23805
+
23806
+ if completed % max(1, total_requests // 5) == 0 or completed == total_requests:
23807
+ _safe_print(f" ⚡ Progress: {completed}/{total_requests}...")
23808
+
23809
+ try:
23810
+ result = future.result(timeout=120)
23811
+ results.append((idx, result))
23812
+ except Exception as e:
23813
+ req_data = all_request_data[idx]
23814
+ results.append((idx, (None, req_data[1], req_data[2], req_data[3], e)))
23815
+ except (TimeoutError, _FuturesTimeoutError):
23816
+ _safe_print(f" ❌ [TIMEOUT] as_completed timed out after 1800s — "
23817
+ f"dropping hung chunks")
23818
+ for future, idx in future_to_idx.items():
23819
+ if not future.done():
23820
+ future.cancel()
23821
+ req_data = all_request_data[idx]
23822
+ results.append((idx, (None, req_data[1], req_data[2], req_data[3],
23823
+ TimeoutError('global timeout'))))
23756
23824
 
23757
23825
  # Sort by original index to maintain order
23758
23826
  results.sort(key=lambda x: x[0])
@@ -28592,6 +28660,8 @@ def _init_duckdb_storage_tables(conn):
28592
28660
  print(f"[DUCKDB] ⚠️ Legacy migration warning: {_mig_err}", flush=True)
28593
28661
 
28594
28662
  # OPTIONS_EOD_CLOSE - EOD options from stock-opts-by-param and options-rawiv (close prices)
28663
+ # No PK: a PK's ART index is RAM-resident in DuckDB and OOMs small containers on
28664
+ # multi-million-row tables; dedup lives in _duckdb_insert_dedup(). Do not re-add.
28595
28665
  _OPTIONS_EOD_SCHEMA = """(
28596
28666
  option_symbol VARCHAR,
28597
28667
  date DATE,
@@ -28614,26 +28684,32 @@ def _init_duckdb_storage_tables(conn):
28614
28684
  open_interest DOUBLE,
28615
28685
  source_endpoint VARCHAR,
28616
28686
  bid_eod DOUBLE,
28617
- ask_eod DOUBLE,
28618
- PRIMARY KEY (option_symbol, date)
28687
+ ask_eod DOUBLE
28619
28688
  )"""
28689
+ # Rebuild legacy caches that still carry the PK, preserving rows
28690
+ for _opt_tbl_mig in ('options_eod_close', 'options_eod_1545'):
28691
+ try:
28692
+ _existing_tbls = [t[0] for t in conn.execute("SHOW TABLES").fetchall()]
28693
+ if f'{_opt_tbl_mig}__nopk' in _existing_tbls:
28694
+ conn.execute(f"DROP TABLE {_opt_tbl_mig}__nopk")
28695
+ if _opt_tbl_mig not in _existing_tbls:
28696
+ continue
28697
+ _has_pk = conn.execute(
28698
+ "SELECT 1 FROM information_schema.table_constraints "
28699
+ "WHERE table_name = ? AND constraint_type = 'PRIMARY KEY'",
28700
+ [_opt_tbl_mig]).fetchall()
28701
+ if _has_pk:
28702
+ _mig_rows = conn.execute(f"SELECT COUNT(*) FROM {_opt_tbl_mig}").fetchone()[0]
28703
+ conn.execute(f"CREATE TABLE {_opt_tbl_mig}__nopk AS SELECT * FROM {_opt_tbl_mig}")
28704
+ conn.execute(f"DROP TABLE {_opt_tbl_mig}")
28705
+ conn.execute(f"ALTER TABLE {_opt_tbl_mig}__nopk RENAME TO {_opt_tbl_mig}")
28706
+ print(f"[DUCKDB] 🔄 Rebuilt {_opt_tbl_mig} without PRIMARY KEY "
28707
+ f"({_mig_rows:,} rows preserved)", flush=True)
28708
+ except Exception as _pk_err:
28709
+ print(f"[DUCKDB] ⚠️ PK removal migration for {_opt_tbl_mig}: {_pk_err}", flush=True)
28620
28710
  conn.execute(f"CREATE TABLE IF NOT EXISTS options_eod_close {_OPTIONS_EOD_SCHEMA}")
28621
-
28711
+
28622
28712
  # OPTIONS_EOD_1545 - 15:45 snapshot options (same schema, isolated table)
28623
- # Migration: recreate if table exists but lacks PRIMARY KEY (created by older code)
28624
- try:
28625
- _1545_exists = any(t[0] == 'options_eod_1545' for t in conn.execute("SHOW TABLES").fetchall())
28626
- if _1545_exists:
28627
- _1545_constraints = conn.execute(
28628
- "SELECT constraint_type FROM information_schema.table_constraints "
28629
- "WHERE table_name = 'options_eod_1545' AND constraint_type = 'PRIMARY KEY'"
28630
- ).fetchall()
28631
- if not _1545_constraints:
28632
- _1545_rows = conn.execute("SELECT COUNT(*) FROM options_eod_1545").fetchone()[0]
28633
- conn.execute("DROP TABLE options_eod_1545")
28634
- print(f"[DUCKDB] 🔄 Recreating options_eod_1545 with PRIMARY KEY (had {_1545_rows} rows, no PK)", flush=True)
28635
- except Exception as _pk_err:
28636
- print(f"[DUCKDB] ⚠️ PK migration check for options_eod_1545: {_pk_err}", flush=True)
28637
28713
  conn.execute(f"CREATE TABLE IF NOT EXISTS options_eod_1545 {_OPTIONS_EOD_SCHEMA}")
28638
28714
 
28639
28715
  # OPTIONS_INTRADAY - Minute-level options data
@@ -28798,6 +28874,37 @@ def _get_target_table(endpoint: str) -> str:
28798
28874
  return None
28799
28875
 
28800
28876
 
28877
+ _ANTIJOIN_DEDUP_TABLES = ('options_eod_close', 'options_eod_1545')
28878
+
28879
+
28880
+ def _duckdb_insert_dedup(conn, table_name, df_normalized):
28881
+ """Insert df_normalized skipping existing keys.
28882
+
28883
+ options_eod_* have no PK (see _OPTIONS_EOD_SCHEMA): dedup = anti-join on
28884
+ (option_symbol, date) scoped to the batch's date range; in-batch duplicates
28885
+ collapse via QUALIFY. Other tables keep PK + INSERT OR IGNORE."""
28886
+ if table_name in _ANTIJOIN_DEDUP_TABLES:
28887
+ _dmin = df_normalized['date'].min()
28888
+ _dmax = df_normalized['date'].max()
28889
+ conn.execute(f"""
28890
+ INSERT INTO {table_name}
28891
+ SELECT * FROM (
28892
+ SELECT * FROM df_normalized
28893
+ QUALIFY ROW_NUMBER() OVER (PARTITION BY option_symbol, date) = 1
28894
+ ) d
28895
+ WHERE NOT EXISTS (
28896
+ SELECT 1 FROM {table_name} t
28897
+ WHERE t.date >= ? AND t.date <= ?
28898
+ AND t.option_symbol = d.option_symbol AND t.date = d.date
28899
+ )
28900
+ """, [_dmin, _dmax])
28901
+ else:
28902
+ conn.execute(f"""
28903
+ INSERT OR IGNORE INTO {table_name}
28904
+ SELECT * FROM df_normalized
28905
+ """)
28906
+
28907
+
28801
28908
  def _save_to_duckdb_storage(df, endpoint: str, cache_config: Dict[str, Any], debug: bool = False):
28802
28909
  """
28803
28910
  Save DataFrame to DuckDB storage with deduplication.
@@ -28900,10 +29007,15 @@ def _save_to_duckdb_storage(df, endpoint: str, cache_config: Dict[str, Any], deb
28900
29007
  count_before = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]
28901
29008
 
28902
29009
  # Insert with conflict handling (skip duplicates)
28903
- conn.execute(f"""
28904
- INSERT OR IGNORE INTO {table_name}
28905
- SELECT * FROM df_normalized
28906
- """)
29010
+ try:
29011
+ _duckdb_insert_dedup(conn, table_name, df_normalized)
29012
+ except Exception:
29013
+ # CHECKPOINT frees dirty buffer pages; retry once before failing loud
29014
+ try:
29015
+ conn.execute("CHECKPOINT")
29016
+ except Exception:
29017
+ pass
29018
+ _duckdb_insert_dedup(conn, table_name, df_normalized)
28907
29019
 
28908
29020
  # Get count after insert
28909
29021
  count_after = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]
@@ -28962,10 +29074,14 @@ def _save_to_duckdb_storage_batched(df, endpoint: str, cache_config: Dict[str, A
28962
29074
  batch_normalized = _normalize_for_duckdb_storage(batch, endpoint, table_name)
28963
29075
 
28964
29076
  # Insert batch
28965
- conn.execute(f"""
28966
- INSERT OR IGNORE INTO {table_name}
28967
- SELECT * FROM batch_normalized
28968
- """)
29077
+ try:
29078
+ _duckdb_insert_dedup(conn, table_name, batch_normalized)
29079
+ except Exception:
29080
+ try:
29081
+ conn.execute("CHECKPOINT")
29082
+ except Exception:
29083
+ pass
29084
+ _duckdb_insert_dedup(conn, table_name, batch_normalized)
28969
29085
 
28970
29086
  # Progress every 5 batches
28971
29087
  if debug and (i // batch_size) % 5 == 0:
@@ -31265,21 +31381,29 @@ class MarketDataManager:
31265
31381
  completed = 0
31266
31382
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as pool:
31267
31383
  futures = {pool.submit(_fetch, req): req for req in requests_list}
31268
- for fut in as_completed(futures):
31269
- completed += 1
31270
- try:
31271
- ep, df = fut.result(timeout=120)
31272
- except Exception:
31273
- ep = futures[fut][0]
31274
- df = None
31275
- if df is not None and not df.empty:
31276
- if 'symbol' not in df.columns:
31277
- df['symbol'] = symbol
31278
- df = self._normalizer.normalize(df, ep)
31279
- collected[ep].append(df)
31280
- self._cache_write(ep, df, cache_key=None)
31281
- if completed % max(1, total // 5) == 0 or completed == total:
31282
- _safe_print(f"[MDM] progress: {completed}/{total}")
31384
+ try:
31385
+ # a hung HTTP call blocks as_completed forever without this
31386
+ for fut in as_completed(futures, timeout=1800):
31387
+ completed += 1
31388
+ try:
31389
+ ep, df = fut.result(timeout=120)
31390
+ except Exception:
31391
+ ep = futures[fut][0]
31392
+ df = None
31393
+ if df is not None and not df.empty:
31394
+ if 'symbol' not in df.columns:
31395
+ df['symbol'] = symbol
31396
+ df = self._normalizer.normalize(df, ep)
31397
+ collected[ep].append(df)
31398
+ self._cache_write(ep, df, cache_key=None)
31399
+ if completed % max(1, total // 5) == 0 or completed == total:
31400
+ _safe_print(f"[MDM] progress: {completed}/{total}")
31401
+ except (TimeoutError, _FuturesTimeoutError):
31402
+ _safe_print(f"[MDM] ❌ [TIMEOUT] as_completed timed out after 1800s — "
31403
+ f"dropping hung requests")
31404
+ for fut in futures:
31405
+ if not fut.done():
31406
+ fut.cancel()
31283
31407
  else:
31284
31408
  for req in requests_list:
31285
31409
  ep, df = _fetch(req)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.142
3
+ Version: 2.144
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.142"
7
+ version = "2.144"
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())