ivolatility-backtesting 2.148__tar.gz → 2.150__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.148 → ivolatility_backtesting-2.150}/PKG-INFO +2 -2
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting/__init__.py +2 -0
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting/ivolatility_backtesting.py +100 -40
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/PKG-INFO +2 -2
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/SOURCES.txt +3 -1
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/requires.txt +1 -1
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/pyproject.toml +2 -2
- ivolatility_backtesting-2.150/tests/test_2149_client_errors.py +131 -0
- ivolatility_backtesting-2.150/tests/test_2150_prompt_patterns.py +356 -0
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/README.md +0 -0
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/setup.cfg +0 -0
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2142_fixes.py +0 -0
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2144_duckdb_dedup.py +0 -0
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2146_vix_vro.py +0 -0
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2147_cents_multiplier.py +0 -0
- {ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2148_loader_memory.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ivolatility_backtesting
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.150
|
|
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
|
{ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/ivolatility_backtesting/__init__.py
RENAMED
|
@@ -28,6 +28,7 @@ from .ivolatility_backtesting import (
|
|
|
28
28
|
FUTURES_HEDGE_MAP, _load_futures_for_hedge,
|
|
29
29
|
set_project_root, get_project_root,
|
|
30
30
|
get_position_multiplier, get_hedge_multiplier,
|
|
31
|
+
parse_earnings_calendar,
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
__all__ = [
|
|
@@ -60,4 +61,5 @@ __all__ = [
|
|
|
60
61
|
'FUTURES_HEDGE_MAP', '_load_futures_for_hedge',
|
|
61
62
|
'set_project_root', 'get_project_root',
|
|
62
63
|
'get_position_multiplier', 'get_hedge_multiplier',
|
|
64
|
+
'parse_earnings_calendar',
|
|
63
65
|
]
|
|
@@ -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__
|
|
@@ -747,6 +758,12 @@ def _get_options_eod_table(config_like: Optional[Dict[str, Any]]) -> str:
|
|
|
747
758
|
return 'options_eod_1545' if snap == '1545' else 'options_eod_close'
|
|
748
759
|
|
|
749
760
|
|
|
761
|
+
def _complete_option_dates_sql(table: str) -> str:
|
|
762
|
+
"""Dates holding both calls and puts; a date with a single type is treated as not loaded."""
|
|
763
|
+
return (f"SELECT date FROM {table} WHERE symbol = ? "
|
|
764
|
+
f"GROUP BY date HAVING COUNT(DISTINCT UPPER(SUBSTR(CAST(type AS VARCHAR), 1, 1))) >= 2")
|
|
765
|
+
|
|
766
|
+
|
|
750
767
|
def _get_legacy_1545_duckdb_path(config_like: Optional[Dict[str, Any]]) -> str:
|
|
751
768
|
"""Legacy pre-refactor 15:45 DuckDB path kept for one-time migration."""
|
|
752
769
|
cache_dir = _resolve_path((config_like or {}).get('cache_dir', 'cache'))
|
|
@@ -1793,8 +1810,10 @@ def _create_sdk_like_session():
|
|
|
1793
1810
|
from urllib3.util.retry import Retry
|
|
1794
1811
|
from requests.adapters import HTTPAdapter
|
|
1795
1812
|
s = _req.Session()
|
|
1796
|
-
|
|
1797
|
-
|
|
1813
|
+
# SDK >= 1.9.6 pauses before the first retry; reuse it so thread sessions behave the same
|
|
1814
|
+
retry = getattr(_ivol_inner, '_retry_strategy', None) or Retry(
|
|
1815
|
+
total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504],
|
|
1816
|
+
allowed_methods=["GET", "POST", "DELETE"])
|
|
1798
1817
|
adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10, max_retries=retry)
|
|
1799
1818
|
s.mount("https://", adapter)
|
|
1800
1819
|
s.mount("http://", adapter)
|
|
@@ -3727,6 +3746,11 @@ STRATEGIES = {
|
|
|
3727
3746
|
},
|
|
3728
3747
|
}
|
|
3729
3748
|
|
|
3749
|
+
STRATEGIES['DIRECTIONAL_LONG'] = {**STRATEGIES['LONG_STOCK'], 'name': 'Directional Long (stock)'}
|
|
3750
|
+
STRATEGIES['DIRECTIONAL_SHORT'] = {**STRATEGIES['SHORT_STOCK'], 'name': 'Directional Short (stock)'}
|
|
3751
|
+
STOCK_STRATEGY_TYPES = ('LONG_STOCK', 'SHORT_STOCK', 'DIRECTIONAL_LONG', 'DIRECTIONAL_SHORT')
|
|
3752
|
+
SHORT_STOCK_TYPES = ('SHORT_STOCK', 'DIRECTIONAL_SHORT')
|
|
3753
|
+
|
|
3730
3754
|
# Backwards compatibility mapping (lowercase to UPPERCASE)
|
|
3731
3755
|
_STRATEGY_NAME_MAP = {
|
|
3732
3756
|
'iron_condor': 'IRON_CONDOR',
|
|
@@ -6139,7 +6163,7 @@ class StrategyRegistry:
|
|
|
6139
6163
|
call_premium = position.get('call_premium', abs(credit))
|
|
6140
6164
|
capital_at_risk = max(stock_value - call_premium, stock_value * 0.8)
|
|
6141
6165
|
locked_capital = stock_value
|
|
6142
|
-
elif position_type in
|
|
6166
|
+
elif position_type in STOCK_STRATEGY_TYPES:
|
|
6143
6167
|
stock_value = underlying_price * contracts * um
|
|
6144
6168
|
capital_at_risk = stock_value
|
|
6145
6169
|
locked_capital = stock_value
|
|
@@ -6411,13 +6435,13 @@ class StrategyRegistry:
|
|
|
6411
6435
|
# Universal CREDIT detection (works for ANY strategy)
|
|
6412
6436
|
is_credit = cls.is_credit_strategy(strategy_type=strategy_type, position=position)
|
|
6413
6437
|
|
|
6414
|
-
# ⚠️ CRITICAL:
|
|
6415
|
-
#
|
|
6416
|
-
if is_credit and not is_short_leg and category
|
|
6438
|
+
# ⚠️ CRITICAL: NEUTRAL (STRADDLE, STRANGLE) and single-leg CREDIT (SHORT_PUT, SHORT_CALL)
|
|
6439
|
+
# legs carry no 'short' prefix. If ALL legs lack it AND position is CREDIT → treat ALL as SHORT
|
|
6440
|
+
if is_credit and not is_short_leg and category in ('NEUTRAL', 'CREDIT'):
|
|
6417
6441
|
# Check if NO legs have 'short' prefix (simple STRADDLE/STRANGLE)
|
|
6418
6442
|
has_short_prefix = any('short' in l['name'].lower() for l in legs)
|
|
6419
6443
|
if not has_short_prefix:
|
|
6420
|
-
#
|
|
6444
|
+
# STRADDLE/STRANGLE, SHORT_PUT/SHORT_CALL: all legs are SHORT for CREDIT
|
|
6421
6445
|
is_short_leg = True
|
|
6422
6446
|
|
|
6423
6447
|
# ⚠️ FIX: For DEBIT strategies used as CREDIT (e.g. DCS SELL, calendar SELL)
|
|
@@ -6621,7 +6645,7 @@ class StrategyRegistry:
|
|
|
6621
6645
|
# total_cost is negative (credit from selling call), so adding it reduces risk
|
|
6622
6646
|
return (stock_price * um * contracts) + total_cost # subtract call premium
|
|
6623
6647
|
|
|
6624
|
-
elif strategy_type in
|
|
6648
|
+
elif strategy_type in STOCK_STRATEGY_TYPES:
|
|
6625
6649
|
stock_price = position_params.get('stock_price', position_params.get('underlying_entry_price', 0))
|
|
6626
6650
|
return stock_price * um * contracts
|
|
6627
6651
|
|
|
@@ -6820,13 +6844,13 @@ class StrategyRegistry:
|
|
|
6820
6844
|
# doubling the call bid while completely missing the stock value.
|
|
6821
6845
|
# Fix: compute P&L directly using underlying_price (available here) + call leg.
|
|
6822
6846
|
# ── LONG_STOCK / SHORT_STOCK — pure stock position, no option legs ──
|
|
6823
|
-
if strategy_type in
|
|
6847
|
+
if strategy_type in STOCK_STRATEGY_TYPES:
|
|
6824
6848
|
stock_exit_value = underlying_price * contracts * um
|
|
6825
|
-
if strategy_type
|
|
6849
|
+
if strategy_type in SHORT_STOCK_TYPES:
|
|
6826
6850
|
pnl = entry_cost - stock_exit_value # short: profit when price drops
|
|
6827
6851
|
else:
|
|
6828
6852
|
pnl = stock_exit_value - entry_cost # long: profit when price rises
|
|
6829
|
-
is_credit = (strategy_type
|
|
6853
|
+
is_credit = (strategy_type in SHORT_STOCK_TYPES)
|
|
6830
6854
|
close_cost = stock_exit_value
|
|
6831
6855
|
# ── COVERED_CALL / COVERED_PUT — stock + option leg ──────────
|
|
6832
6856
|
elif (strategy_type in ('COVERED_CALL', 'COVERED_CALL_ATM', 'COVERED_CALL_25D', 'COVERED_PUT', 'COVERED_PUT_ATM', 'COVERED_PUT_25D')
|
|
@@ -7822,8 +7846,9 @@ def _api_call_direct(endpoint: str, max_retries: int = 3, **kwargs) -> Optional[
|
|
|
7822
7846
|
return None
|
|
7823
7847
|
df = data if isinstance(data, pd.DataFrame) else pd.DataFrame(data)
|
|
7824
7848
|
return df if not df.empty else None
|
|
7825
|
-
except RETRYABLE:
|
|
7826
|
-
if attempt == max_retries - 1:
|
|
7849
|
+
except RETRYABLE as e:
|
|
7850
|
+
if _is_client_error(e) or attempt == max_retries - 1:
|
|
7851
|
+
_safe_print(f" ⚠️ {endpoint}: {e}")
|
|
7827
7852
|
return None
|
|
7828
7853
|
continue
|
|
7829
7854
|
except Exception:
|
|
@@ -7831,6 +7856,12 @@ def _api_call_direct(endpoint: str, max_retries: int = 3, **kwargs) -> Optional[
|
|
|
7831
7856
|
return None
|
|
7832
7857
|
|
|
7833
7858
|
|
|
7859
|
+
def _is_client_error(exc):
|
|
7860
|
+
"""4xx other than 429: the same request cannot succeed on retry."""
|
|
7861
|
+
status = getattr(getattr(exc, 'response', None), 'status_code', None)
|
|
7862
|
+
return status is not None and 400 <= status < 500 and status != 429
|
|
7863
|
+
|
|
7864
|
+
|
|
7834
7865
|
def api_call(endpoint, cache_config=None, debug=False, max_retries=3, skip_parquet_cache=False,
|
|
7835
7866
|
_chunk_info=None, **kwargs):
|
|
7836
7867
|
"""
|
|
@@ -7927,6 +7958,8 @@ def api_call(endpoint, cache_config=None, debug=False, max_retries=3, skip_parqu
|
|
|
7927
7958
|
|
|
7928
7959
|
except RETRYABLE_ERRORS as e:
|
|
7929
7960
|
last_exception = e
|
|
7961
|
+
if _is_client_error(e):
|
|
7962
|
+
raise
|
|
7930
7963
|
if debug_level >= 1:
|
|
7931
7964
|
print(f"[RETRY] Network error on attempt {attempt + 1}/{max_retries}: {type(e).__name__}: {e}")
|
|
7932
7965
|
|
|
@@ -9752,7 +9785,7 @@ class StopLossManager:
|
|
|
9752
9785
|
|
|
9753
9786
|
# --- Stock-based strategies (no option legs for intraday SL) ---
|
|
9754
9787
|
|
|
9755
|
-
elif strategy_type in
|
|
9788
|
+
elif strategy_type in STOCK_STRATEGY_TYPES:
|
|
9756
9789
|
pass
|
|
9757
9790
|
|
|
9758
9791
|
elif strategy_type in ('COVERED_CALL', 'COVERED_CALL_ATM', 'COVERED_CALL_25D', 'COVERED_PUT', 'COVERED_PUT_ATM', 'COVERED_PUT_25D') or 'COVERED_CALL' in strategy_type.upper() or 'COVERED_PUT' in strategy_type.upper():
|
|
@@ -10452,10 +10485,9 @@ class StopLossManager:
|
|
|
10452
10485
|
pnl_from_baseline = current_value - baseline_close_cost # change in close cost
|
|
10453
10486
|
denom = abs(baseline_close_cost)
|
|
10454
10487
|
else:
|
|
10455
|
-
#
|
|
10456
|
-
|
|
10457
|
-
|
|
10458
|
-
pnl_from_baseline = position_value_now - entry_value
|
|
10488
|
+
# Simple credit (straddle, single leg): entry_value is the 09:30 close cost
|
|
10489
|
+
# (or the credit before any reset); current_value is -close cost now
|
|
10490
|
+
pnl_from_baseline = abs(entry_value) + current_value
|
|
10459
10491
|
denom = abs(entry_value)
|
|
10460
10492
|
intraday_pnl_pct = (pnl_from_baseline / denom) * 100 if denom != 0 else 0
|
|
10461
10493
|
else:
|
|
@@ -11323,6 +11355,18 @@ class StopLossManager:
|
|
|
11323
11355
|
|
|
11324
11356
|
def _check_pl_loss_stop(self, pos, kwargs):
|
|
11325
11357
|
"""Stop-loss based on actual P&L"""
|
|
11358
|
+
# Stock positions: entry_price may be per share or a whole-position dollar value, so measure the
|
|
11359
|
+
# per-share move from underlying_entry_price (per share in both conventions)
|
|
11360
|
+
if pos.get('sl_baseline_reset') and pos.get('strategy_type') in STOCK_STRATEGY_TYPES:
|
|
11361
|
+
base_px = pos.get('underlying_entry_price')
|
|
11362
|
+
px = kwargs.get('underlying_price')
|
|
11363
|
+
if base_px and px:
|
|
11364
|
+
move_pct = (px - base_px) / base_px * 100
|
|
11365
|
+
is_short = pos.get('is_short_bias') or pos.get('strategy_type') in SHORT_STOCK_TYPES
|
|
11366
|
+
stock_pnl_pct = -move_pct if is_short else move_pct
|
|
11367
|
+
stop_level = -pos['stop_value'] * 100
|
|
11368
|
+
return stock_pnl_pct <= stop_level, stop_level, 'pl_loss'
|
|
11369
|
+
|
|
11326
11370
|
pnl_pct = kwargs.get('pnl_pct')
|
|
11327
11371
|
|
|
11328
11372
|
# [EARNINGS SL FIX] Force recalculation when baseline was reset
|
|
@@ -12686,7 +12730,7 @@ class PositionManager:
|
|
|
12686
12730
|
call_intrinsic = max(0, underlying_price - call_strike)
|
|
12687
12731
|
intrinsic_value = call_intrinsic * unit_multiplier * contracts # Only call obligation
|
|
12688
12732
|
|
|
12689
|
-
elif strategy_type in
|
|
12733
|
+
elif strategy_type in STOCK_STRATEGY_TYPES:
|
|
12690
12734
|
intrinsic_value = underlying_price * unit_multiplier * contracts
|
|
12691
12735
|
if self.debug:
|
|
12692
12736
|
print(f"[INTRINSIC SETTLEMENT] {position_id} - {strategy_type}:")
|
|
@@ -12825,7 +12869,16 @@ class PositionManager:
|
|
|
12825
12869
|
# BACKWARD COMPATIBILITY: If 'type' passed but 'strategy_type' not, copy type → strategy_type
|
|
12826
12870
|
if 'strategy_type' not in kwargs and 'type' in kwargs:
|
|
12827
12871
|
kwargs['strategy_type'] = kwargs['type']
|
|
12828
|
-
|
|
12872
|
+
|
|
12873
|
+
# DIRECTIONAL_* pass shares in `quantity` with contracts=0; stock P&L paths use contracts × unit_multiplier
|
|
12874
|
+
if kwargs.get('strategy_type') in ('DIRECTIONAL_LONG', 'DIRECTIONAL_SHORT'):
|
|
12875
|
+
if not kwargs.get('contracts'):
|
|
12876
|
+
kwargs['contracts'] = quantity
|
|
12877
|
+
kwargs.setdefault('unit_multiplier', 1)
|
|
12878
|
+
if not kwargs.get('total_cost'):
|
|
12879
|
+
entry_px = kwargs.get('underlying_entry_price') or entry_price
|
|
12880
|
+
kwargs['total_cost'] = entry_px * kwargs['contracts'] * kwargs['unit_multiplier']
|
|
12881
|
+
|
|
12829
12882
|
# AUTO-DETECT position_type if not provided
|
|
12830
12883
|
if position_type is None:
|
|
12831
12884
|
strategy_type = kwargs.get('strategy_type')
|
|
@@ -13484,7 +13537,7 @@ class PositionManager:
|
|
|
13484
13537
|
_is_stock_position = (
|
|
13485
13538
|
'COVERED_CALL' in strategy_type.upper()
|
|
13486
13539
|
or 'COVERED_PUT' in strategy_type.upper()
|
|
13487
|
-
or strategy_type in
|
|
13540
|
+
or strategy_type in STOCK_STRATEGY_TYPES
|
|
13488
13541
|
)
|
|
13489
13542
|
if _is_stock_position:
|
|
13490
13543
|
# entry_value/current_value are full position values
|
|
@@ -13499,10 +13552,16 @@ class PositionManager:
|
|
|
13499
13552
|
is_credit = StrategyRegistry.is_credit_strategy(
|
|
13500
13553
|
strategy_type=strategy_type, position=position
|
|
13501
13554
|
)
|
|
13555
|
+
# entry_value may be the reset 09:30 baseline; the trade is booked against its cost
|
|
13556
|
+
_orig_cost = abs(position.get('total_cost') or entry_value)
|
|
13502
13557
|
if is_credit:
|
|
13503
|
-
intraday_pnl =
|
|
13558
|
+
intraday_pnl = _orig_cost + current_value
|
|
13559
|
+
_pnl_denom = position.get('entry_max_risk') or position.get('max_risk') or _orig_cost
|
|
13504
13560
|
else:
|
|
13505
|
-
intraday_pnl = current_value -
|
|
13561
|
+
intraday_pnl = current_value - _orig_cost
|
|
13562
|
+
_pnl_denom = _orig_cost
|
|
13563
|
+
if _pnl_denom:
|
|
13564
|
+
intraday_pnl_pct = (intraday_pnl / _pnl_denom) * 100
|
|
13506
13565
|
|
|
13507
13566
|
if self.debug:
|
|
13508
13567
|
print(f" [Stop Loss] Using intraday P&L: {intraday_pnl_pct:.2f}% (${intraday_pnl:.2f}) instead of EOD: {current_pnl_pct:.2f}% (${current_pnl:.2f})")
|
|
@@ -13819,11 +13878,11 @@ class PositionManager:
|
|
|
13819
13878
|
|
|
13820
13879
|
# LONG_STOCK / SHORT_STOCK: no option legs → compute P&L directly from stock price
|
|
13821
13880
|
strategy_type = position.get('strategy_type', self.config.get('strategy_type', 'STRADDLE'))
|
|
13822
|
-
if leg_data is None and strategy_type in
|
|
13881
|
+
if leg_data is None and strategy_type in STOCK_STRATEGY_TYPES:
|
|
13823
13882
|
entry_cost = abs(position.get('total_cost', 0))
|
|
13824
13883
|
contracts = position.get('contracts', 1)
|
|
13825
13884
|
stock_exit_value = stock_price * contracts * _pos_unit_multiplier(position)
|
|
13826
|
-
if strategy_type
|
|
13885
|
+
if strategy_type in SHORT_STOCK_TYPES:
|
|
13827
13886
|
pnl = entry_cost - stock_exit_value
|
|
13828
13887
|
else:
|
|
13829
13888
|
pnl = stock_exit_value - entry_cost
|
|
@@ -14866,7 +14925,7 @@ class PositionManager:
|
|
|
14866
14925
|
# ========================================
|
|
14867
14926
|
# LONG STOCK (pure stock position)
|
|
14868
14927
|
# ========================================
|
|
14869
|
-
elif position_type in
|
|
14928
|
+
elif position_type in STOCK_STRATEGY_TYPES:
|
|
14870
14929
|
contracts = position.get('contracts', 1)
|
|
14871
14930
|
underlying_price = position.get('underlying_entry_price', 0)
|
|
14872
14931
|
stock_value = underlying_price * contracts * _pos_unit_multiplier(position)
|
|
@@ -19278,9 +19337,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
|
|
|
19278
19337
|
_rich_print(f" 📋 DB DTE: 0-{int(db_max_dte)}")
|
|
19279
19338
|
|
|
19280
19339
|
# Get dates that have options data
|
|
19281
|
-
options_dates_result = db.execute_read(
|
|
19282
|
-
SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?
|
|
19283
|
-
""", [symbol]).fetchdf()
|
|
19340
|
+
options_dates_result = db.execute_read(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
|
|
19284
19341
|
options_dates = set(pd.to_datetime(options_dates_result['date']).dt.strftime('%Y-%m-%d'))
|
|
19285
19342
|
|
|
19286
19343
|
_rich_print(f" 🔍 Need: {extended_start} → {end_date}, DTE 0-{required_max_dte}")
|
|
@@ -19436,9 +19493,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
|
|
|
19436
19493
|
if trading_days:
|
|
19437
19494
|
try:
|
|
19438
19495
|
db.refresh_conn()
|
|
19439
|
-
_post_df = db.execute_read(
|
|
19440
|
-
f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
|
|
19441
|
-
).fetchdf()
|
|
19496
|
+
_post_df = db.execute_read(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
|
|
19442
19497
|
_post_dates = set(pd.to_datetime(_post_df['date']).dt.strftime('%Y-%m-%d'))
|
|
19443
19498
|
_req_min = pd.to_datetime(extended_start)
|
|
19444
19499
|
_req_max = pd.to_datetime(end_date)
|
|
@@ -20063,9 +20118,7 @@ def _preload_duckdb_SSD_storage(config, cache_config):
|
|
|
20063
20118
|
# Cold-path completeness: every backtest trading day must exist in
|
|
20064
20119
|
# the options table; misses are loud + flag _failed_chunks
|
|
20065
20120
|
try:
|
|
20066
|
-
_cc_df = conn.execute(
|
|
20067
|
-
f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
|
|
20068
|
-
).fetchdf()
|
|
20121
|
+
_cc_df = conn.execute(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
|
|
20069
20122
|
_cc_have = set(pd.to_datetime(_cc_df['date']).dt.strftime('%Y-%m-%d'))
|
|
20070
20123
|
_cc_need = {pd.Timestamp(d).strftime('%Y-%m-%d') for d in backtest_trading_days}
|
|
20071
20124
|
_cc_missing = sorted(_cc_need - _cc_have)
|
|
@@ -21853,8 +21906,11 @@ def _load_futures_for_hedge(config, preloaded):
|
|
|
21853
21906
|
if r.status_code == 200:
|
|
21854
21907
|
data = r.json().get('data', [])
|
|
21855
21908
|
all_rows.extend(data)
|
|
21856
|
-
|
|
21857
|
-
|
|
21909
|
+
else:
|
|
21910
|
+
print(f" ⚠️ futures/eod/prices {fut_root} {current:%Y-%m-%d}..{chunk_end:%Y-%m-%d}: "
|
|
21911
|
+
f"HTTP {r.status_code} {r.text[:300]}")
|
|
21912
|
+
except Exception as e:
|
|
21913
|
+
print(f" ⚠️ futures/eod/prices {fut_root} {current:%Y-%m-%d}: {e}")
|
|
21858
21914
|
current = chunk_end + _td(days=1)
|
|
21859
21915
|
|
|
21860
21916
|
if all_rows:
|
|
@@ -23682,6 +23738,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
|
|
|
23682
23738
|
# and _try_read_from_duckdb_storage). Setting the old per-row lazy
|
|
23683
23739
|
# patch here would be redundant and wasteful (extra intraday API calls
|
|
23684
23740
|
# for data that is already correct in DuckDB).
|
|
23741
|
+
_activate_runtime_stock_shim(config, result)
|
|
23685
23742
|
_timing.end_stage() # End "Loading Data" timing
|
|
23686
23743
|
return result
|
|
23687
23744
|
# Check if lock was the reason for failure
|
|
@@ -23720,6 +23777,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
|
|
|
23720
23777
|
return None
|
|
23721
23778
|
if config.get('bar_exit') == '16:00':
|
|
23722
23779
|
set_option_patch_context(OptionPatchContext(_sym, _fetch_opt_intraday_duckdb_ram))
|
|
23780
|
+
_activate_runtime_stock_shim(config, result)
|
|
23723
23781
|
_timing.end_stage() # End "Loading Data" timing
|
|
23724
23782
|
return result
|
|
23725
23783
|
print(" ⚠️ DuckDB RAM reader: No cached Parquet data, falling back to API...")
|
|
@@ -24967,7 +25025,7 @@ def parse_earnings_calendar(earnings_df):
|
|
|
24967
25025
|
|
|
24968
25026
|
Returns:
|
|
24969
25027
|
list: [{'date': datetime.date, 'estimate': float, 'reported': float,
|
|
24970
|
-
'time_of_day': str}, ...]
|
|
25028
|
+
'time_of_day': str, 'time_of_day_code': str}, ...]
|
|
24971
25029
|
Sorted by date ascending.
|
|
24972
25030
|
|
|
24973
25031
|
Usage in strategy:
|
|
@@ -24995,11 +25053,13 @@ def parse_earnings_calendar(earnings_df):
|
|
|
24995
25053
|
else:
|
|
24996
25054
|
date_obj = earning_date
|
|
24997
25055
|
|
|
25056
|
+
time_of_day = record.get('time_of_day_code', 'UNK')
|
|
24998
25057
|
earnings_events.append({
|
|
24999
25058
|
'date': date_obj,
|
|
25000
25059
|
'estimate': record.get('estimate'),
|
|
25001
25060
|
'reported': record.get('reported_earning'),
|
|
25002
|
-
'time_of_day':
|
|
25061
|
+
'time_of_day': time_of_day,
|
|
25062
|
+
'time_of_day_code': time_of_day,
|
|
25003
25063
|
})
|
|
25004
25064
|
|
|
25005
25065
|
return sorted(earnings_events, key=lambda x: x['date'])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ivolatility_backtesting
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.150
|
|
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
|
|
@@ -12,4 +12,6 @@ tests/test_2142_fixes.py
|
|
|
12
12
|
tests/test_2144_duckdb_dedup.py
|
|
13
13
|
tests/test_2146_vix_vro.py
|
|
14
14
|
tests/test_2147_cents_multiplier.py
|
|
15
|
-
tests/test_2148_loader_memory.py
|
|
15
|
+
tests/test_2148_loader_memory.py
|
|
16
|
+
tests/test_2149_client_errors.py
|
|
17
|
+
tests/test_2150_prompt_patterns.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.150"
|
|
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,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)
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# Patterns that backtest_master_prompt.md teaches the agent, checked against known answers.
|
|
2
|
+
# Run: python3 tests/test_2150_prompt_patterns.py (or pytest). Offline.
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
|
|
9
|
+
import matplotlib
|
|
10
|
+
matplotlib.use('Agg')
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
sys.path.insert(0, os.path.join(_REPO, 'ivolatility_backtesting'))
|
|
16
|
+
import ivolatility_backtesting as lib # noqa: E402
|
|
17
|
+
|
|
18
|
+
EXP = pd.Timestamp('2024-02-16')
|
|
19
|
+
DAY = dt.date(2024, 1, 5)
|
|
20
|
+
SL20 = {'type': 'pl_loss', 'min_days_before_check': 0, 'pl_loss_settings': {'pl_loss': 0.20},
|
|
21
|
+
'intraday_settings': {'intraday_mode': 'disabled'}}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _chain(put_bid, put_ask, call_bid, call_ask, underlying=460.0):
|
|
25
|
+
rows = []
|
|
26
|
+
for typ, bid, ask, delta in (('P', put_bid, put_ask, -0.5), ('C', call_bid, call_ask, 0.5)):
|
|
27
|
+
rows.append({'date': pd.Timestamp(DAY), 'expiration': EXP, 'strike': 470.0, 'type': typ,
|
|
28
|
+
'bid': bid, 'ask': ask, 'delta': delta, 'iv': 0.2, 'price': (bid + ask) / 2,
|
|
29
|
+
'dte': 42, 'underlying_price': underlying})
|
|
30
|
+
return pd.DataFrame(rows)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _stock_row(price):
|
|
34
|
+
return pd.DataFrame([{'date': pd.Timestamp(DAY), 'close': price, 'open': price, 'high': price, 'low': price}])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pm(strategy_type, sl=SL20):
|
|
38
|
+
return lib.PositionManager({'symbol': 'SPY', 'debuginfo': 0, 'strategy_type': strategy_type,
|
|
39
|
+
'stop_loss_enabled': True, 'stop_loss_config': sl}, debug=False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _price_data(pm, stock_price, chain):
|
|
43
|
+
return pm.build_price_data(DAY, stock_price, chain,
|
|
44
|
+
lambda s, e, t: lib.get_option_by_strike_exp(chain, s, e, t),
|
|
45
|
+
stock_row=_stock_row(stock_price))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _short_option(strategy_type, chain):
|
|
49
|
+
pm = _pm(strategy_type)
|
|
50
|
+
pm.open_position(position_id='P1', symbol='SPY', entry_date=dt.date(2024, 1, 2), entry_price=0,
|
|
51
|
+
quantity=100, strategy_type=strategy_type, contracts=1, total_cost=-500.0,
|
|
52
|
+
is_short_bias=True, entry_max_risk=500.0, strike=470.0, expiration=EXP,
|
|
53
|
+
underlying_entry_price=470.0)
|
|
54
|
+
pdx = _price_data(pm, 460.0, chain)
|
|
55
|
+
return pm, pdx
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_short_single_leg_buys_back_at_ask():
|
|
59
|
+
# credit 500, buy back at ask 10.00 x 100 = 1000 -> P&L -500
|
|
60
|
+
put_rich = _chain(9.9, 10.0, 1.0, 1.1)
|
|
61
|
+
call_rich = _chain(1.0, 1.1, 9.9, 10.0)
|
|
62
|
+
for strategy_type, chain in (('SHORT_PUT', put_rich), ('PUT_ATM', put_rich),
|
|
63
|
+
('SHORT_CALL', call_rich), ('CALL', call_rich)):
|
|
64
|
+
pm, pdx = _short_option(strategy_type, chain)
|
|
65
|
+
assert round(pdx['P1']['pnl']) == -500, (strategy_type, pdx['P1']['pnl'])
|
|
66
|
+
hits = pm.check_positions(DAY, pdx, underlying_price=460.0)
|
|
67
|
+
assert [h['exit_reason'] for h in hits] == ['stop_loss_pl_loss'], (strategy_type, hits)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_short_single_leg_profit_when_option_decays():
|
|
71
|
+
put_cheap = _chain(0.9, 1.0, 9.9, 10.0)
|
|
72
|
+
pm, pdx = _short_option('SHORT_PUT', put_cheap)
|
|
73
|
+
assert round(pdx['P1']['pnl']) == 400, pdx['P1']['pnl']
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_long_call_unchanged():
|
|
77
|
+
chain = _chain(1.0, 1.1, 9.9, 10.0)
|
|
78
|
+
pm = _pm('CALL')
|
|
79
|
+
pm.open_position(position_id='P1', symbol='SPY', entry_date=dt.date(2024, 1, 2), entry_price=5.0,
|
|
80
|
+
quantity=100, strategy_type='CALL', contracts=1, total_cost=500.0,
|
|
81
|
+
entry_max_risk=500.0, strike=470.0, expiration=EXP, underlying_entry_price=470.0)
|
|
82
|
+
pdx = _price_data(pm, 460.0, chain)
|
|
83
|
+
assert round(pdx['P1']['pnl']) == 490, pdx['P1']['pnl']
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_multi_leg_close_cost_unchanged():
|
|
87
|
+
expected = {
|
|
88
|
+
'IRON_CONDOR': (-320.0, 320.0), 'IRON_BUTTERFLY': (-320.0, 320.0),
|
|
89
|
+
'BEAR_CALL_SPREAD': (-160.0, 160.0), 'BULL_PUT_SPREAD': (-160.0, 160.0),
|
|
90
|
+
'BULL_CALL_SPREAD': (240.0, 160.0), 'STRADDLE': (680.0, 600.0), 'STRANGLE': (680.0, 600.0),
|
|
91
|
+
'CALENDAR_SPREAD': (680.0, 600.0), 'COVERED_CALL': (680.0, 600.0),
|
|
92
|
+
}
|
|
93
|
+
reg = lib.StrategyRegistry
|
|
94
|
+
for strategy_type, (credit_cost, debit_cost) in expected.items():
|
|
95
|
+
legs = [leg.get('field_prefix', leg['name']) for leg in reg.get(strategy_type)['legs']]
|
|
96
|
+
leg_data = {p: {'bid': 1.0 + i, 'ask': 1.2 + i} for i, p in enumerate(legs)}
|
|
97
|
+
got = []
|
|
98
|
+
for credit in (True, False):
|
|
99
|
+
pos = {'contracts': 2, 'unit_multiplier': 100, 'entry_price': 0 if credit else 3.0,
|
|
100
|
+
'total_cost': -300.0 if credit else 300.0, 'is_short_bias': credit}
|
|
101
|
+
got.append(round(reg.calculate_close_cost(strategy_type, pos, leg_data), 6))
|
|
102
|
+
assert tuple(got) == (credit_cost, debit_cost), (strategy_type, got)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _directional(strategy_type, side, entry, now, sl_pct=0.03, with_total_cost=True):
|
|
106
|
+
sl = dict(SL20, pl_loss_settings={'pl_loss': sl_pct})
|
|
107
|
+
pm = _pm(strategy_type, sl=sl)
|
|
108
|
+
shares = 10
|
|
109
|
+
extra = {'total_cost': entry * shares} if with_total_cost else {}
|
|
110
|
+
pm.open_position(position_id='D1', strategy_type=strategy_type, symbol='SPY',
|
|
111
|
+
entry_date=dt.date(2024, 1, 2), entry_price=entry, entry_max_risk=entry * shares,
|
|
112
|
+
quantity=shares, contracts=0, is_short_bias=(side == 'SHORT'), position_type=side,
|
|
113
|
+
underlying_entry_price=entry, **extra)
|
|
114
|
+
pdx = _price_data(pm, now, pd.DataFrame())
|
|
115
|
+
return pm, pdx
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_directional_stock_positions_have_pnl_and_stop():
|
|
119
|
+
for strategy_type, side, now in (('DIRECTIONAL_LONG', 'LONG', 90.0), ('DIRECTIONAL_SHORT', 'SHORT', 110.0)):
|
|
120
|
+
pm, pdx = _directional(strategy_type, side, 100.0, now)
|
|
121
|
+
assert 'D1' in pdx, strategy_type
|
|
122
|
+
assert round(pdx['D1']['pnl']) == -100, (strategy_type, pdx['D1'])
|
|
123
|
+
hits = pm.check_positions(DAY, pdx, underlying_price=now)
|
|
124
|
+
assert [h['exit_reason'] for h in hits] == ['stop_loss_pl_loss'], (strategy_type, hits)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_directional_without_total_cost_uses_entry_value():
|
|
128
|
+
for strategy_type, side in (('DIRECTIONAL_LONG', 'LONG'), ('DIRECTIONAL_SHORT', 'SHORT')):
|
|
129
|
+
pm, pdx = _directional(strategy_type, side, 100.0, 100.0, sl_pct=0.5, with_total_cost=False)
|
|
130
|
+
assert round(pdx['D1']['pnl']) == 0, (strategy_type, pdx['D1'])
|
|
131
|
+
pm, pdx = _directional(strategy_type, side, 100.0, 90.0 if side == 'LONG' else 110.0, sl_pct=0.5, with_total_cost=False)
|
|
132
|
+
assert round(pdx['D1']['pnl']) == -100, (strategy_type, pdx['D1'])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_directional_signal_close_keeps_pnl_and_label():
|
|
136
|
+
pm, pdx = _directional('DIRECTIONAL_LONG', 'LONG', 100.0, 110.0, sl_pct=0.5)
|
|
137
|
+
pnl = pm.close_position_by_signal('D1', DAY, pdx, 'z_score_exit')
|
|
138
|
+
assert round(pnl) == 100, pnl
|
|
139
|
+
trade = pm.get_closed_trades()[-1]
|
|
140
|
+
assert round(trade['pnl']) == 100, trade
|
|
141
|
+
assert trade.get('strategy_type') == 'DIRECTIONAL_LONG', trade.get('strategy_type')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _stock_100(strategy_type, sl, now):
|
|
145
|
+
pm = _pm(strategy_type, sl=dict(sl, min_days_before_check=0))
|
|
146
|
+
short = strategy_type in ('SHORT_STOCK', 'DIRECTIONAL_SHORT')
|
|
147
|
+
pm.open_position(position_id='S1', strategy_type=strategy_type, symbol='SPY', entry_date=dt.date(2024, 1, 2),
|
|
148
|
+
entry_price=100.0, quantity=100, contracts=100, unit_multiplier=1, total_cost=10000.0,
|
|
149
|
+
entry_max_risk=10000.0, is_short_bias=short, position_type='SHORT' if short else 'LONG',
|
|
150
|
+
underlying_entry_price=100.0)
|
|
151
|
+
pdx = _price_data(pm, now, pd.DataFrame())
|
|
152
|
+
return [h['exit_reason'] for h in pm.check_positions(DAY, pdx, underlying_price=now)]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_stock_pl_loss_with_baseline_reset_uses_per_share_move():
|
|
156
|
+
sl = {'type': 'pl_loss', 'pl_loss_settings': {'pl_loss': 0.03}, 'reset_baseline_on_sl_start': True,
|
|
157
|
+
'intraday_settings': {'intraday_mode': 'disabled'}}
|
|
158
|
+
for strategy_type, small, big in (('DIRECTIONAL_SHORT', 100.3, 103.5), ('SHORT_STOCK', 100.3, 103.5),
|
|
159
|
+
('DIRECTIONAL_LONG', 99.7, 96.5), ('LONG_STOCK', 99.7, 96.5)):
|
|
160
|
+
assert _stock_100(strategy_type, sl, small) == [], (strategy_type, small)
|
|
161
|
+
assert _stock_100(strategy_type, sl, big) == ['stop_loss_pl_loss'], (strategy_type, big)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_stock_pl_loss_reset_with_dollar_entry_price():
|
|
165
|
+
sl = {'type': 'pl_loss', 'pl_loss_settings': {'pl_loss': 0.50}, 'reset_baseline_on_sl_start': True,
|
|
166
|
+
'min_days_before_check': 0, 'intraday_settings': {'intraday_mode': 'disabled'}}
|
|
167
|
+
|
|
168
|
+
def run(strategy_type, now):
|
|
169
|
+
pm = _pm(strategy_type, sl=sl)
|
|
170
|
+
short = strategy_type == 'SHORT_STOCK'
|
|
171
|
+
pm.open_position(position_id='G1', strategy_type=strategy_type, symbol='SPY', entry_date=dt.date(2024, 1, 2),
|
|
172
|
+
entry_price=10000.0, quantity=100, contracts=1, total_cost=10000.0, entry_max_risk=10000.0,
|
|
173
|
+
is_short_bias=short, position_type='SHORT' if short else 'LONG', underlying_entry_price=100.0)
|
|
174
|
+
pdx = _price_data(pm, now, pd.DataFrame())
|
|
175
|
+
return [h['exit_reason'] for h in pm.check_positions(DAY, pdx, underlying_price=now)]
|
|
176
|
+
|
|
177
|
+
assert run('LONG_STOCK', 100.5) == []
|
|
178
|
+
assert run('LONG_STOCK', 49.0) == ['stop_loss_pl_loss']
|
|
179
|
+
assert run('SHORT_STOCK', 160.0) == ['stop_loss_pl_loss']
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_long_stock_price_stops_do_not_fire_on_gains():
|
|
183
|
+
for sl in ({'type': 'fixed_pct', 'value': 0.03}, {'type': 'trailing', 'value': 0.03, 'trailing_distance': 0.03}):
|
|
184
|
+
for strategy_type in ('LONG_STOCK', 'DIRECTIONAL_LONG'):
|
|
185
|
+
assert _stock_100(strategy_type, sl, 103.0) == [], (strategy_type, sl['type'])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_long_short_stock_unchanged():
|
|
189
|
+
for strategy_type, now in (('LONG_STOCK', 90.0), ('SHORT_STOCK', 110.0)):
|
|
190
|
+
pm = _pm(strategy_type, sl=dict(SL20, pl_loss_settings={'pl_loss': 0.03}))
|
|
191
|
+
pm.open_position(position_id='S1', strategy_type=strategy_type, symbol='SPY',
|
|
192
|
+
entry_date=dt.date(2024, 1, 2), entry_price=100.0, quantity=10, contracts=10,
|
|
193
|
+
unit_multiplier=1, total_cost=1000.0, entry_max_risk=1000.0,
|
|
194
|
+
is_short_bias=(strategy_type == 'SHORT_STOCK'), underlying_entry_price=100.0)
|
|
195
|
+
pdx = _price_data(pm, now, pd.DataFrame())
|
|
196
|
+
assert round(pdx['S1']['pnl']) == -100, (strategy_type, pdx['S1'])
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_earnings_calendar_keeps_time_of_day_code():
|
|
200
|
+
df = pd.DataFrame([{'earning_date': '2024-01-12', 'estimate': 3.3, 'reported_earning': 3.9, 'time_of_day_code': 'BMO'},
|
|
201
|
+
{'earning_date': '2024-04-12', 'estimate': 4.1, 'reported_earning': 4.4, 'time_of_day_code': 'AMC'}])
|
|
202
|
+
events = lib.parse_earnings_calendar(df)
|
|
203
|
+
assert [e['time_of_day_code'] for e in events] == ['BMO', 'AMC'], events
|
|
204
|
+
assert [e['time_of_day'] for e in events] == ['BMO', 'AMC'], events
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_package_exports_parse_earnings_calendar():
|
|
208
|
+
r = subprocess.run([sys.executable, '-c', 'from ivolatility_backtesting import parse_earnings_calendar'],
|
|
209
|
+
cwd=_REPO, capture_output=True, text=True)
|
|
210
|
+
assert r.returncode == 0, r.stderr[-400:]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_duckdb_preload_returns_option_aligned_stock():
|
|
214
|
+
adjusted = pd.DataFrame({'date': pd.to_datetime(['2024-01-02', '2024-01-03']),
|
|
215
|
+
'close': [48.17, 47.57], 'unadjusted_close': [481.68, 475.69],
|
|
216
|
+
'runtime_close': [481.68, 475.69], 'split_factor': [10.0, 10.0]})
|
|
217
|
+
canned = {'_preloaded_stock': adjusted.copy(),
|
|
218
|
+
'_preloaded_options': pd.DataFrame({'date': pd.to_datetime(['2024-01-02'])})}
|
|
219
|
+
saved_ssd, saved_ram = lib._preload_duckdb_SSD_storage, lib._try_duckdb_preload
|
|
220
|
+
try:
|
|
221
|
+
lib._preload_duckdb_SSD_storage = lambda config, cache_config: {k: v.copy() for k, v in canned.items()}
|
|
222
|
+
lib._try_duckdb_preload = lambda config, cache_config, debug=False: {k: v.copy() for k, v in canned.items()}
|
|
223
|
+
for mode in ('duckdb_SSD_storage', 'duckdb_RAM_reader'):
|
|
224
|
+
cfg = {'symbol': 'NVDA', 'start_date': '2024-01-02', 'end_date': '2024-01-03', 'debuginfo': 0,
|
|
225
|
+
'strategy_type': 'STRADDLE', 'cache_config': {'cache_mode': mode}}
|
|
226
|
+
out = lib.preload_data_universal(cfg)
|
|
227
|
+
assert list(out['_preloaded_stock']['close'].round(2)) == [481.68, 475.69], (mode, out['_preloaded_stock'])
|
|
228
|
+
assert list(out['_preloaded_stock_adjusted']['close'].round(2)) == [48.17, 47.57], mode
|
|
229
|
+
finally:
|
|
230
|
+
lib._preload_duckdb_SSD_storage, lib._try_duckdb_preload = saved_ssd, saved_ram
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# --- event-day stop (reset_baseline_on_sl_start) with intraday bars -------------------------------
|
|
234
|
+
|
|
235
|
+
def _event_day_stop(strategy_type, pl_loss, bars_by_time, eod_chain, **open_kwargs):
|
|
236
|
+
sl = {'type': 'pl_loss', 'min_days_before_check': 0, 'pl_loss_settings': {'pl_loss': pl_loss},
|
|
237
|
+
'reset_baseline_on_sl_start': True,
|
|
238
|
+
'intraday_settings': {'intraday_mode': 'auto', 'minute_interval': 'MINUTE_5',
|
|
239
|
+
'reset_baseline_on_sl_start': True}}
|
|
240
|
+
pm = _pm(strategy_type, sl)
|
|
241
|
+
pm.sl_manager._get_legs_intraday_data = lambda *a, **k: (bars_by_time, sorted(bars_by_time))
|
|
242
|
+
pm.open_position(position_id='P1', symbol='SPY', entry_date=dt.date(2024, 1, 2), quantity=100,
|
|
243
|
+
strategy_type=strategy_type, contracts=1, strike=470.0, expiration=EXP,
|
|
244
|
+
underlying_entry_price=460.0, **open_kwargs)
|
|
245
|
+
pdx = _price_data(pm, 460.0, eod_chain)
|
|
246
|
+
return pm.check_positions(DAY, pdx, underlying_price=460.0)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _put_bars(asks):
|
|
250
|
+
return {t: [{'strike': 470.0, 'type': 'P', 'expiration': EXP, 'bid': a - 0.05, 'ask': a}]
|
|
251
|
+
for t, a in asks.items()}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
SHORT_500 = dict(entry_price=0, total_cost=-500.0, is_short_bias=True, entry_max_risk=500.0)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_event_day_short_put_no_stop_without_a_real_loss():
|
|
258
|
+
for label, asks, thr in (('flat', {'09:30': 5.0, '09:35': 5.0, '15:55': 5.0}, 1.0),
|
|
259
|
+
('adverse then recovers', {'09:30': 5.0, '09:35': 5.05, '15:55': 4.9}, 1.0),
|
|
260
|
+
('favorable', {'09:30': 5.0, '09:35': 4.0, '15:55': 4.0}, 0.4)):
|
|
261
|
+
eod_ask = asks['15:55']
|
|
262
|
+
hits = _event_day_stop('SHORT_PUT', thr, _put_bars(asks),
|
|
263
|
+
_chain(eod_ask - 0.05, eod_ask, 1.0, 1.1), **SHORT_500)
|
|
264
|
+
assert hits == [], (label, hits)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_event_day_short_put_real_loss_books_pnl_from_premium():
|
|
268
|
+
hits = _event_day_stop('SHORT_PUT', 1.0, _put_bars({'09:30': 5.0, '09:35': 11.0, '15:55': 11.0}),
|
|
269
|
+
_chain(10.95, 11.0, 1.0, 1.1), **SHORT_500)
|
|
270
|
+
assert len(hits) == 1 and hits[0]['options_stop_trigger_time'] == '09:35', hits
|
|
271
|
+
assert round(hits[0]['pnl']) == -600 and round(hits[0]['pnl_pct']) == -120, hits[0]
|
|
272
|
+
hits = _event_day_stop('SHORT_PUT', 0.4, _put_bars({'09:30': 5.0, '09:35': 7.5, '15:55': 7.5}),
|
|
273
|
+
_chain(7.45, 7.5, 1.0, 1.1), **SHORT_500)
|
|
274
|
+
assert len(hits) == 1 and hits[0]['options_stop_trigger_time'] == '09:35', hits
|
|
275
|
+
assert round(hits[0]['pnl']) == -250 and round(hits[0]['pnl_pct']) == -50, hits[0]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_event_day_long_call_books_pnl_from_premium_not_from_0930():
|
|
279
|
+
bars = {t: [{'strike': 470.0, 'type': 'C', 'expiration': EXP, 'bid': b, 'ask': b + 0.05}]
|
|
280
|
+
for t, b in {'09:30': 3.0, '09:35': 1.5, '15:55': 1.5}.items()}
|
|
281
|
+
hits = _event_day_stop('CALL', 0.4, bars, _chain(1.0, 1.1, 1.5, 1.55),
|
|
282
|
+
entry_price=500.0, total_cost=500.0, call_entry_bid=5.0)
|
|
283
|
+
assert len(hits) == 1 and hits[0]['options_stop_trigger_time'] == '09:35', hits
|
|
284
|
+
assert round(hits[0]['pnl']) == -350, hits[0]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _cache_gap_probe(puts_missing_on):
|
|
288
|
+
import contextlib, io, tempfile
|
|
289
|
+
tmp = tempfile.mkdtemp(prefix='ivb_cache_')
|
|
290
|
+
cc = lib._normalize_cache_config({'cache_mode': 'duckdb_SSD_storage', 'cache_dir': tmp, 'disk_enabled': True})
|
|
291
|
+
conn = lib._get_duckdb_storage_conn(cc)
|
|
292
|
+
for table in ('stock_eod', 'options_eod_1545'):
|
|
293
|
+
conn.execute(f"DELETE FROM {table} WHERE symbol = 'AAPL'")
|
|
294
|
+
days = [d.date() for d in pd.bdate_range('2024-01-02', '2024-01-31')]
|
|
295
|
+
exp = dt.date(2024, 2, 16)
|
|
296
|
+
stock = pd.DataFrame({'symbol': 'AAPL', 'date': days, 'open': 180.0, 'high': 182.0, 'low': 179.0, 'close': 181.0,
|
|
297
|
+
'volume': 1e6, 'bid': 180.9, 'ask': 181.1, 'unadjusted_close': 181.0, 'source_endpoint': 't'})
|
|
298
|
+
conn.execute("INSERT INTO stock_eod SELECT * FROM stock")
|
|
299
|
+
rows = [dict(option_symbol=f'AAPL_{exp}_{k}_{t}', date=d, symbol='AAPL', expiration=exp, strike=k, type=t,
|
|
300
|
+
bid=1.0, ask=1.2, price=1.1, underlying_price=181.0, iv=0.25, delta=0.5 if t == 'C' else -0.5,
|
|
301
|
+
gamma=0.01, theta=-0.05, vega=0.1, rho=0.01, dte=float((exp - d).days), volume=10.0,
|
|
302
|
+
open_interest=100.0, source_endpoint='t', bid_eod=1.0, ask_eod=1.2)
|
|
303
|
+
for d in days for t in ('C', 'P') for k in (180.0, 185.0)
|
|
304
|
+
if not (t == 'P' and puts_missing_on(d))]
|
|
305
|
+
opts = pd.DataFrame(rows)
|
|
306
|
+
conn.execute("INSERT INTO options_eod_1545 SELECT * FROM opts")
|
|
307
|
+
config = {'symbol': 'AAPL', 'start_date': '2024-01-08', 'end_date': '2024-01-26', 'dte_target': 35,
|
|
308
|
+
'dte_tolerance': 10, 'options_snapshot_mode': '1545', 'use_duckdb_indicators': True,
|
|
309
|
+
'cache_config': cc, 'debuginfo': 0}
|
|
310
|
+
fetched = []
|
|
311
|
+
saved = {n: getattr(lib, n) for n in ('_fetch_date_range_data', '_load_stock_to_duckdb', '_load_ivx_to_duckdb',
|
|
312
|
+
'_patch_eod_prices_to_1545', '_load_futures_for_hedge', '_attach_vro_settlement')}
|
|
313
|
+
try:
|
|
314
|
+
lib._fetch_date_range_data = lambda cfg, c, sym, s, e, dfrom, dto: (fetched.append((s, e)), 0)[1]
|
|
315
|
+
lib._load_stock_to_duckdb = lambda *a, **k: None
|
|
316
|
+
lib._load_ivx_to_duckdb = lambda *a, **k: None
|
|
317
|
+
lib._patch_eod_prices_to_1545 = lambda *a, **k: {}
|
|
318
|
+
lib._load_futures_for_hedge = lambda *a, **k: None
|
|
319
|
+
lib._attach_vro_settlement = lambda *a, **k: None
|
|
320
|
+
lib._clear_gap_detection_cache()
|
|
321
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
322
|
+
result = lib._try_read_from_duckdb_storage(config, cc, 'AAPL', '2024-01-02', '2024-01-31', '2024-01-08')
|
|
323
|
+
finally:
|
|
324
|
+
for n, f in saved.items():
|
|
325
|
+
setattr(lib, n, f)
|
|
326
|
+
return result is None, fetched
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_duckdb_cache_days_with_only_calls_are_refetched():
|
|
330
|
+
rejected, fetched = _cache_gap_probe(lambda d: d.day in (10, 11, 12))
|
|
331
|
+
assert not rejected and fetched, 'calls-only days were accepted as complete'
|
|
332
|
+
assert fetched[0][0] <= '2024-01-10' and fetched[-1][1] >= '2024-01-12', fetched
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_duckdb_cache_all_days_calls_only_forces_full_reload():
|
|
336
|
+
rejected, fetched = _cache_gap_probe(lambda d: True)
|
|
337
|
+
assert rejected and fetched == []
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_duckdb_cache_complete_days_are_not_refetched():
|
|
341
|
+
rejected, fetched = _cache_gap_probe(lambda d: False)
|
|
342
|
+
assert not rejected and fetched == []
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
if __name__ == '__main__':
|
|
346
|
+
failed = 0
|
|
347
|
+
for name, fn in list(globals().items()):
|
|
348
|
+
if name.startswith('test_') and callable(fn):
|
|
349
|
+
try:
|
|
350
|
+
fn()
|
|
351
|
+
print(f'PASS {name}')
|
|
352
|
+
except Exception:
|
|
353
|
+
failed += 1
|
|
354
|
+
print(f'FAIL {name}')
|
|
355
|
+
traceback.print_exc()
|
|
356
|
+
sys.exit(1 if failed else 0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2144_duckdb_dedup.py
RENAMED
|
File without changes
|
|
File without changes
|
{ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2147_cents_multiplier.py
RENAMED
|
File without changes
|
{ivolatility_backtesting-2.148 → ivolatility_backtesting-2.150}/tests/test_2148_loader_memory.py
RENAMED
|
File without changes
|