ivolatility-backtesting 1.35__tar.gz → 1.37__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 1.35
3
+ Version: 1.37
4
4
  Summary: A universal backtesting framework for financial strategies using the IVolatility API.
5
5
  Author-email: IVolatility <support@ivolatility.com>
6
6
  Project-URL: Homepage, https://ivolatility.com
@@ -19,7 +19,8 @@ from .ivolatility_backtesting import (
19
19
  _process_options_df,
20
20
  precalculate_indicators_from_config, build_indicator_lookup,
21
21
  INDICATOR_REGISTRY, auto_calculate_lookback_period,
22
- calculate_iv_lean_from_ivx, preload_ivx_zscore_cache
22
+ calculate_iv_lean_from_ivx, preload_ivx_zscore_cache,
23
+ calculate_iv_percentile_from_ivx # For earnings strategies
23
24
  )
24
25
 
25
26
  __all__ = [
@@ -45,5 +46,6 @@ __all__ = [
45
46
  'precalculate_indicators_from_config', 'build_indicator_lookup',
46
47
  'INDICATOR_REGISTRY', 'auto_calculate_lookback_period',
47
48
  # IV Lean / Z-Score functions
48
- 'calculate_iv_lean_from_ivx', 'preload_ivx_zscore_cache'
49
+ 'calculate_iv_lean_from_ivx', 'preload_ivx_zscore_cache',
50
+ 'calculate_iv_percentile_from_ivx' # For earnings strategies
49
51
  ]
@@ -2365,7 +2365,9 @@ class StrategyRegistry:
2365
2365
  strategy = cls.get(strategy_type)
2366
2366
 
2367
2367
  if not strategy or 'legs' not in strategy:
2368
- return pos_data # Fallback: pass everything as-is
2368
+ # Fallback: filter out fields passed explicitly to close_position()
2369
+ excluded = ['pnl', 'pnl_pct', 'price', 'exit_reason', 'close_reason', 'position_id']
2370
+ return {k: v for k, v in pos_data.items() if k not in excluded}
2369
2371
 
2370
2372
  kwargs = {}
2371
2373
 
@@ -3023,7 +3025,8 @@ class StrategyRegistry:
3023
3025
  if not strategy or 'legs' not in strategy:
3024
3026
  # Fallback: return all pos_data (for backward compatibility)
3025
3027
  # BUT exclude fields that are ALWAYS passed explicitly to close_position()
3026
- kwargs = {k: v for k, v in pos_data.items() if k not in ['pnl', 'pnl_pct', 'price']}
3028
+ excluded_fields = ['pnl', 'pnl_pct', 'price', 'exit_reason', 'close_reason', 'position_id']
3029
+ kwargs = {k: v for k, v in pos_data.items() if k not in excluded_fields}
3027
3030
  return kwargs
3028
3031
 
3029
3032
  kwargs = {}
@@ -4842,6 +4845,39 @@ class PositionManager:
4842
4845
  self.sl_config = None
4843
4846
  self.sl_manager = None
4844
4847
 
4848
+ # ================================================================
4849
+ # ENTRY COST CALCULATION (wrapper for StrategyRegistry)
4850
+ # ================================================================
4851
+ def calculate_entry_cost(self, leg_data, contracts=1, is_short=None):
4852
+ """
4853
+ Calculate entry cost for current strategy (uses strategy_type from config).
4854
+
4855
+ This is a convenience wrapper around StrategyRegistry.calculate_entry_cost()
4856
+ that automatically uses strategy_type from self.config.
4857
+
4858
+ Args:
4859
+ leg_data: Dict with option data at entry
4860
+ contracts: Number of contracts (default=1 for per-contract calculation)
4861
+ is_short: Override for SHORT detection (True = sell to open, False = buy to open)
4862
+ Required for strategies like STRADDLE/IV_LEAN where leg names don't have 'short_' prefix
4863
+
4864
+ Returns:
4865
+ dict: {
4866
+ 'total': Total cost (positive for DEBIT, negative for CREDIT),
4867
+ 'per_leg': {leg_name: cost, ...},
4868
+ 'net_credit': bool,
4869
+ }
4870
+
4871
+ Example:
4872
+ entry_cost = position_mgr.calculate_entry_cost(
4873
+ {'call': call_data, 'put': put_data},
4874
+ contracts=2,
4875
+ is_short=True
4876
+ )
4877
+ """
4878
+ strategy_type = self.config.get('strategy_type', 'STRADDLE')
4879
+ return StrategyRegistry.calculate_entry_cost(strategy_type, leg_data, contracts, is_short)
4880
+
4845
4881
  # ================================================================
4846
4882
  # UNIVERSAL INTRINSIC VALUE CALCULATION METHOD
4847
4883
  # ================================================================
@@ -5500,16 +5536,26 @@ class PositionManager:
5500
5536
  # Determine stop_type: 'expiration' (market data) or 'expiration_intrinsic' (no data)
5501
5537
  stop_type = 'expiration_intrinsic' if used_intrinsic else 'expiration'
5502
5538
 
5503
- to_close.append({
5539
+ # ✅ AUTO-GENERATE close kwargs (so strategy code doesn't need to call generate_close_position_kwargs)
5540
+ strategy_type = position.get('strategy_type', 'STRADDLE')
5541
+ pos_data = price_data.get(position_id, {})
5542
+ close_kwargs = StrategyRegistry.generate_close_position_kwargs(strategy_type, pos_data)
5543
+
5544
+ stop_info = {
5504
5545
  'position_id': position_id,
5505
5546
  'symbol': position['symbol'],
5506
5547
  'stop_type': stop_type,
5548
+ 'close_reason': stop_type, # ✅ For strategy stats
5549
+ 'stat_key': 'expiration_exits', # ✅ For strategy stats
5507
5550
  'stop_level': None,
5508
5551
  'current_price': current_price,
5509
5552
  'pnl': current_pnl,
5510
5553
  'pnl_pct': current_pnl_pct,
5511
- 'settlement_type': 'intrinsic' if used_intrinsic else 'market'
5512
- })
5554
+ 'settlement_type': 'intrinsic' if used_intrinsic else 'market',
5555
+ **close_kwargs # ✅ Include leg exit data automatically
5556
+ }
5557
+
5558
+ to_close.append(stop_info)
5513
5559
 
5514
5560
  if self.debug:
5515
5561
  print(f"[PositionManager] 📅 EXPIRATION: {position_id} expired on {expiration}")
@@ -5706,6 +5752,10 @@ class PositionManager:
5706
5752
  # For other stop types: generic reason
5707
5753
  stop_info['exit_reason'] = f"stop_loss_{stop_type}"
5708
5754
 
5755
+ # ✅ ADD close_reason and stat_key for strategy stats
5756
+ stop_info['close_reason'] = stop_info['exit_reason']
5757
+ stop_info['stat_key'] = 'stoploss_triggered'
5758
+
5709
5759
  # ✅ ADD stop-loss metadata for CSV export (stop_threshold, actual_value)
5710
5760
  # These fields are needed for detailed analysis of stop-loss triggers
5711
5761
  if stop_type == 'pl_loss':
@@ -5731,6 +5781,12 @@ class PositionManager:
5731
5781
  # Just merge them into stop_info (no need for second mapping!)
5732
5782
  stop_info.update(intraday_data)
5733
5783
 
5784
+ # ✅ AUTO-GENERATE close kwargs (so strategy code doesn't need to call generate_close_position_kwargs)
5785
+ strategy_type = position.get('strategy_type', 'STRADDLE')
5786
+ pos_data = price_data.get(position_id, {})
5787
+ close_kwargs = StrategyRegistry.generate_close_position_kwargs(strategy_type, pos_data)
5788
+ stop_info.update(close_kwargs)
5789
+
5734
5790
  to_close.append(stop_info)
5735
5791
 
5736
5792
  if self.debug:
@@ -5739,6 +5795,148 @@ class PositionManager:
5739
5795
 
5740
5796
  return to_close
5741
5797
 
5798
+ def build_price_data(self, current_date, stock_price, options_df, get_option_func,
5799
+ indicators=None, extra_fields_callback=None):
5800
+ """
5801
+ Build price_data dict for all open positions.
5802
+
5803
+ Args:
5804
+ current_date: Current backtest date
5805
+ stock_price: Current underlying price
5806
+ options_df: Options data for current date
5807
+ get_option_func: Function to get option by (strike, exp, type)
5808
+ indicators: Optional dict of indicators for current date
5809
+ All fields will be added to price_data with 'exit_' prefix
5810
+ (e.g., z_score → exit_z_score, iv_percentile → exit_iv_percentile)
5811
+ extra_fields_callback: Optional function(position, price_data) to add custom fields
5812
+ (e.g., directional stop fields, earnings data)
5813
+
5814
+ Returns:
5815
+ Dict[position_id, price_data] for all open positions
5816
+ """
5817
+ price_data = {}
5818
+
5819
+ for position in self.get_open_positions():
5820
+ # Get leg data from current market prices
5821
+ leg_data = StrategyRegistry.get_leg_data_for_position(
5822
+ position, options_df, get_option_func
5823
+ )
5824
+
5825
+ if leg_data is not None:
5826
+ # Get strategy type
5827
+ strategy_type = position.get('strategy_type', self.config.get('strategy_type', 'STRADDLE'))
5828
+
5829
+ # Auto-generate price_data (framework calculates P&L automatically!)
5830
+ price_data[position['id']] = StrategyRegistry.generate_price_data(
5831
+ strategy_type=strategy_type,
5832
+ leg_data=leg_data,
5833
+ underlying_price=stock_price,
5834
+ position=position
5835
+ )
5836
+
5837
+ # Add ALL indicators with 'exit_' prefix (universal for any strategy)
5838
+ if indicators:
5839
+ for indicator_name, indicator_value in indicators.items():
5840
+ if indicator_value is not None:
5841
+ # Add with exit_ prefix if not already prefixed
5842
+ field_name = f'exit_{indicator_name}' if not indicator_name.startswith('exit_') else indicator_name
5843
+ price_data[position['id']][field_name] = indicator_value
5844
+
5845
+ # Add custom fields via callback (e.g., directional stop fields)
5846
+ if extra_fields_callback:
5847
+ extra_fields = extra_fields_callback(position, price_data[position['id']])
5848
+ if extra_fields:
5849
+ price_data[position['id']].update(extra_fields)
5850
+
5851
+ return price_data
5852
+
5853
+ def close_remaining_positions(self, final_date, stock_price, options_df, get_option_func,
5854
+ indicators=None):
5855
+ """
5856
+ Close all remaining open positions at end of backtest.
5857
+
5858
+ Args:
5859
+ final_date: Final backtest date
5860
+ stock_price: Final underlying price
5861
+ options_df: Options data for final date
5862
+ get_option_func: Function to get option by (strike, exp, type)
5863
+ indicators: Optional dict of indicators for final date
5864
+
5865
+ Returns:
5866
+ Total P&L from closing positions
5867
+ """
5868
+ if len(self.get_open_positions()) == 0:
5869
+ return 0
5870
+
5871
+ if self.debug:
5872
+ print(f"\n⚠️ Closing {len(self.get_open_positions())} remaining open positions...")
5873
+
5874
+ # Build price_data for all open positions
5875
+ price_data = self.build_price_data(
5876
+ final_date, stock_price, options_df, get_option_func, indicators
5877
+ )
5878
+
5879
+ # Close all positions
5880
+ self.close_all_positions(final_date, price_data, 'end_of_backtest')
5881
+
5882
+ # Calculate total P&L
5883
+ total_pnl = sum(data.get('pnl', 0) for data in price_data.values())
5884
+
5885
+ if self.debug:
5886
+ print(f" ✓ Closed {len(price_data)} positions at end of period")
5887
+
5888
+ return total_pnl
5889
+
5890
+ def close_position_by_signal(self, position_id, exit_date, price_data, close_reason='signal_exit'):
5891
+ """
5892
+ Close position manually by signal (z-score exit, earnings exit, etc.).
5893
+
5894
+ This is a convenience method that handles all the boilerplate:
5895
+ - Gets position data from price_data
5896
+ - Generates close kwargs automatically
5897
+ - Closes the position
5898
+ - Returns pnl for capital update
5899
+
5900
+ Args:
5901
+ position_id: ID of position to close
5902
+ exit_date: Current date
5903
+ price_data: Dict from build_price_data() or check_positions() context
5904
+ close_reason: Reason for closing (e.g., 'z_score_exit', 'earnings_exit')
5905
+
5906
+ Returns:
5907
+ float: P&L of the closed position
5908
+
5909
+ Example:
5910
+ if abs(z_score) <= config['z_score_exit']:
5911
+ pnl = position_mgr.close_position_by_signal(
5912
+ position['id'], current_date, price_data, 'z_score_exit'
5913
+ )
5914
+ stats['signal_exits'] += 1
5915
+ capital += pnl
5916
+ """
5917
+ # Get position data
5918
+ pos_data = price_data.get(position_id, {})
5919
+ pnl = pos_data.get('pnl', 0)
5920
+ pnl_pct = pos_data.get('pnl_pct', 0)
5921
+
5922
+ # Generate close kwargs automatically
5923
+ strategy_type = self.config.get('strategy_type', 'STRADDLE')
5924
+ kwargs = StrategyRegistry.generate_close_position_kwargs(strategy_type, pos_data)
5925
+
5926
+ # Close position
5927
+ self.close_position(
5928
+ position_id=position_id,
5929
+ exit_date=exit_date,
5930
+ exit_price=0.0,
5931
+ close_reason=close_reason,
5932
+ pnl=pnl,
5933
+ pnl_pct=pnl_pct,
5934
+ stat_key='signal_exits', # For consistency with other exits
5935
+ **kwargs
5936
+ )
5937
+
5938
+ return pnl
5939
+
5742
5940
  def check_profit_target(self, current_date, stock_price, options_df, get_option_func):
5743
5941
  """
5744
5942
  Check all positions for profit target achievement.
@@ -5832,6 +6030,8 @@ class PositionManager:
5832
6030
  to_close.append({
5833
6031
  'position_id': position_id,
5834
6032
  'symbol': position['symbol'],
6033
+ 'close_reason': 'profit_target', # ✅ For strategy stats
6034
+ 'stat_key': 'profit_target_exits', # ✅ For strategy stats
5835
6035
  'pnl': current_pnl,
5836
6036
  'pnl_pct': pnl_pct,
5837
6037
  'target_pct': target_pct,
@@ -7482,6 +7682,138 @@ class ChartGenerator:
7482
7682
  print(f"Chart saved: {filename}")
7483
7683
 
7484
7684
  return filename
7685
+
7686
+ @staticmethod
7687
+ def create_optimization_summary(results_df, metric='sharpe', filename='optimization_summary.png',
7688
+ show_plots=True, silent=False):
7689
+ """
7690
+ Create 6-panel optimization summary chart.
7691
+
7692
+ Panels:
7693
+ 1. Sharpe vs Return (colored by drawdown, sized by trades)
7694
+ 2. Win Rate vs Profit Factor (colored by Sharpe)
7695
+ 3. Sharpe distribution histogram
7696
+ 4. Trade counts distribution
7697
+ 5. Top 10 combinations by metric
7698
+ 6. Parameter heatmap (if exactly 2 params)
7699
+
7700
+ Args:
7701
+ results_df: DataFrame with optimization results
7702
+ metric: Optimization metric ('sharpe', 'total_return', etc.)
7703
+ filename: Output filename
7704
+ show_plots: If True, display chart
7705
+ silent: If True, suppress print output
7706
+
7707
+ Returns:
7708
+ str: Path to saved chart, or None if failed
7709
+
7710
+ Example:
7711
+ chart_path = ChartGenerator.create_optimization_summary(
7712
+ results_df, metric='sharpe',
7713
+ filename=os.path.join(folder, 'optimization_summary.png')
7714
+ )
7715
+ """
7716
+ try:
7717
+ import matplotlib.pyplot as plt
7718
+ import seaborn as sns
7719
+ sns.set_style("whitegrid")
7720
+
7721
+ # Create figure with 6 subplots
7722
+ fig = plt.figure(figsize=(20, 12))
7723
+ fig.suptitle('Optimization Results Summary', fontsize=16, fontweight='bold', y=0.995)
7724
+
7725
+ # 1. Sharpe vs Return (colored by drawdown, sized by trades)
7726
+ ax1 = plt.subplot(2, 3, 1)
7727
+ scatter1 = ax1.scatter(results_df['total_return'], results_df['sharpe'],
7728
+ c=results_df['max_drawdown'], s=results_df['total_trades']*5,
7729
+ cmap='RdYlGn_r', alpha=0.6, edgecolors='black', linewidth=0.5)
7730
+ ax1.set_xlabel('Total Return (%)')
7731
+ ax1.set_ylabel('Sharpe Ratio')
7732
+ ax1.set_title('Sharpe vs Return (size=trades, color=drawdown)')
7733
+ plt.colorbar(scatter1, ax=ax1, label='Max Drawdown (%)')
7734
+ ax1.grid(True, alpha=0.3)
7735
+
7736
+ # 2. Win Rate vs Profit Factor (colored by Sharpe)
7737
+ ax2 = plt.subplot(2, 3, 2)
7738
+ scatter2 = ax2.scatter(results_df['win_rate'], results_df['profit_factor'],
7739
+ c=results_df['sharpe'], s=100, cmap='viridis',
7740
+ alpha=0.6, edgecolors='black', linewidth=0.5)
7741
+ ax2.set_xlabel('Win Rate (%)')
7742
+ ax2.set_ylabel('Profit Factor')
7743
+ ax2.set_title('Win Rate vs Profit Factor (color=Sharpe)')
7744
+ plt.colorbar(scatter2, ax=ax2, label='Sharpe Ratio')
7745
+ ax2.grid(True, alpha=0.3)
7746
+
7747
+ # 3. Distribution of Sharpe Ratios
7748
+ ax3 = plt.subplot(2, 3, 3)
7749
+ ax3.hist(results_df['sharpe'], bins=20, color='steelblue', alpha=0.7, edgecolor='black')
7750
+ ax3.axvline(results_df['sharpe'].mean(), color='red', linestyle='--',
7751
+ label=f'Mean: {results_df["sharpe"].mean():.2f}')
7752
+ ax3.axvline(results_df['sharpe'].median(), color='green', linestyle='--',
7753
+ label=f'Median: {results_df["sharpe"].median():.2f}')
7754
+ ax3.set_xlabel('Sharpe Ratio')
7755
+ ax3.set_ylabel('Frequency')
7756
+ ax3.set_title('Distribution of Sharpe Ratios')
7757
+ ax3.legend()
7758
+ ax3.grid(True, alpha=0.3)
7759
+
7760
+ # 4. Distribution of Trade Counts
7761
+ ax4 = plt.subplot(2, 3, 4)
7762
+ ax4.hist(results_df['total_trades'], bins=20, color='coral', alpha=0.7, edgecolor='black')
7763
+ ax4.set_xlabel('Total Trades')
7764
+ ax4.set_ylabel('Frequency')
7765
+ ax4.set_title('Distribution of Trade Counts')
7766
+ ax4.grid(True, alpha=0.3)
7767
+
7768
+ # 5. Top 10 Combinations by metric
7769
+ ax5 = plt.subplot(2, 3, 5)
7770
+ top10 = results_df.nlargest(10, metric)
7771
+ top10_labels = [f"#{i+1}" for i in range(len(top10))]
7772
+ bars = ax5.barh(top10_labels, top10[metric], color='green', alpha=0.7, edgecolor='black')
7773
+ ax5.set_xlabel(metric.replace('_', ' ').title())
7774
+ ax5.set_title(f'Top 10 Combinations by {metric.replace("_", " ").title()}')
7775
+ ax5.invert_yaxis()
7776
+ ax5.grid(True, alpha=0.3, axis='x')
7777
+
7778
+ # 6. Heatmap of parameter combinations (if exactly 2 params)
7779
+ ax6 = plt.subplot(2, 3, 6)
7780
+ # Identify parameter columns (exclude metric columns)
7781
+ metric_cols = ['combination_id', 'is_valid', 'invalid_reason', 'total_return',
7782
+ 'sharpe', 'sortino', 'calmar', 'max_drawdown', 'win_rate',
7783
+ 'profit_factor', 'total_trades', 'avg_win', 'avg_loss',
7784
+ 'volatility', 'stop_loss_type', 'stop_loss_value']
7785
+ param_cols = [col for col in results_df.columns if col not in metric_cols]
7786
+
7787
+ if len(param_cols) == 2:
7788
+ # Create pivot table for heatmap
7789
+ pivot = results_df.pivot_table(values=metric, index=param_cols[0],
7790
+ columns=param_cols[1], aggfunc='mean')
7791
+ sns.heatmap(pivot, annot=True, fmt='.3f', cmap='RdYlGn', ax=ax6,
7792
+ cbar_kws={'label': metric.replace('_', ' ').title()})
7793
+ ax6.set_title(f'{metric.replace("_", " ").title()} Heatmap')
7794
+ else:
7795
+ ax6.text(0.5, 0.5, f'Heatmap requires\nexactly 2 parameters\n(found {len(param_cols)})',
7796
+ ha='center', va='center', fontsize=14, transform=ax6.transAxes)
7797
+ ax6.set_title(f'{metric.replace("_", " ").title()} Heatmap')
7798
+ ax6.axis('off')
7799
+
7800
+ plt.tight_layout()
7801
+ plt.savefig(filename, dpi=150, bbox_inches='tight')
7802
+
7803
+ if show_plots:
7804
+ plt.show()
7805
+ else:
7806
+ plt.close()
7807
+
7808
+ if not silent:
7809
+ print(f"✓ Optimization chart saved: {filename}")
7810
+
7811
+ return filename
7812
+
7813
+ except Exception as e:
7814
+ if not silent:
7815
+ print(f"⚠️ Could not create optimization charts: {e}")
7816
+ return None
7485
7817
 
7486
7818
 
7487
7819
  def create_stoploss_charts(analyzer, filename='stoploss_analysis.png', show_plots=True):
@@ -7785,8 +8117,13 @@ def format_params_string(config):
7785
8117
  # ═══════════════════════════════════════════════════════════════════════
7786
8118
  # 2. AUTOMATIC STRATEGY DETECTION (from STRATEGIES registry)
7787
8119
  # ═══════════════════════════════════════════════════════════════════════
8120
+ # First try signature-based detection
7788
8121
  strategy_type_lower = detect_strategy_type(config)
7789
8122
 
8123
+ # ✅ Fallback: Use explicit strategy_type from config if detection failed
8124
+ if not strategy_type_lower and config.get('strategy_type'):
8125
+ strategy_type_lower = config['strategy_type'].lower()
8126
+
7790
8127
  # Debug: Show detection result
7791
8128
  if config.get('debuginfo', 0) >= 2:
7792
8129
  print(f"[DEBUG format_params_string] Detected strategy: {strategy_type_lower}")
@@ -7807,15 +8144,32 @@ def format_params_string(config):
7807
8144
  except Exception as e:
7808
8145
  pass # Will be handled below
7809
8146
 
8147
+ # ✅ Helper: Get value from config OR nested configs (earnings_config, stop_loss_config)
8148
+ def get_param_value(key):
8149
+ """Get parameter value from config, earnings_config, or stop_loss_config"""
8150
+ if key in config:
8151
+ return config[key]
8152
+ # Check earnings_config
8153
+ earnings_cfg = config.get('earnings_config', {})
8154
+ if key in earnings_cfg:
8155
+ return earnings_cfg[key]
8156
+ # Check stop_loss_config
8157
+ sl_cfg = config.get('stop_loss_config', {})
8158
+ if key in sl_cfg:
8159
+ return sl_cfg[key]
8160
+ if key == 'stop_loss_pct' and 'value' in sl_cfg:
8161
+ return sl_cfg['value']
8162
+ return None
8163
+
7810
8164
  # Format strategy-specific parameters
7811
8165
  for param_def in strategy['file_naming']['format']:
7812
8166
  key = param_def['key']
7813
8167
  code = param_def['code']
7814
8168
  format_func = param_def['formatter']
7815
8169
 
7816
- if key in config:
8170
+ value = get_param_value(key)
8171
+ if value is not None:
7817
8172
  try:
7818
- value = config[key]
7819
8173
  formatted = format_func(value)
7820
8174
  if code:
7821
8175
  parts.append(f"{code}{formatted}")
@@ -7826,6 +8180,28 @@ def format_params_string(config):
7826
8180
  # Debug: Show missing keys
7827
8181
  elif config.get('debuginfo', 0) >= 2:
7828
8182
  print(f"[DEBUG format_params_string] Missing key '{key}' in config for strategy {strategy_type_upper}")
8183
+
8184
+ # ═══════════════════════════════════════════════════════════════════
8185
+ # AUTO-FORMAT EARNINGS PARAMS (if earnings_config exists)
8186
+ # ═══════════════════════════════════════════════════════════════════
8187
+ earnings_cfg = config.get('earnings_config', {})
8188
+ if earnings_cfg.get('mode') in ['trade_around', 'avoid']:
8189
+ earnings_formats = [
8190
+ ('entry_days_before', 'ED', lambda x: f"{int(x)}"),
8191
+ ('exit_days_after', 'XD', lambda x: f"{int(x)}"),
8192
+ ('iv_percentile_min', 'IV', lambda x: f"{int(x)}" if x else None),
8193
+ ('min_implied_move', 'IM', lambda x: f"{int(x*100)}" if x else None),
8194
+ ]
8195
+ for key, code, fmt in earnings_formats:
8196
+ # Check both root config and earnings_config
8197
+ value = config.get(key) or earnings_cfg.get(key)
8198
+ if value is not None:
8199
+ try:
8200
+ formatted = fmt(value)
8201
+ if formatted and code:
8202
+ parts.append(f"{code}{formatted}")
8203
+ except:
8204
+ pass
7829
8205
 
7830
8206
  # Add stop-loss (if enabled)
7831
8207
  if config.get('stop_loss_enabled') and 'stop_loss_config' in config:
@@ -8590,6 +8966,80 @@ class StopLossConfig:
8590
8966
  return merged
8591
8967
 
8592
8968
 
8969
+ # ============================================================
8970
+ # BASELINE STOP-LOSS CONFIGURATION HELPER
8971
+ # ============================================================
8972
+ def configure_baseline_stop_loss(base_config, stop_loss_config, optimization_config, verbose=True):
8973
+ """
8974
+ Configure stop-loss for baseline test with pre-defined variables.
8975
+
8976
+ Simplifies baseline configuration by:
8977
+ 1. Pre-defining ALL stop-loss variables (avoids NameError)
8978
+ 2. Using flat if/elif/else structure (avoids indentation errors)
8979
+ 3. Printing stop-loss info (if verbose)
8980
+ 4. Returning properly configured config
8981
+
8982
+ Args:
8983
+ base_config: Base configuration dict (e.g., optimized_config)
8984
+ stop_loss_config: STOP_LOSS_CONFIG dict
8985
+ optimization_config: OPTIMIZATION_CONFIG dict with param_grid
8986
+ verbose: If True, print stop-loss configuration info
8987
+
8988
+ Returns:
8989
+ tuple: (configured_config, sl_values)
8990
+ - configured_config: Config with proper stop_loss_config
8991
+ - sl_values: Dict with extracted SL values for reference
8992
+
8993
+ Example:
8994
+ baseline_config, sl_values = configure_baseline_stop_loss(
8995
+ optimized_config, STOP_LOSS_CONFIG, OPTIMIZATION_CONFIG
8996
+ )
8997
+ """
8998
+ param_grid = optimization_config.get('param_grid', {})
8999
+ sl_enabled = stop_loss_config.get('enable_in', {}).get('baseline', False)
9000
+
9001
+ # Pre-define ALL stop-loss variables (avoids NameError in any branch)
9002
+ sl_values = {
9003
+ 'simple': param_grid.get('stop_loss_values', [0.04])[0],
9004
+ 'pl_loss': param_grid.get('combined_pl_loss', [0.10])[0],
9005
+ 'directional': param_grid.get('combined_directional', [0.04])[0],
9006
+ 'logic': stop_loss_config.get('combined_settings', {}).get('logic', 'and'),
9007
+ 'enabled': sl_enabled,
9008
+ 'type': stop_loss_config.get('type', 'none')
9009
+ }
9010
+
9011
+ # Print info (if verbose)
9012
+ if verbose:
9013
+ if not sl_enabled:
9014
+ print("Stop-Loss: DISABLED")
9015
+ elif stop_loss_config.get('type') == 'combined':
9016
+ logic_upper = sl_values['logic'].upper()
9017
+ print(f"Stop-Loss: Combined {logic_upper} (P&L {sl_values['pl_loss']*100:.0f}% {logic_upper} Directional {sl_values['directional']*100:.0f}%) - ENABLED")
9018
+ else:
9019
+ sl_type = stop_loss_config.get('type', 'directional')
9020
+ print(f"Stop-Loss: {sl_values['simple']*100:.0f}% {sl_type} - ENABLED")
9021
+
9022
+ # Create configured config
9023
+ configured_config = base_config.copy()
9024
+ configured_config['stop_loss_enabled'] = sl_enabled
9025
+
9026
+ # Configure stop_loss_config based on type (flat structure)
9027
+ if not sl_enabled:
9028
+ configured_config['stop_loss_config'] = {'enabled': False, 'type': 'none', 'value': 0}
9029
+ elif stop_loss_config.get('type') == 'combined':
9030
+ configured_config['stop_loss_config'] = stop_loss_config.copy()
9031
+ configured_config['stop_loss_config']['combined_settings'] = {
9032
+ 'pl_loss': sl_values['pl_loss'],
9033
+ 'directional': sl_values['directional'],
9034
+ 'logic': sl_values['logic']
9035
+ }
9036
+ else:
9037
+ configured_config['stop_loss_config'] = stop_loss_config.copy()
9038
+ configured_config['stop_loss_config']['value'] = sl_values['simple']
9039
+
9040
+ return configured_config, sl_values
9041
+
9042
+
8593
9043
  def create_stoploss_comparison_chart(results, filename='stoploss_comparison.png', show_plots=True):
8594
9044
  """Create comparison chart"""
8595
9045
  try:
@@ -9325,6 +9775,27 @@ def _auto_detect_requests(config):
9325
9775
  }
9326
9776
  })
9327
9777
 
9778
+ # ========================================================
9779
+ # AUTO-DETECT EARNINGS CALENDAR
9780
+ # ========================================================
9781
+ # Load earnings calendar if earnings_config is specified
9782
+ # Works with ANY strategy type (STRADDLE, IRON_CONDOR, etc.)
9783
+ earnings_config = config.get('earnings_config', {})
9784
+ earnings_mode = earnings_config.get('mode')
9785
+
9786
+ if earnings_mode in ['trade_around', 'avoid']:
9787
+ print(f" 📅 Earnings mode: {earnings_mode} - loading earnings calendar")
9788
+ requests.append({
9789
+ 'name': 'earnings',
9790
+ 'endpoint': '/equities/eod/history-earnings-calendar',
9791
+ 'params': {
9792
+ 'symbols': config['symbol'],
9793
+ 'startDate': config['start_date'],
9794
+ 'endDate': config['end_date']
9795
+ },
9796
+ 'chunking': {'enabled': False} # Single request (lightweight)
9797
+ })
9798
+
9328
9799
  return requests
9329
9800
 
9330
9801
 
@@ -9645,6 +10116,82 @@ def _auto_process_dates(df):
9645
10116
  return df
9646
10117
 
9647
10118
 
10119
+ # ============================================================================
10120
+ # EARNINGS STRATEGY HELPERS
10121
+ # ============================================================================
10122
+
10123
+ def parse_earnings_calendar(earnings_df):
10124
+ """
10125
+ Parse preloaded earnings DataFrame to list of dicts for strategy use.
10126
+
10127
+ Framework loads earnings via _auto_detect_requests() when earnings_config['mode']
10128
+ is set. This helper converts the DataFrame to a sorted list for iteration.
10129
+
10130
+ Args:
10131
+ earnings_df: Preloaded DataFrame from config.get('_preloaded_earnings')
10132
+
10133
+ Returns:
10134
+ list: [{'date': datetime.date, 'estimate': float, 'reported': float,
10135
+ 'time_of_day': str}, ...]
10136
+ Sorted by date ascending.
10137
+
10138
+ Usage in strategy:
10139
+ earnings_df = config.get('_preloaded_earnings', pd.DataFrame())
10140
+ earnings_events = parse_earnings_calendar(earnings_df)
10141
+
10142
+ for event in earnings_events:
10143
+ earning_date = event['date']
10144
+ # ... process event
10145
+ """
10146
+ from datetime import datetime
10147
+
10148
+ if earnings_df is None or earnings_df.empty:
10149
+ return []
10150
+
10151
+ earnings_events = []
10152
+ for _, record in earnings_df.iterrows():
10153
+ earning_date = record.get('earning_date')
10154
+ if earning_date:
10155
+ # Handle both string and date formats
10156
+ if isinstance(earning_date, str):
10157
+ date_obj = datetime.strptime(earning_date, '%Y-%m-%d').date()
10158
+ elif hasattr(earning_date, 'date'):
10159
+ date_obj = earning_date.date()
10160
+ else:
10161
+ date_obj = earning_date
10162
+
10163
+ earnings_events.append({
10164
+ 'date': date_obj,
10165
+ 'estimate': record.get('estimate'),
10166
+ 'reported': record.get('reported_earning'),
10167
+ 'time_of_day': record.get('time_of_day_code', 'UNK')
10168
+ })
10169
+
10170
+ return sorted(earnings_events, key=lambda x: x['date'])
10171
+
10172
+
10173
+ def find_next_trading_day(trading_days, target_date):
10174
+ """
10175
+ Find the next available trading day on or after target_date.
10176
+
10177
+ Args:
10178
+ trading_days: Sorted list of trading days (datetime.date objects)
10179
+ target_date: Target date to find trading day for
10180
+
10181
+ Returns:
10182
+ datetime.date: Next trading day, or None if not found
10183
+
10184
+ Usage:
10185
+ trading_days = sorted(stock_df['date'].unique())
10186
+ entry_day = find_next_trading_day(trading_days, earning_date - timedelta(days=1))
10187
+ exit_day = find_next_trading_day(trading_days, earning_date + timedelta(days=1))
10188
+ """
10189
+ for day in trading_days:
10190
+ if day >= target_date:
10191
+ return day
10192
+ return None
10193
+
10194
+
9648
10195
  # ============================================================================
9649
10196
  # INDICATOR PRE-CALCULATION SYSTEM - Universal helpers
9650
10197
  # ============================================================================
@@ -10118,6 +10665,10 @@ def optimize_parameters(base_config, param_grid, strategy_function,
10118
10665
  optimization_start_time = datetime.now()
10119
10666
  start_time_str = optimization_start_time.strftime('%Y-%m-%d %H:%M:%S')
10120
10667
 
10668
+ # Detect preloaded data
10669
+ preloaded_keys = [k for k in base_config.keys() if k.startswith('_preloaded_')]
10670
+ has_preloaded_data = len(preloaded_keys) > 0
10671
+
10121
10672
  print("\n" + "="*80)
10122
10673
  print(" "*20 + "PARAMETER OPTIMIZATION")
10123
10674
  print("="*80)
@@ -10125,9 +10676,11 @@ def optimize_parameters(base_config, param_grid, strategy_function,
10125
10676
  print(f"Period: {base_config.get('start_date')} to {base_config.get('end_date')}")
10126
10677
  print(f"Optimization Metric: {optimization_metric}")
10127
10678
  print(f"Min Trades: {min_trades}")
10128
- print(f"🕐 Started: {start_time_str}")
10129
10679
  if max_drawdown_limit:
10130
10680
  print(f"Max Drawdown Limit: {max_drawdown_limit*100:.0f}%")
10681
+ if has_preloaded_data:
10682
+ print(f"✅ Preloaded data: {', '.join([k.replace('_preloaded_', '') for k in preloaded_keys])}")
10683
+ print(f"🕐 Started: {start_time_str}")
10131
10684
  print("="*80 + "\n")
10132
10685
 
10133
10686
  # Generate all combinations
@@ -10267,6 +10820,42 @@ def optimize_parameters(base_config, param_grid, strategy_function,
10267
10820
  # ✨ CRITICAL: Enable stop-loss when combined_* params are used
10268
10821
  test_config['stop_loss_enabled'] = True
10269
10822
 
10823
+ # ═══════════════════════════════════════════════════════════════════
10824
+ # EARNINGS CONFIG SUPPORT (v2.22+)
10825
+ # ═══════════════════════════════════════════════════════════════════
10826
+ # If param_grid contains earnings-related parameters, automatically
10827
+ # update earnings_config with them
10828
+ # ✅ Use .get() NOT .pop() - keep params in test_config for format_params_string!
10829
+ earnings_params = {}
10830
+ if 'entry_days_before' in test_config:
10831
+ earnings_params['entry_days_before'] = test_config['entry_days_before']
10832
+ if 'exit_days_after' in test_config:
10833
+ earnings_params['exit_days_after'] = test_config['exit_days_after']
10834
+ if 'min_implied_move' in test_config:
10835
+ earnings_params['min_implied_move'] = test_config['min_implied_move']
10836
+ if 'iv_percentile_min' in test_config:
10837
+ earnings_params['iv_percentile_min'] = test_config['iv_percentile_min']
10838
+ if 'earnings_buffer_days' in test_config:
10839
+ earnings_params['buffer_days'] = test_config['earnings_buffer_days']
10840
+
10841
+ # If any earnings params exist, update earnings_config
10842
+ if earnings_params:
10843
+ if 'earnings_config' not in test_config:
10844
+ test_config['earnings_config'] = {}
10845
+
10846
+ # Merge earnings_params into earnings_config
10847
+ test_config['earnings_config'].update(earnings_params)
10848
+
10849
+ # ═══════════════════════════════════════════════════════════════════
10850
+ # STOP-LOSS CONFIG SUPPORT
10851
+ # ═══════════════════════════════════════════════════════════════════
10852
+ # If param_grid contains stop_loss_pct, update stop_loss_config['value']
10853
+ if 'stop_loss_pct' in test_config:
10854
+ if 'stop_loss_config' not in test_config:
10855
+ test_config['stop_loss_config'] = {}
10856
+ test_config['stop_loss_config']['value'] = test_config['stop_loss_pct']
10857
+ test_config['stop_loss_enabled'] = True
10858
+
10270
10859
  # Update name
10271
10860
  param_str = "_".join([f"{k}={v}" for k, v in zip(param_names, param_combo)])
10272
10861
  test_config['strategy_name'] = f"{base_config.get('strategy_name', 'Strategy')} [{param_str}]"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 1.35
3
+ Version: 1.37
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 = "1.35"
7
+ version = "1.37"
8
8
  description = "A universal backtesting framework for financial strategies using the IVolatility API."
9
9
  readme = "README.md"
10
10
  authors = [