Skip to main content

upgraded IV rank filtering

the IV rank problem
#

my original algo only sells premium when IV rank > 40

IV rank = where current IV sits relative to its 52-week range

formula: (current_IV - 52_week_low) / (52_week_high - 52_week_low) * 100

if IV rank is 60, that means current IV is at 60th percentile of its annual range

problem: IV rank alone doesn’t tell you if premium is actually rich

example from last week:

  • march 20: IV rank was 42, but actual IV was only 14
  • march 21: IV rank was 41, but actual IV was 22

both trades qualified (IV rank > 40) but march 21 had way better premium despite similar IV rank

i was leaving money on the table

the upgrade
#

added two more filters on top of IV rank:

  1. absolute IV threshold: current IV must be > 18
  2. IV percentile: current IV must be in top 30% vs 90-day rolling average

here’s the implementation:

import pandas as pd
import numpy as np
from datetime import datetime, timedelta

class VolatilityFilter:
    """
    Enhanced volatility filtering for premium selling
    Combines IV rank, absolute IV, and IV percentile
    """

    def __init__(self, min_iv_rank=40, min_absolute_iv=18, min_iv_percentile=70):
        self.min_iv_rank = min_iv_rank
        self.min_absolute_iv = min_absolute_iv
        self.min_iv_percentile = min_iv_percentile

        # cache for IV history
        self.iv_history = []

    def calculate_iv_rank(self, current_iv, iv_history_52w):
        """
        Calculate IV rank (52-week)

        Args:
            current_iv: Current implied volatility
            iv_history_52w: Series of IV values over past 52 weeks

        Returns:
            IV rank (0-100)
        """
        iv_min = iv_history_52w.min()
        iv_max = iv_history_52w.max()

        if iv_max == iv_min:
            return 50.0  # neutral if no range

        iv_rank = ((current_iv - iv_min) / (iv_max - iv_min)) * 100
        return iv_rank

    def calculate_iv_percentile(self, current_iv, iv_history_90d):
        """
        Calculate what percentile current IV is vs 90-day history

        Args:
            current_iv: Current IV
            iv_history_90d: Series of IV values over past 90 days

        Returns:
            Percentile (0-100)
        """
        below_current = (iv_history_90d < current_iv).sum()
        total_days = len(iv_history_90d)

        percentile = (below_current / total_days) * 100
        return percentile

    def should_sell_premium(self, current_iv, iv_52w, iv_90d):
        """
        Determine if conditions are right to sell premium

        Args:
            current_iv: Current IV level
            iv_52w: 52-week IV history
            iv_90d: 90-day IV history

        Returns:
            tuple: (should_sell: bool, reason: str, metrics: dict)
        """
        # calculate all metrics
        iv_rank = self.calculate_iv_rank(current_iv, iv_52w)
        iv_percentile = self.calculate_iv_percentile(current_iv, iv_90d)

        metrics = {
            'current_iv': current_iv,
            'iv_rank': iv_rank,
            'iv_percentile': iv_percentile,
            '52w_min': iv_52w.min(),
            '52w_max': iv_52w.max(),
            '90d_avg': iv_90d.mean()
        }

        # check all three filters
        checks = {
            'iv_rank': iv_rank >= self.min_iv_rank,
            'absolute_iv': current_iv >= self.min_absolute_iv,
            'iv_percentile': iv_percentile >= self.min_iv_percentile
        }

        # all must pass
        should_sell = all(checks.values())

        # build reason string
        if should_sell:
            reason = f"✅ All filters passed - IV rank {iv_rank:.1f}, Absolute IV {current_iv:.1f}, Percentile {iv_percentile:.1f}"
        else:
            failed = [k for k, v in checks.items() if not v]
            reason = f"❌ Failed: {', '.join(failed)}"

        return should_sell, reason, metrics

    def get_premium_quality_score(self, current_iv, iv_52w, iv_90d):
        """
        Score from 0-100 indicating how attractive premium is
        Higher = better opportunity
        """
        iv_rank = self.calculate_iv_rank(current_iv, iv_52w)
        iv_percentile = self.calculate_iv_percentile(current_iv, iv_90d)

        # normalize absolute IV (assume 10-40 range, clip extremes)
        iv_normalized = np.clip((current_iv - 10) / 30, 0, 1) * 100

        # weighted average of all three
        score = (
            iv_rank * 0.4 +           # 40% weight on IV rank
            iv_normalized * 0.3 +     # 30% weight on absolute IV
            iv_percentile * 0.3       # 30% weight on IV percentile
        )

        return score


