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