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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.137
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: Calculate IV Lean
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
- (call_iv - put_iv) as iv_lean
4465
+ {_mode_expr} as iv_lean
4353
4466
  FROM atm_ivs
4354
- WHERE call_iv IS NOT NULL
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): {stats[0]} rows ({elapsed:.2f}s)")
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
- sql = f"""
4612
- CREATE OR REPLACE TEMP TABLE realized_vol AS
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
- '/equities/eod/ivx',
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
- '/equities/eod/ivx',
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
- Chunked: ≤10 calendar days per call (one day ≈ 40 contracts for ES,
21251
- so 10 days stays comfortably under the 500-row API hard limit).
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 = 10 # ~400 rows worst case for a liquid root
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
- params = {'symbol': req_symbol, 'from': c_start, 'to': c_end}
21292
- if region:
21293
- params['region'] = region
21294
- return _futures_http_get('/futures/eod/prices', params,
21295
- timeout=60, debuginfo=debuginfo)
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
- all_rows = []
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) for cs, ce in chunks}
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
- rows = f.result() or []
21303
- all_rows.extend(rows)
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
- rows = _fetch(cs, ce) or []
21307
- all_rows.extend(rows)
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 all_rows:
21405
+ if not parts:
21310
21406
  print(f" ⚠️ No futures prices returned for {symbol}")
21311
21407
  return None
21312
21408
 
21313
- df = pd.DataFrame(all_rows)
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 futures-options endpoint is **per-tradeDate** (no date range param),
21349
- so we loop trading_days × {C, P} and parallelize. Loaded rows are
21350
- normalized to the equity-options schema (see _normalize_futures_options_df)
21351
- so the existing OptionsChunkManager works unchanged.
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 td in trading_days:
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
- 'tradeDate': td_str,
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(trading_days)} days × C/P = {total} requests "
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
- return _futures_http_get('/futures/eod/fut-opts-by-param', params,
21462
- timeout=60, debuginfo=debuginfo)
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(rows):
21785
+ def _save_chunk(df):
21508
21786
  nonlocal rows_saved_total, rows_filtered_total
21509
- if not rows or duck_conn is None:
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 for p in all_requests}
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
- rows = f.result() or []
21569
- _save_chunk(rows)
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
- rows = _fetch(params) or []
21576
- _save_chunk(rows)
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 (per-tradeDate endpoint
21892
- # can't isolate DTE band, so we wipe & reload for this range)
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
- _opts_df = chunk_mgr.conn.execute(f"""
24035
- SELECT * FROM {_opt_tbl}
24036
- WHERE symbol = ? AND dte <= ?
24037
- """, [symbol, dte_max]).fetchdf()
24038
-
24039
- if not _opts_df.empty:
24040
- _opts_df = normalize_dataframe(_opts_df, data_type='options')
24041
- preloaded_data['_preloaded_options'] = _opts_df
24042
- _options_loaded_for_indicators = True
24043
- print(f" ℹ️ Auto-loaded {len(_opts_df):,} options rows from DuckDB for indicator calculation")
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.137
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.137"
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 = [