ivolatility-backtesting 2.147__tar.gz → 2.149__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.

Potentially problematic release.


This version of ivolatility-backtesting might be problematic. Click here for more details.

Files changed (17) hide show
  1. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/PKG-INFO +2 -2
  2. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting/ivolatility_backtesting.py +134 -50
  3. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting.egg-info/PKG-INFO +2 -2
  4. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting.egg-info/SOURCES.txt +3 -1
  5. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting.egg-info/requires.txt +1 -1
  6. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/pyproject.toml +2 -2
  7. ivolatility_backtesting-2.149/tests/test_2148_loader_memory.py +223 -0
  8. ivolatility_backtesting-2.149/tests/test_2149_client_errors.py +131 -0
  9. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/README.md +0 -0
  10. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting/__init__.py +0 -0
  11. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
  12. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
  13. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/setup.cfg +0 -0
  14. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/tests/test_2142_fixes.py +0 -0
  15. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/tests/test_2144_duckdb_dedup.py +0 -0
  16. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/tests/test_2146_vix_vro.py +0 -0
  17. {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/tests/test_2147_cents_multiplier.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.147
3
+ Version: 2.149
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
@@ -19,7 +19,7 @@ Requires-Dist: pandas<3.0,>=1.5.0
19
19
  Requires-Dist: numpy<3.0,>=1.21.0
20
20
  Requires-Dist: matplotlib>=3.5.0
21
21
  Requires-Dist: seaborn>=0.11.0
22
- Requires-Dist: ivolatility>=1.8.2
22
+ Requires-Dist: ivolatility>=1.9.6
23
23
  Requires-Dist: psutil>=7.1.0
24
24
 
25
25
  # IVolatility Backtesting Framework v1.32
@@ -40,13 +40,24 @@ import os
40
40
  # Distinctive User-Agent on ALL REST traffic: lets the gateway/Graylog tell
41
41
  # framework calls apart from hand-written scripts, and workspace runs from
42
42
  # local pip installs (CODER_AGENT_URL is set inside Coder workspaces only).
43
+ def _ivb_client():
44
+ if os.getenv('IVOL_CLIENT'):
45
+ return os.getenv('IVOL_CLIENT')
46
+ if os.getenv('CODER_AGENT_URL'):
47
+ return 'workspace'
48
+ # venv created by the IVolAI plugin (ivolai-dist setup_env.sh); pods use /home/coder/.venv
49
+ if os.path.normpath(__import__('sys').prefix).endswith(os.path.join('ivolatility', '.venv')):
50
+ return 'plugin'
51
+ return 'local'
52
+
53
+
43
54
  def _ivb_user_agent():
44
55
  try:
45
56
  from importlib.metadata import version as _pkg_version
46
57
  _ver = _pkg_version('ivolatility_backtesting')
47
58
  except Exception:
48
59
  _ver = 'dev'
49
- _env = 'workspace' if os.getenv('CODER_AGENT_URL') else 'local'
60
+ _env = _ivb_client()
50
61
  try:
51
62
  import requests as _rq
52
63
  _rq_ver = _rq.__version__
@@ -518,9 +529,8 @@ from typing import Callable, Dict, List, Optional, Tuple, Union, Any
518
529
  # malloc_trim→250MB). malloc_trim(0) forces glibc to return free pages. Resolved
519
530
  # lazily once; a no-op on musl/macOS (returns None / raises → swallowed).
520
531
  _libc_malloc_trim = None
521
- def _release_freed_memory():
522
- """gc.collect() + glibc malloc_trim(0) — actually shrinks RSS, unlike gc alone."""
523
- gc.collect()
532
+ def _trim_heap():
533
+ """glibc malloc_trim(0) without a gc pass — cheap enough to call after every chunk."""
524
534
  global _libc_malloc_trim
525
535
  if _libc_malloc_trim is None:
526
536
  try:
@@ -534,6 +544,67 @@ def _release_freed_memory():
534
544
  except Exception:
535
545
  pass
536
546
 
547
+
548
+ def _release_freed_memory():
549
+ """gc.collect() + glibc malloc_trim(0) — actually shrinks RSS, unlike gc alone."""
550
+ gc.collect()
551
+ _trim_heap()
552
+
553
+
554
+ import threading
555
+
556
+ # Admission control by cgroup free memory; the first download always passes so a load never stalls.
557
+ def _env_int(name, default):
558
+ try:
559
+ return int(os.environ.get(name, default))
560
+ except ValueError:
561
+ return default
562
+
563
+
564
+ _API_MEM_EST_MB = _env_int('IVOL_API_MEM_EST_MB', 150)
565
+ _API_MEM_SAFETY_MB = _env_int('IVOL_API_MEM_SAFETY_MB', 300)
566
+ _api_mem_lock = threading.Lock()
567
+ _api_mem_inflight = 0
568
+
569
+
570
+ def _cgroup_free_mb():
571
+ """Container free memory in MB (limit minus usage excluding inactive file cache); None without a cgroup v2 limit."""
572
+ try:
573
+ with open('/sys/fs/cgroup/memory.max') as f:
574
+ raw = f.read().strip()
575
+ if raw == 'max':
576
+ return None
577
+ with open('/sys/fs/cgroup/memory.current') as f:
578
+ current = int(f.read())
579
+ inactive = 0
580
+ with open('/sys/fs/cgroup/memory.stat') as f:
581
+ for line in f:
582
+ if line.startswith('inactive_file '):
583
+ inactive = int(line.split()[1])
584
+ break
585
+ return (int(raw) - (current - inactive)) >> 20
586
+ except (OSError, ValueError):
587
+ return None
588
+
589
+
590
+ def _api_mem_acquire():
591
+ """Block until the container can fit one more in-flight download."""
592
+ global _api_mem_inflight
593
+ while True:
594
+ with _api_mem_lock:
595
+ free_mb = _cgroup_free_mb()
596
+ if (free_mb is None or _api_mem_inflight == 0
597
+ or free_mb - _api_mem_inflight * _API_MEM_EST_MB >= _API_MEM_EST_MB + _API_MEM_SAFETY_MB):
598
+ _api_mem_inflight += 1
599
+ return
600
+ time.sleep(0.5)
601
+
602
+
603
+ def _api_mem_release():
604
+ global _api_mem_inflight
605
+ with _api_mem_lock:
606
+ _api_mem_inflight = max(0, _api_mem_inflight - 1)
607
+
537
608
  # Trim once at import: if the lib is re-imported into a kernel that already ran
538
609
  # heavy work, hand back any glibc arenas before this session allocates.
539
610
  _release_freed_memory()
@@ -1733,8 +1804,10 @@ def _create_sdk_like_session():
1733
1804
  from urllib3.util.retry import Retry
1734
1805
  from requests.adapters import HTTPAdapter
1735
1806
  s = _req.Session()
1736
- retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504],
1737
- allowed_methods=["GET", "POST", "DELETE"])
1807
+ # SDK >= 1.9.6 pauses before the first retry; reuse it so thread sessions behave the same
1808
+ retry = getattr(_ivol_inner, '_retry_strategy', None) or Retry(
1809
+ total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504],
1810
+ allowed_methods=["GET", "POST", "DELETE"])
1738
1811
  adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10, max_retries=retry)
