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.
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/PKG-INFO +3 -3
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/ivolatility_backtesting/ivolatility_backtesting.py +217 -93
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/ivolatility_backtesting.egg-info/PKG-INFO +3 -3
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/ivolatility_backtesting.egg-info/SOURCES.txt +2 -1
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/ivolatility_backtesting.egg-info/requires.txt +2 -2
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/pyproject.toml +3 -3
- ivolatility_backtesting-2.144/tests/test_2144_duckdb_dedup.py +191 -0
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/README.md +0 -0
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/ivolatility_backtesting/__init__.py +0 -0
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
- {ivolatility_backtesting-2.142 → ivolatility_backtesting-2.144}/setup.cfg +0 -0
- {ivolatility_backtesting-2.142 → 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))
|
|
@@ -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
|
-
|
|
21792
|
-
|
|
21793
|
-
|
|
21794
|
-
|
|
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
|
-
|
|
22324
|
-
|
|
22325
|
-
|
|
22326
|
-
|
|
22327
|
-
|
|
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
|
-
|
|
22382
|
+
f._result = None
|
|
22330
22383
|
except Exception:
|
|
22331
22384
|
pass
|
|
22332
|
-
|
|
22333
|
-
|
|
22334
|
-
|
|
22335
|
-
|
|
22336
|
-
|
|
22337
|
-
|
|
22338
|
-
|
|
22339
|
-
|
|
22340
|
-
|
|
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
|
-
|
|
23744
|
-
|
|
23745
|
-
|
|
23746
|
-
|
|
23747
|
-
|
|
23748
|
-
|
|
23749
|
-
|
|
23750
|
-
|
|
23751
|
-
|
|
23752
|
-
|
|
23753
|
-
|
|
23754
|
-
|
|
23755
|
-
|
|
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
|
-
|
|
28904
|
-
|
|
28905
|
-
|
|
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
|
-
|
|
28966
|
-
|
|
28967
|
-
|
|
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
|
-
|
|
31269
|
-
|
|
31270
|
-
|
|
31271
|
-
|
|
31272
|
-
|
|
31273
|
-
|
|
31274
|
-
|
|
31275
|
-
|
|
31276
|
-
|
|
31277
|
-
|
|
31278
|
-
|
|
31279
|
-
|
|
31280
|
-
|
|
31281
|
-
|
|
31282
|
-
|
|
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.
|
|
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.142 → ivolatility_backtesting-2.144}/ivolatility_backtesting/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|