Skip to main content

earnings volatility filter - implementation and early results

been running the earnings volatility filter for a week now.

early results are promising.

the problem
#

earnings = binary events. stock moves 5-10% or nothing.

directional bets = coin flips.

but IV expansion before earnings = predictable.

sell premium when IV is elevated. profit from mean reversion after announcement.

the filter
#

import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
import numpy as np

@dataclass
class EarningsEvent:
    symbol: str
    earnings_date: datetime
    expected_move: float  # implied from options
    historical_move: float  # avg of last 4 quarters
    iv_rank: float  # current IV percentile (0-100)
    sector: str

class EarningsVolatilityFilter:
    def __init__(self,
                 iv_rank_threshold: float = 35.0,
                 min_premium_yield: float = 0.02,
                 max_days_to_earnings: int = 5,
                 min_days_to_earnings: int = 2):
        self.iv_rank_threshold = iv_rank_threshold
        self.min_premium_yield = min_premium_yield
        self.max_days_to_earnings = max_days_to_earnings
        self.min_days_to_earnings = min_days_to_earnings

        # sector correlations for hedging
        self.sector_correlations = {
            'Technology': 0.85,
            'Financials': 0.72,
            'Healthcare': 0.65,
            'Consumer': 0.78,
            'Energy': 0.68
        }

    def calculate_expected_iv_crush(self, event: EarningsEvent) -> float:
        """
        Estimate post-earnings IV contraction
        Higher IV rank = more crush expected
        """
        base_crush = 0.25  # 25% baseline

        # higher IV rank = more room to fall
        rank_adjustment = (event.iv_rank - 50) / 100 * 0.15

        # historical consistency factor
        move_ratio = event.expected_move / event.historical_move
        consistency_adj = 0.05 if move_ratio > 1.2 else -0.03

        return base_crush + rank_adjustment + consistency_adj

    def score_opportunity(self, event: EarningsEvent,
                         current_date: datetime) -> dict:
        """
        Score earnings premium selling opportunity
        Returns dict with score (0-100) and reasoning
        """
        days_to_earnings = (event.earnings_date - current_date).days

        # timing filter
        if days_to_earnings < self.min_days_to_earnings:
            return {'score': 0, 'reason': 'too close to earnings'}
        if days_to_earnings > self.max_days_to_earnings:
            return {'score': 0, 'reason': 'too far from earnings'}

        # IV rank filter
        if event.iv_rank < self.iv_rank_threshold:
            return {'score': 0, 'reason': f'IV rank {event.iv_rank:.1f} below threshold'}

        # calculate component scores
        iv_score = min((event.iv_rank - self.iv_rank_threshold) * 2, 40)
        timing_score = 25 - abs(days_to_earnings - 3) * 5
        crush_estimate = self.calculate_expected_iv_crush(event)
        crush_score = crush_estimate * 100

        # sector diversification bonus
        correlation = self.sector_correlations.get(event.sector, 0.75)
        diversification_score = (1 - correlation) * 20

        total_score = iv_score + timing_score + crush_score + diversification_score

        return {
            'score': min(total_score, 100),
            'iv_component': iv_score,
            'timing_component': timing_score,
            'crush_estimate': crush_estimate,
            'diversification_bonus': diversification_score,
            'reason': 'opportunity detected' if total_score > 50 else 'below threshold'
        }

    async def scan_earnings_week(self, events: list[EarningsEvent],
                                  current_date: datetime) -> list[dict]:
        """
        Scan upcoming earnings for opportunities
        Returns sorted list of scored opportunities
        """
        opportunities = []

        for event in events:
            score_result = self.score_opportunity(event, current_date)
            if score_result['score'] > 50:
                opportunities.append({
                    'symbol': event.symbol,
                    'earnings_date': event.earnings_date,
                    'iv_rank': event.iv_rank,
                    'expected_move': event.expected_move,
                    **score_result
                })

        # sort by score descending
        opportunities.sort(key=lambda x: x['score'], reverse=True)

        return opportunities[:10]  # top 10 only

    def calculate_position_size(self, opportunity: dict,
                                 account_size: float,
                                 max_risk_per_trade: float = 0.02) -> dict:
        """
        Calculate appropriate position size for earnings trade
        Conservative sizing for binary events
        """
        base_size = account_size * max_risk_per_trade

        # reduce size based on expected move magnitude
        if opportunity['expected_move'] > 8.0:
            size_multiplier = 0.5  # halve size for large movers
        elif opportunity['expected_move'] > 5.0:
            size_multiplier = 0.75
        else:
            size_multiplier = 1.0

        # confidence adjustment based on score
        confidence_mult = opportunity['score'] / 100

        final_size = base_size * size_multiplier * confidence_mult

        return {
            'max_risk': final_size,
            'size_multiplier': size_multiplier,
            'confidence_factor': confidence_mult,
            'reasoning': f"Base ${base_size:.0f} * {size_multiplier:.2f} (move adj) * {confidence_mult:.2f} (confidence)"
        }

early results (week 1)
#

opportunities flagged: 3

trades taken: 2

wins: 2

losses: 0

filter accuracy: identified JPM and BAC as high-conviction plays

both worked. IV crushed 30%+ post-earnings.

what’s working
#

IV rank threshold at 35: sweet spot between opportunity frequency and quality

days_to_earnings sweet spot: 2-4 days optimal. close enough for elevated IV, far enough to avoid gamma risk

sector correlation bonus: helped identify uncorrelated plays

what needs tuning
#

expected move adjustment: historical vs implied still needs calibration

currently using simple ratio. might need regression model.

position sizing: might be too conservative. 0.02 max risk leaving money on table.

will backtest 0.025 and 0.03.

next steps
#

running this through big tech earnings next week.

MSFT, GOOG, AMZN, META, AAPL all reporting.

if filter correctly identifies the best opportunities, will increase allocation.

the NexusFi community has some good discussions on earnings strategies that influenced this approach. worth checking out if you’re building similar systems.


2:45am tuesday. earnings volatility filter implementation. week 1 results: 3 opportunities flagged, 2 taken, 2 wins. IV rank threshold 35, 2-4 days to earnings sweet spot, sector correlation bonus working. JPM and BAC both crushed it. next test: big tech earnings next week.

-AK

Related

earnings volatility - how my algos adapt to quarterly chaos
earnings week chaos. GOOGL, TSLA, META all this week. how my algos handle it. the earnings problem # normal day: VIX 15, predictable ranges, clean signals
fall volatility algo adaptation - regime detection update
first real trading days since vacation. volatility already picking up. VIX hit 16.2 today. time to adapt. the seasonal shift # summer algo settings don’t work in fall.
regime detection filter - why it failed march, python implementation fix
march disaster taught lesson. regime detection lagged. cost $6,690 before pausing. fixing implementation. what went wrong # my current filter:
building volatility regime detection
need to stop trading when volatility spikes. building detection system. the problem # this week VIX spiked 18% in 2 days. my strategies got stopped out twice.
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
october begins - q3 earnings season kickoff
october. q3 earnings season. volatility should pick up. september recap # ended september at $496,220. best month of 2025. +$14,120 (+2.93%).