1739
1812
  s.mount("https://", adapter)
1740
1813
  s.mount("http://", adapter)
@@ -7415,8 +7488,7 @@ class APIHelper:
7415
7488
  if response.empty:
7416
7489
  return None
7417
7490
 
7418
- records = response.to_dict('records')
7419
- return {'data': records, 'status': 'success'}
7491
+ return {'data': response, 'status': 'success', '_is_df': True}
7420
7492
 
7421
7493
  if debug:
7422
7494
  print(f"[APIHelper] Unexpected type: {type(response)}")
@@ -7763,8 +7835,9 @@ def _api_call_direct(endpoint: str, max_retries: int = 3, **kwargs) -> Optional[
7763
7835
  return None
7764
7836
  df = data if isinstance(data, pd.DataFrame) else pd.DataFrame(data)
7765
7837
  return df if not df.empty else None
7766
- except RETRYABLE:
7767
- if attempt == max_retries - 1:
7838
+ except RETRYABLE as e:
7839
+ if _is_client_error(e) or attempt == max_retries - 1:
7840
+ _safe_print(f" ⚠️ {endpoint}: {e}")
7768
7841
  return None
7769
7842
  continue
7770
7843
  except Exception:
@@ -7772,6 +7845,12 @@ def _api_call_direct(endpoint: str, max_retries: int = 3, **kwargs) -> Optional[
7772
7845
  return None
7773
7846
 
7774
7847
 
7848
+ def _is_client_error(exc):
7849
+ """4xx other than 429: the same request cannot succeed on retry."""
7850
+ status = getattr(getattr(exc, 'response', None), 'status_code', None)
7851
+ return status is not None and 400 <= status < 500 and status != 429
7852
+
7853
+
7775
7854
  def api_call(endpoint, cache_config=None, debug=False, max_retries=3, skip_parquet_cache=False,
7776
7855
  _chunk_info=None, **kwargs):
7777
7856
  """
@@ -7868,6 +7947,8 @@ def api_call(endpoint, cache_config=None, debug=False, max_retries=3, skip_parqu
7868
7947
 
7869
7948
  except RETRYABLE_ERRORS as e:
7870
7949
  last_exception = e
7950
+ if _is_client_error(e):
7951
+ raise
7871
7952
  if debug_level >= 1:
7872
7953
  print(f"[RETRY] Network error on attempt {attempt + 1}/{max_retries}: {type(e).__name__}: {e}")
7873
7954
 
@@ -20193,23 +20274,28 @@ def _api_call_logged(endpoint, cache_config, skip_parquet_cache=False, debuginfo
20193
20274
 
20194
20275
  # Make API call with debug level
20195
20276
  # debuginfo: 0=silent, 1=basic, 2=detailed (URLs), 3=verbose timing
20196
- response = api_call(
20197
- endpoint,
20198
- cache_config,
20199
- debug=debuginfo, # Pass integer directly
20200
- skip_parquet_cache=skip_parquet_cache,
20201
- _chunk_info=_chunk_info,
20202
- **normalized_params
20203
- )
20204
-
20277
+ _api_mem_acquire()
20278
+ try:
20279
+ response = api_call(
20280
+ endpoint,
20281
+ cache_config,
20282
+ debug=debuginfo, # Pass integer directly
20283
+ skip_parquet_cache=skip_parquet_cache,
20284
+ _chunk_info=_chunk_info,
20285
+ **normalized_params
20286
+ )
20287
+ finally:
20288
+ _api_mem_release()
20289
+
20205
20290
  # Parse response
20206
20291
  if response is None:
20207
20292
  return None
20208
-
20293
+
20209
20294
  if isinstance(response, pd.DataFrame):
20210
20295
  return response if not response.empty else None
20211
20296
  elif isinstance(response, dict) and 'data' in response:
20212
- df = pd.DataFrame(response['data'])
20297
+ data = response['data']
20298
+ df = data if isinstance(data, pd.DataFrame) else pd.DataFrame(data)
20213
20299
  return df if not df.empty else None
20214
20300
 
20215
20301
  return None
@@ -20308,16 +20394,14 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
20308
20394
  print(f" ⚡ PARALLEL mode: {total_requests} requests with {max_workers} workers")
20309
20395
 
20310
20396
  all_data = []
20397
+ # Legacy (pandas-indicator) mode still returns the sample if reading DuckDB back fails.
20398
+ _keep_sample = not config.get('use_duckdb_indicators', True)
20311
20399
  total_rows = 0
20312
20400
  api_debuginfo = debuginfo
20313
20401
  # Resolve snapshot-aware endpoint ONCE for both fetch and save
20314
20402
  _save_ep = _get_options_endpoints(_get_options_snapshot_mode(config))['filtered']
20315
20403
 
20316
- # MEMFIX(backpressure): воркеры качают быстрее, чем главный поток успевает
20317
- # писать чанк в DuckDB (запись ~3с), поэтому готовые, но ещё не обработанные
20318
- # DataFrame копились в памяти. Семафор держит очередь готовых результатов
20319
- # ограниченной: воркер не отдаёт результат, пока главный поток не разгрёб
20320
- # предыдущие. Это ограничивает пик числом воркеров, а не длиной периода.
20404
+ # Caps downloaded-but-not-yet-written chunks; each loop must release after writing a non-empty chunk.
20321
20405
  import threading as _mf_thr
20322
20406
  _mf_inflight = _mf_thr.Semaphore(max(2, max_workers))
20323
20407
 
@@ -20438,29 +20522,27 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
20438
20522
  _safe_print(f" ⏱️ [SLOW DUCKDB WRITE] {_db_elapsed:.1f}s for {len(df)} rows (chunk {completed})")
20439
20523
  rows_saved_total += rows_saved
20440
20524
  total_rows += len(df)
20441
-
20442
- if len(all_data) < 5:
20525
+
20526
+ # Sample is only a fallback for a failed DuckDB write.
20527
+ if rows_saved_total > 0 and not _keep_sample:
20528
+ all_data.clear()
20529
+ elif len(all_data) < 5:
20443
20530
  all_data.append(df)
20444
-
20531
+
20445
20532
  if df is not None and not getattr(df, 'empty', True):
20446
20533
  try:
20447
20534
  _mf_inflight.release()
20448
20535
  except Exception:
20449
20536
  pass
20450
- # MEMFIX: release the chunk once it is persisted — the Future keeps a
20451
- # reference to its result until the executor block exits, so without this
20452
- # the whole fetch accumulates in RAM regardless of DuckDB writes.
20537
+ # The Future holds its result until the executor exits; drop it once persisted.
20453
20538
  try:
20454
20539
  future._result = None
20455
20540
  except Exception:
20456
20541
  pass
20457
20542
  df = None
20543
+ _trim_heap()
20458
20544
  if completed % 25 == 0:
20459
- # MEMFIX(page-cache): страницы записанного .duckdb оседают в file-кэше
20460
- # cgroup и подтягивают memory.current к лимиту. Кэш вытесняемый, но
20461
- # держит счётчик у потолка и сокращает буфер до OOM. Сбрасываем его:
20462
- # сначала fdatasync (грязные страницы иначе не освободить), затем
20463
- # POSIX_FADV_DONTNEED на файл БД и его WAL.
20545
+ # Written .duckdb pages count toward the cgroup limit: fdatasync, then drop them from page cache.
20464
20546
  try:
20465
20547
  import os as _os4
20466
20548
  _dbp = None
@@ -20487,13 +20569,7 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
20487
20569
  pass
20488
20570
  except Exception:
20489
20571
  pass
20490
- import gc as _gc
20491
- _gc.collect()
20492
- try:
20493
- import ctypes as _ct
20494
- _ct.CDLL("libc.so.6").malloc_trim(0)
20495
- except Exception:
20496
- pass
20572
+ _release_freed_memory()
20497
20573
 
20498
20574
  _watchdog_completed[0] = completed
20499
20575
  if completed % 10 == 0 or completed == total_requests:
@@ -20534,11 +20610,16 @@ def _load_options_to_duckdb(config, cache_config, symbol, start_date, end_date):
20534
20610
  )
20535
20611
  rows_saved_total += rows_saved
20536
20612
  total_rows += len(df)
20537
-
20538
- # Keep small sample for return
20539
- if len(all_data) < 5:
20613
+
20614
+ # Sample is only a fallback for a failed DuckDB write.
20615
+ if rows_saved_total > 0 and not _keep_sample:
20616
+ all_data.clear()
20617
+ elif len(all_data) < 5:
20540
20618
  all_data.append(df)
20541
-
20619
+ _mf_inflight.release()
20620
+ df = None
20621
+ _trim_heap()
20622
+
20542
20623
  # Progress summary every 10 requests
20543
20624
  if (i + 1) % 10 == 0 or i == len(all_requests) - 1:
20544
20625
  print(f" 📦 Request {i + 1}/{total_requests}: {total_rows:,} rows so far")
@@ -21794,8 +21875,11 @@ def _load_futures_for_hedge(config, preloaded):
21794
21875
  if r.status_code == 200:
21795
21876
  data = r.json().get('data', [])
21796
21877
  all_rows.extend(data)
21797
- except Exception:
21798
- pass
21878
+ else:
21879
+ print(f" ⚠️ futures/eod/prices {fut_root} {current:%Y-%m-%d}..{chunk_end:%Y-%m-%d}: "
21880
+ f"HTTP {r.status_code} {r.text[:300]}")
21881
+ except Exception as e:
21882
+ print(f" ⚠️ futures/eod/prices {fut_root} {current:%Y-%m-%d}: {e}")
21799
21883
  current = chunk_end + _td(days=1)
21800
21884
 
21801
21885
  if all_rows:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 2.147
3
+ Version: 2.149
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
@@ -19,7 +19,7 @@ Requires-Dist: pandas<3.0,>=1.5.0
19
19
  Requires-Dist: numpy<3.0,>=1.21.0
20
20
  Requires-Dist: matplotlib>=3.5.0
21
21
  Requires-Dist: seaborn>=0.11.0
22
- Requires-Dist: ivolatility>=1.8.2
22
+ Requires-Dist: ivolatility>=1.9.6
23
23
  Requires-Dist: psutil>=7.1.0
24
24
 
25
25
  # IVolatility Backtesting Framework v1.32
@@ -11,4 +11,6 @@ ivolatility_backtesting.egg-info/top_level.txt
11
11
  tests/test_2142_fixes.py
12
12
  tests/test_2144_duckdb_dedup.py
13
13
  tests/test_2146_vix_vro.py
14
- tests/test_2147_cents_multiplier.py
14
+ tests/test_2147_cents_multiplier.py
15
+ tests/test_2148_loader_memory.py
16
+ tests/test_2149_client_errors.py
@@ -2,5 +2,5 @@ pandas<3.0,>=1.5.0
2
2
  numpy<3.0,>=1.21.0
3
3
  matplotlib>=3.5.0
4
4
  seaborn>=0.11.0
5
- ivolatility>=1.8.2
5
+ ivolatility>=1.9.6
6
6
  psutil>=7.1.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ivolatility_backtesting"
7
- version = "2.147"
7
+ version = "2.149"
8
8
  description = "A universal backtesting framework for financial strategies using the IVolatility API."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -17,7 +17,7 @@ dependencies = [
17
17
  "numpy>=1.21.0,<3.0",
18
18
  "matplotlib>=3.5.0",
19
19
  "seaborn>=0.11.0",
20
- "ivolatility>=1.8.2",
20
+ "ivolatility>=1.9.6",
21
21
  "psutil>=7.1.0",
22
22
  ]
23
23
  keywords = ["backtesting", "finance", "trading", "ivolatility"]
@@ -0,0 +1,223 @@
1
+ # Loader memory: DataFrame passthrough, download admission by cgroup free memory, heap trim.
2
+ # Run: python3 tests/test_2148_loader_memory.py (or pytest)
3
+ import os
4
+ import sys
5
+ import threading
6
+ import time
7
+ import traceback
8
+
9
+ import matplotlib
10
+ matplotlib.use('Agg')
11
+
12
+ _REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
13
+ sys.path.insert(0, os.path.join(_REPO, 'ivolatility_backtesting'))
14
+ import ivolatility_backtesting as lib # noqa: E402
15
+
16
+ import pandas as pd # noqa: E402
17
+
18
+
19
+ def _chain(n=3):
20
+ return pd.DataFrame({
21
+ 'date': ['2024-01-02'] * n,
22
+ 'expiration': ['2024-01-19'] * n,
23
+ 'strike': [100.0 + i for i in range(n)],
24
+ 'Call/Put': ['C'] * n,
25
+ 'bid': [1.0 + i for i in range(n)],
26
+ 'ask': [1.1 + i for i in range(n)],
27
+ })
28
+
29
+
30
+ def test_normalize_keeps_same_dataframe():
31
+ df = _chain()
32
+ out = lib.APIHelper.normalize_response(df)
33
+ assert out['status'] == 'success'
34
+ assert out['_is_df'] is True
35
+ assert out['data'] is df
36
+
37
+
38
+ def test_normalize_empty_dataframe_is_none():
39
+ assert lib.APIHelper.normalize_response(pd.DataFrame()) is None
40
+
41
+
42
+ def test_normalize_dict_passthrough_unchanged():
43
+ resp = {'data': [{'a': 1}], 'status': 'success'}
44
+ assert lib.APIHelper.normalize_response(resp) is resp
45
+
46
+
47
+ def test_api_call_logged_returns_frame_without_copy():
48
+ df = _chain()
49
+ orig = lib.api_call
50
+ try:
51
+ lib.api_call = lambda *a, **k: {'data': df, 'status': 'success', '_is_df': True}
52
+ got = lib._api_call_logged('/equities/eod/options-rawiv', {}, symbol='SPY',
53
+ from_='2024-01-02', to='2024-01-02')
54
+ assert got is df
55
+ lib.api_call = lambda *a, **k: {'data': df.to_dict('records'), 'status': 'success'}
56
+ got = lib._api_call_logged('/equities/eod/options-rawiv', {}, symbol='SPY',
57
+ from_='2024-01-02', to='2024-01-02')
58
+ pd.testing.assert_frame_equal(got, df)
59
+ lib.api_call = lambda *a, **k: {'data': pd.DataFrame(), 'status': 'success', '_is_df': True}
60
+ assert lib._api_call_logged('/equities/eod/options-rawiv', {}, symbol='SPY') is None
61
+ finally:
62
+ lib.api_call = orig
63
+ assert lib._api_mem_inflight == 0
64
+
65
+
66
+ def test_api_call_logged_releases_slot_on_error():
67
+ orig = lib.api_call
68
+
69
+ def boom(*a, **k):
70
+ raise RuntimeError('network')
71
+ try:
72
+ lib.api_call = boom
73
+ try:
74
+ lib._api_call_logged('/equities/eod/options-rawiv', {}, symbol='SPY')
75
+ except RuntimeError:
76
+ pass
77
+ finally:
78
+ lib.api_call = orig
79
+ assert lib._api_mem_inflight == 0
80
+
81
+
82
+ def test_gate_first_download_always_passes():
83
+ orig = lib._cgroup_free_mb
84
+ try:
85
+ lib._cgroup_free_mb = lambda: 0
86
+ lib._api_mem_acquire()
87
+ assert lib._api_mem_inflight == 1
88
+ lib._api_mem_release()
89
+ assert lib._api_mem_inflight == 0
90
+ finally:
91
+ lib._cgroup_free_mb = orig
92
+
93
+
94
+ def test_gate_waits_for_memory_then_admits():
95
+ free = {'mb': 400}
96
+ orig = lib._cgroup_free_mb
97
+ try:
98
+ lib._cgroup_free_mb = lambda: free['mb']
99
+ lib._api_mem_acquire()
100
+ t = threading.Thread(target=lib._api_mem_acquire, daemon=True)
101
+ t.start()
102
+ t.join(1.2)
103
+ assert t.is_alive(), 'second download must wait: 400 - 150 < 150 + 300'
104
+ assert lib._api_mem_inflight == 1
105
+ free['mb'] = 2000
106
+ t.join(2.0)
107
+ assert not t.is_alive()
108
+ assert lib._api_mem_inflight == 2
109
+ finally:
110
+ lib._cgroup_free_mb = orig
111
+ lib._api_mem_release()
112
+ lib._api_mem_release()
113
+ assert lib._api_mem_inflight == 0
114
+
115
+
116
+ def test_gate_reserves_per_inflight_download():
117
+ orig = lib._cgroup_free_mb
118
+ try:
119
+ # 900 free: 1st passes (inflight 0), 2nd 900-150=750 >= 450, 3rd 900-300=600 >= 450,
120
+ # 4th 900-450=450 >= 450, 5th 900-600=300 < 450 → waits.
121
+ lib._cgroup_free_mb = lambda: 900
122
+ for _ in range(4):
123
+ lib._api_mem_acquire()
124
+ assert lib._api_mem_inflight == 4
125
+ t = threading.Thread(target=lib._api_mem_acquire, daemon=True)
126
+ t.start()
127
+ t.join(1.2)
128
+ assert t.is_alive()
129
+ lib._api_mem_release()
130
+ t.join(2.0)
131
+ assert not t.is_alive()
132
+ finally:
133
+ lib._cgroup_free_mb = orig
134
+ for _ in range(4):
135
+ lib._api_mem_release()
136
+ assert lib._api_mem_inflight == 0
137
+
138
+
139
+ def test_gate_is_noop_without_cgroup_limit():
140
+ orig = lib._cgroup_free_mb
141
+ try:
142
+ lib._cgroup_free_mb = lambda: None
143
+ start = time.time()
144
+ for _ in range(8):
145
+ lib._api_mem_acquire()
146
+ assert time.time() - start < 0.5
147
+ finally:
148
+ lib._cgroup_free_mb = orig
149
+ for _ in range(8):
150
+ lib._api_mem_release()
151
+ assert lib._api_mem_inflight == 0
152
+
153
+
154
+ def test_release_never_goes_negative():
155
+ lib._api_mem_release()
156
+ assert lib._api_mem_inflight == 0
157
+
158
+
159
+ def test_cgroup_free_mb_type():
160
+ v = lib._cgroup_free_mb()
161
+ assert v is None or isinstance(v, int)
162
+
163
+
164
+ def _run_loader(config, calls, timeout=20):
165
+ saved = []
166
+ orig_call, orig_save = lib._api_call_logged, lib._save_to_duckdb_storage
167
+
168
+ def fake_call(endpoint, cache_config, **params):
169
+ calls.append(params['cp'])
170
+ return _chain(4)
171
+
172
+ def fake_save(df, endpoint, cache_config, debug=False):
173
+ saved.append(len(df))
174
+ return len(df)
175
+ box = {}
176
+ try:
177
+ lib._api_call_logged, lib._save_to_duckdb_storage = fake_call, fake_save
178
+ t = threading.Thread(target=lambda: box.setdefault('r', lib._load_options_to_duckdb(
179
+ config, {}, 'SPY', '2024-01-01', '2024-01-30')), daemon=True)
180
+ t.start()
181
+ t.join(timeout)
182
+ return (not t.is_alive()), saved
183
+ finally:
184
+ lib._api_call_logged, lib._save_to_duckdb_storage = orig_call, orig_save
185
+
186
+
187
+ def test_sequential_loader_does_not_stall():
188
+ # 6 chunks x C/P = 12 requests > semaphore size max(2, workers=2)
189
+ cfg = {'strategy_type': 'STRADDLE', 'chunk_days_options': 5, 'parallel_mode': False,
190
+ 'parallel_max_workers': 2, 'debuginfo': 0}
191
+ calls = []
192
+ finished, saved = _run_loader(cfg, calls)
193
+ assert finished, f'sequential load stalled after {len(calls)} requests'
194
+ assert len(calls) == 12 and saved == [4] * 12
195
+
196
+
197
+ def test_parallel_loader_saves_every_chunk():
198
+ cfg = {'strategy_type': 'STRADDLE', 'chunk_days_options': 5, 'parallel_mode': True,
199
+ 'parallel_max_workers': 3, 'debuginfo': 0}
200
+ calls = []
201
+ finished, saved = _run_loader(cfg, calls)
202
+ assert finished
203
+ assert len(calls) == 12 and saved == [4] * 12
204
+
205
+
206
+ def test_trim_heap_safe_everywhere():
207
+ lib._trim_heap()
208
+ lib._trim_heap()
209
+ lib._release_freed_memory()
210
+
211
+
212
+ if __name__ == '__main__':
213
+ failed = 0
214
+ for name, fn in sorted((n, f) for n, f in globals().items() if n.startswith('test_') and callable(f)):
215
+ try:
216
+ fn()
217
+ print(f'PASS {name}')
218
+ except Exception:
219
+ failed += 1
220
+ print(f'FAIL {name}')
221
+ traceback.print_exc()
222
+ print(f'{failed} failed')
223
+ sys.exit(1 if failed else 0)
@@ -0,0 +1,131 @@
1
+ # No retry on 4xx, pause before retrying a 429, client tag in User-Agent.
2
+ # Run: python3 tests/test_2149_client_errors.py (or pytest). No network: a local server stands in for the API.
3
+ import json
4
+ import os
5
+ import sys
6
+ import threading
7
+ import time
8
+ import traceback
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+
11
+ import matplotlib
12
+ matplotlib.use('Agg')
13
+
14
+ HITS = {}
15
+
16
+
17
+ class _Api(BaseHTTPRequestHandler):
18
+ def log_message(self, *args):
19
+ pass
20
+
21
+ def _reply(self, code, body):
22
+ data = json.dumps(body).encode()
23
+ self.send_response(code)
24
+ self.send_header('Content-Type', 'application/json')
25
+ self.send_header('Content-Length', str(len(data)))
26
+ self.end_headers()
27
+ self.wfile.write(data)
28
+
29
+ def do_GET(self):
30
+ path = self.path.split('?')[0]
31
+ HITS[path] = HITS.get(path, 0) + 1
32
+ if path == '/busy' and HITS[path] == 1:
33
+ self._reply(429, {'code': 429, 'message': 'per-second rate limit'})
34
+ elif path == '/equities/eod/stock-prices':
35
+ self._reply(403, {'code': 403, 'name': 'Forbidden',
36
+ 'message': 'Trial records limit exhausted. To continue, please upgrade to a paid plan'})
37
+ else:
38
+ self._reply(200, {'ok': True})
39
+
40
+
41
+ # CI runners set HTTP_PROXY; the local stand-in API must bypass it
42
+ for _var in ('NO_PROXY', 'no_proxy'):
43
+ os.environ[_var] = ','.join(filter(None, [os.environ.get(_var), '127.0.0.1', 'localhost']))
44
+
45
+ _SERVER = ThreadingHTTPServer(('127.0.0.1', 0), _Api)
46
+ threading.Thread(target=_SERVER.serve_forever, daemon=True).start()
47
+ _BASE = f'http://127.0.0.1:{_SERVER.server_address[1]}'
48
+ os.environ['IVOL_API_BASE_URL'] = _BASE
49
+ os.environ['API_KEY'] = 'TESTKEY000000000'
50
+
51
+ _REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
52
+ sys.path.insert(0, os.path.join(_REPO, 'ivolatility_backtesting'))
53
+ import ivolatility_backtesting as lib # noqa: E402
54
+ import requests # noqa: E402
55
+
56
+
57
+ def _sdk_version():
58
+ return tuple(int(x) for x in lib.ivol.__version__.split('.')[:3])
59
+
60
+
61
+ def test_client_error_not_retried():
62
+ HITS.pop('/equities/eod/stock-prices', None)
63
+ try:
64
+ lib.api_call('/equities/eod/stock-prices', symbol='AAPL', from_='2024-01-02', to='2024-01-03')
65
+ raise AssertionError('403 must raise')
66
+ except requests.HTTPError as e:
67
+ assert e.response.status_code == 403
68
+ if _sdk_version() >= (1, 9, 6):
69
+ assert 'Trial records limit' in str(e)
70
+ assert HITS['/equities/eod/stock-prices'] == 1
71
+
72
+
73
+ def test_direct_call_not_retried_on_client_error():
74
+ HITS.pop('/equities/eod/stock-prices', None)
75
+ assert lib._api_call_direct('/equities/eod/stock-prices', symbol='AAPL',
76
+ from_='2024-01-02', to='2024-01-03') is None
77
+ assert HITS['/equities/eod/stock-prices'] == 1
78
+
79
+
80
+ def test_thread_session_pauses_before_retrying_429():
81
+ if _sdk_version() < (1, 9, 6):
82
+ return
83
+ HITS.pop('/busy', None)
84
+ t0 = time.time()
85
+ r = lib._create_sdk_like_session().get(f'{_BASE}/busy', timeout=10)
86
+ assert r.status_code == 200 and HITS['/busy'] == 2
87
+ assert time.time() - t0 >= 1.0
88
+
89
+
90
+ def test_is_client_error():
91
+ class E(Exception):
92
+ def __init__(self, code):
93
+ self.response = type('R', (), {'status_code': code})()
94
+ assert lib._is_client_error(E(403)) and lib._is_client_error(E(400))
95
+ assert not lib._is_client_error(E(429)) and not lib._is_client_error(E(500))
96
+ assert not lib._is_client_error(ValueError())
97
+
98
+
99
+ def test_client_tag():
100
+ saved = {k: os.environ.pop(k, None) for k in ('IVOL_CLIENT', 'CODER_AGENT_URL')}
101
+ saved_prefix = sys.prefix
102
+ try:
103
+ sys.prefix = '/opt/venv'
104
+ assert lib._ivb_client() == 'local'
105
+ sys.prefix = os.path.join('/Users/x', 'ivolatility', '.venv')
106
+ assert lib._ivb_client() == 'plugin'
107
+ os.environ['CODER_AGENT_URL'] = 'http://agent'
108
+ assert lib._ivb_client() == 'workspace'
109
+ os.environ['IVOL_CLIENT'] = 'chat'
110
+ assert lib._ivb_client() == 'chat'
111
+ finally:
112
+ sys.prefix = saved_prefix
113
+ for k, v in saved.items():
114
+ os.environ.pop(k, None)
115
+ if v is not None:
116
+ os.environ[k] = v
117
+
118
+
119
+ if __name__ == '__main__':
120
+ failed = 0
121
+ for name, fn in list(globals().items()):
122
+ if name.startswith('test_') and callable(fn):
123
+ try:
124
+ fn()
125
+ print(f'PASS {name}')
126
+ except Exception:
127
+ failed += 1
128
+ print(f'FAIL {name}')
129
+ traceback.print_exc()
130
+ _SERVER.shutdown()
131
+ sys.exit(1 if failed else 0)