ivolatility-backtesting 2.137__tar.gz → 2.138__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.137 → ivolatility_backtesting-2.138}/PKG-INFO +1 -1
- {ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/ivolatility_backtesting/ivolatility_backtesting.py +658 -176
- {ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/ivolatility_backtesting.egg-info/PKG-INFO +1 -1
- {ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/pyproject.toml +1 -1
- {ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/README.md +0 -0
- {ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/ivolatility_backtesting/__init__.py +0 -0
- {ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/ivolatility_backtesting.egg-info/SOURCES.txt +0 -0
- {ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
- {ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/ivolatility_backtesting.egg-info/requires.txt +0 -0
- {ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
- {ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ivolatility_backtesting
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.138
|
|
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
|
|
@@ -463,6 +463,34 @@ from itertools import product
|
|
|
463
463
|
import sys
|
|
464
464
|
import gc
|
|
465
465
|
from typing import Callable, Dict, List, Optional, Tuple, Union, Any
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# glibc holds freed arenas instead of returning them to the OS, so RSS grows
|
|
469
|
+
# monotonically across a bulk load even though Python has freed the DataFrames
|
|
470
|
+
# (gc.collect() does NOT shrink RSS — verified: 4 futures chunks 346MB, gc→346MB,
|
|
471
|
+
# malloc_trim→250MB). malloc_trim(0) forces glibc to return free pages. Resolved
|
|
472
|
+
# lazily once; a no-op on musl/macOS (returns None / raises → swallowed).
|
|
473
|
+
_libc_malloc_trim = None
|
|
474
|
+
def _release_freed_memory():
|
|
475
|
+
"""gc.collect() + glibc malloc_trim(0) — actually shrinks RSS, unlike gc alone."""
|
|
476
|
+
gc.collect()
|
|
477
|
+
global _libc_malloc_trim
|
|
478
|
+
if _libc_malloc_trim is None:
|
|
479
|
+
try:
|
|
480
|
+
import ctypes
|
|
481
|
+
_libc_malloc_trim = ctypes.CDLL("libc.so.6").malloc_trim
|
|
482
|
+
except Exception:
|
|
483
|
+
_libc_malloc_trim = False # unavailable (macOS/musl) → skip silently
|
|
484
|
+
if _libc_malloc_trim:
|
|
485
|
+
try:
|
|
486
|
+
_libc_malloc_trim(0)
|
|
487
|
+
except Exception:
|
|
488
|
+
pass
|
|
489
|
+
|
|
490
|
+
# Trim once at import: if the lib is re-imported into a kernel that already ran
|
|
491
|
+
# heavy work, hand back any glibc arenas before this session allocates.
|
|
492
|
+
_release_freed_memory()
|
|
493
|
+
|
|
466
494
|
from abc import ABC, abstractmethod
|
|
467
495
|
from contextlib import contextmanager
|
|
468
496
|
|
|
@@ -1506,6 +1534,13 @@ def run_backtest_notebook(
|
|
|
1506
1534
|
def _stage(name: str):
|
|
1507
1535
|
return timing_stage(name, enabled=bool(timing))
|
|
1508
1536
|
|
|
1537
|
+
# Reset the global timing tracker at the START of every run. It's a module
|
|
1538
|
+
# singleton, so without this the "Total time" and per-stage seconds
|
|
1539
|
+
# ACCUMULATE across every backtest in the same Jupyter kernel — a 2-min
|
|
1540
|
+
# hot-cache run would print 12 min because it summed prior runs.
|
|
1541
|
+
if timing:
|
|
1542
|
+
_timing.reset()
|
|
1543
|
+
|
|
1509
1544
|
# 0) Header + paths
|
|
1510
1545
|
if print_config:
|
|
1511
1546
|
print_strategy_config(base_config, mode=mode)
|
|
@@ -4231,7 +4266,33 @@ class DuckDBIndicatorManager:
|
|
|
4231
4266
|
def _create_all_indicator_tables(self):
|
|
4232
4267
|
"""Create temp tables for ALL indicators."""
|
|
4233
4268
|
import time
|
|
4234
|
-
|
|
4269
|
+
|
|
4270
|
+
# Start the indicator SQL from a low RSS floor: the preceding load+dedup
|
|
4271
|
+
# leaves freed-but-not-returned glibc arenas, and the window-function
|
|
4272
|
+
# self-joins below add their own working set on top → OOM on small
|
|
4273
|
+
# containers. Return pages to the OS first.
|
|
4274
|
+
_release_freed_memory()
|
|
4275
|
+
|
|
4276
|
+
# Cap DuckDB's working memory for the indicator SQL: iv_lean_raw does 3
|
|
4277
|
+
# self-joins over the full options table (the single biggest indicator
|
|
4278
|
+
# allocation — +340MB at 1GB limit, +245MB at 200MB as DuckDB spills to
|
|
4279
|
+
# SSD). A tight cap trades a little speed for a much lower peak, which
|
|
4280
|
+
# is what a 3GB container needs. ~⅙ of the cgroup limit, floor 256MB.
|
|
4281
|
+
try:
|
|
4282
|
+
_lim_mb = 512
|
|
4283
|
+
for _p in ('/sys/fs/cgroup/memory.max',
|
|
4284
|
+
'/sys/fs/cgroup/memory/memory.limit_in_bytes'):
|
|
4285
|
+
try:
|
|
4286
|
+
_raw = open(_p).read().strip()
|
|
4287
|
+
if _raw != 'max' and int(_raw) < (1 << 50):
|
|
4288
|
+
_lim_mb = max(256, int(int(_raw) / 6 / 1024 / 1024))
|
|
4289
|
+
break
|
|
4290
|
+
except Exception:
|
|
4291
|
+
continue
|
|
4292
|
+
self.conn.execute(f"SET memory_limit='{_lim_mb}MB'")
|
|
4293
|
+
except Exception:
|
|
4294
|
+
pass
|
|
4295
|
+
|
|
4235
4296
|
print("\n" + "="*80)
|
|
4236
4297
|
print("🦆 DUCKDB INDICATOR CALCULATION (Memory-Efficient)")
|
|
4237
4298
|
print("="*80)
|
|
@@ -4264,14 +4325,61 @@ class DuckDBIndicatorManager:
|
|
|
4264
4325
|
print(f"\n✅ All indicators calculated in {total_time:.2f}s")
|
|
4265
4326
|
print("="*80)
|
|
4266
4327
|
|
|
4328
|
+
def _has_ivx_data(self):
|
|
4329
|
+
"""True if the `ivx` table holds rows for this symbol.
|
|
4330
|
+
|
|
4331
|
+
Futures only have IVX when `/futures/eod/ivx` was loaded (account
|
|
4332
|
+
access + listed root). When present, the IVX-based indicators run via
|
|
4333
|
+
the normal SQL path; when absent they fall back to empty tables so the
|
|
4334
|
+
combined view still builds. Cached (one query per manager).
|
|
4335
|
+
"""
|
|
4336
|
+
if getattr(self, '_ivx_present', None) is not None:
|
|
4337
|
+
return self._ivx_present
|
|
4338
|
+
present = False
|
|
4339
|
+
try:
|
|
4340
|
+
tbls = [t[0] for t in self.conn.execute("SHOW TABLES").fetchall()]
|
|
4341
|
+
if 'ivx' in tbls:
|
|
4342
|
+
present = self.conn.execute(
|
|
4343
|
+
"SELECT 1 FROM ivx WHERE symbol = ? LIMIT 1",
|
|
4344
|
+
[self.symbol]).fetchone() is not None
|
|
4345
|
+
except Exception:
|
|
4346
|
+
present = False
|
|
4347
|
+
self._ivx_present = present
|
|
4348
|
+
return present
|
|
4349
|
+
|
|
4267
4350
|
def _create_iv_lean_raw_table(self):
|
|
4268
4351
|
"""Create IV Lean Z-Score from raw options data (ATM strikes)."""
|
|
4269
4352
|
import time
|
|
4270
4353
|
t0 = time.time()
|
|
4271
|
-
|
|
4354
|
+
|
|
4272
4355
|
dte_min = self.dte_target - self.dte_tolerance
|
|
4273
4356
|
dte_max = self.dte_target + self.dte_tolerance
|
|
4274
|
-
|
|
4357
|
+
|
|
4358
|
+
# input_mode: which ATM series to Z-score — parity with the IVX path
|
|
4359
|
+
# (calculate_iv_lean_from_ivx): 'lean' (call−put, default), 'call',
|
|
4360
|
+
# 'put', 'mean' ((call+put)/2 — IV LEVEL, e.g. futures mean-IV signal,
|
|
4361
|
+
# where IVX is unavailable). The 'iv_lean' column carries the raw
|
|
4362
|
+
# input value regardless of mode (same convention as the IVX path).
|
|
4363
|
+
input_mode = (self.config or {}).get('input_mode', 'lean')
|
|
4364
|
+
_MODE_EXPR = {
|
|
4365
|
+
'lean': '(call_iv - put_iv)',
|
|
4366
|
+
'call': 'call_iv',
|
|
4367
|
+
'put': 'put_iv',
|
|
4368
|
+
'mean': '((call_iv + put_iv) / 2.0)',
|
|
4369
|
+
}
|
|
4370
|
+
if input_mode not in _MODE_EXPR:
|
|
4371
|
+
print(f" ⚠️ iv_lean_raw: input_mode='{input_mode}' not supported by the "
|
|
4372
|
+
f"raw path (supports {sorted(_MODE_EXPR)}); falling back to 'lean'")
|
|
4373
|
+
input_mode = 'lean'
|
|
4374
|
+
_mode_expr = _MODE_EXPR[input_mode]
|
|
4375
|
+
if input_mode == 'call':
|
|
4376
|
+
_mode_filter = "call_iv IS NOT NULL AND call_iv > 0"
|
|
4377
|
+
elif input_mode == 'put':
|
|
4378
|
+
_mode_filter = "put_iv IS NOT NULL AND put_iv > 0"
|
|
4379
|
+
else:
|
|
4380
|
+
_mode_filter = ("call_iv IS NOT NULL AND put_iv IS NOT NULL "
|
|
4381
|
+
"AND call_iv > 0 AND put_iv > 0")
|
|
4382
|
+
|
|
4275
4383
|
# Complex SQL: find ATM strike per day, get Call/Put IV, calculate Z-Score
|
|
4276
4384
|
_opt_tbl = _get_options_eod_table(self.config)
|
|
4277
4385
|
sql = f"""
|
|
@@ -4285,9 +4393,14 @@ class DuckDBIndicatorManager:
|
|
|
4285
4393
|
WHERE symbol = '{self.symbol}'
|
|
4286
4394
|
GROUP BY date
|
|
4287
4395
|
),
|
|
4288
|
-
-- Step 2: Find ATM strike per day (closest to stock price)
|
|
4396
|
+
-- Step 2: Find ATM strike per day (closest to stock price).
|
|
4397
|
+
-- Project ONLY date+strike (the only columns atm_strikes reads) instead
|
|
4398
|
+
-- of o.* — materializing all ~27 columns × millions of rows was the
|
|
4399
|
+
-- single biggest indicator-phase allocation (+365MB on a 3M-row futures
|
|
4400
|
+
-- table). date+strike alone cuts that to a fraction.
|
|
4289
4401
|
options_with_diff AS (
|
|
4290
|
-
SELECT o
|
|
4402
|
+
SELECT o.date,
|
|
4403
|
+
o.strike,
|
|
4291
4404
|
u.stock_price,
|
|
4292
4405
|
ABS(o.strike - u.stock_price) as strike_diff
|
|
4293
4406
|
FROM {_opt_tbl} o
|
|
@@ -4342,19 +4455,16 @@ class DuckDBIndicatorManager:
|
|
|
4342
4455
|
AND o.symbol = '{self.symbol}'
|
|
4343
4456
|
GROUP BY c.date, c.atm_strike, c.stock_price
|
|
4344
4457
|
),
|
|
4345
|
-
-- Step 4:
|
|
4458
|
+
-- Step 4: Build the input series for the configured input_mode
|
|
4346
4459
|
iv_lean_daily AS (
|
|
4347
|
-
SELECT
|
|
4460
|
+
SELECT
|
|
4348
4461
|
date,
|
|
4349
4462
|
stock_price,
|
|
4350
4463
|
call_iv,
|
|
4351
4464
|
put_iv,
|
|
4352
|
-
|
|
4465
|
+
{_mode_expr} as iv_lean
|
|
4353
4466
|
FROM atm_ivs
|
|
4354
|
-
WHERE
|
|
4355
|
-
AND put_iv IS NOT NULL
|
|
4356
|
-
AND call_iv > 0
|
|
4357
|
-
AND put_iv > 0
|
|
4467
|
+
WHERE {_mode_filter}
|
|
4358
4468
|
),
|
|
4359
4469
|
-- Step 5: Rolling statistics using SELF-JOIN (exactly matches pandas logic)
|
|
4360
4470
|
-- pandas: lookback_start <= date <= current_date
|
|
@@ -4394,7 +4504,8 @@ class DuckDBIndicatorManager:
|
|
|
4394
4504
|
total_rows = self.conn.execute("SELECT COUNT(*) FROM iv_lean_raw").fetchone()[0]
|
|
4395
4505
|
self._tables_created.add('iv_lean_raw')
|
|
4396
4506
|
elapsed = time.time() - t0
|
|
4397
|
-
print(f" ✅ iv_lean_raw (Z from ATM options
|
|
4507
|
+
print(f" ✅ iv_lean_raw (Z from ATM options, mode={input_mode}): "
|
|
4508
|
+
f"{stats[0]} rows ({elapsed:.2f}s)")
|
|
4398
4509
|
|
|
4399
4510
|
# Warn when ≥50% of dates have NULL z_score. Root cause is almost
|
|
4400
4511
|
# always lookback_period too small: SQL hardcodes window_count ≥ 20
|
|
@@ -4447,6 +4558,19 @@ class DuckDBIndicatorManager:
|
|
|
4447
4558
|
|
|
4448
4559
|
def _create_iv_lean_ivx_table(self):
|
|
4449
4560
|
"""Create IV Lean Z-Score from IVX data."""
|
|
4561
|
+
# Futures WITHOUT loaded IVX → create the table EMPTY with the same
|
|
4562
|
+
# columns so the FULL OUTER JOIN combined view still builds (a failed
|
|
4563
|
+
# CREATE would kill ALL indicators, incl. iv_lean_raw/rvol which work
|
|
4564
|
+
# fine for futures). When `/futures/eod/ivx` WAS loaded, fall through
|
|
4565
|
+
# to the normal SQL path — it works identically to equity.
|
|
4566
|
+
if (self.config or {}).get('asset_type') == 'futures' and not self._has_ivx_data():
|
|
4567
|
+
self.conn.execute(
|
|
4568
|
+
"CREATE OR REPLACE TEMP TABLE iv_lean_ivx "
|
|
4569
|
+
"(date DATE, iv_lean DOUBLE, mean_lean DOUBLE, "
|
|
4570
|
+
"std_lean DOUBLE, z_score DOUBLE)")
|
|
4571
|
+
self._tables_created.add('iv_lean_ivx')
|
|
4572
|
+
print(' ⏭️ iv_lean_ivx skipped — no IVX data loaded for this futures symbol')
|
|
4573
|
+
return
|
|
4450
4574
|
import time
|
|
4451
4575
|
t0 = time.time()
|
|
4452
4576
|
|
|
@@ -4504,6 +4628,14 @@ class DuckDBIndicatorManager:
|
|
|
4504
4628
|
|
|
4505
4629
|
def _create_iv_rank_table(self):
|
|
4506
4630
|
"""Create IV Rank from IVX data."""
|
|
4631
|
+
if (self.config or {}).get('asset_type') == 'futures' and not self._has_ivx_data():
|
|
4632
|
+
self.conn.execute(
|
|
4633
|
+
"CREATE OR REPLACE TEMP TABLE iv_rank_ivx "
|
|
4634
|
+
"(date DATE, atm_iv DOUBLE, iv_high DOUBLE, "
|
|
4635
|
+
"iv_low DOUBLE, iv_rank DOUBLE)")
|
|
4636
|
+
self._tables_created.add('iv_rank_ivx')
|
|
4637
|
+
print(' ⏭️ iv_rank_ivx skipped — no IVX data loaded for this futures symbol')
|
|
4638
|
+
return
|
|
4507
4639
|
import time
|
|
4508
4640
|
t0 = time.time()
|
|
4509
4641
|
|
|
@@ -4556,6 +4688,13 @@ class DuckDBIndicatorManager:
|
|
|
4556
4688
|
|
|
4557
4689
|
def _create_iv_percentile_table(self):
|
|
4558
4690
|
"""Create IV Percentile from IVX data."""
|
|
4691
|
+
if (self.config or {}).get('asset_type') == 'futures' and not self._has_ivx_data():
|
|
4692
|
+
self.conn.execute(
|
|
4693
|
+
"CREATE OR REPLACE TEMP TABLE iv_percentile_ivx "
|
|
4694
|
+
"(date DATE, atm_iv DOUBLE, iv_percentile DOUBLE)")
|
|
4695
|
+
self._tables_created.add('iv_percentile_ivx')
|
|
4696
|
+
print(' ⏭️ iv_percentile_ivx skipped — no IVX data loaded for this futures symbol')
|
|
4697
|
+
return
|
|
4559
4698
|
import time
|
|
4560
4699
|
t0 = time.time()
|
|
4561
4700
|
|
|
@@ -4607,11 +4746,39 @@ class DuckDBIndicatorManager:
|
|
|
4607
4746
|
"""Create Realized Volatility from stock data."""
|
|
4608
4747
|
import time
|
|
4609
4748
|
t0 = time.time()
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4749
|
+
|
|
4750
|
+
if (self.config or {}).get('asset_type') == 'futures':
|
|
4751
|
+
# Futures: stock_eod has no rows for the root — the underlying
|
|
4752
|
+
# series lives in futures_eod. Use the front-month contract
|
|
4753
|
+
# (highest volume per date, same rule as the stock shim), and
|
|
4754
|
+
# compute log-returns WITHIN a contract (PARTITION BY
|
|
4755
|
+
# future_symbol): a March→June roll is a price-level jump, not a
|
|
4756
|
+
# market move, and would spike RVol with a phantom return.
|
|
4757
|
+
daily_returns_cte = f"""
|
|
4758
|
+
WITH front AS (
|
|
4759
|
+
SELECT date, future_symbol, close,
|
|
4760
|
+
ROW_NUMBER() OVER (
|
|
4761
|
+
PARTITION BY date
|
|
4762
|
+
ORDER BY COALESCE(volume, 0) DESC, future_symbol
|
|
4763
|
+
) as rn
|
|
4764
|
+
FROM futures_eod
|
|
4765
|
+
WHERE symbol = '{self.symbol}'
|
|
4766
|
+
AND close IS NOT NULL
|
|
4767
|
+
AND close > 0
|
|
4768
|
+
),
|
|
4769
|
+
daily_returns AS (
|
|
4770
|
+
SELECT
|
|
4771
|
+
date,
|
|
4772
|
+
close,
|
|
4773
|
+
LN(close / LAG(close) OVER (
|
|
4774
|
+
PARTITION BY future_symbol ORDER BY date)) as log_return
|
|
4775
|
+
FROM front
|
|
4776
|
+
WHERE rn = 1
|
|
4777
|
+
),"""
|
|
4778
|
+
else:
|
|
4779
|
+
daily_returns_cte = f"""
|
|
4613
4780
|
WITH daily_returns AS (
|
|
4614
|
-
SELECT
|
|
4781
|
+
SELECT
|
|
4615
4782
|
date,
|
|
4616
4783
|
close,
|
|
4617
4784
|
LN(close / LAG(close) OVER (ORDER BY date)) as log_return
|
|
@@ -4619,7 +4786,11 @@ class DuckDBIndicatorManager:
|
|
|
4619
4786
|
WHERE symbol = '{self.symbol}'
|
|
4620
4787
|
AND close IS NOT NULL
|
|
4621
4788
|
AND close > 0
|
|
4622
|
-
),
|
|
4789
|
+
),"""
|
|
4790
|
+
|
|
4791
|
+
sql = f"""
|
|
4792
|
+
CREATE OR REPLACE TEMP TABLE realized_vol AS
|
|
4793
|
+
{daily_returns_cte}
|
|
4623
4794
|
with_vol AS (
|
|
4624
4795
|
SELECT
|
|
4625
4796
|
date,
|
|
@@ -19904,10 +20075,17 @@ def _load_ivx_to_duckdb(config, cache_config, symbol, start_date, end_date):
|
|
|
19904
20075
|
rows, which happens with any range longer than ~2 years. To stay under
|
|
19905
20076
|
the threshold and receive fast direct-CSV responses, we split the full
|
|
19906
20077
|
range into 365-calendar-day chunks (~252 trading days each).
|
|
20078
|
+
|
|
20079
|
+
Asset-class aware: `asset_type='futures'` reads `/futures/eod/ivx`
|
|
20080
|
+
(same `{tenor} IV Call/Put/Mean` columns as equity → normalized into the
|
|
20081
|
+
SAME `ivx` table). Equity reads `/equities/eod/ivx`.
|
|
19907
20082
|
"""
|
|
19908
20083
|
debuginfo = config.get('debuginfo', 0)
|
|
19909
20084
|
# Regional routing (see _load_stock_to_duckdb): non-US IVX needs region.
|
|
19910
20085
|
region = config.get('region')
|
|
20086
|
+
ivx_endpoint = ('/futures/eod/ivx'
|
|
20087
|
+
if config.get('asset_type') == 'futures'
|
|
20088
|
+
else '/equities/eod/ivx')
|
|
19911
20089
|
|
|
19912
20090
|
# ── build 365-day chunks ────────────────────────────────────────────────
|
|
19913
20091
|
IVX_CHUNK_DAYS = 365 # ~252 trading days → comfortably under 500-row limit
|
|
@@ -19930,7 +20108,7 @@ def _load_ivx_to_duckdb(config, cache_config, symbol, start_date, end_date):
|
|
|
19930
20108
|
for i, (c_start, c_end) in enumerate(chunks, 1):
|
|
19931
20109
|
chunk_info = {'chunk': i, 'total': n_chunks, 'start': c_start, 'end': c_end}
|
|
19932
20110
|
chunk_df = _api_call_logged(
|
|
19933
|
-
|
|
20111
|
+
ivx_endpoint,
|
|
19934
20112
|
cache_config,
|
|
19935
20113
|
skip_parquet_cache=True,
|
|
19936
20114
|
debuginfo=debuginfo,
|
|
@@ -19955,7 +20133,7 @@ def _load_ivx_to_duckdb(config, cache_config, symbol, start_date, end_date):
|
|
|
19955
20133
|
# Save to DuckDB storage
|
|
19956
20134
|
rows_saved = _save_to_duckdb_storage(
|
|
19957
20135
|
df,
|
|
19958
|
-
|
|
20136
|
+
ivx_endpoint,
|
|
19959
20137
|
cache_config,
|
|
19960
20138
|
debug=False,
|
|
19961
20139
|
)
|
|
@@ -21138,117 +21316,15 @@ def _normalize_futures_options_df(df, root_symbol, root_to_category=None):
|
|
|
21138
21316
|
return df
|
|
21139
21317
|
|
|
21140
21318
|
|
|
21141
|
-
def _futures_http_get(endpoint, params, timeout=60, retries=2, debuginfo=0):
|
|
21142
|
-
"""GET helper with 1 retry for the futures REST endpoints (not in SDK).
|
|
21143
|
-
|
|
21144
|
-
Handles the 500-row response limit (asynchronous CSV overflow path):
|
|
21145
|
-
- When JSON `data` is non-empty (≤500 rows), return it directly.
|
|
21146
|
-
- When `data == []` AND `status.urlForDetails` is set, the API queued
|
|
21147
|
-
an asynchronous job. We:
|
|
21148
|
-
1. Poll `urlForDetails` (returns list-of-1-dict with `meta.status`)
|
|
21149
|
-
until `meta.status == 'COMPLETE'` (~1-3 polls, ~2-6 seconds).
|
|
21150
|
-
2. Download the gzipped CSV from `/data/download/{ruid}`
|
|
21151
|
-
(NOT the `urlForDetails` URL — that returns JSON status only).
|
|
21152
|
-
3. Decompress, parse, return rows.
|
|
21153
|
-
|
|
21154
|
-
Returns a list of dicts (one per row), or None on failure.
|
|
21155
|
-
"""
|
|
21156
|
-
import requests as _requests
|
|
21157
|
-
import time as _time
|
|
21158
|
-
api_key = APIManager._api_key or os.getenv('API_KEY', '')
|
|
21159
|
-
if not api_key:
|
|
21160
|
-
if debuginfo >= 1:
|
|
21161
|
-
print(f" ⚠️ {endpoint}: no API_KEY available")
|
|
21162
|
-
return None
|
|
21163
|
-
full_params = dict(params)
|
|
21164
|
-
full_params['apiKey'] = api_key
|
|
21165
|
-
last_err = None
|
|
21166
|
-
for attempt in range(1, retries + 1):
|
|
21167
|
-
try:
|
|
21168
|
-
r = _requests.get(f'https://restapi.ivolatility.com{endpoint}',
|
|
21169
|
-
params=full_params, timeout=timeout)
|
|
21170
|
-
if r.status_code != 200:
|
|
21171
|
-
last_err = f"HTTP {r.status_code}"
|
|
21172
|
-
if debuginfo >= 2:
|
|
21173
|
-
print(f" ⚠️ {endpoint} HTTP {r.status_code} (attempt {attempt})")
|
|
21174
|
-
continue
|
|
21175
|
-
payload = r.json()
|
|
21176
|
-
data = payload.get('data', []) or []
|
|
21177
|
-
if data:
|
|
21178
|
-
return data
|
|
21179
|
-
# Empty data → check for overflow async CSV job
|
|
21180
|
-
url_for_details = (payload.get('status') or {}).get('urlForDetails')
|
|
21181
|
-
if url_for_details:
|
|
21182
|
-
# 1. Poll status → wait for COMPLETE
|
|
21183
|
-
ruid = None
|
|
21184
|
-
n_rec = None
|
|
21185
|
-
MAX_POLL_SECONDS = 60
|
|
21186
|
-
POLL_INTERVAL = 2
|
|
21187
|
-
for _poll in range(MAX_POLL_SECONDS // POLL_INTERVAL):
|
|
21188
|
-
try:
|
|
21189
|
-
sr = _requests.get(url_for_details, timeout=15)
|
|
21190
|
-
sj = sr.json()
|
|
21191
|
-
item = sj[0] if isinstance(sj, list) and sj else sj
|
|
21192
|
-
meta = (item or {}).get('meta', {}) if isinstance(item, dict) else {}
|
|
21193
|
-
status_val = meta.get('status')
|
|
21194
|
-
if status_val == 'COMPLETE':
|
|
21195
|
-
ruid = meta.get('ruid')
|
|
21196
|
-
n_rec = meta.get('recordsCount')
|
|
21197
|
-
break
|
|
21198
|
-
if status_val in ('FAILED', 'ERROR'):
|
|
21199
|
-
last_err = f"job {status_val}: {meta}"
|
|
21200
|
-
break
|
|
21201
|
-
except Exception as _pe:
|
|
21202
|
-
last_err = f"poll parse: {_pe}"
|
|
21203
|
-
_time.sleep(POLL_INTERVAL)
|
|
21204
|
-
|
|
21205
|
-
if not ruid:
|
|
21206
|
-
last_err = last_err or "polling timed out for async CSV job"
|
|
21207
|
-
if debuginfo >= 1:
|
|
21208
|
-
print(f" ⚠️ {endpoint} CSV polling failed: {last_err}")
|
|
21209
|
-
continue
|
|
21210
|
-
|
|
21211
|
-
# 2. Download from /data/download/{ruid}
|
|
21212
|
-
if debuginfo >= 1:
|
|
21213
|
-
print(f" 📥 {endpoint}: {n_rec} rows via async CSV "
|
|
21214
|
-
f"(ruid={ruid[:8]}...)")
|
|
21215
|
-
try:
|
|
21216
|
-
file_url = f"https://restapi.ivolatility.com/data/download/{ruid}"
|
|
21217
|
-
fr = _requests.get(file_url, params={'apiKey': api_key}, timeout=120)
|
|
21218
|
-
if fr.status_code != 200:
|
|
21219
|
-
last_err = f"download HTTP {fr.status_code}"
|
|
21220
|
-
continue
|
|
21221
|
-
import io as _io, csv as _csv, gzip as _gzip
|
|
21222
|
-
body = fr.content
|
|
21223
|
-
if body[:2] == b'\x1f\x8b':
|
|
21224
|
-
body = _gzip.decompress(body)
|
|
21225
|
-
text = body.decode('utf-8', errors='replace')
|
|
21226
|
-
reader = _csv.DictReader(_io.StringIO(text))
|
|
21227
|
-
return [dict(row) for row in reader]
|
|
21228
|
-
except Exception as e:
|
|
21229
|
-
last_err = f"CSV download failed: {e}"
|
|
21230
|
-
if debuginfo >= 2:
|
|
21231
|
-
print(f" ⚠️ {endpoint} CSV download: {e}")
|
|
21232
|
-
continue
|
|
21233
|
-
# Genuinely empty
|
|
21234
|
-
return []
|
|
21235
|
-
except Exception as e:
|
|
21236
|
-
last_err = str(e)
|
|
21237
|
-
if debuginfo >= 2:
|
|
21238
|
-
print(f" ⚠️ {endpoint} request failed (attempt {attempt}): {e}")
|
|
21239
|
-
if debuginfo >= 1:
|
|
21240
|
-
print(f" ❌ {endpoint} gave up: {last_err}")
|
|
21241
|
-
return None
|
|
21242
|
-
|
|
21243
|
-
|
|
21244
21319
|
def _load_futures_prices_to_duckdb(config, cache_config, symbol,
|
|
21245
21320
|
start_date, end_date, region=None,
|
|
21246
21321
|
api_underlying_symbol=None):
|
|
21247
21322
|
"""Load EOD futures prices (full chain per day) from /futures/eod/prices.
|
|
21248
21323
|
|
|
21249
21324
|
Output table: `futures_eod` (created if missing).
|
|
21250
|
-
|
|
21251
|
-
|
|
21325
|
+
Loaded in ONE from/to range call by default (mirrors _load_stock_to_duckdb);
|
|
21326
|
+
responses >500 rows arrive via the SDK's async-CSV path automatically.
|
|
21327
|
+
`config['fut_price_chunk_days']` can lower the chunk size if ever needed.
|
|
21252
21328
|
|
|
21253
21329
|
Args:
|
|
21254
21330
|
symbol: user-facing alias stored in DB (`'ZB'`).
|
|
@@ -21262,7 +21338,7 @@ def _load_futures_prices_to_duckdb(config, cache_config, symbol,
|
|
|
21262
21338
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
21263
21339
|
debuginfo = config.get('debuginfo', 0)
|
|
21264
21340
|
|
|
21265
|
-
FUT_PRICE_CHUNK_DAYS =
|
|
21341
|
+
FUT_PRICE_CHUNK_DAYS = int(config.get('fut_price_chunk_days', 100000))
|
|
21266
21342
|
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
|
21267
21343
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
|
21268
21344
|
if start_dt > end_dt:
|
|
@@ -21287,30 +21363,50 @@ def _load_futures_prices_to_duckdb(config, cache_config, symbol,
|
|
|
21287
21363
|
if req_symbol != symbol and debuginfo >= 1:
|
|
21288
21364
|
print(f" ℹ️ API prices symbol={req_symbol!r} (user alias={symbol!r})")
|
|
21289
21365
|
|
|
21290
|
-
def _fetch(c_start, c_end):
|
|
21291
|
-
|
|
21292
|
-
|
|
21293
|
-
|
|
21294
|
-
|
|
21295
|
-
|
|
21366
|
+
def _fetch(c_start, c_end, chunk_idx=1):
|
|
21367
|
+
# Through the SDK (api_call): session-level urllib3 Retry on 429/5xx
|
|
21368
|
+
# plus async-CSV overflow handling come for free, same as equity.
|
|
21369
|
+
try:
|
|
21370
|
+
return _api_call_logged(
|
|
21371
|
+
'/futures/eod/prices',
|
|
21372
|
+
cache_config,
|
|
21373
|
+
skip_parquet_cache=True,
|
|
21374
|
+
debuginfo=debuginfo,
|
|
21375
|
+
_chunk_info={'chunk': chunk_idx, 'total': len(chunks),
|
|
21376
|
+
'start': c_start, 'end': c_end},
|
|
21377
|
+
symbol=req_symbol,
|
|
21378
|
+
from_=c_start,
|
|
21379
|
+
to=c_end,
|
|
21380
|
+
# per-request SDK override: a multi-year range job can exceed
|
|
21381
|
+
# the global 90s file-download timeout
|
|
21382
|
+
fileDownloadTimeout=int(config.get('fut_file_download_timeout', 600)),
|
|
21383
|
+
**({'region': region} if region else {}),
|
|
21384
|
+
)
|
|
21385
|
+
except Exception as e:
|
|
21386
|
+
if debuginfo >= 1:
|
|
21387
|
+
print(f" ❌ /futures/eod/prices {c_start}→{c_end}: {e}")
|
|
21388
|
+
return None
|
|
21296
21389
|
|
|
21297
|
-
|
|
21390
|
+
parts = []
|
|
21298
21391
|
if use_parallel:
|
|
21299
|
-
with ThreadPoolExecutor(max_workers=max_workers) as exe:
|
|
21300
|
-
futures = {exe.submit(_fetch, cs, ce): (cs, ce)
|
|
21392
|
+
with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as exe:
|
|
21393
|
+
futures = {exe.submit(_fetch, cs, ce, i + 1): (cs, ce)
|
|
21394
|
+
for i, (cs, ce) in enumerate(chunks)}
|
|
21301
21395
|
for f in as_completed(futures):
|
|
21302
|
-
|
|
21303
|
-
|
|
21396
|
+
part = f.result()
|
|
21397
|
+
if part is not None and not part.empty:
|
|
21398
|
+
parts.append(part)
|
|
21304
21399
|
else:
|
|
21305
|
-
for cs, ce in chunks:
|
|
21306
|
-
|
|
21307
|
-
|
|
21400
|
+
for i, (cs, ce) in enumerate(chunks):
|
|
21401
|
+
part = _fetch(cs, ce, i + 1)
|
|
21402
|
+
if part is not None and not part.empty:
|
|
21403
|
+
parts.append(part)
|
|
21308
21404
|
|
|
21309
|
-
if not
|
|
21405
|
+
if not parts:
|
|
21310
21406
|
print(f" ⚠️ No futures prices returned for {symbol}")
|
|
21311
21407
|
return None
|
|
21312
21408
|
|
|
21313
|
-
df = pd.
|
|
21409
|
+
df = pd.concat(parts, ignore_index=True)
|
|
21314
21410
|
df = _normalize_futures_prices_df(df, symbol)
|
|
21315
21411
|
df = df.drop(columns=[c for c in ('record_no',) if c in df.columns])
|
|
21316
21412
|
|
|
@@ -21345,10 +21441,13 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
|
|
|
21345
21441
|
delete_existing=True):
|
|
21346
21442
|
"""Load futures options via /futures/eod/fut-opts-by-param into DuckDB.
|
|
21347
21443
|
|
|
21348
|
-
The
|
|
21349
|
-
|
|
21350
|
-
|
|
21351
|
-
|
|
21444
|
+
The endpoint accepts `from`/`to` date ranges (verified empirically —
|
|
21445
|
+
its docstring-era reputation as "per-tradeDate only" was wrong), so we
|
|
21446
|
+
chunk CONSECUTIVE runs of trading_days into density-sized ranges × {C, P}
|
|
21447
|
+
and parallelize. Responses >500 rows arrive via the SDK's async-CSV path.
|
|
21448
|
+
Loaded rows are normalized to the equity-options schema (see
|
|
21449
|
+
_normalize_futures_options_df) so the existing OptionsChunkManager works
|
|
21450
|
+
unchanged.
|
|
21352
21451
|
|
|
21353
21452
|
Args:
|
|
21354
21453
|
symbol: user-facing alias stored in the DB (`'ZB'`, `'6E'`).
|
|
@@ -21423,16 +21522,145 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
|
|
|
21423
21522
|
if req_symbol != symbol and debuginfo >= 1:
|
|
21424
21523
|
print(f" ℹ️ API underlyingSymbol={req_symbol!r} "
|
|
21425
21524
|
f"(user alias={symbol!r}) — using internal IVOL_symbol")
|
|
21525
|
+
td_strs = sorted({td.strftime('%Y-%m-%d') if hasattr(td, 'strftime')
|
|
21526
|
+
else str(td)[:10] for td in trading_days})
|
|
21527
|
+
|
|
21528
|
+
# Big async-CSV jobs need more than the SDK's global 90s file timeout
|
|
21529
|
+
# (a full-chain multi-month job can run longer); the SDK accepts a
|
|
21530
|
+
# per-request override via params (popped before the URL is built).
|
|
21531
|
+
FUT_FILE_DOWNLOAD_TIMEOUT = int(config.get('fut_file_download_timeout', 600))
|
|
21532
|
+
|
|
21533
|
+
# Chunk size — mirror the equity priority chain (_load_options_to_duckdb).
|
|
21534
|
+
# Uses the SAME `chunk_days_options` key as equity (no futures-specific
|
|
21535
|
+
# alias): the concept is identical — "days per options chunk":
|
|
21536
|
+
# 1. explicit chunk_days_options (rarely needed)
|
|
21537
|
+
# 2. dynamic (default): real row density — from DB if present, else a
|
|
21538
|
+
# 1-day probe call — sized to an environment-aware job-rows target
|
|
21539
|
+
# 3. static 90d fallback (density unknown)
|
|
21540
|
+
# Equity targets ≤400 rows to stay in the fast JSON path; futures chunks
|
|
21541
|
+
# deliberately ride the async-CSV path (few big jobs beat thousands of
|
|
21542
|
+
# tiny calls on these endpoints), so the target is a JOB size that keeps
|
|
21543
|
+
# each download safely inside RAM and the download timeout.
|
|
21544
|
+
#
|
|
21545
|
+
# The job-rows target is sized to the ENVIRONMENT, not a constant: up to
|
|
21546
|
+
# `parallel_max_workers` job DataFrames sit in RAM at once (~450 bytes/row
|
|
21547
|
+
# in pandas, ~25 mixed cols), so budget ~20% of available RAM across the
|
|
21548
|
+
# workers, clamped to [50k, 500k]. Mac with 8GB free → 500k rows/job
|
|
21549
|
+
# (fewest requests); 3-4GB container → ~150-250k (safe). Override with
|
|
21550
|
+
# config['fut_target_job_rows'].
|
|
21551
|
+
_explicit_target = config.get('fut_target_job_rows')
|
|
21552
|
+
if _explicit_target and int(_explicit_target) > 0:
|
|
21553
|
+
FUT_TARGET_JOB_ROWS = int(_explicit_target)
|
|
21554
|
+
else:
|
|
21555
|
+
try:
|
|
21556
|
+
import psutil as _ps
|
|
21557
|
+
_avail_ram = _ps.virtual_memory().available
|
|
21558
|
+
except Exception:
|
|
21559
|
+
_avail_ram = 4 * 1024 ** 3 # assume a small container when unknown
|
|
21560
|
+
# Inside a container psutil reads /proc/meminfo = HOST memory (a 4Gi
|
|
21561
|
+
# pod on a 93Gi node sees 60+Gi "available"). Respect the cgroup
|
|
21562
|
+
# limit: available = limit - current usage (v2, then v1 paths).
|
|
21563
|
+
for _lim_p, _use_p in (
|
|
21564
|
+
('/sys/fs/cgroup/memory.max', '/sys/fs/cgroup/memory.current'),
|
|
21565
|
+
('/sys/fs/cgroup/memory/memory.limit_in_bytes',
|
|
21566
|
+
'/sys/fs/cgroup/memory/memory.usage_in_bytes')):
|
|
21567
|
+
try:
|
|
21568
|
+
_lim_raw = open(_lim_p).read().strip()
|
|
21569
|
+
if _lim_raw == 'max':
|
|
21570
|
+
break # cgroup v2 without a limit → host numbers are real
|
|
21571
|
+
_lim = int(_lim_raw)
|
|
21572
|
+
if _lim >= 1 << 50:
|
|
21573
|
+
break # v1 "unlimited" sentinel
|
|
21574
|
+
_used = int(open(_use_p).read().strip())
|
|
21575
|
+
_avail_ram = min(_avail_ram, max(0, _lim - _used))
|
|
21576
|
+
break
|
|
21577
|
+
except FileNotFoundError:
|
|
21578
|
+
continue
|
|
21579
|
+
except Exception:
|
|
21580
|
+
break
|
|
21581
|
+
_n_workers = int(config.get('parallel_max_workers', 5)) or 5
|
|
21582
|
+
FUT_TARGET_JOB_ROWS = int(max(50_000, min(500_000,
|
|
21583
|
+
(_avail_ram * 0.20) / _n_workers / 450)))
|
|
21584
|
+
_opt_tbl_probe = _get_options_eod_table(config)
|
|
21585
|
+
_explicit_chunk = config.get('chunk_days_options')
|
|
21586
|
+
if _explicit_chunk and int(_explicit_chunk) > 0:
|
|
21587
|
+
fut_opt_chunk = int(_explicit_chunk)
|
|
21588
|
+
else:
|
|
21589
|
+
rows_per_day = None
|
|
21590
|
+
# 2a. density from DB (max rows per day per C/P side, like equity)
|
|
21591
|
+
try:
|
|
21592
|
+
_dconn = _get_duckdb_storage_conn(cache_config)
|
|
21593
|
+
if _dconn is not None:
|
|
21594
|
+
_tbls = [t[0] for t in _dconn.execute("SHOW TABLES").fetchall()]
|
|
21595
|
+
if _opt_tbl_probe in _tbls:
|
|
21596
|
+
_r = _dconn.execute(
|
|
21597
|
+
f"SELECT MAX(cnt) FROM (SELECT date, type, COUNT(*) AS cnt "
|
|
21598
|
+
f"FROM {_opt_tbl_probe} WHERE symbol = ? GROUP BY date, type)",
|
|
21599
|
+
[symbol]).fetchone()
|
|
21600
|
+
if _r and _r[0]:
|
|
21601
|
+
rows_per_day = int(_r[0])
|
|
21602
|
+
except Exception:
|
|
21603
|
+
pass
|
|
21604
|
+
# 2b. cold start: probe 1 day (C side) to measure live density.
|
|
21605
|
+
# Probe the MIDDLE of the period: edge days (e.g. Dec 31) often carry
|
|
21606
|
+
# a thin chain (few listed series in the DTE band) and under-estimate
|
|
21607
|
+
# density → oversized jobs.
|
|
21608
|
+
if rows_per_day is None:
|
|
21609
|
+
try:
|
|
21610
|
+
_probe_day = td_strs[len(td_strs) // 2]
|
|
21611
|
+
_pp = {'underlyingSymbol': req_symbol,
|
|
21612
|
+
'from_': _probe_day, 'to': _probe_day,
|
|
21613
|
+
'dteFrom': dte_from, 'dteTo': dte_to, 'cp': 'C',
|
|
21614
|
+
'fileDownloadTimeout': FUT_FILE_DOWNLOAD_TIMEOUT}
|
|
21615
|
+
if selection_type == 'delta':
|
|
21616
|
+
_pp['deltaFrom'] = delta_from
|
|
21617
|
+
_pp['deltaTo'] = delta_to
|
|
21618
|
+
else:
|
|
21619
|
+
_pp['moneynessFrom'] = moneyness_from
|
|
21620
|
+
_pp['moneynessTo'] = moneyness_to
|
|
21621
|
+
if region:
|
|
21622
|
+
_pp['region'] = region
|
|
21623
|
+
_pdf = _api_call_logged('/futures/eod/fut-opts-by-param',
|
|
21624
|
+
cache_config, skip_parquet_cache=True,
|
|
21625
|
+
debuginfo=0, **_pp)
|
|
21626
|
+
if _pdf is not None and len(_pdf) > 0:
|
|
21627
|
+
rows_per_day = int(len(_pdf))
|
|
21628
|
+
except Exception:
|
|
21629
|
+
pass
|
|
21630
|
+
if rows_per_day and rows_per_day > 0:
|
|
21631
|
+
fut_opt_chunk = max(1, min(90, FUT_TARGET_JOB_ROWS // rows_per_day))
|
|
21632
|
+
if debuginfo >= 1:
|
|
21633
|
+
print(f" 📐 Futures chunk sizing: ~{rows_per_day:,} rows/day "
|
|
21634
|
+
f"→ {fut_opt_chunk}d chunks (target ≤{FUT_TARGET_JOB_ROWS:,} rows/job)")
|
|
21635
|
+
else:
|
|
21636
|
+
fut_opt_chunk = 90 # static fallback, density unknown
|
|
21637
|
+
|
|
21638
|
+
# Build date chunks from CONSECUTIVE runs of trading_days, then split
|
|
21639
|
+
# long runs into ≤fut_opt_chunk ranges. Per-run grouping (not one
|
|
21640
|
+
# min..max span) matters for gap-fill: with delete_existing=False the
|
|
21641
|
+
# caller passes only the MISSING dates, and a min..max span would re-fetch
|
|
21642
|
+
# dates already present in DuckDB → appended duplicates.
|
|
21643
|
+
date_chunks = []
|
|
21644
|
+
for run_start, run_end in _group_consecutive_dates(td_strs):
|
|
21645
|
+
cur = datetime.strptime(run_start, '%Y-%m-%d')
|
|
21646
|
+
run_end_dt = datetime.strptime(run_end, '%Y-%m-%d')
|
|
21647
|
+
while cur <= run_end_dt:
|
|
21648
|
+
ce = min(cur + timedelta(days=fut_opt_chunk - 1), run_end_dt)
|
|
21649
|
+
date_chunks.append((cur.strftime('%Y-%m-%d'), ce.strftime('%Y-%m-%d')))
|
|
21650
|
+
cur = ce + timedelta(days=1)
|
|
21651
|
+
|
|
21426
21652
|
all_requests = []
|
|
21427
|
-
for
|
|
21428
|
-
td_str = td.strftime('%Y-%m-%d') if hasattr(td, 'strftime') else str(td)[:10]
|
|
21653
|
+
for chunk_start, chunk_end in date_chunks:
|
|
21429
21654
|
for cp in ('C', 'P'):
|
|
21430
21655
|
params = {
|
|
21431
21656
|
'underlyingSymbol': req_symbol,
|
|
21432
|
-
'
|
|
21657
|
+
'from_': chunk_start,
|
|
21658
|
+
'to': chunk_end,
|
|
21433
21659
|
'dteFrom': dte_from,
|
|
21434
21660
|
'dteTo': dte_to,
|
|
21435
21661
|
'cp': cp,
|
|
21662
|
+
# per-request SDK override: big async jobs exceed the global 90s
|
|
21663
|
+
'fileDownloadTimeout': FUT_FILE_DOWNLOAD_TIMEOUT,
|
|
21436
21664
|
}
|
|
21437
21665
|
if selection_type == 'delta':
|
|
21438
21666
|
# ⚠ CRITICAL: puts have negative delta — invert range
|
|
@@ -21451,15 +21679,32 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
|
|
|
21451
21679
|
|
|
21452
21680
|
total = len(all_requests)
|
|
21453
21681
|
print(f" 📡 API request: /futures/eod/fut-opts-by-param "
|
|
21454
|
-
f"{len(
|
|
21455
|
-
f"(DTE {dte_from}-{dte_to})")
|
|
21682
|
+
f"{len(date_chunks)} date-chunks × C/P = {total} requests "
|
|
21683
|
+
f"(chunk≤{fut_opt_chunk}d, DTE {dte_from}-{dte_to})")
|
|
21456
21684
|
|
|
21457
21685
|
use_parallel = config.get('parallel_mode', True) and total > 4
|
|
21458
21686
|
max_workers = config.get('parallel_max_workers', 5)
|
|
21459
21687
|
|
|
21460
|
-
def _fetch(params):
|
|
21461
|
-
|
|
21462
|
-
|
|
21688
|
+
def _fetch(params, chunk_idx=1):
|
|
21689
|
+
# Through the SDK (api_call): session-level urllib3 Retry on 429/5xx
|
|
21690
|
+
# plus async-CSV overflow handling come for free, same as equity.
|
|
21691
|
+
try:
|
|
21692
|
+
return _api_call_logged(
|
|
21693
|
+
'/futures/eod/fut-opts-by-param',
|
|
21694
|
+
cache_config,
|
|
21695
|
+
skip_parquet_cache=True,
|
|
21696
|
+
debuginfo=debuginfo,
|
|
21697
|
+
_chunk_info={'chunk': chunk_idx, 'total': total,
|
|
21698
|
+
'start': params.get('from_', '?'),
|
|
21699
|
+
'end': params.get('to', '?')},
|
|
21700
|
+
**params,
|
|
21701
|
+
)
|
|
21702
|
+
except Exception as e:
|
|
21703
|
+
if debuginfo >= 1:
|
|
21704
|
+
print(f" ❌ fut-opts-by-param "
|
|
21705
|
+
f"{params.get('from_')}→{params.get('to')} "
|
|
21706
|
+
f"{params.get('cp')}: {e}")
|
|
21707
|
+
return None
|
|
21463
21708
|
|
|
21464
21709
|
# Resolve allowed categories from policy framework (see OPT_CATEGORY_POLICIES).
|
|
21465
21710
|
# `None` means no category filtering ('all' policy).
|
|
@@ -21481,6 +21726,39 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
|
|
|
21481
21726
|
sample_rows = []
|
|
21482
21727
|
completed = 0
|
|
21483
21728
|
|
|
21729
|
+
# Bound DuckDB's own RAM during the bulk INSERT: without a cap it grows its
|
|
21730
|
+
# buffer/WAL in memory across millions of inserted rows and OOMs the kernel
|
|
21731
|
+
# mid-load on small containers (verified: full-chain ES 1yr → SIGKILL).
|
|
21732
|
+
# A cap makes DuckDB spill to disk instead. Sized to ~⅓ of the cgroup/host
|
|
21733
|
+
# limit (the rest is the kernel + pandas buffers + services); override with
|
|
21734
|
+
# config['fut_duckdb_memory_limit'] (e.g. '1GB').
|
|
21735
|
+
if duck_conn is not None:
|
|
21736
|
+
try:
|
|
21737
|
+
_dml = config.get('fut_duckdb_memory_limit')
|
|
21738
|
+
if not _dml:
|
|
21739
|
+
_avail = 4 * 1024 ** 3
|
|
21740
|
+
try:
|
|
21741
|
+
import psutil as _ps
|
|
21742
|
+
_avail = _ps.virtual_memory().total
|
|
21743
|
+
except Exception:
|
|
21744
|
+
pass
|
|
21745
|
+
for _lp, _ in (('/sys/fs/cgroup/memory.max', None),):
|
|
21746
|
+
try:
|
|
21747
|
+
_raw = open(_lp).read().strip()
|
|
21748
|
+
if _raw != 'max' and int(_raw) < (1 << 50):
|
|
21749
|
+
_avail = min(_avail, int(_raw))
|
|
21750
|
+
except Exception:
|
|
21751
|
+
pass
|
|
21752
|
+
_dml = f"{max(512, int(_avail / 3 / 1024 / 1024))}MB"
|
|
21753
|
+
duck_conn.execute(f"SET memory_limit='{_dml}'")
|
|
21754
|
+
duck_conn.execute("SET preserve_insertion_order=false")
|
|
21755
|
+
if debuginfo >= 1:
|
|
21756
|
+
print(f" 🧱 DuckDB memory_limit={_dml}, "
|
|
21757
|
+
f"preserve_insertion_order=false")
|
|
21758
|
+
except Exception as _e:
|
|
21759
|
+
if debuginfo >= 1:
|
|
21760
|
+
print(f" ⚠️ could not set DuckDB memory_limit: {_e}")
|
|
21761
|
+
|
|
21484
21762
|
# Pre-clean existing rows for this symbol & date window so re-runs don't
|
|
21485
21763
|
# accumulate duplicates (previous behaviour: 12+ copies after 12 runs →
|
|
21486
21764
|
# undefined picks in get_option_by_strike_exp).
|
|
@@ -21504,11 +21782,10 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
|
|
|
21504
21782
|
if debuginfo >= 1:
|
|
21505
21783
|
print(f" ⚠️ pre-INSERT DELETE failed (ignored): {e}")
|
|
21506
21784
|
|
|
21507
|
-
def _save_chunk(
|
|
21785
|
+
def _save_chunk(df):
|
|
21508
21786
|
nonlocal rows_saved_total, rows_filtered_total
|
|
21509
|
-
if
|
|
21787
|
+
if df is None or df.empty or duck_conn is None:
|
|
21510
21788
|
return
|
|
21511
|
-
df = pd.DataFrame(rows)
|
|
21512
21789
|
df = _normalize_futures_options_df(df, symbol, root_to_category=root_to_category)
|
|
21513
21790
|
|
|
21514
21791
|
# Policy-based filter: keep only rows whose opt_category is allowed.
|
|
@@ -21554,30 +21831,142 @@ def _load_futures_options_to_duckdb(config, cache_config, symbol,
|
|
|
21554
21831
|
f"CREATE TABLE {table} AS SELECT * FROM df_for_db WHERE 1=0")
|
|
21555
21832
|
duck_conn.execute(f"INSERT INTO {table} SELECT * FROM df_for_db")
|
|
21556
21833
|
rows_saved_total += len(df)
|
|
21834
|
+
# Per-chunk insert log — same format as the equity path
|
|
21835
|
+
# (_save_to_duckdb_storage debug=True), so futures runs aren't
|
|
21836
|
+
# silent between the every-20-requests progress summaries.
|
|
21837
|
+
_safe_print(f"[DUCKDB_STORAGE] {table}: +{len(df):,} new rows")
|
|
21838
|
+
# Keep only a TINY sample (legacy return value), not the whole
|
|
21839
|
+
# chunk — holding 5 full chunks pinned them in RAM for the whole
|
|
21840
|
+
# load (real RAM peak with full chains).
|
|
21557
21841
|
if len(sample_rows) < 5:
|
|
21558
|
-
sample_rows.append(df)
|
|
21842
|
+
sample_rows.append(df.head(5).copy())
|
|
21559
21843
|
except Exception as e:
|
|
21560
21844
|
if debuginfo >= 1:
|
|
21561
21845
|
print(f" ⚠️ {table} DuckDB write failed: {e}")
|
|
21846
|
+
finally:
|
|
21847
|
+
# Free the per-chunk pandas buffers immediately — the API→DuckDB
|
|
21848
|
+
# pandas DataFrame (raw + normalized + filtered copies) is the real
|
|
21849
|
+
# RAM peak, and at workers>1 several coexist. gc.collect() alone does
|
|
21850
|
+
# NOT shrink RSS (glibc keeps freed arenas); _release_freed_memory
|
|
21851
|
+
# adds malloc_trim(0) to actually return pages to the OS.
|
|
21852
|
+
try:
|
|
21853
|
+
del df_for_db
|
|
21854
|
+
except Exception:
|
|
21855
|
+
pass
|
|
21856
|
+
try:
|
|
21857
|
+
del df
|
|
21858
|
+
except Exception:
|
|
21859
|
+
pass
|
|
21860
|
+
_release_freed_memory()
|
|
21861
|
+
|
|
21862
|
+
# Flush DuckDB's WAL to disk every N chunks so it doesn't grow unbounded in
|
|
21863
|
+
# RAM across a multi-million-row load (the other half of the OOM fix).
|
|
21864
|
+
_CKPT_EVERY = 20
|
|
21865
|
+
def _maybe_checkpoint():
|
|
21866
|
+
if duck_conn is not None and completed % _CKPT_EVERY == 0:
|
|
21867
|
+
try:
|
|
21868
|
+
with _duckdb_write_lock:
|
|
21869
|
+
duck_conn.execute("CHECKPOINT")
|
|
21870
|
+
except Exception:
|
|
21871
|
+
pass # best-effort; e.g. if another tx is open
|
|
21562
21872
|
|
|
21563
21873
|
if use_parallel:
|
|
21564
21874
|
with _thread_safe_session(), ThreadPoolExecutor(max_workers=max_workers) as exe:
|
|
21565
|
-
futs = {exe.submit(_fetch, p): p
|
|
21875
|
+
futs = {exe.submit(_fetch, p, i + 1): p
|
|
21876
|
+
for i, p in enumerate(all_requests)}
|
|
21566
21877
|
for f in as_completed(futs):
|
|
21567
21878
|
completed += 1
|
|
21568
|
-
|
|
21569
|
-
|
|
21879
|
+
_save_chunk(f.result())
|
|
21880
|
+
_maybe_checkpoint()
|
|
21570
21881
|
if completed % 20 == 0 or completed == total:
|
|
21571
21882
|
print(f" ⚡ {completed}/{total} requests, "
|
|
21572
21883
|
f"{rows_saved_total:,} rows so far")
|
|
21573
21884
|
else:
|
|
21574
21885
|
for i, params in enumerate(all_requests, 1):
|
|
21575
|
-
|
|
21576
|
-
_save_chunk(
|
|
21886
|
+
completed = i
|
|
21887
|
+
_save_chunk(_fetch(params, i))
|
|
21888
|
+
_maybe_checkpoint()
|
|
21577
21889
|
if i % 20 == 0 or i == total:
|
|
21578
21890
|
print(f" 📦 {i}/{total} requests, "
|
|
21579
21891
|
f"{rows_saved_total:,} rows so far")
|
|
21580
21892
|
|
|
21893
|
+
# ── Deterministic contract selection (stable-root dedup) ───────────────
|
|
21894
|
+
# Futures list several option roots at the same (date, strike, expiration,
|
|
21895
|
+
# type) — e.g. quarterly ES and weekly EW3 both expiring the 3rd Friday —
|
|
21896
|
+
# with INDEPENDENT quotes (observed 2.4× price gap on dead/stale series).
|
|
21897
|
+
# get_option_by_strike_exp picks the first matching row, so duplicate keys
|
|
21898
|
+
# make backtests depend on row insert order. A per-day "most liquid" pick
|
|
21899
|
+
# is not enough either: the winning root can flip between days (observed
|
|
21900
|
+
# 16/590 keys in a 5-day ES sample), switching the quote source mid-position
|
|
21901
|
+
# and injecting phantom P&L jumps. So: keep ONE root per (strike,
|
|
21902
|
+
# expiration, type) — the highest TOTAL volume across all loaded dates —
|
|
21903
|
+
# falling back per-day to the next-best root when the winner has no quote
|
|
21904
|
+
# that day. Scoped to the whole symbol so append-fills can't disagree with
|
|
21905
|
+
# the original pick; also collapses duplicates left by earlier append runs.
|
|
21906
|
+
if duck_conn is not None and rows_saved_total > 0:
|
|
21907
|
+
try:
|
|
21908
|
+
# The winner is chosen by GLOBAL liquidity (root total volume across
|
|
21909
|
+
# ALL dates), so the criterion needs the whole period — but a single
|
|
21910
|
+
# `DELETE ... NOT IN (winners)` over the full table materializes the
|
|
21911
|
+
# multi-million-row winner set in RAM (+478MB on 3.7M rows; can't be
|
|
21912
|
+
# capped — at memory_limit=300MB DuckDB itself OOMs). So:
|
|
21913
|
+
# Phase 1: compute per-root total volume once (tiny — one row/root)
|
|
21914
|
+
# Phase 2: delete dups MONTH-BY-MONTH using that global table.
|
|
21915
|
+
# Each month's DELETE touches ~250k rows → +58MB peak, 8× less, and
|
|
21916
|
+
# the result is identical (winner per key is the same regardless of
|
|
21917
|
+
# how the DELETE is batched — verified row-for-row).
|
|
21918
|
+
duck_conn.execute(f"""
|
|
21919
|
+
CREATE OR REPLACE TEMP TABLE _fut_rv AS
|
|
21920
|
+
SELECT strike, expiration, type, opt_root,
|
|
21921
|
+
SUM(COALESCE(volume, 0)) AS rtv
|
|
21922
|
+
FROM {table} WHERE symbol = ?
|
|
21923
|
+
GROUP BY strike, expiration, type, opt_root
|
|
21924
|
+
""", [symbol])
|
|
21925
|
+
months = [r[0] for r in duck_conn.execute(
|
|
21926
|
+
f"SELECT DISTINCT date_trunc('month', date) FROM {table} "
|
|
21927
|
+
f"WHERE symbol = ? ORDER BY 1", [symbol]).fetchall()]
|
|
21928
|
+
removed = 0
|
|
21929
|
+
for _mon in months:
|
|
21930
|
+
removed += duck_conn.execute(f"""
|
|
21931
|
+
WITH joined AS (
|
|
21932
|
+
SELECT t.rowid AS rid, t.date, t.strike, t.expiration, t.type,
|
|
21933
|
+
rv.rtv,
|
|
21934
|
+
COALESCE(t.volume, -1) AS v,
|
|
21935
|
+
-COALESCE(t.ask - t.bid, 1e12) AS ns
|
|
21936
|
+
FROM {table} t
|
|
21937
|
+
JOIN _fut_rv rv USING (strike, expiration, type, opt_root)
|
|
21938
|
+
WHERE t.symbol = ?
|
|
21939
|
+
AND date_trunc('month', t.date) = ?
|
|
21940
|
+
),
|
|
21941
|
+
winners AS (
|
|
21942
|
+
SELECT arg_max(rid, [rtv, v, ns, CAST(-rid AS DOUBLE)]) AS keep
|
|
21943
|
+
FROM joined
|
|
21944
|
+
GROUP BY date, strike, expiration, type
|
|
21945
|
+
)
|
|
21946
|
+
DELETE FROM {table}
|
|
21947
|
+
WHERE symbol = ?
|
|
21948
|
+
AND date_trunc('month', date) = ?
|
|
21949
|
+
AND rowid NOT IN (SELECT keep FROM winners)
|
|
21950
|
+
""", [symbol, _mon, symbol, _mon]).fetchone()[0]
|
|
21951
|
+
duck_conn.execute("DROP TABLE IF EXISTS _fut_rv")
|
|
21952
|
+
if removed:
|
|
21953
|
+
print(f" 🔁 Stable-root dedup: removed {removed:,} "
|
|
21954
|
+
f"duplicate (date,strike,exp,type) rows")
|
|
21955
|
+
except Exception as e:
|
|
21956
|
+
print(f" ⚠️ stable-root dedup failed (rows kept as-is): {e}")
|
|
21957
|
+
try:
|
|
21958
|
+
duck_conn.execute("DROP TABLE IF EXISTS _fut_rv")
|
|
21959
|
+
except Exception:
|
|
21960
|
+
pass
|
|
21961
|
+
# Flush WAL and return pages to the OS so the NEXT phase (indicator SQL)
|
|
21962
|
+
# doesn't start on top of accumulated RSS → OOM.
|
|
21963
|
+
try:
|
|
21964
|
+
with _duckdb_write_lock:
|
|
21965
|
+
duck_conn.execute("CHECKPOINT")
|
|
21966
|
+
except Exception:
|
|
21967
|
+
pass
|
|
21968
|
+
_release_freed_memory()
|
|
21969
|
+
|
|
21581
21970
|
print(f" 💾 Saved {rows_saved_total:,} futures-options rows to DuckDB ({table})")
|
|
21582
21971
|
if rows_filtered_total > 0:
|
|
21583
21972
|
policy_name = config.get('opt_root_policy', 'tradable')
|
|
@@ -21888,8 +22277,8 @@ def _try_read_futures_from_duckdb_storage(config, cache_config, symbol,
|
|
|
21888
22277
|
delete_existing=False, # APPEND, don't wipe
|
|
21889
22278
|
)
|
|
21890
22279
|
elif dte_gap:
|
|
21891
|
-
# DTE-only gap → re-fetch full window (
|
|
21892
|
-
#
|
|
22280
|
+
# DTE-only gap → re-fetch full window (existing rows only
|
|
22281
|
+
# cover the narrower DTE band, so we wipe & reload the range)
|
|
21893
22282
|
extended_mask = stock_df['date'] <= req_max
|
|
21894
22283
|
extended_trading_days = sorted(stock_df.loc[extended_mask, 'date'].unique())
|
|
21895
22284
|
print(f" 📦 DTE-only gap → re-fetch {len(extended_trading_days)} days "
|
|
@@ -21974,6 +22363,40 @@ def _preload_futures_to_duckdb(config, cache_config):
|
|
|
21974
22363
|
print(f"\n 🔮 Futures preload: {symbol} ({region}/{mic or 'auto-MIC'}) "
|
|
21975
22364
|
f"{start_date} → {end_date}")
|
|
21976
22365
|
|
|
22366
|
+
# Return any glibc arenas held from a previous run in this (reused) kernel
|
|
22367
|
+
# before we start allocating — Jupyter keeps the kernel alive between runs,
|
|
22368
|
+
# so a prior backtest's freed-but-not-returned pages would inflate the
|
|
22369
|
+
# starting RSS and bring OOM closer.
|
|
22370
|
+
_release_freed_memory()
|
|
22371
|
+
|
|
22372
|
+
# db_force_clear means "don't trust the cache, reload from scratch". A plain
|
|
22373
|
+
# per-symbol DELETE leaves the singleton DuckDB connection's buffer pool
|
|
22374
|
+
# (up to memory_limit, ~1GB) resident in RAM — pure waste when we're about
|
|
22375
|
+
# to refill it anyway. So CLOSE the singleton connection (its buffer pool is
|
|
22376
|
+
# freed) and reopen lazily with an empty buffer. The .duckdb FILE is NOT
|
|
22377
|
+
# deleted — it is shared by every symbol (equity, other futures, ivx), so
|
|
22378
|
+
# the on-disk data of other symbols stays intact; only this symbol's rows
|
|
22379
|
+
# are re-fetched (the loader does a scoped DELETE WHERE symbol=…).
|
|
22380
|
+
if config.get('db_force_clear', False):
|
|
22381
|
+
global _DUCKDB_STORAGE_CONN
|
|
22382
|
+
try:
|
|
22383
|
+
if _DUCKDB_STORAGE_CONN is not None:
|
|
22384
|
+
try:
|
|
22385
|
+
_DUCKDB_STORAGE_CONN.execute("CHECKPOINT") # persist to disk first
|
|
22386
|
+
except Exception:
|
|
22387
|
+
pass
|
|
22388
|
+
try:
|
|
22389
|
+
_DUCKDB_STORAGE_CONN.close()
|
|
22390
|
+
except Exception:
|
|
22391
|
+
pass
|
|
22392
|
+
_DUCKDB_STORAGE_CONN = None
|
|
22393
|
+
_release_freed_memory()
|
|
22394
|
+
print(f" 🧹 db_force_clear: closed DuckDB conn to drop "
|
|
22395
|
+
f"the buffer pool (file & other symbols kept)")
|
|
22396
|
+
except Exception as _e:
|
|
22397
|
+
if debuginfo >= 1:
|
|
22398
|
+
print(f" ⚠️ db_force_clear conn-reset skipped: {_e}")
|
|
22399
|
+
|
|
21977
22400
|
# ── 0. Fast-path: full gap-detection cache (equity-parity) ──────────────
|
|
21978
22401
|
# Mirrors _try_read_from_duckdb_storage(). If the DB already has data
|
|
21979
22402
|
# for this symbol/window, the helper detects what's missing, fills only
|
|
@@ -22042,6 +22465,38 @@ def _preload_futures_to_duckdb(config, cache_config):
|
|
|
22042
22465
|
api_underlying_symbol=meta.get('root')) # use IVOL_symbol for API
|
|
22043
22466
|
print(f" ⏱ futures-options load: {_time.time()-t0:.2f}s")
|
|
22044
22467
|
|
|
22468
|
+
# ── 4b. Load futures IVX (best-effort) ──────────────────────────────────
|
|
22469
|
+
# /futures/eod/ivx delivers the same {tenor} IV Call/Put/Mean columns as
|
|
22470
|
+
# equity → lands in the shared `ivx` table. With it the IVX-based
|
|
22471
|
+
# indicators (iv_rank/iv_percentile/iv_lean_ivx, input_mode='mean' via
|
|
22472
|
+
# IVX) work for futures exactly like equities. If the account has no
|
|
22473
|
+
# futures-IVX access (or the root has none), the call returns 0 rows and
|
|
22474
|
+
# the indicator manager falls back to empty IVX tables (raw indicators
|
|
22475
|
+
# still compute). NOT fatal — wrapped so preload always completes.
|
|
22476
|
+
ivx_loaded = 0
|
|
22477
|
+
try:
|
|
22478
|
+
_ivx_df = _load_ivx_to_duckdb(
|
|
22479
|
+
config, cache_config, meta.get('root', symbol),
|
|
22480
|
+
extended_start, end_date)
|
|
22481
|
+
ivx_loaded = 0 if _ivx_df is None else len(_ivx_df)
|
|
22482
|
+
if ivx_loaded:
|
|
22483
|
+
# API returns the IVOL root ('ES'); rows are stored under that
|
|
22484
|
+
# symbol, which matches `symbol` for ES/NQ/CL/GC but not aliases
|
|
22485
|
+
# like ZB('US')/6E('EC'). Re-stamp to the user-facing alias so the
|
|
22486
|
+
# indicator SQL (WHERE symbol = '<alias>') finds them.
|
|
22487
|
+
if meta.get('root') and meta['root'] != symbol:
|
|
22488
|
+
_dc = _get_duckdb_storage_conn(cache_config)
|
|
22489
|
+
if _dc is not None:
|
|
22490
|
+
_dc.execute("UPDATE ivx SET symbol = ? WHERE symbol = ?",
|
|
22491
|
+
[symbol, meta['root']])
|
|
22492
|
+
print(f" ✅ futures IVX: {ivx_loaded:,} rows → `ivx` table")
|
|
22493
|
+
else:
|
|
22494
|
+
print(f" ⏭️ futures IVX: 0 rows (no access/data) — "
|
|
22495
|
+
f"raw indicators only")
|
|
22496
|
+
except Exception as _e:
|
|
22497
|
+
if debuginfo >= 1:
|
|
22498
|
+
print(f" ⏭️ futures IVX skipped: {_e}")
|
|
22499
|
+
|
|
22045
22500
|
# ── 5. ChunkManager on the futures_options_eod_close table ──────────────
|
|
22046
22501
|
print(f"\n[3/3] 📊 Building OptionsChunkManager (futures_options_eod_close)")
|
|
22047
22502
|
dte_target = config.get('dte_target', 35)
|
|
@@ -24030,17 +24485,39 @@ def precalculate_indicators_from_config(config, preloaded_data, param_grid=None)
|
|
|
24030
24485
|
chunk_mgr._ensure_connection()
|
|
24031
24486
|
symbol = config.get('symbol', 'SPY')
|
|
24032
24487
|
dte_max = int(config.get('dte_target', 30)) + int(config.get('dte_tolerance', 10))
|
|
24033
|
-
|
|
24034
|
-
|
|
24035
|
-
|
|
24036
|
-
|
|
24037
|
-
|
|
24038
|
-
|
|
24039
|
-
|
|
24040
|
-
|
|
24041
|
-
|
|
24042
|
-
|
|
24043
|
-
|
|
24488
|
+
|
|
24489
|
+
# OOM guard: a full-chain symbol (futures ES ≈ 15k rows/day) can be
|
|
24490
|
+
# millions of rows — `SELECT *` into pandas would blow a 3-4GB
|
|
24491
|
+
# container. Count first; project only the columns the pandas
|
|
24492
|
+
# indicator calculators actually read; refuse above the cap rather
|
|
24493
|
+
# than OOM-kill the kernel mid-optimization.
|
|
24494
|
+
_max_rows = int(config.get('indicator_autoload_max_rows', 4_000_000))
|
|
24495
|
+
_row_count = chunk_mgr.conn.execute(
|
|
24496
|
+
f"SELECT COUNT(*) FROM {_opt_tbl} WHERE symbol = ? AND dte <= ?",
|
|
24497
|
+
[symbol, dte_max]).fetchone()[0]
|
|
24498
|
+
if _row_count > _max_rows:
|
|
24499
|
+
print(f" ⚠️ Skipping options auto-load for indicators: {_row_count:,} rows "
|
|
24500
|
+
f"> cap {_max_rows:,} (indicator_autoload_max_rows). Options-based "
|
|
24501
|
+
f"pandas indicators will be SKIPPED. Options: raise the cap, narrow "
|
|
24502
|
+
f"dte_target/dte_tolerance, or use single mode (SQL indicators).")
|
|
24503
|
+
else:
|
|
24504
|
+
_tbl_cols = [c[1] for c in chunk_mgr.conn.execute(
|
|
24505
|
+
f"PRAGMA table_info('{_opt_tbl}')").fetchall()]
|
|
24506
|
+
_needed = ['date', 'symbol', 'expiration', 'strike', 'type', 'iv',
|
|
24507
|
+
'delta', 'gamma', 'theta', 'vega', 'dte', 'volume',
|
|
24508
|
+
'open_interest', 'underlying_price', 'bid', 'ask', 'mid']
|
|
24509
|
+
_proj = [c for c in _needed if c in _tbl_cols] or ['*']
|
|
24510
|
+
_opts_df = chunk_mgr.conn.execute(f"""
|
|
24511
|
+
SELECT {', '.join(_proj)} FROM {_opt_tbl}
|
|
24512
|
+
WHERE symbol = ? AND dte <= ?
|
|
24513
|
+
""", [symbol, dte_max]).fetchdf()
|
|
24514
|
+
|
|
24515
|
+
if not _opts_df.empty:
|
|
24516
|
+
_opts_df = normalize_dataframe(_opts_df, data_type='options')
|
|
24517
|
+
preloaded_data['_preloaded_options'] = _opts_df
|
|
24518
|
+
_options_loaded_for_indicators = True
|
|
24519
|
+
print(f" ℹ️ Auto-loaded {len(_opts_df):,} options rows from DuckDB "
|
|
24520
|
+
f"for indicator calculation ({len(_proj)} cols)")
|
|
24044
24521
|
except Exception as e:
|
|
24045
24522
|
print(f" ⚠️ Failed to auto-load options from DuckDB: {e}")
|
|
24046
24523
|
|
|
@@ -28494,6 +28971,11 @@ def close_duckdb_storage():
|
|
|
28494
28971
|
print(f"[DUCKDB] 🔐 CLOSED (close_duckdb_storage) | PID={os.getpid()}", flush=True)
|
|
28495
28972
|
_DUCKDB_STORAGE_CONN.close()
|
|
28496
28973
|
_DUCKDB_STORAGE_CONN = None
|
|
28974
|
+
# This runs at the END of every run (framework cleanup). Closing the
|
|
28975
|
+
# connection frees DuckDB's buffer pool inside the process, but glibc
|
|
28976
|
+
# keeps those arenas — return them to the OS so the kernel's RSS
|
|
28977
|
+
# actually drops between runs, not just DuckDB's internal accounting.
|
|
28978
|
+
_release_freed_memory()
|
|
28497
28979
|
|
|
28498
28980
|
|
|
28499
28981
|
def cleanup_duckdb_runtime(config: Dict[str, Any], silent: bool = True) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ivolatility_backtesting
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.138
|
|
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
|
|
@@ -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.138"
|
|
8
8
|
description = "A universal backtesting framework for financial strategies using the IVolatility API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
File without changes
|
{ivolatility_backtesting-2.137 → ivolatility_backtesting-2.138}/ivolatility_backtesting/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|