ivolatility-backtesting 1.34__tar.gz → 1.36__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.34
3
+ Version: 1.36
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 = {}
@@ -3656,10 +3659,10 @@ def _api_call_internal(endpoint, cache_config, debug, debug_level, **kwargs):
3656
3659
  elif debug or cache_config.get('debug', False):
3657
3660
  print(f"[CACHE] ✓ Cache hit: {endpoint} ({len(cached_data) if hasattr(cached_data, '__len__') else '?'} records)")
3658
3661
 
3659
- # ✅ OPTIMIZED: Return DataFrame directly without conversion!
3660
- # Flag '_is_dataframe' tells caller to skip pd.DataFrame() creation
3662
+ # ✅ CONSISTENT: Always return list format for backward compatibility
3663
+ # This ensures `if not response.get('data')` works correctly in all strategies
3661
3664
  if isinstance(cached_data, pd.DataFrame):
3662
- return {'data': cached_data, 'status': 'success', '_is_dataframe': True}
3665
+ return {'data': cached_data.to_dict('records'), 'status': 'success'}
3663
3666
  return cached_data
3664
3667
 
3665
3668
  if debug_level >= 3:
@@ -3773,6 +3776,84 @@ def _api_call_internal(endpoint, cache_config, debug, debug_level, **kwargs):
3773
3776
  return None
3774
3777
 
3775
3778
 
3779
+ # ============================================================
3780
+ # API RESPONSE HELPERS
3781
+ # ============================================================
3782
+ def get_api_data(response, as_dataframe=True):
3783
+ """
3784
+ Universal helper to extract data from api_call response.
3785
+ Works with both list and DataFrame formats from cache.
3786
+
3787
+ Args:
3788
+ response: Response dict from api_call()
3789
+ as_dataframe: If True (default), always return DataFrame.
3790
+ If False, return list of dicts.
3791
+
3792
+ Returns:
3793
+ DataFrame/list or None if no valid data
3794
+
3795
+ Example:
3796
+ # Get stock data as DataFrame
3797
+ stock_df = get_api_data(api_call('/equities/eod/stock-prices', ...))
3798
+ if stock_df is None:
3799
+ print("No data!")
3800
+
3801
+ # Get as list of dicts
3802
+ records = get_api_data(response, as_dataframe=False)
3803
+ """
3804
+ if response is None:
3805
+ return None
3806
+
3807
+ data = response.get('data')
3808
+ if data is None:
3809
+ return None
3810
+
3811
+ # Handle DataFrame (from cache)
3812
+ if isinstance(data, pd.DataFrame):
3813
+ if data.empty:
3814
+ return None
3815
+ return data if as_dataframe else data.to_dict('records')
3816
+
3817
+ # Handle list (from API)
3818
+ if not data: # empty list
3819
+ return None
3820
+
3821
+ return pd.DataFrame(data) if as_dataframe else data
3822
+
3823
+
3824
+ def is_api_response_valid(response):
3825
+ """
3826
+ Check if api_call response contains valid non-empty data.
3827
+ Works with both list and DataFrame formats.
3828
+
3829
+ Args:
3830
+ response: Response dict from api_call()
3831
+
3832
+ Returns:
3833
+ bool: True if response contains valid non-empty data
3834
+
3835
+ Example:
3836
+ response = api_call('/equities/eod/stock-prices', ...)
3837
+ if not is_api_response_valid(response):
3838
+ print("No data available!")
3839
+ return
3840
+
3841
+ # Safe to use response['data'] now
3842
+ df = pd.DataFrame(response['data'])
3843
+ """
3844
+ if response is None:
3845
+ return False
3846
+
3847
+ data = response.get('data')
3848
+ if data is None:
3849
+ return False
3850
+
3851
+ if isinstance(data, pd.DataFrame):
3852
+ return not data.empty
3853
+
3854
+ return bool(data)
3855
+
3856
+
3776
3857
  # ============================================================
3777
3858
  # OPTIONS DATA HELPERS
3778
3859
  # ============================================================
@@ -4764,6 +4845,39 @@ class PositionManager:
4764
4845
  self.sl_config = None
4765
4846
  self.sl_manager = None
4766
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
+
4767
4881
  # ================================================================
4768
4882
  # UNIVERSAL INTRINSIC VALUE CALCULATION METHOD
4769
4883
  # ================================================================
@@ -5422,16 +5536,26 @@ class PositionManager:
5422
5536
  # Determine stop_type: 'expiration' (market data) or 'expiration_intrinsic' (no data)
5423
5537
  stop_type = 'expiration_intrinsic' if used_intrinsic else 'expiration'
5424
5538
 
