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.
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/PKG-INFO +3 -3
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/ivolatility_backtesting/ivolatility_backtesting.py +194 -92
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/ivolatility_backtesting.egg-info/PKG-INFO +3 -3
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/ivolatility_backtesting.egg-info/SOURCES.txt +2 -1
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/ivolatility_backtesting.egg-info/requires.txt +2 -2
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/pyproject.toml +3 -3
- ivolatility_backtesting-2.144/tests/test_2144_duckdb_dedup.py +191 -0
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/README.md +0 -0
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/ivolatility_backtesting/__init__.py +0 -0
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/setup.cfg +0 -0
- {ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/tests/test_2142_fixes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ivolatility_backtesting
|
|
3
|
-
Version: 2.
|
|
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
|
|
19
|
-
Requires-Dist: numpy
|
|
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
|
-
|
|
18491
|
-
|
|
18492
|
-
|
|
18493
|
-
|
|
18494
|
-
|
|
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
|
-
|
|
18498
|
-
|
|
18499
|
-
|
|
18500
|
-
|
|
18501
|
-
|
|
18502
|
-
|
|
18503
|
-
|
|
18504
|
-
|
|
18505
|
-
|
|
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
|
-
|
|
21814
|
-
|
|
21815
|
-
|
|
21816
|
-
|
|
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
|
-
|
|
22346
|
-
|
|
22347
|
-
|
|
22348
|
-
|
|
22349
|
-
|
|
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
|
-
|
|
22382
|
+
f._result = None
|
|
22352
22383
|
except Exception:
|
|
22353
22384
|
pass
|
|
22354
|
-
|
|
22355
|
-
|
|
22356
|
-
|
|
22357
|
-
|
|
22358
|
-
|
|
22359
|
-
|
|
22360
|
-
|
|
22361
|
-
|
|
22362
|
-
|
|
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
|
-
|
|
23766
|
-
|
|
23767
|
-
|
|
23768
|
-
|
|
23769
|
-
|
|
23770
|
-
|
|
23771
|
-
|
|
23772
|
-
|
|
23773
|
-
|
|
23774
|
-
|
|
23775
|
-
|
|
23776
|
-
|
|
23777
|
-
|
|
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
|
-
|
|
28926
|
-
|
|
28927
|
-
|
|
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
|
-
|
|
28988
|
-
|
|
28989
|
-
|
|
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
|
-
|
|
31291
|
-
|
|
31292
|
-
|
|
31293
|
-
|
|
31294
|
-
|
|
31295
|
-
|
|
31296
|
-
|
|
31297
|
-
|
|
31298
|
-
|
|
31299
|
-
|
|
31300
|
-
|
|
31301
|
-
|
|
31302
|
-
|
|
31303
|
-
|
|
31304
|
-
|
|
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.
|
|
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
|
|
19
|
-
Requires-Dist: numpy
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ivolatility_backtesting"
|
|
7
|
-
version = "2.
|
|
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())
|
|
File without changes
|
{ivolatility_backtesting-2.143 → ivolatility_backtesting-2.144}/ivolatility_backtesting/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|