ivolatility-backtesting 2.149__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.149 → ivolatility_backtesting-2.150}/PKG-INFO +1 -1
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting/__init__.py +2 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting/ivolatility_backtesting.py +68 -33
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/PKG-INFO +1 -1
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/SOURCES.txt +2 -1
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/pyproject.toml +1 -1
- ivolatility_backtesting-2.150/tests/test_2150_prompt_patterns.py +356 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/README.md +0 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/requires.txt +0 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/setup.cfg +0 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2142_fixes.py +0 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2144_duckdb_dedup.py +0 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2146_vix_vro.py +0 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2147_cents_multiplier.py +0 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2148_loader_memory.py +0 -0
- {ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2149_client_errors.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
|
{ivolatility_backtesting-2.149 → 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
|
]
|
|
@@ -758,6 +758,12 @@ def _get_options_eod_table(config_like: Optional[Dict[str, Any]]) -> str:
|
|
|
758
758
|
return 'options_eod_1545' if snap == '1545' else 'options_eod_close'
|
|
759
759
|
|
|
760
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
|
+
|
|
761
767
|
def _get_legacy_1545_duckdb_path(config_like: Optional[Dict[str, Any]]) -> str:
|
|
762
768
|
"""Legacy pre-refactor 15:45 DuckDB path kept for one-time migration."""
|
|
763
769
|
cache_dir = _resolve_path((config_like or {}).get('cache_dir', 'cache'))
|
|
@@ -3740,6 +3746,11 @@ STRATEGIES = {
|
|
|
3740
3746
|
},
|
|
3741
3747
|
}
|
|
3742
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
|
+
|
|
3743
3754
|
# Backwards compatibility mapping (lowercase to UPPERCASE)
|
|
3744
3755
|
_STRATEGY_NAME_MAP = {
|
|
3745
3756
|
'iron_condor': 'IRON_CONDOR',
|
|
@@ -6152,7 +6163,7 @@ class StrategyRegistry:
|
|
|
6152
6163
|
call_premium = position.get('call_premium', abs(credit))
|
|
6153
6164
|
capital_at_risk = max(stock_value - call_premium, stock_value * 0.8)
|
|
6154
6165
|
locked_capital = stock_value
|
|
6155
|
-
elif position_type in
|
|
6166
|
+
elif position_type in STOCK_STRATEGY_TYPES:
|
|
6156
6167
|
stock_value = underlying_price * contracts * um
|
|
6157
6168
|
capital_at_risk = stock_value
|
|
6158
6169
|
locked_capital = stock_value
|
|
@@ -6424,13 +6435,13 @@ class StrategyRegistry:
|
|
|
6424
6435
|
# Universal CREDIT detection (works for ANY strategy)
|
|
6425
6436
|
is_credit = cls.is_credit_strategy(strategy_type=strategy_type, position=position)
|
|
6426
6437
|
|
|
6427
|
-
# ⚠️ CRITICAL:
|
|
6428
|
-
#
|
|
6429
|
-
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'):
|
|
6430
6441
|
# Check if NO legs have 'short' prefix (simple STRADDLE/STRANGLE)
|
|
6431
6442
|
has_short_prefix = any('short' in l['name'].lower() for l in legs)
|
|
6432
6443
|
if not has_short_prefix:
|
|
6433
|
-
#
|
|
6444
|
+
# STRADDLE/STRANGLE, SHORT_PUT/SHORT_CALL: all legs are SHORT for CREDIT
|
|
6434
6445
|
is_short_leg = True
|
|
6435
6446
|
|
|
6436
6447
|
# ⚠️ FIX: For DEBIT strategies used as CREDIT (e.g. DCS SELL, calendar SELL)
|
|
@@ -6634,7 +6645,7 @@ class StrategyRegistry:
|
|
|
6634
6645
|
# total_cost is negative (credit from selling call), so adding it reduces risk
|
|
6635
6646
|
return (stock_price * um * contracts) + total_cost # subtract call premium
|
|
6636
6647
|
|
|
6637
|
-
elif strategy_type in
|
|
6648
|
+
elif strategy_type in STOCK_STRATEGY_TYPES:
|
|
6638
6649
|
stock_price = position_params.get('stock_price', position_params.get('underlying_entry_price', 0))
|
|
6639
6650
|
return stock_price * um * contracts
|
|
6640
6651
|
|
|
@@ -6833,13 +6844,13 @@ class StrategyRegistry:
|
|
|
6833
6844
|
# doubling the call bid while completely missing the stock value.
|
|
6834
6845
|
# Fix: compute P&L directly using underlying_price (available here) + call leg.
|
|
6835
6846
|
# ── LONG_STOCK / SHORT_STOCK — pure stock position, no option legs ──
|
|
6836
|
-
if strategy_type in
|
|
6847
|
+
if strategy_type in STOCK_STRATEGY_TYPES:
|
|
6837
6848
|
stock_exit_value = underlying_price * contracts * um
|
|
6838
|
-
if strategy_type
|
|
6849
|
+
if strategy_type in SHORT_STOCK_TYPES:
|
|
6839
6850
|
pnl = entry_cost - stock_exit_value # short: profit when price drops
|
|
6840
6851
|
else:
|
|
6841
6852
|
pnl = stock_exit_value - entry_cost # long: profit when price rises
|
|
6842
|
-
is_credit = (strategy_type
|
|
6853
|
+
is_credit = (strategy_type in SHORT_STOCK_TYPES)
|
|
6843
6854
|
close_cost = stock_exit_value
|
|
6844
6855
|
# ── COVERED_CALL / COVERED_PUT — stock + option leg ──────────
|
|
6845
6856
|
elif (strategy_type in ('COVERED_CALL', 'COVERED_CALL_ATM', 'COVERED_CALL_25D', 'COVERED_PUT', 'COVERED_PUT_ATM', 'COVERED_PUT_25D')
|
|
@@ -9774,7 +9785,7 @@ class StopLossManager:
|
|
|
9774
9785
|
|
|
9775
9786
|
# --- Stock-based strategies (no option legs for intraday SL) ---
|
|
9776
9787
|
|
|
9777
|
-
elif strategy_type in
|
|
9788
|
+
elif strategy_type in STOCK_STRATEGY_TYPES:
|
|
9778
9789
|
pass
|
|
9779
9790
|
|
|
9780
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():
|
|
@@ -10474,10 +10485,9 @@ class StopLossManager:
|
|
|
10474
10485
|
pnl_from_baseline = current_value - baseline_close_cost # change in close cost
|
|
10475
10486
|
denom = abs(baseline_close_cost)
|
|
10476
10487
|
else:
|
|
10477
|
-
#
|
|
10478
|
-
|
|
10479
|
-
|
|
10480
|
-
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
|
|
10481
10491
|
denom = abs(entry_value)
|
|
10482
10492
|
intraday_pnl_pct = (pnl_from_baseline / denom) * 100 if denom != 0 else 0
|
|
10483
10493
|
else:
|
|
@@ -11345,6 +11355,18 @@ class StopLossManager:
|
|
|
11345
11355
|
|
|
11346
11356
|
def _check_pl_loss_stop(self, pos, kwargs):
|
|
11347
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
|
+
|
|
11348
11370
|
pnl_pct = kwargs.get('pnl_pct')
|
|
11349
11371
|
|
|
11350
11372
|
# [EARNINGS SL FIX] Force recalculation when baseline was reset
|
|
@@ -12708,7 +12730,7 @@ class PositionManager:
|
|
|
12708
12730
|
call_intrinsic = max(0, underlying_price - call_strike)
|
|
12709
12731
|
intrinsic_value = call_intrinsic * unit_multiplier * contracts # Only call obligation
|
|
12710
12732
|
|
|
12711
|
-
elif strategy_type in
|
|
12733
|
+
elif strategy_type in STOCK_STRATEGY_TYPES:
|
|
12712
12734
|
intrinsic_value = underlying_price * unit_multiplier * contracts
|
|
12713
12735
|
if self.debug:
|
|
12714
12736
|
print(f"[INTRINSIC SETTLEMENT] {position_id} - {strategy_type}:")
|
|
@@ -12847,7 +12869,16 @@ class PositionManager:
|
|
|
12847
12869
|
# BACKWARD COMPATIBILITY: If 'type' passed but 'strategy_type' not, copy type → strategy_type
|
|
12848
12870
|
if 'strategy_type' not in kwargs and 'type' in kwargs:
|
|
12849
12871
|
kwargs['strategy_type'] = kwargs['type']
|
|
12850
|
-
|
|
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
|
+
|
|
12851
12882
|
# AUTO-DETECT position_type if not provided
|
|
12852
12883
|
if position_type is None:
|
|
12853
12884
|
strategy_type = kwargs.get('strategy_type')
|
|
@@ -13506,7 +13537,7 @@ class PositionManager:
|
|
|
13506
13537
|
_is_stock_position = (
|
|
13507
13538
|
'COVERED_CALL' in strategy_type.upper()
|
|
13508
13539
|
or 'COVERED_PUT' in strategy_type.upper()
|
|
13509
|
-
or strategy_type in
|
|
13540
|
+
or strategy_type in STOCK_STRATEGY_TYPES
|
|
13510
13541
|
)
|
|
13511
13542
|
if _is_stock_position:
|
|
13512
13543
|
# entry_value/current_value are full position values
|
|
@@ -13521,10 +13552,16 @@ class PositionManager:
|
|
|
13521
13552
|
is_credit = StrategyRegistry.is_credit_strategy(
|
|
13522
13553
|
strategy_type=strategy_type, position=position
|
|
13523
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)
|
|
13524
13557
|
if is_credit:
|
|
13525
|
-
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
|
|
13526
13560
|
else:
|
|
13527
|
-
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
|
|
13528
13565
|
|
|
13529
13566
|
if self.debug:
|
|
13530
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})")
|
|
@@ -13841,11 +13878,11 @@ class PositionManager:
|
|
|
13841
13878
|
|
|
13842
13879
|
# LONG_STOCK / SHORT_STOCK: no option legs → compute P&L directly from stock price
|
|
13843
13880
|
strategy_type = position.get('strategy_type', self.config.get('strategy_type', 'STRADDLE'))
|
|
13844
|
-
if leg_data is None and strategy_type in
|
|
13881
|
+
if leg_data is None and strategy_type in STOCK_STRATEGY_TYPES:
|
|
13845
13882
|
entry_cost = abs(position.get('total_cost', 0))
|
|
13846
13883
|
contracts = position.get('contracts', 1)
|
|
13847
13884
|
stock_exit_value = stock_price * contracts * _pos_unit_multiplier(position)
|
|
13848
|
-
if strategy_type
|
|
13885
|
+
if strategy_type in SHORT_STOCK_TYPES:
|
|
13849
13886
|
pnl = entry_cost - stock_exit_value
|
|
13850
13887
|
else:
|
|
13851
13888
|
pnl = stock_exit_value - entry_cost
|
|
@@ -14888,7 +14925,7 @@ class PositionManager:
|
|
|
14888
14925
|
# ========================================
|
|
14889
14926
|
# LONG STOCK (pure stock position)
|
|
14890
14927
|
# ========================================
|
|
14891
|
-
elif position_type in
|
|
14928
|
+
elif position_type in STOCK_STRATEGY_TYPES:
|
|
14892
14929
|
contracts = position.get('contracts', 1)
|
|
14893
14930
|
underlying_price = position.get('underlying_entry_price', 0)
|
|
14894
14931
|
stock_value = underlying_price * contracts * _pos_unit_multiplier(position)
|
|
@@ -19300,9 +19337,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
|
|
|
19300
19337
|
_rich_print(f" 📋 DB DTE: 0-{int(db_max_dte)}")
|
|
19301
19338
|
|
|
19302
19339
|
# Get dates that have options data
|
|
19303
|
-
options_dates_result = db.execute_read(
|
|
19304
|
-
SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?
|
|
19305
|
-
""", [symbol]).fetchdf()
|
|
19340
|
+
options_dates_result = db.execute_read(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
|
|
19306
19341
|
options_dates = set(pd.to_datetime(options_dates_result['date']).dt.strftime('%Y-%m-%d'))
|
|
19307
19342
|
|
|
19308
19343
|
_rich_print(f" 🔍 Need: {extended_start} → {end_date}, DTE 0-{required_max_dte}")
|
|
@@ -19458,9 +19493,7 @@ def _try_read_from_duckdb_storage(config, cache_config, symbol, extended_start,
|
|
|
19458
19493
|
if trading_days:
|
|
19459
19494
|
try:
|
|
19460
19495
|
db.refresh_conn()
|
|
19461
|
-
_post_df = db.execute_read(
|
|
19462
|
-
f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
|
|
19463
|
-
).fetchdf()
|
|
19496
|
+
_post_df = db.execute_read(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
|
|
19464
19497
|
_post_dates = set(pd.to_datetime(_post_df['date']).dt.strftime('%Y-%m-%d'))
|
|
19465
19498
|
_req_min = pd.to_datetime(extended_start)
|
|
19466
19499
|
_req_max = pd.to_datetime(end_date)
|
|
@@ -20085,9 +20118,7 @@ def _preload_duckdb_SSD_storage(config, cache_config):
|
|
|
20085
20118
|
# Cold-path completeness: every backtest trading day must exist in
|
|
20086
20119
|
# the options table; misses are loud + flag _failed_chunks
|
|
20087
20120
|
try:
|
|
20088
|
-
_cc_df = conn.execute(
|
|
20089
|
-
f"SELECT DISTINCT date FROM {_opt_tbl} WHERE symbol = ?", [symbol]
|
|
20090
|
-
).fetchdf()
|
|
20121
|
+
_cc_df = conn.execute(_complete_option_dates_sql(_opt_tbl), [symbol]).fetchdf()
|
|
20091
20122
|
_cc_have = set(pd.to_datetime(_cc_df['date']).dt.strftime('%Y-%m-%d'))
|
|
20092
20123
|
_cc_need = {pd.Timestamp(d).strftime('%Y-%m-%d') for d in backtest_trading_days}
|
|
20093
20124
|
_cc_missing = sorted(_cc_need - _cc_have)
|
|
@@ -23707,6 +23738,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
|
|
|
23707
23738
|
# and _try_read_from_duckdb_storage). Setting the old per-row lazy
|
|
23708
23739
|
# patch here would be redundant and wasteful (extra intraday API calls
|
|
23709
23740
|
# for data that is already correct in DuckDB).
|
|
23741
|
+
_activate_runtime_stock_shim(config, result)
|
|
23710
23742
|
_timing.end_stage() # End "Loading Data" timing
|
|
23711
23743
|
return result
|
|
23712
23744
|
# Check if lock was the reason for failure
|
|
@@ -23745,6 +23777,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
|
|
|
23745
23777
|
return None
|
|
23746
23778
|
if config.get('bar_exit') == '16:00':
|
|
23747
23779
|
set_option_patch_context(OptionPatchContext(_sym, _fetch_opt_intraday_duckdb_ram))
|
|
23780
|
+
_activate_runtime_stock_shim(config, result)
|
|
23748
23781
|
_timing.end_stage() # End "Loading Data" timing
|
|
23749
23782
|
return result
|
|
23750
23783
|
print(" ⚠️ DuckDB RAM reader: No cached Parquet data, falling back to API...")
|
|
@@ -24992,7 +25025,7 @@ def parse_earnings_calendar(earnings_df):
|
|
|
24992
25025
|
|
|
24993
25026
|
Returns:
|
|
24994
25027
|
list: [{'date': datetime.date, 'estimate': float, 'reported': float,
|
|
24995
|
-
'time_of_day': str}, ...]
|
|
25028
|
+
'time_of_day': str, 'time_of_day_code': str}, ...]
|
|
24996
25029
|
Sorted by date ascending.
|
|
24997
25030
|
|
|
24998
25031
|
Usage in strategy:
|
|
@@ -25020,11 +25053,13 @@ def parse_earnings_calendar(earnings_df):
|
|
|
25020
25053
|
else:
|
|
25021
25054
|
date_obj = earning_date
|
|
25022
25055
|
|
|
25056
|
+
time_of_day = record.get('time_of_day_code', 'UNK')
|
|
25023
25057
|
earnings_events.append({
|
|
25024
25058
|
'date': date_obj,
|
|
25025
25059
|
'estimate': record.get('estimate'),
|
|
25026
25060
|
'reported': record.get('reported_earning'),
|
|
25027
|
-
'time_of_day':
|
|
25061
|
+
'time_of_day': time_of_day,
|
|
25062
|
+
'time_of_day_code': time_of_day,
|
|
25028
25063
|
})
|
|
25029
25064
|
|
|
25030
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
|
|
@@ -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 = [
|
|
@@ -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
|
|
File without changes
|
{ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2144_duckdb_dedup.py
RENAMED
|
File without changes
|
|
File without changes
|
{ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2147_cents_multiplier.py
RENAMED
|
File without changes
|
{ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2148_loader_memory.py
RENAMED
|
File without changes
|
{ivolatility_backtesting-2.149 → ivolatility_backtesting-2.150}/tests/test_2149_client_errors.py
RENAMED
|
File without changes
|