5425
- 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 = {
5426
5545
  'position_id': position_id,
5427
5546
  'symbol': position['symbol'],
5428
5547
  'stop_type': stop_type,
5548
+ 'close_reason': stop_type, # ✅ For strategy stats
5549
+ 'stat_key': 'expiration_exits', # ✅ For strategy stats
5429
5550
  'stop_level': None,
5430
5551
  'current_price': current_price,
5431
5552
  'pnl': current_pnl,
5432
5553
  'pnl_pct': current_pnl_pct,
5433
- 'settlement_type': 'intrinsic' if used_intrinsic else 'market'
5434
- })
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)
5435
5559
 
5436
5560
  if self.debug:
5437
5561
  print(f"[PositionManager] 📅 EXPIRATION: {position_id} expired on {expiration}")
@@ -5628,6 +5752,10 @@ class PositionManager:
5628
5752
  # For other stop types: generic reason
5629
5753
  stop_info['exit_reason'] = f"stop_loss_{stop_type}"
5630
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
+
5631
5759
  # ✅ ADD stop-loss metadata for CSV export (stop_threshold, actual_value)
5632
5760
  # These fields are needed for detailed analysis of stop-loss triggers
5633
5761
  if stop_type == 'pl_loss':
@@ -5653,6 +5781,12 @@ class PositionManager:
5653
5781
  # Just merge them into stop_info (no need for second mapping!)
5654
5782
  stop_info.update(intraday_data)
5655
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
+
5656
5790
  to_close.append(stop_info)
5657
5791
 
5658
5792
  if self.debug:
@@ -5661,6 +5795,148 @@ class PositionManager:
5661
5795
 
5662
5796
  return to_close
5663
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
+
5664
5940
  def check_profit_target(self, current_date, stock_price, options_df, get_option_func):
