ivolatility-backtesting 2.143__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.143
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))
@@ -20241,7 +20258,7 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
20241
20258
  _bt_logger.info(f"[DIAG] MAIN LOOP future ready completed={completed}/{total_requests} chunk_id={_chunk_id}")
20242
20259
  try:
20243
20260
  df = future.result(timeout=120)
20244
- except TimeoutError:
20261
+ except (TimeoutError, _FuturesTimeoutError):
20245
20262
  _safe_print(f" ⏱️ [TIMEOUT] future.result() timed out after 120s (chunk {completed}/{total_requests})")
20246
20263
  _bt_logger.info(f"[DIAG] FUTURE TIMEOUT chunk={_chunk_id} completed={completed}/{total_requests}")
20247
20264
  df = None
@@ -20332,7 +20349,7 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
20332
20349
  if completed % 10 == 0 or completed == total_requests:
20333
20350
  _wall = _time_mod.time() - _parallel_start
20334
20351
  _safe_print(f" ⚡ Progress: {completed}/{total_requests} requests, {total_rows:,} rows, wall={_wall:.1f}s")
20335
- except TimeoutError:
20352
+ except (TimeoutError, _FuturesTimeoutError):
20336
20353
  _wall = _time_mod.time() - _parallel_start
20337
20354
  _pending = [cid for f, cid in futures.items() if not f.done()]
20338
20355
  _safe_print(f" ❌ [GLOBAL TIMEOUT] as_completed timed out after {_PARALLEL_GLOBAL_TIMEOUT}s (wall={_wall:.1f}s)")
@@ -21810,10 +21827,18 @@ def _load_futures_prices_to_duckdb(config, cache_config, symbol,
21810
21827
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as exe:
21811
21828
  futures = {exe.submit(_fetch, cs, ce, i + 1): (cs, ce)
21812
21829
  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)
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()
21817
21842
  else:
21818
21843
  for i, (cs, ce) in enumerate(chunks):
21819
21844
  part = _fetch(cs, ce, i + 1)
@@ -22342,24 +22367,34 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
22342
22367
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as exe:
22343
22368
  futs = {exe.submit(_fetch, p, i + 1): p
22344
22369
  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):
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
22350
22381
  try:
22351
- _mf_fut_sem.release()
22382
+ f._result = None
22352
22383
  except Exception:
22353
22384
  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")
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'))
22363
22398
  else:
22364
22399
  for i, params in enumerate(all_requests, 1):
22365
22400
  completed = i
@@ -23758,23 +23793,34 @@ def preload_data_universal(config, data_requests=None, debug=False):
23758
23793
 
23759
23794
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as executor:
23760
23795
  future_to_idx = {
23761
- executor.submit(fetch_chunk, req_data): i
23796
+ executor.submit(fetch_chunk, req_data): i
23762
23797
  for i, req_data in enumerate(all_request_data)
23763
23798
  }
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)))
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'))))
23778
23824
 
23779
23825
  # Sort by original index to maintain order
23780
23826
  results.sort(key=lambda x: x[0])
@@ -28614,6 +28660,8 @@ def _init_duckdb_storage_tables(conn):
28614
28660
  print(f"[DUCKDB] ⚠️ Legacy migration warning: {_mig_err}", flush=True)
28615
28661
 
28616
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.
28617
28665
  _OPTIONS_EOD_SCHEMA = """(
28618
28666
  option_symbol VARCHAR,
28619
28667
  date DATE,
@@ -28636,26 +28684,32 @@ def _init_duckdb_storage_tables(conn):
28636
28684
  open_interest DOUBLE,
28637
28685
  source_endpoint VARCHAR,
28638
28686
  bid_eod DOUBLE,
28639
- ask_eod DOUBLE,
28640
- PRIMARY KEY (option_symbol, date)
28687
+ ask_eod DOUBLE
28641
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)
28642
28710
  conn.execute(f"CREATE TABLE IF NOT EXISTS options_eod_close {_OPTIONS_EOD_SCHEMA}")
28643
-
28711
+
28644
28712
  # 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
28713
  conn.execute(f"CREATE TABLE IF NOT EXISTS options_eod_1545 {_OPTIONS_EOD_SCHEMA}")
28660
28714
 
28661
28715
  # OPTIONS_INTRADAY - Minute-level options data
@@ -28820,6 +28874,37 @@ def _get_target_table(endpoint: str) -> str:
28820
28874
  return None
28821
28875
 
28822
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
+
28823
28908
  def _save_to_duckdb_storage(df, endpoint: str, cache_config: Dict[str, Any], debug: bool = False):
28824
28909
  """
28825
28910
  Save DataFrame to DuckDB storage with deduplication.
@@ -28922,10 +29007,15 @@ def _save_to_duckdb_storage(df, endpoint: str, cache_config: Dict[str, Any], deb
28922
29007
  count_before = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]
28923
29008
 
28924
29009
  # Insert with conflict handling (skip duplicates)
28925
- conn.execute(f"""
28926
- INSERT OR IGNORE INTO {table_name}
28927
- SELECT * FROM df_normalized
28928
- """)
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)
28929
29019
 
28930
29020
  # Get count after insert
28931
29021
  count_after = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]
@@ -28984,10 +29074,14 @@ def _save_to_duckdb_storage_batched(df, endpoint: str, cache_config: Dict[str, A
28984
29074
  batch_normalized = _normalize_for_duckdb_storage(batch, endpoint, table_name)
28985
29075
 
28986
29076
  # Insert batch
28987
- conn.execute(f"""
28988
- INSERT OR IGNORE INTO {table_name}
28989
- SELECT * FROM batch_normalized
28990
- """)
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)
28991
29085
 
28992
29086
  # Progress every 5 batches
28993
29087
  if debug and (i // batch_size) % 5 == 0:
@@ -31287,21 +31381,29 @@ class MarketDataManager:
31287
31381
  completed = 0
31288
31382
  with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as pool:
31289
31383
  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}")
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()
31305
31407
  else:
31306
31408
  for req in requests_list:
31307
31409
  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.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.143"
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())