# integration with main strategy
class SPXCreditSpreadStrategy(bt.Strategy):
    """
    Credit spread strategy with enhanced volatility filtering
    """

    params = (
        ('min_iv_rank', 40),
        ('min_absolute_iv', 18),
        ('min_iv_percentile', 70),
        ('max_positions', 5),
    )

    def __init__(self):
        super().__init__()
        self.vol_filter = VolatilityFilter(
            min_iv_rank=self.params.min_iv_rank,
            min_absolute_iv=self.params.min_absolute_iv,
            min_iv_percentile=self.params.min_iv_percentile
        )

    def next(self):
        # get current IV data
        current_iv = self.get_current_iv('SPX')
        iv_52w = self.get_iv_history(days=365)
        iv_90d = self.get_iv_history(days=90)

        # check if we should sell premium
        should_sell, reason, metrics = self.vol_filter.should_sell_premium(
            current_iv, iv_52w, iv_90d
        )

        if not should_sell:
            self.log(f"Skipping - {reason}")
            return

        # get premium quality score
        quality_score = self.vol_filter.get_premium_quality_score(
            current_iv, iv_52w, iv_90d
        )

        self.log(f"Premium quality: {quality_score:.1f}/100")

        # only trade if quality score > 60
        if quality_score < 60:
            self.log(f"Quality score too low: {quality_score:.1f}")
            return

        # proceed with trade entry
        self.log(f"✅ Entering trade - {reason}")
        # ... rest of entry logic ...

backtest comparison
#

ran 2021-2022 backtest with old vs new filtering:

old filter (IV rank > 40 only):

  • trades per year: 240
  • win rate: 72%
  • annual return: 14.3%
  • sharpe: 1.64

new filter (IV rank + absolute IV + percentile):

  • trades per year: 186
  • win rate: 78%
  • annual return: 15.7%
  • sharpe: 1.81

fewer trades but way better quality

the new filter caught 4 periods in 2022 where IV rank was elevated but absolute IV was still low (june, september, november, december)

those months i would’ve sold premium for shit credits. new filter kept me out

march performance
#

applied new filter retroactively to march:

  • march 15: would’ve traded (IV rank 48, abs IV 22, percentile 82) ✅
  • march 17: would NOT have traded (IV rank 42, abs IV 16, percentile 55) ❌
  • march 20: would NOT have traded (IV rank 41, abs IV 14, percentile 48) ❌
  • march 21: would’ve traded (IV rank 45, abs IV 22, percentile 75) ✅

i actually did trade march 17 and march 20 with old filter. both were mediocre:

  • march 17: $0.70 credit (low)
  • march 20: $0.65 credit (really low)

if i’d had new filter running, i would’ve skipped those and waited for better setups

going live april 1
#

new filter goes into production april 1

expecting fewer trades but higher win rate and better returns per trade

will track actual results and compare to backtest prediction

-AK

Related

modeling slippage the right way
the slippage problem is worse than i thought # after 2 weeks live trading (10 total trades), my average slippage is $6.40 per spread
first algo went live today
three years of paper trading, finally real money # started learning algo trading april 2020 during COVID lockdown. i was 16, bored af at home, discovered r/algotrading and fell into the rabbit hole