5665
5941
  """
5666
5942
  Check all positions for profit target achievement.
@@ -5754,6 +6030,8 @@ class PositionManager:
5754
6030
  to_close.append({
5755
6031
  'position_id': position_id,
5756
6032
  'symbol': position['symbol'],
6033
+ 'close_reason': 'profit_target', # ✅ For strategy stats
6034
+ 'stat_key': 'profit_target_exits', # ✅ For strategy stats
5757
6035
  'pnl': current_pnl,
5758
6036
  'pnl_pct': pnl_pct,
5759
6037
  'target_pct': target_pct,
@@ -7404,6 +7682,138 @@ class ChartGenerator:
7404
7682
  print(f"Chart saved: {filename}")
7405
7683
 
7406
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
7407
7817
 
7408
7818
 
7409
7819
  def create_stoploss_charts(analyzer, filename='stoploss_analysis.png', show_plots=True):
@@ -7707,8 +8117,13 @@ def format_params_string(config):
7707
8117
  # ═══════════════════════════════════════════════════════════════════════
7708
8118
  # 2. AUTOMATIC STRATEGY DETECTION (from STRATEGIES registry)
7709
8119
  # ═══════════════════════════════════════════════════════════════════════
8120
+ # First try signature-based detection
7710
8121
  strategy_type_lower = detect_strategy_type(config)
7711
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
+
7712
8127
  # Debug: Show detection result
7713
8128
  if config.get('debuginfo', 0) >= 2:
7714
8129
  print(f"[DEBUG format_params_string] Detected strategy: {strategy_type_lower}")
@@ -7729,15 +8144,32 @@ def format_params_string(config):
7729
8144
  except Exception as e:
7730
8145
  pass # Will be handled below
7731
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
+
7732
8164
  # Format strategy-specific parameters
7733
8165
  for param_def in strategy['file_naming']['format']:
7734
8166
  key = param_def['key']
7735
8167
  code = param_def['code']
7736
8168
  format_func = param_def['formatter']
7737
8169
 
7738
- if key in config:
8170
+ value = get_param_value(key)
8171
+ if value is not None:
7739
8172
  try:
7740
- value = config[key]
7741
8173
  formatted = format_func(value)
7742
8174
  if code:
7743
8175
  parts.append(f"{code}{formatted}")
@@ -7748,6 +8180,28 @@ def format_params_string(config):
7748
8180
  # Debug: Show missing keys
7749
8181
  elif config.get('debuginfo', 0) >= 2:
7750
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
7751
8205
 
7752
8206
  # Add stop-loss (if enabled)
7753
8207
  if config.get('stop_loss_enabled') and 'stop_loss_config' in config:
@@ -8512,6 +8966,80 @@ class StopLossConfig:
8512
8966
  return merged
8513
8967
 
8514
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
+
8515
9043
  def create_stoploss_comparison_chart(results, filename='stoploss_comparison.png', show_plots=True):
8516
9044
  """Create comparison chart"""
8517
9045
  try:
@@ -8918,11 +9446,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
8918
9446
 
8919
9447
  response = api_call(endpoint, cache_config, debug=debuginfo, **params)
8920
9448
  if response and 'data' in response:
8921
- # OPTIMIZED: Skip DataFrame creation if already DataFrame (from memory cache)
8922
- if response.get('_is_dataframe'):
8923
- df = response['data']
8924
- else:
8925
- df = pd.DataFrame(response['data'])
9449
+ df = pd.DataFrame(response['data'])
8926
9450
  if len(df) > 0:
8927
9451
  all_data.append(df)
8928
9452
 
@@ -8983,11 +9507,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
8983
9507
 
8984
9508
  response = api_call(endpoint, cache_config, debug=debuginfo, **params)
8985
9509
  if response and 'data' in response:
8986
- # OPTIMIZED: Skip DataFrame creation if already DataFrame (from memory cache)
8987
- if response.get('_is_dataframe'):
8988
- df = response['data'] # Already a DataFrame!
8989
- else:
8990
- df = pd.DataFrame(response['data'])
9510
+ df = pd.DataFrame(response['data'])
8991
9511
  if len(df) > 0:
8992
9512
  # Add cp type if not in response
8993
9513
  if cp_type and 'type' not in df.columns and 'optionType' not in df.columns:
@@ -9009,11 +9529,7 @@ def preload_data_universal(config, data_requests=None, debug=False):
9009
9529
  params = base_params.copy()
9010
9530
  response = api_call(endpoint, cache_config, debug=debuginfo, **params)
9011
9531
  if response and 'data' in response:
9012
- # OPTIMIZED: Skip DataFrame creation if already DataFrame (from memory cache)
9013
- if response.get('_is_dataframe'):
9014
- df = response['data']
9015
- else:
9016
- df = pd.DataFrame(response['data'])
9532
+ df = pd.DataFrame(response['data'])
9017
9533
  if len(df) > 0:
9018
9534
  all_data.append(df)
9019
9535
 
@@ -9259,6 +9775,27 @@ def _auto_detect_requests(config):
9259
9775
  }
9260
9776
  })
9261
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
+
9262
9799
  return requests
9263
9800
 
9264
9801
 
@@ -9579,6 +10116,82 @@ def _auto_process_dates(df):
9579
10116
  return df
9580
10117
 
9581
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
+
9582
10195
  # ============================================================================
9583
10196
  # INDICATOR PRE-CALCULATION SYSTEM - Universal helpers
9584
10197
  # ============================================================================
@@ -10052,6 +10665,10 @@ def optimize_parameters(base_config, param_grid, strategy_function,
10052
10665
  optimization_start_time = datetime.now()
10053
10666
  start_time_str = optimization_start_time.strftime('%Y-%m-%d %H:%M:%S')
10054
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
+
10055
10672
  print("\n" + "="*80)
10056
10673
  print(" "*20 + "PARAMETER OPTIMIZATION")
10057
10674
  print("="*80)
@@ -10059,9 +10676,11 @@ def optimize_parameters(base_config, param_grid, strategy_function,
10059
10676
  print(f"Period: {base_config.get('start_date')} to {base_config.get('end_date')}")
10060
10677
  print(f"Optimization Metric: {optimization_metric}")
10061
10678
  print(f"Min Trades: {min_trades}")
10062
- print(f"🕐 Started: {start_time_str}")
10063
10679
  if max_drawdown_limit:
10064
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}")
10065
10684
  print("="*80 + "\n")
10066
10685
 
10067
10686
  # Generate all combinations
@@ -10201,6 +10820,42 @@ def optimize_parameters(base_config, param_grid, strategy_function,
10201
10820
  # ✨ CRITICAL: Enable stop-loss when combined_* params are used
10202
10821
  test_config['stop_loss_enabled'] = True
10203
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
+
10204
10859
  # Update name
10205
10860
  param_str = "_".join([f"{k}={v}" for k, v in zip(param_names, param_combo)])
10206
10861
  test_config['strategy_name'] = f"{base_config.get('strategy_name', 'Strategy')} [{param_str}]"
@@ -11217,7 +11872,7 @@ class UniversalCacheManager:
11217
11872
  __all__ = [
11218
11873
  'BacktestResults', 'BacktestAnalyzer', 'ResultsReporter',
11219
11874
  'ChartGenerator', 'ResultsExporter', 'run_backtest', 'run_backtest_with_stoploss',
11220
- 'init_api', 'api_call', 'APIHelper', 'APIManager',
11875
+ 'init_api', 'api_call', 'get_api_data', 'is_api_response_valid', 'APIHelper', 'APIManager',
11221
11876
  'ResourceMonitor', 'create_progress_bar', 'update_progress', 'format_time',
11222
11877
  'StopLossManager', 'PositionManager', 'StopLossConfig',
11223
11878
  'calculate_stoploss_metrics', 'print_stoploss_section', 'create_stoploss_charts',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ivolatility_backtesting
3
- Version: 1.34
3
+ Version: 1.36
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.34"
7
+ version = "1.36"
8
8
  description = "A universal backtesting framework for financial strategies using the IVolatility API."
9
9
  readme = "README.md"
10
10
  authors = [