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.
Files changed (15) hide show
  1. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/PKG-INFO +1 -1
  2. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting/__init__.py +2 -2
  3. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting/ivolatility_backtesting.py +240 -121
  4. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting.egg-info/PKG-INFO +1 -1
  5. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting.egg-info/SOURCES.txt +2 -1
  6. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/pyproject.toml +1 -1
  7. ivolatility_backtesting-2.147/tests/test_2147_cents_multiplier.py +305 -0
  8. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/README.md +0 -0
  9. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting.egg-info/dependency_links.txt +0 -0
  10. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting.egg-info/requires.txt +0 -0
  11. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/ivolatility_backtesting.egg-info/top_level.txt +0 -0
  12. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/setup.cfg +0 -0
  13. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/tests/test_2142_fixes.py +0 -0
  14. {ivolatility_backtesting-2.146 → ivolatility_backtesting-2.147}/tests/test_2144_duckdb_dedup.py +0 -0
  15. {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.146
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
@@ -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 * 100 * contracts) - credit
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 * 100) - (credit / contracts if contracts > 0 else 0)
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 * 100 * contracts) - credit
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 * 100 * contracts) - credit
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
- underlying_price = position.get('underlying_entry_price', 0)
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 * 100
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.get('unit_multiplier', 100)
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 * 100 * contracts) - credit (only ONE side can lose)
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 * 100 * contracts) + total_cost # total_cost already includes contracts
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 * 100 * contracts) - credit
6549
- return (spread_width * 100 * contracts) + total_cost # total_cost already includes contracts
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 * 100 * contracts) - credit (similar to Iron Condor)
6554
- return (wing_width * 100 * contracts) + total_cost # total_cost already includes contracts
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 * 100 * contracts) + total_cost # subtract call premium
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 * 100 * contracts
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 * 100 * contracts) + total_cost # total_cost already includes contracts
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 * 100
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 * 100
6780
- put_close_cost = put_close_ask * contracts * 100
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 * 100
6795
- call_close_cost = call_close_ask * contracts * 100
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 * 100 * contracts # Pay for long
9779
+ entry_value += entry_price * _pos_unit_multiplier(pos) * contracts # Pay for long
9777
9780
  else:
9778
- entry_value -= entry_price * 100 * contracts # Receive for short
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 * 100 * contracts
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 * 100 * contracts
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 * 100
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 * 100
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) * 100
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 * 100
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 * 100 # call ≈ 0
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 * 100
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, …). Replaces previous hardcoded *100.
12394
- unit_multiplier = position.get('unit_multiplier', 100)
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.get('unit_multiplier', 100)
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 * 100) - total_cost_per_contract
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 * 100) - total_cost_per_contract
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 * 100
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 * 100 * contracts - credit
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 * 100) - credit_per_contract
14700
- max_loss_per_contract = (wing_width * 100) - (credit / contracts if contracts > 0 else 0)
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 * 100 * contracts) - credit
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 * 100 * contracts) - total_cost
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 * 100 * contracts) - credit
14753
- max_loss = (wing_width * 100 * contracts) - total_cost
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 * 100
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 * 100
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 * 100 * contracts (cash secured)
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 * 100 * contracts
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'] * 100
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
- cost_per_straddle = (call_bid + put_bid) * 100
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. Two asset classes need a correction:
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
- # FX futures: API contract_size = base currency size (e.g. 6E = 125_000),
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
- # Same dual-alias treatment for FX futures.
21075
- _FX_FUTURES_ROOTS = frozenset({
21076
- '6E','6J','6B','6A','6C','6N','6S','6M', # Globex aliases
21077
- 'EC','JY','BP','AD','CD','NE','SF','MP', # internal CME codes
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
- - FX (6E/6J/...): WARN + return 1.0 (force user to override)
21279
- - everything else: contract_size as-is
21280
- 4. Fallback → 100
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
- 6E (EUR/USD, no rule): 1.0 ← + warning, override needed
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
- return float(override)
21377
+ override = float(override)
21306
21378
  except (TypeError, ValueError):
21307
- pass
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: contract_size is face value; per-point P&L = face / 100
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
- # FX futures: per-point multiplier depends on quote convention; we cannot
21358
- # derive it from contract_size alone. Warn loudly and return 1.0 so any
21359
- # P&L computed without an override is obviously wrong.
21360
- if candidate_roots & _FX_FUTURES_ROOTS:
21361
- which = sorted(candidate_roots & _FX_FUTURES_ROOTS)[0]
21362
- print(f" ⚠️ get_position_multiplier: FX futures root {which!r} requires "
21363
- f"config['multiplier_override'] (contract_size={raw} is base-currency size, "
21364
- f"not per-point P&L multiplier). Returning 1.0 — fix your config.")
21365
- return 1.0
21366
-
21367
- # Everything else (indices, commodities, metals, energies): contract_size IS multiplier
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
- mult = meta['multiplier']
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
- dte_target = config.get('dte_target', 35)
23194
- dte_tolerance = config.get('dte_tolerance', 10)
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
- symbol = config.get('symbol', '')
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
- pos_delta = -current_delta * 100 * contracts if is_short else current_delta * 100 * contracts
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 (e.g., hedge rebalance cash flow).
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 * 100 * contracts)
32353
+ options_unrealized += abs(total_cost) - (close_price * _pos_unit_multiplier(pos) * contracts)
32237
32354
  else:
32238
- options_unrealized += (close_price * 100 * contracts) - abs(total_cost)
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
- # Hedge P&L: realized (cycle_hedge_pnl) + unrealized (hedge_shares * stock_mid)
32244
- hedge_unrealized = cycle_hedge_pnl + hedge_shares * stock_mid if hedge_shares else 0.0
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.146
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
@@ -10,4 +10,5 @@ ivolatility_backtesting.egg-info/requires.txt
10
10
  ivolatility_backtesting.egg-info/top_level.txt
11
11
  tests/test_2142_fixes.py
12
12
  tests/test_2144_duckdb_dedup.py
13
- tests/test_2146_vix_vro.py
13
+ tests/test_2146_vix_vro.py
14
+ tests/test_2147_cents_multiplier.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ivolatility_backtesting"
7
- version = "2.146"
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)