ivolatility-backtesting 2.146__tar.gz → 2.147__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.
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/PKG-INFO +1 -1
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting/__init__.py +2 -2
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting/ivolatility_backtesting.py +240 -121
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting.egg-info/PKG-INFO +1 -1
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting.egg-info/SOURCES.txt +2 -1
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/pyproject.toml +1 -1
- ivolatility_backtesting-2.147/tests/test_2147_cents_multiplier.py +305 -0
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/README.md +0 -0
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting.egg-info/requires.txt +0 -0
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/setup.cfg +0 -0
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/tests/test_2142_fixes.py +0 -0
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/tests/test_2144_duckdb_dedup.py +0 -0
- {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/tests/test_2146_vix_vro.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ivolatility_backtesting
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.147
|
|
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.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting/__init__.py
RENAMED
|
@@ -27,7 +27,7 @@ from .ivolatility_backtesting import (
|
|
|
27
27
|
check_portfolio_stop_loss,
|
|
28
28
|
FUTURES_HEDGE_MAP, _load_futures_for_hedge,
|
|
29
29
|
set_project_root, get_project_root,
|
|
30
|
-
get_position_multiplier,
|
|
30
|
+
get_position_multiplier, get_hedge_multiplier,
|
|
31
31
|
)
|
|
32
32
|
|
|
33
33
|
__all__ = [
|
|
@@ -59,5 +59,5 @@ __all__ = [
|
|
|
59
59
|
'check_portfolio_stop_loss',
|
|
60
60
|
'FUTURES_HEDGE_MAP', '_load_futures_for_hedge',
|
|
61
61
|
'set_project_root', 'get_project_root',
|
|
62
|
-
'get_position_multiplier',
|
|
62
|
+
'get_position_multiplier', 'get_hedge_multiplier',
|
|
63
63
|
]
|
|
@@ -6029,15 +6029,16 @@ class StrategyRegistry:
|
|
|
6029
6029
|
credit = abs(total_cost)
|
|
6030
6030
|
debit = abs(total_cost)
|
|
6031
6031
|
premium = abs(total_cost)
|
|
6032
|
-
|
|
6032
|
+
um = _pos_unit_multiplier(position)
|
|
6033
|
+
|
|
6033
6034
|
# Category-specific calculations (driven by STRATEGIES['category'])
|
|
6034
6035
|
category = strategy['category']
|
|
6035
|
-
|
|
6036
|
+
|
|
6036
6037
|
if category == 'CREDIT':
|
|
6037
|
-
# Credit spreads: max loss = (width *
|
|
6038
|
+
# Credit spreads: max loss = (width * unit_multiplier * contracts) - credit
|
|
6038
6039
|
if position_type == 'IRON_CONDOR':
|
|
6039
6040
|
wing_width = position.get('wing_width', 5)
|
|
6040
|
-
max_loss_per_contract = (wing_width *
|
|
6041
|
+
max_loss_per_contract = (wing_width * um) - (credit / contracts if contracts > 0 else 0)
|
|
6041
6042
|
max_loss = max_loss_per_contract * contracts
|
|
6042
6043
|
capital_at_risk = max(max_loss * 1.5, credit * 2)
|
|
6043
6044
|
locked_capital = credit
|
|
@@ -6047,39 +6048,39 @@ class StrategyRegistry:
|
|
|
6047
6048
|
long_strike = position.get('long_strike', 0)
|
|
6048
6049
|
if short_strike and long_strike:
|
|
6049
6050
|
spread_width = abs(long_strike - short_strike)
|
|
6050
|
-
max_loss = (spread_width *
|
|
6051
|
+
max_loss = (spread_width * um * contracts) - credit
|
|
6051
6052
|
capital_at_risk = max(abs(max_loss) * 1.5, credit * 2)
|
|
6052
6053
|
locked_capital = credit
|
|
6053
6054
|
else:
|
|
6054
6055
|
capital_at_risk = credit * 2
|
|
6055
6056
|
locked_capital = credit
|
|
6056
|
-
|
|
6057
|
+
|
|
6057
6058
|
elif position_type == 'IRON_BUTTERFLY':
|
|
6058
6059
|
wing_width = position.get('wing_width', 10)
|
|
6059
|
-
max_loss = (wing_width *
|
|
6060
|
+
max_loss = (wing_width * um * contracts) - credit
|
|
6060
6061
|
capital_at_risk = max(abs(max_loss) * 1.5, credit * 2)
|
|
6061
6062
|
locked_capital = credit
|
|
6062
|
-
|
|
6063
|
+
|
|
6063
6064
|
else:
|
|
6064
6065
|
# Generic credit strategy
|
|
6065
6066
|
capital_at_risk = credit * 2
|
|
6066
6067
|
locked_capital = credit
|
|
6067
|
-
|
|
6068
|
+
|
|
6068
6069
|
elif category == 'DEBIT':
|
|
6069
6070
|
# Debit strategies: max loss = debit paid
|
|
6070
6071
|
capital_at_risk = debit * 1.5
|
|
6071
6072
|
locked_capital = debit
|
|
6072
|
-
|
|
6073
|
+
|
|
6073
6074
|
elif category == 'NEUTRAL':
|
|
6075
|
+
underlying_price = position.get('underlying_entry_price', 0)
|
|
6074
6076
|
if position_type == 'COVERED_CALL':
|
|
6075
|
-
|
|
6076
|
-
stock_value = underlying_price * contracts * 100
|
|
6077
|
+
stock_value = underlying_price * contracts * um
|
|
6077
6078
|
# Use call_premium if available, fallback to credit for backward compatibility
|
|
6078
6079
|
call_premium = position.get('call_premium', abs(credit))
|
|
6079
6080
|
capital_at_risk = max(stock_value - call_premium, stock_value * 0.8)
|
|
6080
6081
|
locked_capital = stock_value
|
|
6081
6082
|
elif position_type in ('LONG_STOCK', 'SHORT_STOCK'):
|
|
6082
|
-
stock_value = underlying_price * contracts *
|
|
6083
|
+
stock_value = underlying_price * contracts * um
|
|
6083
6084
|
capital_at_risk = stock_value
|
|
6084
6085
|
locked_capital = stock_value
|
|
6085
6086
|
else:
|
|
@@ -6322,7 +6323,7 @@ class StrategyRegistry:
|
|
|
6322
6323
|
return 0.0
|
|
6323
6324
|
|
|
6324
6325
|
if unit_multiplier is None:
|
|
6325
|
-
unit_multiplier = position
|
|
6326
|
+
unit_multiplier = _pos_unit_multiplier(position)
|
|
6326
6327
|
|
|
6327
6328
|
category = strategy.get('category', 'DEBIT')
|
|
6328
6329
|
contracts = position.get('contracts', 1)
|
|
@@ -6526,7 +6527,8 @@ class StrategyRegistry:
|
|
|
6526
6527
|
category = strategy.get('category', 'DEBIT')
|
|
6527
6528
|
total_cost = entry_cost_info.get('total', 0)
|
|
6528
6529
|
contracts = position_params.get('contracts', 1)
|
|
6529
|
-
|
|
6530
|
+
um = _pos_unit_multiplier(position_params)
|
|
6531
|
+
|
|
6530
6532
|
# NEUTRAL strategies (STRADDLE/STRANGLE) can be DEBIT or CREDIT
|
|
6531
6533
|
# Check if it's a DEBIT position by looking at total_cost sign
|
|
6532
6534
|
is_debit = total_cost > 0
|
|
@@ -6539,34 +6541,34 @@ class StrategyRegistry:
|
|
|
6539
6541
|
# NOTE: total_cost is ALREADY multiplied by contracts in calculate_entry_cost()!
|
|
6540
6542
|
if strategy_type == 'IRON_CONDOR':
|
|
6541
6543
|
wing_width = position_params.get('wing_width', 0)
|
|
6542
|
-
# Max risk = (wing_width *
|
|
6544
|
+
# Max risk = (wing_width * unit_multiplier * contracts) - credit (only ONE side can lose)
|
|
6543
6545
|
# total_cost is negative for credit, so we add it
|
|
6544
|
-
return (wing_width *
|
|
6545
|
-
|
|
6546
|
+
return (wing_width * um * contracts) + total_cost # total_cost already includes contracts
|
|
6547
|
+
|
|
6546
6548
|
elif strategy_type in ['BULL_PUT_SPREAD', 'BEAR_CALL_SPREAD', 'CREDIT_SPREAD']:
|
|
6547
6549
|
spread_width = position_params.get('spread_width', 0)
|
|
6548
|
-
# Max risk = (spread_width *
|
|
6549
|
-
return (spread_width *
|
|
6550
|
-
|
|
6550
|
+
# Max risk = (spread_width * unit_multiplier * contracts) - credit
|
|
6551
|
+
return (spread_width * um * contracts) + total_cost # total_cost already includes contracts
|
|
6552
|
+
|
|
6551
6553
|
elif strategy_type == 'IRON_BUTTERFLY':
|
|
6552
6554
|
wing_width = position_params.get('wing_width', 0)
|
|
6553
|
-
# Max risk = (wing_width *
|
|
6554
|
-
return (wing_width *
|
|
6555
|
-
|
|
6555
|
+
# Max risk = (wing_width * unit_multiplier * contracts) - credit (similar to Iron Condor)
|
|
6556
|
+
return (wing_width * um * contracts) + total_cost # total_cost already includes contracts
|
|
6557
|
+
|
|
6556
6558
|
elif strategy_type == 'COVERED_CALL':
|
|
6557
6559
|
# Max risk = stock cost - call premium (stock can go to $0, but we keep premium)
|
|
6558
6560
|
stock_price = position_params.get('stock_price', 0)
|
|
6559
6561
|
# total_cost is negative (credit from selling call), so adding it reduces risk
|
|
6560
|
-
return (stock_price *
|
|
6561
|
-
|
|
6562
|
+
return (stock_price * um * contracts) + total_cost # subtract call premium
|
|
6563
|
+
|
|
6562
6564
|
elif strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
|
|
6563
6565
|
stock_price = position_params.get('stock_price', position_params.get('underlying_entry_price', 0))
|
|
6564
|
-
return stock_price *
|
|
6565
|
-
|
|
6566
|
+
return stock_price * um * contracts
|
|
6567
|
+
|
|
6566
6568
|
elif strategy_type == 'CASH_SECURED_PUT':
|
|
6567
6569
|
strike = position_params.get('strike', 0)
|
|
6568
6570
|
# Max risk = (strike * contracts) - premium (if stock goes to $0)
|
|
6569
|
-
return (strike *
|
|
6571
|
+
return (strike * um * contracts) + total_cost # total_cost already includes contracts
|
|
6570
6572
|
|
|
6571
6573
|
elif strategy_type in ['STRADDLE', 'STRANGLE'] or category == 'NEUTRAL':
|
|
6572
6574
|
# For short NEUTRAL strategies (short straddle/strangle)
|
|
@@ -6744,6 +6746,7 @@ class StrategyRegistry:
|
|
|
6744
6746
|
# 2. Calculate P&L
|
|
6745
6747
|
entry_cost = position.get('total_cost', 0)
|
|
6746
6748
|
contracts = position.get('contracts', 1)
|
|
6749
|
+
um = _pos_unit_multiplier(position)
|
|
6747
6750
|
|
|
6748
6751
|
# ── COVERED_CALL special case ──────────────────────────────────────
|
|
6749
6752
|
# COVERED_CALL = long stock + short call.
|
|
@@ -6758,7 +6761,7 @@ class StrategyRegistry:
|
|
|
6758
6761
|
# Fix: compute P&L directly using underlying_price (available here) + call leg.
|
|
6759
6762
|
# ── LONG_STOCK / SHORT_STOCK — pure stock position, no option legs ──
|
|
6760
6763
|
if strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
|
|
6761
|
-
stock_exit_value = underlying_price * contracts *
|
|
6764
|
+
stock_exit_value = underlying_price * contracts * um
|
|
6762
6765
|
if strategy_type == 'SHORT_STOCK':
|
|
6763
6766
|
pnl = entry_cost - stock_exit_value # short: profit when price drops
|
|
6764
6767
|
else:
|
|
@@ -6776,8 +6779,8 @@ class StrategyRegistry:
|
|
|
6776
6779
|
put_data = leg_data.get('put')
|
|
6777
6780
|
if put_data is not None:
|
|
6778
6781
|
put_close_ask = put_data.get('ask', put_data.get('bid', 0))
|
|
6779
|
-
stock_exit_value = underlying_price * contracts *
|
|
6780
|
-
put_close_cost = put_close_ask * contracts *
|
|
6782
|
+
stock_exit_value = underlying_price * contracts * um
|
|
6783
|
+
put_close_cost = put_close_ask * contracts * um
|
|
6781
6784
|
# Short stock gains when price drops: entry_cost - stock_exit - put_buyback
|
|
6782
6785
|
pnl = entry_cost - stock_exit_value - put_close_cost
|
|
6783
6786
|
is_credit = False
|
|
@@ -6791,8 +6794,8 @@ class StrategyRegistry:
|
|
|
6791
6794
|
call_data = leg_data.get('call')
|
|
6792
6795
|
if call_data is not None:
|
|
6793
6796
|
call_close_ask = call_data.get('ask', call_data.get('bid', 0))
|
|
6794
|
-
stock_exit_value = underlying_price * contracts *
|
|
6795
|
-
call_close_cost = call_close_ask * contracts *
|
|
6797
|
+
stock_exit_value = underlying_price * contracts * um
|
|
6798
|
+
call_close_cost = call_close_ask * contracts * um
|
|
6796
6799
|
pnl = stock_exit_value - call_close_cost - entry_cost
|
|
6797
6800
|
is_credit = False # Treat as DEBIT for % formula (entry_cost is positive)
|
|
6798
6801
|
close_cost = stock_exit_value - call_close_cost # for fall-through fields
|
|
@@ -9773,9 +9776,9 @@ class StopLossManager:
|
|
|
9773
9776
|
|
|
9774
9777
|
if entry_price and entry_price > 0:
|
|
9775
9778
|
if leg['direction'] == 'long':
|
|
9776
|
-
entry_value += entry_price *
|
|
9779
|
+
entry_value += entry_price * _pos_unit_multiplier(pos) * contracts # Pay for long
|
|
9777
9780
|
else:
|
|
9778
|
-
entry_value -= entry_price *
|
|
9781
|
+
entry_value -= entry_price * _pos_unit_multiplier(pos) * contracts # Receive for short
|
|
9779
9782
|
|
|
9780
9783
|
return abs(entry_value) if entry_value != 0 else abs(pos.get('entry_price', 0))
|
|
9781
9784
|
|
|
@@ -9872,11 +9875,11 @@ class StopLossManager:
|
|
|
9872
9875
|
if leg['direction'] == 'long':
|
|
9873
9876
|
# To close long position, we SELL at bid
|
|
9874
9877
|
current_price = matching_bar.get('bid', 0)
|
|
9875
|
-
current_value += current_price *
|
|
9878
|
+
current_value += current_price * _pos_unit_multiplier(pos) * contracts
|
|
9876
9879
|
else:
|
|
9877
9880
|
# To close short position, we BUY at ask
|
|
9878
9881
|
current_price = matching_bar.get('ask', 0)
|
|
9879
|
-
current_value -= current_price *
|
|
9882
|
+
current_value -= current_price * _pos_unit_multiplier(pos) * contracts
|
|
9880
9883
|
|
|
9881
9884
|
# ── COVERED_CALL stock leg adjustment ────────────────────────────────
|
|
9882
9885
|
# The stock leg is NOT in the options bars, but intraday option bars include
|
|
@@ -9894,10 +9897,10 @@ class StopLossManager:
|
|
|
9894
9897
|
if underlying_price_bar and underlying_price_bar > 0:
|
|
9895
9898
|
if 'COVERED_PUT' in strategy_type.upper():
|
|
9896
9899
|
# Short stock: closing means buying back → subtract
|
|
9897
|
-
current_value -= underlying_price_bar * contracts *
|
|
9900
|
+
current_value -= underlying_price_bar * contracts * _pos_unit_multiplier(pos)
|
|
9898
9901
|
else:
|
|
9899
9902
|
# Long stock (COVERED_CALL): closing means selling → add
|
|
9900
|
-
current_value += underlying_price_bar * contracts *
|
|
9903
|
+
current_value += underlying_price_bar * contracts * _pos_unit_multiplier(pos)
|
|
9901
9904
|
|
|
9902
9905
|
return current_value
|
|
9903
9906
|
|
|
@@ -10250,7 +10253,7 @@ class StopLossManager:
|
|
|
10250
10253
|
else:
|
|
10251
10254
|
print(f" [P&L Stop Intraday] Stock: {len(bars)} bars, entry=${entry_price:.2f}, threshold={loss_threshold_pct:.2f}%")
|
|
10252
10255
|
|
|
10253
|
-
qty = pos.get('quantity') or pos.get('contracts', 1) *
|
|
10256
|
+
qty = pos.get('quantity') or pos.get('contracts', 1) * _pos_unit_multiplier(pos)
|
|
10254
10257
|
stock_entry_value = entry_price * qty
|
|
10255
10258
|
|
|
10256
10259
|
for bar_idx, bar in enumerate(bars):
|
|
@@ -10263,7 +10266,7 @@ class StopLossManager:
|
|
|
10263
10266
|
|
|
10264
10267
|
if _use_cc_pnl:
|
|
10265
10268
|
# COVERED_CALL/PUT: option leg near-zero (deep OTM), stock dominates
|
|
10266
|
-
bar_stock_value = bar_price * _cc_contracts *
|
|
10269
|
+
bar_stock_value = bar_price * _cc_contracts * _pos_unit_multiplier(pos)
|
|
10267
10270
|
if 'COVERED_PUT' in pos.get('strategy_type', '').upper():
|
|
10268
10271
|
# Short stock: gains when price drops
|
|
10269
10272
|
bar_pnl_pct = (_cc_total_cost - bar_stock_value) / _cc_total_cost * 100
|
|
@@ -10279,7 +10282,7 @@ class StopLossManager:
|
|
|
10279
10282
|
if _use_cc_pnl:
|
|
10280
10283
|
# COVERED_CALL: return full position values for correct $ P&L
|
|
10281
10284
|
_stop_entry_value = _cc_total_cost
|
|
10282
|
-
_stop_current_value = bar_price * _cc_contracts *
|
|
10285
|
+
_stop_current_value = bar_price * _cc_contracts * _pos_unit_multiplier(pos) # call ≈ 0
|
|
10283
10286
|
else:
|
|
10284
10287
|
_stop_entry_value = stock_entry_value
|
|
10285
10288
|
_stop_current_value = bar_price * qty
|
|
@@ -10587,7 +10590,7 @@ class StopLossManager:
|
|
|
10587
10590
|
sp = sb.get('lastPrice', 0)
|
|
10588
10591
|
st = sb.get('lastDateTime', '')
|
|
10589
10592
|
if sp and sp > 0 and '00:00:00' not in str(st):
|
|
10590
|
-
stock_value_931 = sp * _cc_contracts *
|
|
10593
|
+
stock_value_931 = sp * _cc_contracts * _pos_unit_multiplier(pos)
|
|
10591
10594
|
baseline_value = stock_value_931 + _cc_call_value # stock + call
|
|
10592
10595
|
if self.debuginfo >= 2:
|
|
10593
10596
|
print(f"[SL BASELINE RESET] {position_id}: CC stock@9:31=${sp:.2f} call=${_cc_call_value:.2f} → baseline=${baseline_value:.0f}")
|
|
@@ -12390,8 +12393,8 @@ class PositionManager:
|
|
|
12390
12393
|
intrinsic_value = 0
|
|
12391
12394
|
|
|
12392
12395
|
# Per-point multiplier stamped on position at open (100 for equity,
|
|
12393
|
-
# 50 for ES, 1000 for CL,
|
|
12394
|
-
unit_multiplier = position
|
|
12396
|
+
# 50 for ES, 1000 for CL, 50 for ZS, …).
|
|
12397
|
+
unit_multiplier = _pos_unit_multiplier(position)
|
|
12395
12398
|
|
|
12396
12399
|
# Get strategy info from registry
|
|
12397
12400
|
strategy_info = STRATEGIES.get(strategy_type, {})
|
|
@@ -12849,6 +12852,7 @@ class PositionManager:
|
|
|
12849
12852
|
put_entry_bid=kwargs.get('put_entry_bid'),
|
|
12850
12853
|
total_cost=kwargs.get('total_cost'),
|
|
12851
12854
|
min_days_before_check=kwargs.get('min_days_before_check', 0),
|
|
12855
|
+
unit_multiplier=position['unit_multiplier'],
|
|
12852
12856
|
)
|
|
12853
12857
|
# [FIX 4] Forward spread/diagonal/calendar fields needed by _get_position_legs()
|
|
12854
12858
|
# These are stored via **kwargs in add_position() and read by CUSTOM fallback
|
|
@@ -13058,7 +13062,7 @@ class PositionManager:
|
|
|
13058
13062
|
current_pnl = entry_premium - intrinsic_value
|
|
13059
13063
|
else:
|
|
13060
13064
|
current_pnl = intrinsic_value - entry_premium
|
|
13061
|
-
_um = position
|
|
13065
|
+
_um = _pos_unit_multiplier(position)
|
|
13062
13066
|
current_price = (intrinsic_value / (_um * contracts)) if contracts > 0 else 0
|
|
13063
13067
|
|
|
13064
13068
|
# P&L %
|
|
@@ -13103,7 +13107,7 @@ class PositionManager:
|
|
|
13103
13107
|
wing_width = abs(long_put - short_put)
|
|
13104
13108
|
if wing_width > 0:
|
|
13105
13109
|
total_cost_per_contract = abs(position.get('total_cost', 0)) / position.get('contracts', 1)
|
|
13106
|
-
max_risk_per_contract = (wing_width *
|
|
13110
|
+
max_risk_per_contract = (wing_width * _pos_unit_multiplier(position)) - total_cost_per_contract
|
|
13107
13111
|
max_risk_total = max_risk_per_contract * position.get('contracts', 1)
|
|
13108
13112
|
current_pnl_pct = (current_pnl / max_risk_total * 100) if max_risk_total > 0 else 0
|
|
13109
13113
|
elif current_pnl_pct is None:
|
|
@@ -13216,7 +13220,7 @@ class PositionManager:
|
|
|
13216
13220
|
if wing_width > 0:
|
|
13217
13221
|
# Use abs() because total_cost is negative for credits
|
|
13218
13222
|
total_cost_per_contract = abs(position.get('total_cost', 0)) / position.get('contracts', 1)
|
|
13219
|
-
max_risk_per_contract = (wing_width *
|
|
13223
|
+
max_risk_per_contract = (wing_width * _pos_unit_multiplier(position)) - total_cost_per_contract
|
|
13220
13224
|
contracts = position.get('contracts', 1)
|
|
13221
13225
|
max_risk_total = max_risk_per_contract * contracts
|
|
13222
13226
|
|
|
@@ -13759,7 +13763,7 @@ class PositionManager:
|
|
|
13759
13763
|
if leg_data is None and strategy_type in ('LONG_STOCK', 'SHORT_STOCK'):
|
|
13760
13764
|
entry_cost = abs(position.get('total_cost', 0))
|
|
13761
13765
|
contracts = position.get('contracts', 1)
|
|
13762
|
-
stock_exit_value = stock_price * contracts *
|
|
13766
|
+
stock_exit_value = stock_price * contracts * _pos_unit_multiplier(position)
|
|
13763
13767
|
if strategy_type == 'SHORT_STOCK':
|
|
13764
13768
|
pnl = entry_cost - stock_exit_value
|
|
13765
13769
|
else:
|
|
@@ -14691,13 +14695,13 @@ class PositionManager:
|
|
|
14691
14695
|
# ========================================
|
|
14692
14696
|
if position_type == 'IRON_CONDOR':
|
|
14693
14697
|
# Iron Condor: short call spread + short put spread
|
|
14694
|
-
# Max loss = wing_width *
|
|
14698
|
+
# Max loss = wing_width * unit_multiplier * contracts - credit
|
|
14695
14699
|
wing_width = position.get('wing_width', 5)
|
|
14696
14700
|
contracts = position.get('contracts', 1)
|
|
14697
14701
|
credit = position.get('total_cost', 0)
|
|
14698
14702
|
|
|
14699
|
-
# Max loss per spread = (wing_width *
|
|
14700
|
-
max_loss_per_contract = (wing_width *
|
|
14703
|
+
# Max loss per spread = (wing_width * unit_multiplier) - credit_per_contract
|
|
14704
|
+
max_loss_per_contract = (wing_width * _pos_unit_multiplier(position)) - (credit / contracts if contracts > 0 else 0)
|
|
14701
14705
|
max_loss = max_loss_per_contract * contracts
|
|
14702
14706
|
|
|
14703
14707
|
# Capital at risk = max loss + safety buffer
|
|
@@ -14720,11 +14724,11 @@ class PositionManager:
|
|
|
14720
14724
|
if short_strike and long_strike:
|
|
14721
14725
|
spread_width = abs(long_strike - short_strike)
|
|
14722
14726
|
|
|
14723
|
-
# Credit spreads: max loss = (spread_width *
|
|
14727
|
+
# Credit spreads: max loss = (spread_width * unit_multiplier * contracts) - credit
|
|
14724
14728
|
# Debit spreads: max loss = debit paid (already in total_cost)
|
|
14725
14729
|
if position_type in ['BULL_PUT_SPREAD', 'BEAR_CALL_SPREAD', 'CREDIT_SPREAD']:
|
|
14726
14730
|
# Credit spread: we received premium, max loss is spread width minus credit
|
|
14727
|
-
max_loss = (spread_width *
|
|
14731
|
+
max_loss = (spread_width * _pos_unit_multiplier(position) * contracts) - total_cost
|
|
14728
14732
|
else:
|
|
14729
14733
|
# Debit spread: we paid premium, max loss is what we paid
|
|
14730
14734
|
max_loss = total_cost
|
|
@@ -14749,8 +14753,8 @@ class PositionManager:
|
|
|
14749
14753
|
|
|
14750
14754
|
if position_type == 'IRON_BUTTERFLY':
|
|
14751
14755
|
# Iron butterfly: short straddle + protective wings
|
|
14752
|
-
# Max loss = (wing_width *
|
|
14753
|
-
max_loss = (wing_width *
|
|
14756
|
+
# Max loss = (wing_width * unit_multiplier * contracts) - credit
|
|
14757
|
+
max_loss = (wing_width * _pos_unit_multiplier(position) * contracts) - total_cost
|
|
14754
14758
|
capital_at_risk += max(abs(max_loss) * 1.5, abs(total_cost) * 2)
|
|
14755
14759
|
else:
|
|
14756
14760
|
# Regular butterfly: max loss = net debit
|
|
@@ -14796,7 +14800,7 @@ class PositionManager:
|
|
|
14796
14800
|
|
|
14797
14801
|
# Capital at risk = stock value (we own stock)
|
|
14798
14802
|
# Call premium reduces risk slightly
|
|
14799
|
-
stock_value = underlying_price * contracts *
|
|
14803
|
+
stock_value = underlying_price * contracts * _pos_unit_multiplier(position)
|
|
14800
14804
|
capital_at_risk += max(stock_value - call_premium, stock_value * 0.8)
|
|
14801
14805
|
locked_capital += stock_value
|
|
14802
14806
|
|
|
@@ -14806,7 +14810,7 @@ class PositionManager:
|
|
|
14806
14810
|
elif position_type in ('LONG_STOCK', 'SHORT_STOCK'):
|
|
14807
14811
|
contracts = position.get('contracts', 1)
|
|
14808
14812
|
underlying_price = position.get('underlying_entry_price', 0)
|
|
14809
|
-
stock_value = underlying_price * contracts *
|
|
14813
|
+
stock_value = underlying_price * contracts * _pos_unit_multiplier(position)
|
|
14810
14814
|
capital_at_risk += stock_value
|
|
14811
14815
|
locked_capital += stock_value
|
|
14812
14816
|
|
|
@@ -14818,9 +14822,9 @@ class PositionManager:
|
|
|
14818
14822
|
strike = position.get('strike', 0)
|
|
14819
14823
|
credit = position.get('total_cost', 0) # Premium received
|
|
14820
14824
|
|
|
14821
|
-
# Capital at risk = strike *
|
|
14825
|
+
# Capital at risk = strike * unit_multiplier * contracts (cash secured)
|
|
14822
14826
|
# We collected premium, so max loss is strike - premium
|
|
14823
|
-
cash_secured = strike *
|
|
14827
|
+
cash_secured = strike * _pos_unit_multiplier(position) * contracts
|
|
14824
14828
|
max_loss = cash_secured - credit
|
|
14825
14829
|
capital_at_risk += max_loss
|
|
14826
14830
|
locked_capital += cash_secured
|
|
@@ -14862,7 +14866,7 @@ class PositionManager:
|
|
|
14862
14866
|
|
|
14863
14867
|
if call_current is not None and put_current is not None:
|
|
14864
14868
|
# Current cost to close (what we'd pay to buy back)
|
|
14865
|
-
current_cost = (call_current['ask'] + put_current['ask']) * position['contracts'] *
|
|
14869
|
+
current_cost = (call_current['ask'] + put_current['ask']) * position['contracts'] * _pos_unit_multiplier(position)
|
|
14866
14870
|
|
|
14867
14871
|
# Premium collected (baseline)
|
|
14868
14872
|
locked_capital += position['total_cost']
|
|
@@ -14958,7 +14962,8 @@ class PositionManager:
|
|
|
14958
14962
|
|
|
14959
14963
|
Example usage:
|
|
14960
14964
|
# In your strategy, when you find an entry signal:
|
|
14961
|
-
|
|
14965
|
+
mult = get_position_multiplier(config, preloaded) # 100 equity, 50 ES, 50 soybeans, ...
|
|
14966
|
+
cost_per_straddle = (call_bid + put_bid) * mult
|
|
14962
14967
|
|
|
14963
14968
|
# Simple usage (no debug)
|
|
14964
14969
|
sizing_info = position_mgr.calculate_position_size(
|
|
@@ -21052,17 +21057,17 @@ FUTURES_HEDGE_MAP = {
|
|
|
21052
21057
|
|
|
21053
21058
|
|
|
21054
21059
|
# ─── Per-point P&L multipliers — special asset classes ──────────────────────
|
|
21055
|
-
# For most futures (ES, NQ, CL, GC, NG, RTY, YM, SI...), API contract_size
|
|
21056
|
-
# IS the per-point P&L multiplier.
|
|
21060
|
+
# For most futures (ES, NQ, CL, GC, NG, RTY, YM, SI, CME FX...), API contract_size
|
|
21061
|
+
# IS the per-point P&L multiplier. Corrections:
|
|
21057
21062
|
#
|
|
21058
21063
|
# bond futures: API contract_size = face value (e.g. ZB = 100_000),
|
|
21059
21064
|
# per-point P&L multiplier = face_value / 100 = $1000/pt
|
|
21060
21065
|
# (because price is quoted in percent of par)
|
|
21066
|
+
# cents-quoted US commodities: contract_size / 100 (lumber / 1000), see _PRICE_UNIT_DIVISORS
|
|
21067
|
+
# power and gas (MEGA_WATT_HOURS, THERMS): size depends on hours/days in the delivery
|
|
21068
|
+
# month, not derivable → ValueError, caller must set multiplier_override
|
|
21061
21069
|
#
|
|
21062
|
-
#
|
|
21063
|
-
# per-point P&L multiplier depends on tick size + quote
|
|
21064
|
-
# convention (varies per pair). We refuse to guess and emit
|
|
21065
|
-
# a warning — caller must set config['multiplier_override'].
|
|
21070
|
+
# CME FX: contract_size = $ per 1.0 move; crosses/USD-base (RY, AJY, CNH, KU) — P&L in quote currency
|
|
21066
21071
|
# Bond futures: list includes both Globex codes (ZB/ZN/...) and the internal
|
|
21067
21072
|
# CME `IVOL_symbol` codes the API actually returns (US/TY/FV/TU/UL/TN).
|
|
21068
21073
|
# get_position_multiplier checks BOTH config['symbol'] and meta['root'] against
|
|
@@ -21071,11 +21076,46 @@ _BOND_FUTURES_ROOTS = frozenset({
|
|
|
21071
21076
|
'ZB','ZN','ZF','ZT','UB','TN', # Globex aliases used by traders
|
|
21072
21077
|
'US','TY','FV','TU','UL', # internal CME codes returned by API
|
|
21073
21078
|
})
|
|
21074
|
-
|
|
21075
|
-
|
|
21076
|
-
|
|
21077
|
-
|
|
21078
|
-
})
|
|
21079
|
+
|
|
21080
|
+
def _pos_unit_multiplier(position):
|
|
21081
|
+
"""$ per 1.0 of option/underlying price for one contract of this position (100 for equity and legacy positions)."""
|
|
21082
|
+
try:
|
|
21083
|
+
return float((position or {}).get('unit_multiplier') or 100.0)
|
|
21084
|
+
except (TypeError, ValueError, AttributeError):
|
|
21085
|
+
return 100.0
|
|
21086
|
+
|
|
21087
|
+
|
|
21088
|
+
# US commodities priced in cents (or $/cwt, $/1000 board feet) while API contract_size
|
|
21089
|
+
# is in physical units: per-point multiplier = contract_size / divisor.
|
|
21090
|
+
# MIC-gated because roots collide across exchanges (C = CBOT corn and ICE London cocoa).
|
|
21091
|
+
_ICE_US = {'IEPA', 'IFUS'}
|
|
21092
|
+
_PRICE_UNIT_DIVISORS = {
|
|
21093
|
+
# CBOT grains, soybean oil, wheat spreads, soy crush — cents per bushel / per lb
|
|
21094
|
+
'S': (100.0, {'XCBT'}), 'ZS': (100.0, {'XCBT'}),
|
|
21095
|
+
'C': (100.0, {'XCBT'}), 'ZC': (100.0, {'XCBT'}),
|
|
21096
|
+
'W': (100.0, {'XCBT'}), 'ZW': (100.0, {'XCBT'}),
|
|
21097
|
+
'O': (100.0, {'XCBT'}), 'ZO': (100.0, {'XCBT'}),
|
|
21098
|
+
'KW': (100.0, {'XCBT'}), 'HRS': (100.0, {'XCBT'}),
|
|
21099
|
+
'BO': (100.0, {'XCBT'}), 'ZL': (100.0, {'XCBT'}),
|
|
21100
|
+
'CKW': (100.0, {'XCBT'}), 'MCX': (100.0, {'XCBT'}), 'WMK': (100.0, {'XCBT'}),
|
|
21101
|
+
'BCX': (100.0, {'XCBT'}),
|
|
21102
|
+
# CME livestock and meat, cents per lb
|
|
21103
|
+
'LC': (100.0, {'XCME'}), 'LE': (100.0, {'XCME'}),
|
|
21104
|
+
'LH': (100.0, {'XCME'}), 'HE': (100.0, {'XCME'}),
|
|
21105
|
+
'FC': (100.0, {'XCME'}), 'GF': (100.0, {'XCME'}),
|
|
21106
|
+
'PRK': (100.0, {'XCME'}), 'PB': (100.0, {'XCME'}),
|
|
21107
|
+
'BTF': (100.0, {'XCME'}), 'BTN': (100.0, {'XCME'}),
|
|
21108
|
+
# CME dairy: milk in $ per cwt with contract_size in lb; butter, whey, NFDM in cents per lb
|
|
21109
|
+
'DA': (100.0, {'XCME'}), 'DK': (100.0, {'XCME'}),
|
|
21110
|
+
'CB': (100.0, {'XCME'}), 'DB': (100.0, {'XCME'}),
|
|
21111
|
+
'DY': (100.0, {'XCME'}), 'NF': (100.0, {'XCME'}),
|
|
21112
|
+
# CME lumber, $ per 1000 board feet with contract_size in board feet
|
|
21113
|
+
'LBR': (1000.0, {'XCME'}), 'LB': (1000.0, {'XCME'}), 'SYP': (1000.0, {'XCME'}),
|
|
21114
|
+
# ICE US softs and cash-settled grains, cents per lb / per bushel
|
|
21115
|
+
'KC': (100.0, _ICE_US), 'SB': (100.0, _ICE_US), 'CT': (100.0, _ICE_US), 'OJ': (100.0, _ICE_US),
|
|
21116
|
+
'SB1': (100.0, _ICE_US), 'SB2': (100.0, _ICE_US), 'CT1': (100.0, _ICE_US), 'CT2': (100.0, _ICE_US),
|
|
21117
|
+
'IS': (100.0, _ICE_US), 'ICN': (100.0, _ICE_US), 'IW3': (100.0, _ICE_US), 'IBO': (100.0, _ICE_US),
|
|
21118
|
+
}
|
|
21079
21119
|
|
|
21080
21120
|
# Non-US futures per-point P&L multiplier — used WHEN the API returns
|
|
21081
21121
|
# contract_size=0 (which it does for every non-US root). Until iVol's backend
|
|
@@ -21087,12 +21127,19 @@ _FX_FUTURES_ROOTS = frozenset({
|
|
|
21087
21127
|
# portfolio aggregation is a separate concern this table does not address.
|
|
21088
21128
|
_NONUS_FUTURES_MULTIPLIERS = {
|
|
21089
21129
|
'BRN': 1000.0, # ICE Brent Crude (NDEX/IFEU, USD)
|
|
21130
|
+
'WAB-D': 1000.0, # ICE Brent Crude Weekly options on BRN futures (NDEX, USD)
|
|
21090
21131
|
'BE': 1000.0, # EUREX Euro-Bund (XDTB, EUR)
|
|
21091
21132
|
'BL': 1000.0, # EUREX Euro-Bobl (XDTB, EUR)
|
|
21092
21133
|
'BZ': 1000.0, # EUREX Euro-Schatz (XDTB, EUR)
|
|
21093
21134
|
'FBTP': 1000.0, # EUREX Euro-BTP (XDTB, EUR)
|
|
21135
|
+
'MBT': 0.1, # CME Micro Bitcoin (0.1 BTC)
|
|
21136
|
+
'MET': 0.1, # CME Micro Ether (0.1 ETH)
|
|
21137
|
+
'BFF': 0.02, # CME Bitcoin Friday (1/50 BTC)
|
|
21094
21138
|
}
|
|
21095
21139
|
|
|
21140
|
+
# Units where API contract_size is per hour / per day of delivery, not per contract.
|
|
21141
|
+
_PER_PERIOD_MEASURES = frozenset({'MEGA_WATT_HOURS', 'THERMS'})
|
|
21142
|
+
|
|
21096
21143
|
|
|
21097
21144
|
# ─── Universal options-root classifier ──────────────────────────────────────
|
|
21098
21145
|
# Driven by the standardized description text inside `root_list` from
|
|
@@ -21267,6 +21314,26 @@ def resolve_opt_category_policy(config):
|
|
|
21267
21314
|
return OPT_CATEGORY_POLICIES.get(policy, OPT_CATEGORY_POLICIES['tradable'])
|
|
21268
21315
|
|
|
21269
21316
|
|
|
21317
|
+
_OVERRIDE_WARNED = set()
|
|
21318
|
+
|
|
21319
|
+
|
|
21320
|
+
def _warn_override_mismatch(config, preloaded, override):
|
|
21321
|
+
"""Print once when a futures override is >10x off the resolved multiplier (e.g. a tick value used as multiplier)."""
|
|
21322
|
+
if config.get('asset_type', 'equity') != 'futures' or not override:
|
|
21323
|
+
return
|
|
21324
|
+
try:
|
|
21325
|
+
auto = get_position_multiplier({k: v for k, v in config.items() if k != 'multiplier_override'}, preloaded)
|
|
21326
|
+
except ValueError:
|
|
21327
|
+
return
|
|
21328
|
+
if not auto or 0.1 <= override / auto <= 10:
|
|
21329
|
+
return
|
|
21330
|
+
key = (config.get('symbol'), override, auto)
|
|
21331
|
+
if key not in _OVERRIDE_WARNED:
|
|
21332
|
+
_OVERRIDE_WARNED.add(key)
|
|
21333
|
+
print(f" ⚠️ multiplier_override={override:g} for {config.get('symbol')} is >10x off the "
|
|
21334
|
+
f"resolved multiplier {auto:g} ($ per 1.0 of price) — check the override.")
|
|
21335
|
+
|
|
21336
|
+
|
|
21270
21337
|
def get_position_multiplier(config, preloaded=None):
|
|
21271
21338
|
"""Return the per-point P&L multiplier for a position.
|
|
21272
21339
|
|
|
@@ -21275,9 +21342,11 @@ def get_position_multiplier(config, preloaded=None):
|
|
|
21275
21342
|
2. asset_type != 'futures' → 100 (standard equity options)
|
|
21276
21343
|
3. preloaded['_futures_meta'] → API-derived multiplier
|
|
21277
21344
|
- bonds (ZB/ZN/ZF/ZT/UB/TN): contract_size / 100
|
|
21278
|
-
-
|
|
21279
|
-
|
|
21280
|
-
|
|
21345
|
+
- US commodities priced in cents / $ per cwt / $ per 1000 bf
|
|
21346
|
+
(see _PRICE_UNIT_DIVISORS): contract_size / divisor
|
|
21347
|
+
- power / gas sized per hour or day (MEGA_WATT_HOURS, THERMS): ValueError
|
|
21348
|
+
- everything else incl. CME FX: contract_size as-is
|
|
21349
|
+
4. contract_size=0 (non-US, micro crypto) → _NONUS_FUTURES_MULTIPLIERS, else ValueError
|
|
21281
21350
|
|
|
21282
21351
|
Args:
|
|
21283
21352
|
config: Strategy config dict (or None)
|
|
@@ -21292,7 +21361,10 @@ def get_position_multiplier(config, preloaded=None):
|
|
|
21292
21361
|
ES (S&P futures, mult=50): 50.0
|
|
21293
21362
|
CL (Crude Oil, mult=1000): 1000.0
|
|
21294
21363
|
ZB (T-Bond, face=100_000): 1000.0 ← / 100 applied
|
|
21295
|
-
|
|
21364
|
+
ZS (Soybeans, 5000 bu, cents): 50.0 ← / 100 applied
|
|
21365
|
+
6E (EUR/USD, 125_000 EUR): 125000.0 ← price is USD per 1 EUR
|
|
21366
|
+
MBT (Micro Bitcoin, size 0): 0.1 ← from _NONUS_FUTURES_MULTIPLIERS
|
|
21367
|
+
TTF (1 MWh per hour): ValueError ← override needed
|
|
21296
21368
|
config['multiplier_override']=42: 42 ← short-circuits everything
|
|
21297
21369
|
"""
|
|
21298
21370
|
if config is None:
|
|
@@ -21302,9 +21374,12 @@ def get_position_multiplier(config, preloaded=None):
|
|
|
21302
21374
|
override = config.get('multiplier_override')
|
|
21303
21375
|
if override is not None:
|
|
21304
21376
|
try:
|
|
21305
|
-
|
|
21377
|
+
override = float(override)
|
|
21306
21378
|
except (TypeError, ValueError):
|
|
21307
|
-
|
|
21379
|
+
override = None
|
|
21380
|
+
if override is not None:
|
|
21381
|
+
_warn_override_mismatch(config, preloaded, override)
|
|
21382
|
+
return override
|
|
21308
21383
|
|
|
21309
21384
|
# 2. Equity path
|
|
21310
21385
|
if config.get('asset_type', 'equity') != 'futures':
|
|
@@ -21349,22 +21424,27 @@ def get_position_multiplier(config, preloaded=None):
|
|
|
21349
21424
|
}
|
|
21350
21425
|
candidate_roots.discard('')
|
|
21351
21426
|
raw = float(meta['multiplier'])
|
|
21427
|
+
mic = (meta.get('mic') or '').upper()
|
|
21352
21428
|
|
|
21353
|
-
# Bond futures
|
|
21354
|
-
if candidate_roots & _BOND_FUTURES_ROOTS:
|
|
21429
|
+
# Bond futures (CBOT only — UB also names a NYMEX Brent swap): contract_size is face value
|
|
21430
|
+
if candidate_roots & _BOND_FUTURES_ROOTS and (not mic or mic == 'XCBT'):
|
|
21355
21431
|
return raw / 100.0
|
|
21356
21432
|
|
|
21357
|
-
|
|
21358
|
-
|
|
21359
|
-
|
|
21360
|
-
|
|
21361
|
-
|
|
21362
|
-
|
|
21363
|
-
|
|
21364
|
-
|
|
21365
|
-
|
|
21366
|
-
|
|
21367
|
-
|
|
21433
|
+
for root in sorted(candidate_roots):
|
|
21434
|
+
spec = _PRICE_UNIT_DIVISORS.get(root)
|
|
21435
|
+
if spec and (not mic or mic in spec[1]):
|
|
21436
|
+
return raw / spec[0]
|
|
21437
|
+
|
|
21438
|
+
measure = (meta.get('measure') or '').upper()
|
|
21439
|
+
if measure in _PER_PERIOD_MEASURES:
|
|
21440
|
+
raise ValueError(
|
|
21441
|
+
f"Futures per-point multiplier for {sorted(candidate_roots)} is not derivable: "
|
|
21442
|
+
f"contract_size={raw} {measure} is per hour/day, the contract covers every "
|
|
21443
|
+
"hour/day of the delivery month. Set config['multiplier_override'] "
|
|
21444
|
+
"(e.g. TTF: MWh in the month = hours in the month)."
|
|
21445
|
+
)
|
|
21446
|
+
|
|
21447
|
+
# Everything else (indices, dollar-priced commodities, metals, energies, CME FX): contract_size IS multiplier
|
|
21368
21448
|
return raw
|
|
21369
21449
|
|
|
21370
21450
|
|
|
@@ -21650,7 +21730,11 @@ def _load_futures_for_hedge(config, preloaded):
|
|
|
21650
21730
|
|
|
21651
21731
|
if meta and meta.get('multiplier'):
|
|
21652
21732
|
exchange = meta.get('exchange') or (map_entry[1] if map_entry else '')
|
|
21653
|
-
|
|
21733
|
+
try:
|
|
21734
|
+
mult = get_position_multiplier({'asset_type': 'futures', 'symbol': fut_root},
|
|
21735
|
+
{'_futures_meta': meta})
|
|
21736
|
+
except ValueError:
|
|
21737
|
+
mult = meta['multiplier']
|
|
21654
21738
|
elif map_entry:
|
|
21655
21739
|
# API resolver failed — fall back to hardcoded map
|
|
21656
21740
|
_, exchange, mult, _, _, _ = map_entry
|
|
@@ -23190,8 +23274,9 @@ def _preload_futures_to_duckdb(config, cache_config):
|
|
|
23190
23274
|
|
|
23191
23275
|
# ── 5. ChunkManager on the futures_options_eod_close table ──────────────
|
|
23192
23276
|
print(f"\n[3/3] 📊 Building OptionsChunkManager (futures_options_eod_close)")
|
|
23193
|
-
|
|
23194
|
-
|
|
23277
|
+
# same defaults as the cache fast-path, so the first run serves the same chain as re-runs
|
|
23278
|
+
dte_target = config.get('dte_target', 7)
|
|
23279
|
+
dte_tolerance = config.get('dte_tolerance', 3)
|
|
23195
23280
|
conn = _get_duckdb_storage_conn(cache_config)
|
|
23196
23281
|
chunk_mgr = OptionsChunkManager(
|
|
23197
23282
|
db_path=db_path,
|
|
@@ -31981,6 +32066,43 @@ class MarketDataManager:
|
|
|
31981
32066
|
# PORTFOLIO-LEVEL FUNCTIONS (delta hedge + portfolio stop-loss)
|
|
31982
32067
|
# ════════════════════════════════════════════════════════════════════════════════
|
|
31983
32068
|
|
|
32069
|
+
def _hedge_instrument_multiplier(config):
|
|
32070
|
+
"""(instrument, $ per 1.0 price move per hedge unit): stock → 1; futures → config / micro contract from FUTURES_HEDGE_MAP."""
|
|
32071
|
+
config = config or {}
|
|
32072
|
+
hedge_config = config.get('delta_hedge', {}) or {}
|
|
32073
|
+
symbol = config.get('symbol', '')
|
|
32074
|
+
instrument = hedge_config.get('instrument', 'auto')
|
|
32075
|
+
if instrument == 'auto':
|
|
32076
|
+
instrument = 'futures' if symbol in FUTURES_HEDGE_MAP else 'stock'
|
|
32077
|
+
if instrument != 'futures':
|
|
32078
|
+
return instrument, 1.0
|
|
32079
|
+
if 'futures_multiplier' in hedge_config:
|
|
32080
|
+
return instrument, float(hedge_config['futures_multiplier'])
|
|
32081
|
+
if symbol in FUTURES_HEDGE_MAP:
|
|
32082
|
+
return instrument, float(FUTURES_HEDGE_MAP[symbol][4])
|
|
32083
|
+
return instrument, 1.0
|
|
32084
|
+
|
|
32085
|
+
|
|
32086
|
+
def get_hedge_multiplier(config):
|
|
32087
|
+
"""$ per 1.0 price move per hedge unit returned by check_portfolio_delta_hedge (1 for stock, 5 for SPX→MES)."""
|
|
32088
|
+
return _hedge_instrument_multiplier(config)[1]
|
|
32089
|
+
|
|
32090
|
+
|
|
32091
|
+
_HEDGE_MULT_WARNED = set()
|
|
32092
|
+
|
|
32093
|
+
|
|
32094
|
+
def _warn_hedge_multiplier_default(config):
|
|
32095
|
+
"""Print once when a futures hedge is valued with the default hedge_multiplier=1.0."""
|
|
32096
|
+
instrument, mult = _hedge_instrument_multiplier(config)
|
|
32097
|
+
symbol = (config or {}).get('symbol')
|
|
32098
|
+
if instrument != 'futures' or mult == 1.0 or symbol in _HEDGE_MULT_WARNED:
|
|
32099
|
+
return
|
|
32100
|
+
_HEDGE_MULT_WARNED.add(symbol)
|
|
32101
|
+
print(f" ⚠️ check_portfolio_stop_loss: {symbol} is hedged with futures (${mult:g} per point per contract) "
|
|
32102
|
+
f"but hedge_multiplier=1.0 — pass hedge_multiplier=get_hedge_multiplier(config) "
|
|
32103
|
+
f"when cycle_hedge_pnl is the sum of action['cash_flow'].")
|
|
32104
|
+
|
|
32105
|
+
|
|
31984
32106
|
def check_portfolio_delta_hedge(position_managers, options_today, get_option_func,
|
|
31985
32107
|
current_hedge_shares, config, stock_row=None):
|
|
31986
32108
|
"""
|
|
@@ -32008,8 +32130,13 @@ def check_portfolio_delta_hedge(position_managers, options_today, get_option_fun
|
|
|
32008
32130
|
Returns:
|
|
32009
32131
|
dict or None. If rebalance needed:
|
|
32010
32132
|
{'action': 'BUY'/'SELL', 'shares_diff': int, 'price': float,
|
|
32133
|
+
'cash_flow': float, # -shares_diff * price * hedge_multiplier, in $
|
|
32011
32134
|
'target_shares': int, 'net_position_delta': float,
|
|
32012
|
-
'position_deltas': list, 'stock_bid': float, 'stock_ask': float
|
|
32135
|
+
'position_deltas': list, 'stock_bid': float, 'stock_ask': float,
|
|
32136
|
+
'instrument': 'stock'/'futures', 'hedge_multiplier': float}
|
|
32137
|
+
shares_diff is in hedge units of hedge_multiplier $ per point: shares for stock,
|
|
32138
|
+
micro futures contracts for SPX/NDX/RUT/DJX ($5/$2/$5/$0.5), futures contracts with
|
|
32139
|
+
delta_hedge['futures_multiplier'], otherwise $1-per-point units.
|
|
32013
32140
|
"""
|
|
32014
32141
|
hedge_config = config.get('delta_hedge', {})
|
|
32015
32142
|
if not hedge_config.get('enabled', False):
|
|
@@ -32019,24 +32146,8 @@ def check_portfolio_delta_hedge(position_managers, options_today, get_option_fun
|
|
|
32019
32146
|
threshold_min = int(hedge_config.get('rebalance_threshold_min', 5))
|
|
32020
32147
|
use_bid_ask = hedge_config.get('use_bid_ask', True)
|
|
32021
32148
|
|
|
32022
|
-
# Determine hedge instrument: stock (multiplier=1) or futures (multiplier from map/config).
|
|
32023
32149
|
# For futures: notebook is responsible for passing futures price data as stock_row.
|
|
32024
|
-
|
|
32025
|
-
instrument = hedge_config.get('instrument', 'auto')
|
|
32026
|
-
if instrument == 'auto':
|
|
32027
|
-
instrument = 'futures' if symbol in FUTURES_HEDGE_MAP else 'stock'
|
|
32028
|
-
|
|
32029
|
-
if instrument == 'futures':
|
|
32030
|
-
if 'futures_multiplier' in hedge_config:
|
|
32031
|
-
hedge_multiplier = float(hedge_config['futures_multiplier'])
|
|
32032
|
-
elif symbol in FUTURES_HEDGE_MAP:
|
|
32033
|
-
# Default to MICRO futures (smaller granularity for finer hedging)
|
|
32034
|
-
_, _, _, _, micro_mult, _ = FUTURES_HEDGE_MAP[symbol]
|
|
32035
|
-
hedge_multiplier = float(micro_mult)
|
|
32036
|
-
else:
|
|
32037
|
-
hedge_multiplier = 1.0
|
|
32038
|
-
else:
|
|
32039
|
-
hedge_multiplier = 1.0 # stock: 1 share = 1 delta
|
|
32150
|
+
instrument, hedge_multiplier = _hedge_instrument_multiplier(config)
|
|
32040
32151
|
|
|
32041
32152
|
if not isinstance(position_managers, (list, tuple)):
|
|
32042
32153
|
position_managers = [position_managers]
|
|
@@ -32085,7 +32196,8 @@ def check_portfolio_delta_hedge(position_managers, options_today, get_option_fun
|
|
|
32085
32196
|
else:
|
|
32086
32197
|
current_delta = float(option_data['delta'])
|
|
32087
32198
|
|
|
32088
|
-
|
|
32199
|
+
_um = _pos_unit_multiplier(pos)
|
|
32200
|
+
pos_delta = -current_delta * _um * contracts if is_short else current_delta * _um * contracts
|
|
32089
32201
|
position_deltas.append({
|
|
32090
32202
|
'position_id': pos_id, 'strategy_type': strategy_type,
|
|
32091
32203
|
'opt_type': opt_type, 'strike': strike, 'current_delta': current_delta,
|
|
@@ -32110,6 +32222,7 @@ def check_portfolio_delta_hedge(position_managers, options_today, get_option_fun
|
|
|
32110
32222
|
'action': 'BUY' if diff > 0 else 'SELL',
|
|
32111
32223
|
'shares_diff': diff, # shares (stock) or contracts (futures)
|
|
32112
32224
|
'price': price,
|
|
32225
|
+
'cash_flow': -diff * price * hedge_multiplier,
|
|
32113
32226
|
'target_shares': target_units, # kept name for stock backward-compat
|
|
32114
32227
|
'current_hedge_shares': current_hedge_shares,
|
|
32115
32228
|
'net_position_delta': round(net_position_delta, 2),
|
|
@@ -32122,10 +32235,14 @@ def check_portfolio_delta_hedge(position_managers, options_today, get_option_fun
|
|
|
32122
32235
|
|
|
32123
32236
|
def check_portfolio_stop_loss(position_managers, options_today, get_option_func,
|
|
32124
32237
|
config, stock_row=None, extra_pnl=0.0,
|
|
32125
|
-
hedge_shares=0, cycle_hedge_pnl=0.0
|
|
32238
|
+
hedge_shares=0, cycle_hedge_pnl=0.0,
|
|
32239
|
+
hedge_multiplier=1.0):
|
|
32126
32240
|
"""
|
|
32127
32241
|
Portfolio-level stop loss. Sums unrealized P&L across ALL open positions
|
|
32128
|
-
in ALL PositionManagers + extra_pnl (
|
|
32242
|
+
in ALL PositionManagers + open hedge + extra_pnl (other off-book P&L, not the hedge).
|
|
32243
|
+
|
|
32244
|
+
Hedge: cycle_hedge_pnl = sum of action['cash_flow'] ($), hedge_shares in hedge units,
|
|
32245
|
+
hedge_multiplier = get_hedge_multiplier(config) (5 for SPX→MES; default 1.0 = shares).
|
|
32129
32246
|
|
|
32130
32247
|
Config:
|
|
32131
32248
|
'portfolio_stop_loss': {
|
|
@@ -32233,15 +32350,17 @@ def check_portfolio_stop_loss(position_managers, options_today, get_option_func,
|
|
|
32233
32350
|
ask = float(option_data.get('ask', 0) or 0)
|
|
32234
32351
|
close_price = ask if is_short else bid
|
|
32235
32352
|
if is_short:
|
|
32236
|
-
options_unrealized += abs(total_cost) - (close_price *
|
|
32353
|
+
options_unrealized += abs(total_cost) - (close_price * _pos_unit_multiplier(pos) * contracts)
|
|
32237
32354
|
else:
|
|
32238
|
-
options_unrealized += (close_price *
|
|
32355
|
+
options_unrealized += (close_price * _pos_unit_multiplier(pos) * contracts) - abs(total_cost)
|
|
32239
32356
|
|
|
32240
32357
|
if total_allocated == 0:
|
|
32241
32358
|
return None
|
|
32242
32359
|
|
|
32243
|
-
#
|
|
32244
|
-
|
|
32360
|
+
# cycle_hedge_pnl must be in the same units as hedge_multiplier: pass get_hedge_multiplier(config) with $ cash flows
|
|
32361
|
+
if hedge_shares and hedge_multiplier == 1.0:
|
|
32362
|
+
_warn_hedge_multiplier_default(config)
|
|
32363
|
+
hedge_unrealized = cycle_hedge_pnl + (hedge_shares * stock_mid * hedge_multiplier if hedge_shares else 0.0)
|
|
32245
32364
|
combined_pnl = options_unrealized + stock_unrealized + extra_pnl + hedge_unrealized
|
|
32246
32365
|
combined_pnl_pct = combined_pnl / total_allocated
|
|
32247
32366
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ivolatility_backtesting
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.147
|
|
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.147"
|
|
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,305 @@
|
|
|
1
|
+
# Per-point multiplier for US commodities priced in cents; contract_size is in physical units.
|
|
2
|
+
# Run: python3 tests/test_2147_cents_multiplier.py (or pytest)
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import traceback
|
|
6
|
+
|
|
7
|
+
import matplotlib
|
|
8
|
+
matplotlib.use('Agg')
|
|
9
|
+
|
|
10
|
+
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
sys.path.insert(0, os.path.join(_REPO, 'ivolatility_backtesting'))
|
|
12
|
+
import ivolatility_backtesting as lib # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import pandas as pd # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _mult(symbol, contract_size, root=None, mic='', measure=''):
|
|
19
|
+
config = {'asset_type': 'futures', 'symbol': symbol}
|
|
20
|
+
meta = {'multiplier': contract_size, 'root': root or symbol, 'mic': mic, 'measure': measure}
|
|
21
|
+
return lib.get_position_multiplier(config, {'_futures_meta': meta})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _PM:
|
|
25
|
+
def __init__(self, positions):
|
|
26
|
+
self._positions = positions
|
|
27
|
+
|
|
28
|
+
def get_open_positions(self):
|
|
29
|
+
return self._positions
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_cents_quoted_grains_divide_by_100():
|
|
33
|
+
# API contract_size / MIC as returned by /futures/eod/fut-underlying-info
|
|
34
|
+
assert _mult('S', 5000.0, mic='XCBT') == 50.0
|
|
35
|
+
assert _mult('C', 5000.0, mic='XCBT') == 50.0
|
|
36
|
+
assert _mult('W', 5000.0, mic='XCBT') == 50.0
|
|
37
|
+
assert _mult('O', 5000.0, mic='XCBT') == 50.0
|
|
38
|
+
assert _mult('BO', 60000.0, mic='XCBT') == 600.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_globex_alias_resolves_through_internal_root():
|
|
42
|
+
assert _mult('ZS', 5000.0, root='S', mic='XCBT') == 50.0
|
|
43
|
+
assert _mult('ZL', 60000.0, root='BO', mic='XCBT') == 600.0
|
|
44
|
+
assert _mult('LE', 40000.0, root='LC', mic='XCME') == 400.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_livestock_milk_softs_lumber():
|
|
48
|
+
assert _mult('LC', 40000.0, mic='XCME') == 400.0
|
|
49
|
+
assert _mult('LH', 40000.0, mic='XCME') == 400.0
|
|
50
|
+
assert _mult('FC', 50000.0, mic='XCME') == 500.0
|
|
51
|
+
assert _mult('DA', 200000.0, mic='XCME') == 2000.0
|
|
52
|
+
assert _mult('KC', 37500.0, mic='IEPA') == 375.0
|
|
53
|
+
assert _mult('SB', 112000.0, mic='IEPA') == 1120.0
|
|
54
|
+
assert _mult('CT', 50000.0, mic='IEPA') == 500.0
|
|
55
|
+
assert _mult('LBR', 27500.0, mic='XCME') == 27.5
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_same_root_on_other_exchange_is_untouched():
|
|
59
|
+
# C / W / CT also exist on ICE London (XNLI) — must not be rescaled
|
|
60
|
+
assert _mult('C', 5000.0, mic='XNLI') == 5000.0
|
|
61
|
+
assert _mult('W', 5000.0, mic='XNLI') == 5000.0
|
|
62
|
+
assert _mult('CT', 50000.0, mic='XNLI') == 50000.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_dollar_priced_and_bonds_unchanged():
|
|
66
|
+
assert _mult('ES', 50.0, mic='XCME') == 50.0
|
|
67
|
+
assert _mult('CL', 1000.0, mic='XNYM') == 1000.0
|
|
68
|
+
assert _mult('GC', 100.0, mic='XCEC') == 100.0
|
|
69
|
+
assert _mult('SM', 100.0, mic='XCBT') == 100.0
|
|
70
|
+
assert _mult('RR', 2000.0, mic='XCBT') == 2000.0
|
|
71
|
+
assert _mult('CC', 10.0, mic='IEPA') == 10.0
|
|
72
|
+
assert _mult('ZB', 100000.0, root='US', mic='XCBT') == 1000.0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_extended_cent_priced_roots():
|
|
76
|
+
assert _mult('KW', 5000.0, mic='XCBT') == 50.0
|
|
77
|
+
assert _mult('HRS', 5000.0, mic='XCBT') == 50.0
|
|
78
|
+
assert _mult('CKW', 5000.0, mic='XCBT') == 50.0
|
|
79
|
+
assert _mult('BCX', 50000.0, mic='XCBT') == 500.0
|
|
80
|
+
assert _mult('PRK', 40000.0, mic='XCME') == 400.0
|
|
81
|
+
assert _mult('BTN', 20000.0, mic='XCME') == 200.0
|
|
82
|
+
assert _mult('DK', 200000.0, mic='XCME') == 2000.0
|
|
83
|
+
assert _mult('CB', 20000.0, mic='XCME') == 200.0
|
|
84
|
+
assert _mult('NF', 44000.0, mic='XCME') == 440.0
|
|
85
|
+
assert _mult('SYP', 22000.0, mic='XCME') == 22.0
|
|
86
|
+
assert _mult('LB', 110000.0, mic='XCME') == 110.0
|
|
87
|
+
assert _mult('OJ', 15000.0, mic='IEPA') == 150.0
|
|
88
|
+
assert _mult('IS', 5000.0, mic='IEPA') == 50.0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_colliding_symbols_on_other_exchanges_untouched():
|
|
92
|
+
# KW on CME, IS on NYMEX (natural gas index), SB1 on NYMEX (WTI-Brent) are other products
|
|
93
|
+
assert _mult('KW', 5000.0, mic='XCME') == 5000.0
|
|
94
|
+
assert _mult('IS', 2500.0, mic='XNYM') == 2500.0
|
|
95
|
+
assert _mult('SB1', 1000.0, mic='XNYM') == 1000.0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_dollar_priced_dairy_metals_rates_unchanged():
|
|
99
|
+
assert _mult('CSC1', 20000.0, mic='XCME') == 20000.0
|
|
100
|
+
assert _mult('BLK', 20000.0, mic='XCME') == 20000.0
|
|
101
|
+
assert _mult('JQ', 1000.0, mic='XCME') == 1000.0
|
|
102
|
+
assert _mult('AL', 44000.0, mic='XCEC') == 44000.0
|
|
103
|
+
assert _mult('COB', 2204.0, mic='XCEC') == 2204.0
|
|
104
|
+
assert _mult('SR3', 2500.0, mic='XCME') == 2500.0
|
|
105
|
+
assert _mult('ED', 2500.0, mic='XCME') == 2500.0
|
|
106
|
+
assert _mult('TU', 200000.0, mic='XCBT') == 2000.0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_override_still_wins():
|
|
110
|
+
config = {'asset_type': 'futures', 'symbol': 'S', 'multiplier_override': 42}
|
|
111
|
+
meta = {'multiplier': 5000.0, 'root': 'S', 'mic': 'XCBT'}
|
|
112
|
+
assert lib.get_position_multiplier(config, {'_futures_meta': meta}) == 42.0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_cme_fx_uses_contract_size():
|
|
116
|
+
# quoted in USD per 1 unit of foreign currency: EC 1.14, JY 0.0063
|
|
117
|
+
assert _mult('6E', 125000.0, root='EC', mic='XCME', measure='CURRENCY') == 125000.0
|
|
118
|
+
assert _mult('JY', 12500000.0, mic='XCME', measure='CURRENCY') == 12500000.0
|
|
119
|
+
assert _mult('MP', 500000.0, mic='XCME', measure='CURRENCY') == 500000.0
|
|
120
|
+
assert _mult('SR3', 2500.0, mic='XCME', measure='CURRENCY') == 2500.0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_power_and_gas_require_override():
|
|
124
|
+
for sym, size, measure in (('TTF', 1.0, 'MEGA_WATT_HOURS'), ('UKG', 1000.0, 'THERMS')):
|
|
125
|
+
try:
|
|
126
|
+
_mult(sym, size, mic='XNYM', measure=measure)
|
|
127
|
+
except ValueError as e:
|
|
128
|
+
assert 'multiplier_override' in str(e)
|
|
129
|
+
else:
|
|
130
|
+
raise AssertionError(f'{sym}: expected ValueError')
|
|
131
|
+
config = {'asset_type': 'futures', 'symbol': 'TTF', 'multiplier_override': 720}
|
|
132
|
+
meta = {'multiplier': 1.0, 'root': 'TTF', 'mic': 'XNYM', 'measure': 'MEGA_WATT_HOURS'}
|
|
133
|
+
assert lib.get_position_multiplier(config, {'_futures_meta': meta}) == 720.0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_micro_crypto_zero_contract_size():
|
|
137
|
+
assert _mult('MBT', 0.0, mic='XCME', measure='BITCOIN') == 0.1
|
|
138
|
+
assert _mult('MET', 0.0, mic='XCME', measure='ETHER') == 0.1
|
|
139
|
+
assert _mult('BFF', 0.0, mic='XCME', measure='BITCOIN') == 0.02
|
|
140
|
+
assert _mult('BTC', 5.0, mic='XCME', measure='BITCOIN') == 5.0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_bond_rule_only_on_cbot():
|
|
144
|
+
assert _mult('UB', 100000.0, root='UL', mic='XCBT', measure='CURRENCY') == 1000.0
|
|
145
|
+
assert _mult('ZT', 200000.0, root='TU', mic='XCBT', measure='CURRENCY') == 2000.0
|
|
146
|
+
assert _mult('UB', 1000.0, mic='XNYM', measure='BARRELS') == 1000.0 # NYMEX Brent swap named UB
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_futures_hedge_default_multiplier_warns_once():
|
|
150
|
+
import contextlib
|
|
151
|
+
import io
|
|
152
|
+
pos = {'strategy_type': 'LONG_CALL', 'contracts': 1, 'strike': 5000.0, 'expiration': '2026-11-20',
|
|
153
|
+
'total_cost': 1000.0, 'entry_max_risk': 1000.0}
|
|
154
|
+
cfg = {'symbol': 'NDX', 'delta_hedge': {'enabled': True},
|
|
155
|
+
'portfolio_stop_loss': {'enabled': True, 'value': -0.10}}
|
|
156
|
+
row = pd.DataFrame([{'close': 5000.0, 'bid': 5000.0, 'ask': 5000.0}])
|
|
157
|
+
get_opt = lambda s, e, t: {'bid': 5.0, 'ask': 5.2} # noqa: E731
|
|
158
|
+
lib._HEDGE_MULT_WARNED.discard('NDX')
|
|
159
|
+
buf = io.StringIO()
|
|
160
|
+
with contextlib.redirect_stdout(buf):
|
|
161
|
+
for _ in range(2):
|
|
162
|
+
lib.check_portfolio_stop_loss([_PM([pos])], None, get_opt, cfg, row, hedge_shares=-2, cycle_hedge_pnl=0.0)
|
|
163
|
+
assert buf.getvalue().count('hedge_multiplier=get_hedge_multiplier(config)') == 1
|
|
164
|
+
buf = io.StringIO()
|
|
165
|
+
with contextlib.redirect_stdout(buf):
|
|
166
|
+
lib.check_portfolio_stop_loss([_PM([pos])], None, get_opt, {**cfg, 'symbol': 'SPY'}, row,
|
|
167
|
+
hedge_shares=-2, cycle_hedge_pnl=0.0)
|
|
168
|
+
assert 'hedge_multiplier' not in buf.getvalue()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_brent_roots():
|
|
172
|
+
assert _mult('BRN', 0.0, mic='NDEX') == 1000.0
|
|
173
|
+
assert _mult('WAB-D', 0.0, mic='NDEX') == 1000.0
|
|
174
|
+
assert _mult('BZ', 1000.0, root='BZ1', mic='XNYM', measure='BARRELS') == 1000.0
|
|
175
|
+
assert _mult('ZO', 1000.0, mic='XNYM', measure='BARRELS') == 1000.0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_pos_unit_multiplier_fallbacks():
|
|
179
|
+
assert lib._pos_unit_multiplier({'unit_multiplier': 50.0}) == 50.0
|
|
180
|
+
assert lib._pos_unit_multiplier({'unit_multiplier': None}) == 100.0
|
|
181
|
+
assert lib._pos_unit_multiplier({}) == 100.0
|
|
182
|
+
assert lib._pos_unit_multiplier(None) == 100.0
|
|
183
|
+
assert lib._pos_unit_multiplier({'unit_multiplier': 'x'}) == 100.0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_calculate_risk_uses_unit_multiplier():
|
|
187
|
+
ic = {'strategy_type': 'IRON_CONDOR', 'contracts': 1, 'total_cost': -200.0, 'wing_width': 10}
|
|
188
|
+
assert lib.StrategyRegistry.calculate_risk(ic)[0] == 1200.0 # (10*100-200)*1.5
|
|
189
|
+
assert lib.StrategyRegistry.calculate_risk({**ic, 'unit_multiplier': 50.0})[0] == 450.0
|
|
190
|
+
cc = {'strategy_type': 'COVERED_CALL', 'contracts': 2, 'total_cost': -300.0,
|
|
191
|
+
'underlying_entry_price': 100.0, 'call_premium': 300.0}
|
|
192
|
+
assert lib.StrategyRegistry.calculate_risk(cc) == (19700.0, 20000.0)
|
|
193
|
+
assert lib.StrategyRegistry.calculate_risk({**cc, 'unit_multiplier': 50.0}) == (9700.0, 10000.0)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_hedge_instrument_multiplier():
|
|
197
|
+
assert lib._hedge_instrument_multiplier({'symbol': 'SPX', 'delta_hedge': {'enabled': True}}) == ('futures', 5.0)
|
|
198
|
+
assert lib._hedge_instrument_multiplier({'symbol': 'SPY', 'delta_hedge': {'enabled': True}}) == ('stock', 1.0)
|
|
199
|
+
cfg = {'symbol': 'SPX', 'delta_hedge': {'enabled': True, 'futures_multiplier': 50}}
|
|
200
|
+
assert lib._hedge_instrument_multiplier(cfg) == ('futures', 50.0)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_delta_hedge_futures_option_units():
|
|
204
|
+
# 2 long soybean calls, delta 0.5, $50 per 1 cent → $50 per cent of exposure → -50 units
|
|
205
|
+
pos = {'id': 'p1', 'strategy_type': 'LONG_CALL', 'contracts': 2, 'strike': 1000.0,
|
|
206
|
+
'expiration': '2026-11-20', 'unit_multiplier': 50.0}
|
|
207
|
+
cfg = {'symbol': 'S', 'asset_type': 'futures',
|
|
208
|
+
'delta_hedge': {'enabled': True, 'rebalance_threshold_min': 1,
|
|
209
|
+
'rebalance_threshold_pct': 0.0, 'use_bid_ask': False}}
|
|
210
|
+
a = lib.check_portfolio_delta_hedge([_PM([pos])], None, lambda s, e, t: {'delta': 0.5}, 0, cfg)
|
|
211
|
+
assert a['target_shares'] == -50 and a['hedge_multiplier'] == 1.0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_delta_hedge_spx_micro_futures_cash_flow():
|
|
215
|
+
pos = {'id': 'p1', 'strategy_type': 'LONG_CALL', 'contracts': 1, 'strike': 5000.0,
|
|
216
|
+
'expiration': '2026-11-20'}
|
|
217
|
+
cfg = {'symbol': 'SPX', 'delta_hedge': {'enabled': True, 'rebalance_threshold_min': 1,
|
|
218
|
+
'rebalance_threshold_pct': 0.0, 'use_bid_ask': False}}
|
|
219
|
+
row = pd.DataFrame([{'close': 5000.0, 'bid': 5000.0, 'ask': 5000.0}])
|
|
220
|
+
a = lib.check_portfolio_delta_hedge([_PM([pos])], None, lambda s, e, t: {'delta': 0.5}, 0, cfg, row)
|
|
221
|
+
assert a['instrument'] == 'futures' and a['hedge_multiplier'] == 5.0
|
|
222
|
+
assert a['shares_diff'] == -10 # 0.5 * 100 * 1 / 5 MES
|
|
223
|
+
assert a['cash_flow'] == 10 * 5000.0 * 5.0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_portfolio_stop_hedge_valued_per_contract():
|
|
227
|
+
pos = {'strategy_type': 'LONG_CALL', 'contracts': 1, 'strike': 5000.0, 'expiration': '2026-11-20',
|
|
228
|
+
'total_cost': 1000.0, 'entry_max_risk': 1000.0}
|
|
229
|
+
cfg = {'symbol': 'SPX', 'delta_hedge': {'enabled': True},
|
|
230
|
+
'portfolio_stop_loss': {'enabled': True, 'value': -0.10}}
|
|
231
|
+
row = pd.DataFrame([{'close': 5000.0, 'bid': 5000.0, 'ask': 5000.0}])
|
|
232
|
+
get_opt = lambda s, e, t: {'bid': 5.0, 'ask': 5.2} # noqa: E731
|
|
233
|
+
hm = lib.get_hedge_multiplier(cfg)
|
|
234
|
+
assert hm == 5.0
|
|
235
|
+
# short 2 MES sold at 5000, price unchanged → hedge P&L 0, options -500
|
|
236
|
+
r = lib.check_portfolio_stop_loss([_PM([pos])], None, get_opt, cfg, row,
|
|
237
|
+
hedge_shares=-2, cycle_hedge_pnl=2 * 5000.0 * 5.0,
|
|
238
|
+
hedge_multiplier=hm)
|
|
239
|
+
assert r['hedge_unrealized'] == 0.0 and r['combined_pnl'] == -500.0
|
|
240
|
+
# realized hedge P&L kept when the hedge is flat
|
|
241
|
+
r = lib.check_portfolio_stop_loss([_PM([pos])], None, get_opt, cfg, row,
|
|
242
|
+
hedge_shares=0, cycle_hedge_pnl=-300.0)
|
|
243
|
+
assert r['hedge_unrealized'] == -300.0 and r['combined_pnl'] == -800.0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_portfolio_stop_legacy_caller_unchanged():
|
|
247
|
+
# legacy caller: cash_flow = -diff * price without multiplier, no hedge_multiplier kwarg
|
|
248
|
+
pos = {'strategy_type': 'LONG_CALL', 'contracts': 1, 'strike': 5000.0, 'expiration': '2026-11-20',
|
|
249
|
+
'total_cost': 1000.0, 'entry_max_risk': 1000.0}
|
|
250
|
+
cfg = {'symbol': 'SPX', 'delta_hedge': {'enabled': True},
|
|
251
|
+
'portfolio_stop_loss': {'enabled': True, 'value': -0.10}}
|
|
252
|
+
row = pd.DataFrame([{'close': 5000.0, 'bid': 5000.0, 'ask': 5000.0}])
|
|
253
|
+
r = lib.check_portfolio_stop_loss([_PM([pos])], None, lambda s, e, t: {'bid': 5.0, 'ask': 5.2}, cfg, row,
|
|
254
|
+
hedge_shares=-60, cycle_hedge_pnl=60 * 5000.0)
|
|
255
|
+
assert r['hedge_unrealized'] == 0.0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_stop_loss_manager_gets_unit_multiplier():
|
|
259
|
+
cfg = {'asset_type': 'futures', 'symbol': 'ES', 'multiplier_override': 50,
|
|
260
|
+
'stop_loss_enabled': True, 'stop_loss_config': {'type': 'pl_loss', 'value': 0.5}}
|
|
261
|
+
pm = lib.PositionManager(cfg, debug=False)
|
|
262
|
+
pm.open_position(position_id='p1', strategy_type='LONG_CALL', symbol='ES',
|
|
263
|
+
entry_date=pd.Timestamp('2025-01-02'), entry_price=2000.0, quantity=50, contracts=1,
|
|
264
|
+
total_cost=2000.0, strike=6000.0, expiration=pd.Timestamp('2025-02-21'),
|
|
265
|
+
is_short_bias=False, underlying_entry_price=6000.0)
|
|
266
|
+
assert pm.positions['p1']['unit_multiplier'] == 50.0
|
|
267
|
+
assert pm.sl_manager.positions['p1']['unit_multiplier'] == 50.0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_override_far_off_resolved_multiplier_warns_once():
|
|
271
|
+
import contextlib
|
|
272
|
+
import io
|
|
273
|
+
config = {'asset_type': 'futures', 'symbol': '6E', 'multiplier_override': 12.5}
|
|
274
|
+
pre = {'_futures_meta': {'multiplier': 125000.0, 'root': 'EC', 'mic': 'XCME', 'measure': 'CURRENCY'}}
|
|
275
|
+
buf = io.StringIO()
|
|
276
|
+
with contextlib.redirect_stdout(buf):
|
|
277
|
+
assert lib.get_position_multiplier(config, pre) == 12.5
|
|
278
|
+
assert lib.get_position_multiplier(config, pre) == 12.5
|
|
279
|
+
assert buf.getvalue().count('multiplier_override=12.5') == 1
|
|
280
|
+
buf = io.StringIO()
|
|
281
|
+
with contextlib.redirect_stdout(buf):
|
|
282
|
+
assert lib.get_position_multiplier({**config, 'multiplier_override': 125000}, pre) == 125000.0
|
|
283
|
+
assert 'multiplier_override' not in buf.getvalue()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_futures_option_premium_and_stop_values():
|
|
287
|
+
pos = {'strategy_type': 'LONG_CALL', 'contracts': 2, 'strike': 1000.0, 'expiration': '2026-11-20',
|
|
288
|
+
'total_cost': 2000.0, 'entry_max_risk': 2000.0, 'unit_multiplier': 50.0}
|
|
289
|
+
cfg = {'symbol': 'S', 'asset_type': 'futures', 'portfolio_stop_loss': {'enabled': True, 'value': -0.10}}
|
|
290
|
+
r = lib.check_portfolio_stop_loss([_PM([pos])], None, lambda s, e, t: {'bid': 10.0, 'ask': 10.5}, cfg)
|
|
291
|
+
assert r['options_unrealized'] == 10.0 * 50.0 * 2 - 2000.0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == '__main__':
|
|
295
|
+
failed = 0
|
|
296
|
+
for name, fn in sorted((n, f) for n, f in globals().items() if n.startswith('test_') and callable(f)):
|
|
297
|
+
try:
|
|
298
|
+
fn()
|
|
299
|
+
print(f'PASS {name}')
|
|
300
|
+
except Exception:
|
|
301
|
+
failed += 1
|
|
302
|
+
print(f'FAIL {name}')
|
|
303
|
+
traceback.print_exc()
|
|
304
|
+
print(f'{failed} failed')
|
|
305
|
+
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.146 → ivolatility_backtesting-2.147}/tests/test_2144_duckdb_dedup.py
RENAMED
|
File without changes
|
|
File without changes
|