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.
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/PKG-INFO +2 -2
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting/ivolatility_backtesting.py +134 -50
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting.egg-info/PKG-INFO +2 -2
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting.egg-info/SOURCES.txt +3 -1
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting.egg-info/requires.txt +1 -1
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/pyproject.toml +2 -2
- ivolatility_backtesting-2.149/tests/test_2148_loader_memory.py +223 -0
- ivolatility_backtesting-2.149/tests/test_2149_client_errors.py +131 -0
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/README.md +0 -0
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting/__init__.py +0 -0
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/setup.cfg +0 -0
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/tests/test_2142_fixes.py +0 -0
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/tests/test_2144_duckdb_dedup.py +0 -0
- {ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/tests/test_2146_vix_vro.py +0 -0
- {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.
|
|
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.
|
|
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 =
|
|
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
|
|
522
|
-
"""
|
|
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
|
-
|
|
1737
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20197
|
-
|
|
20198
|
-
|
|
20199
|
-
|
|
20200
|
-
|
|
20201
|
-
|
|
20202
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
20539
|
-
if
|
|
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
|
-
|
|
21798
|
-
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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.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.
|
|
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)
|
|
File without changes
|
{ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/ivolatility_backtesting/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/tests/test_2144_duckdb_dedup.py
RENAMED
|
File without changes
|
|
File without changes
|
{ivolatility_backtesting-2.147 → ivolatility_backtesting-2.149}/tests/test_2147_cents_multiplier.py
RENAMED
|
File without changes
|