• Home
  • Blog
  • Publications

Developing intuition about skew¶

I spent my junior summer at a FICC Options desk in Chicago. There, one of the most unintuitive aspects of pricing options was skew: the observation that implied volatility of an asset will vary across different strike prices with the same time to expiry.

The most basic pricing model for options is Black Scholes, which assumes that asset returns are normally distributed with a known mean and standard deviation. Real life is not that neat -- it is intuitive that an asset's returns will be more volatile on certain days (e.g. earnings, announcements, during corporate actions) than others. The implied volatility of an option reflects the market's expectation for how much volatility the asset will incur over the life of an option. Thus, for any given strike price, different expiries for the same asset will have different implied volatilities.

Still, I struggled to understand how over the exact same duration of time, different strikes could have different implied volatilities in a bizarre superposition of standard deviations. I heard explanations involving how far OTM puts were in demand from hedgers, and far OTM calls were in demand from people seeking cheap leveraged exposure to assets. This didn't feel like the full picture -- the structure of skew varies significantly between assets, and many assets have fairly stable skew structures across time. If the phenomenon arose from supply and demand, I'd expect the extent of skew to fluctuate a lot more. The explanation that finally satisfied me was that far OTM options would only have value if there was a large move in asset prices. Conditional on there being a large move, the confidence interval over which asset prices could fall also becomes larger.

Here, I want to explore a simple model. I'll only examine options over a single TTE (for simplicity, we can call this a week). Every time period, asset returns will move according to a baseline normal distribution. Additionally, during some of these time periods, there can be an event. The returns of an event will have their own mean and standard deviation, and will be added to the baseline returns. I want to bolster my own intuition about how skew can arise from this simple, plausible setup.

The first section will cover the shape of the distribution of returns under such a model. Ths second section will visualize put skew as these parameters change. The third section will calculate MLE fits of different stocks under this model. In addition to static graphs, I'll add some code to interact with the model by toggling the parameters in a Jupyter notebook for independent exploration.

0. Model Details:¶

  • Let's say that every day without fail, an asset will have some returns distributed according to a normal distribution.
  • Let's say that there is an additional probabiilty that an event happens. We will represent this as a Bernoulli random variable.
  • The event itself has a specified mean and standard deviation, which will be added to the baseline returns if and only if the event happens.
  • What "simple" distribution most closely approximates this mixed model?
  • If someone knew the generating process, what would the vol smile look like as you changed these five parameters?
    • Baseline mean
    • Baseline std
    • Event mean
    • Event std
    • Event probability
In [1]:
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import norm, bernoulli, t, laplace, kstest
from scipy.optimize import brentq
from ipywidgets import interact, FloatSlider, FloatLogSlider, interactive
from typing import Optional, Union, Callable, Tuple
import warnings

import matplotlib.pyplot as plt

1. Distribution of returns¶

  • Below, I'll plot a randomly drawn sample of 100,000 time periods of asset returns under a mixture model with specified parameters.
  • I'll overlay the MLE estimate of a normal distribution that describes these returns (as Black Scholes uses) and a Laplace distribution to describe these returns (to better capture fat tails and the central tendency).
  • Finally, I will display the Kolmogorov-Smirnov test statistic that measures the similarity between the PDF of the estimated MLE distributions and the true generating process. Here, a lower number is better, and 0 represents a perfect match between the generating CDF and estimated CDF.
In [2]:
def plot_return_distribution_with_bernoulli_events(
        background_std: float, 
        event_prob: float, 
        event_std: float, 
        background_mean: float = 0.0, 
        event_mean: float = 0.0
):
    """
    Interactive widget to plot a histogram of returns if daily returns are governed by the following process: 
        - Returns have a standard background mean/standard deviation
        - There is some probability of an event, governed by a Bernoulli random variable
        - If there is an event, that is governed by a separate event mean and standard deviation
        - Daily returns are N(background_mean, background_std) + event_indicator * N(event_mean, event_std)

    This widget plots a histogram of daily returns drawn from Monte Carlo simulation with overlayed MLE fits and their respective Kolmogorov-Smirnov (KS) statistics:
        - the MLE normal distribution describing returns
        - the MLE Laplace distribution describing returns

    Params: 
        background_std (float) - the unavoidable daily standard deviation in returns
        event_prob (float) - the probability of an event happening, on the domain [0, 1]
        event_std (float) - given that an event happens, the standard deviation of its impact on returns
        background_mean (float) - the mean of background returns
        event_mean (float) - the mean of event returns
    """
    # Simulate data
    background_returns = np.random.normal(background_mean, background_std, 100000)
    event_indicator = bernoulli.rvs(event_prob, size=100000)
    event_returns = np.random.normal(event_mean, event_std, 100000)

    result = background_returns + event_indicator * event_returns

    # MLE Normal fit
    mu, std = norm.fit(result)

    # MLE Laplace fit
    laplace_loc = np.mean(result)
    laplace_scale = np.std(result) / np.sqrt(2)

    # Calculate the Bayes (true) CDF using the known process
    background_cdf = norm.cdf(result, background_mean, background_std)
    event_cdf = norm.cdf(result, event_mean, event_std)
    true_cdf = background_cdf + event_prob * event_cdf

    # Calculate the Normal and Laplace CDFs
    normal_cdf = norm.cdf(result, mu, std)
    laplace_cdf = laplace.cdf(result, loc=laplace_loc, scale=laplace_scale)

    # Calculate Kolmogorov-Smirnov statistics
    ks_normal = np.max(np.abs(true_cdf - normal_cdf))
    ks_laplace = np.max(np.abs(true_cdf - laplace_cdf))

    # Plotting
    plt.figure(figsize=(10, 6))

    # Plot histogram of the results
    sns.histplot(result, bins=200, kde=False, stat='density', color='lightblue', label="Returns")

    # Plot Normal and Laplace fits
    x = np.linspace(min(result), max(result), 1000)
    plt.plot(x, norm.pdf(x, mu, std), label=f'Normal (KS: {ks_normal:.2f})', color='blue')
    plt.plot(x, laplace.pdf(x, loc=laplace_loc, scale=laplace_scale), 
             label=f'Laplace (KS: {ks_laplace:.2f})', color='red')

    # Add title and labels
    plt.legend(title="KS Statistics")
    plt.title("Returns Sampled Distribution with KS Statistics")
    plt.xlabel("Returns")
    plt.ylabel("Density")
    plt.grid(False)
    plt.show()

Baseline: Normally distributed returns with no chance of an event¶

In [3]:
plot_return_distribution_with_bernoulli_events(
        background_std = .1, 
        event_prob = 0.0, 
        event_std = .2, 
        background_mean = 0.0, 
        event_mean = 0.0
)

Normally distributed returns with an event:¶

In [4]:
plot_return_distribution_with_bernoulli_events(
        background_std = .1, 
        event_prob = 0.2, 
        event_std = .2, 
        background_mean = 0.0, 
        event_mean = 0.0
)

Event of higher average magnitude:¶

In [5]:
plot_return_distribution_with_bernoulli_events(
        background_std = .1, 
        event_prob = 0.2, 
        event_std = .4, 
        background_mean = 0.0, 
        event_mean = 0.0
)

Event of the same magnitude with higher probability:¶

In [6]:
plot_return_distribution_with_bernoulli_events(
        background_std = .1, 
        event_prob = 0.44, 
        event_std = .2, 
        background_mean = 0.0, 
        event_mean = 0.0
)

Interactive widget:¶

In [7]:
interactive_plot = interactive(
    plot_return_distribution_with_bernoulli_events,
    background_mean=FloatSlider(min=-0.1, max=0.1, step=0.01, value=0.0, description="Bg Mean"),
    background_std=FloatSlider(min=0.0, max=0.5, step=0.01, value=0.1, description="Bg Std"),
    event_mean=FloatSlider(min=-0.1, max=0.1, step=0.01, value=0.0, description="Event Mean"),
    event_std=FloatSlider(min=0.0, max=0.5, step=0.01, value=0.1, description="Event Std"),
    event_prob=FloatSlider(min=0.0, max=1.0, step=0.01, value=0.1, description="Event Prob")
)
In [8]:
interactive_plot
Out[8]:
interactive(children=(FloatSlider(value=0.1, description='Bg Std', max=0.5, step=0.01), FloatSlider(value=0.1,…

2. Plotting Skew¶

  • Given a return generating process, we can calculate the fair value of an option given a strike price, background mean, background std, event mean, event std, and event prob (assuming an interest rate of 0 and 1 period of returns)
  • Black Scholes is a relative pricing model -- knowing the true value of this option from the step above, what implied volatility can we input into vanilla Black Scholes to match the theoretical value of the option?
  • This provides some intuition for the circumstances that create vol skew and asymmetries in vol skew
In [9]:
def price_option(
    S: float,
    K: float,
    baseline_mean: float,
    baseline_std: float,
    event_mean: float,
    event_std: float,
    p_event: float,
    option_type: str = 'call'
) -> float:
    """
    Price an option using a mixed normal distribution model.
    
    Args:
        S: Current stock price
        K: Strike price
        baseline_mean: Mean of baseline normal distribution
        baseline_std: Standard deviation of baseline distribution
        event_mean: Additional mean if event occurs
        event_std: Additional std if event occurs
        p_event: Probability of event occurring
        option_type: 'call' or 'put'
    
    Returns:
        Option price
    """
    def _normal_price(mean: float, std: float) -> float:
        # Guard against division by zero
        if std == 0:
            # For zero volatility, option value is max(S*exp(mean) - K, 0) for calls
            # or max(K - S*exp(mean), 0) for puts
            forward = S * np.exp(mean)
            if option_type.lower() == 'call':
                return max(forward - K, 0)
            else:
                return max(K - forward, 0)
            
        d1 = (np.log(S/K) + mean + 0.5 * std**2) / std
        d2 = d1 - std
        
        if option_type.lower() == 'call':
            return S * np.exp(mean) * norm.cdf(d1) - K * norm.cdf(d2)
        else:
            return K * norm.cdf(-d2) - S * np.exp(mean) * norm.cdf(-d1)
    
    # When p_event is 0, we only need the baseline case
    if p_event == 0:
        return _normal_price(baseline_mean, baseline_std)
    
    # When p_event is 1, we only need the event case
    if p_event == 1:
        combined_mean = baseline_mean + event_mean
        combined_std = np.sqrt(baseline_std**2 + event_std**2)
        return _normal_price(combined_mean, combined_std)
        
    # Regular case: weighted average of both scenarios
    no_event_price = _normal_price(baseline_mean, baseline_std)
    combined_mean = baseline_mean + event_mean
    combined_std = np.sqrt(baseline_std**2 + event_std**2)
    event_price = _normal_price(combined_mean, combined_std)
    
    return (1 - p_event) * no_event_price + p_event * event_price
In [10]:
def calc_implied_vol(
    price: float,
    S: float,
    K: float,
    T: float = 1.0,
    option_type: str = 'call',
    r: float = 0.0,
    initial_guess: float = 0.3,
    tolerance: float = 1e-5
) -> Optional[float]:
    """
    Calculate implied volatility using Brent's method with robust error handling.
    
    Args:
        price: Market price of option
        S: Current stock price
        K: Strike price
        T: Time to expiration in years
        option_type: 'call' or 'put'
        r: Risk-free rate
        initial_guess: Initial volatility guess
        tolerance: Tolerance for convergence
        
    Returns:
        Implied volatility or None if no solution found
    """
    forward = S * np.exp(r * T)
    
    # Handle edge cases for zero or negative option prices
    if price <= 0:
        return 0.0
        
    # Calculate intrinsic value
    if option_type.lower() == 'call':
        intrinsic = max(0, forward - K)
    else:
        intrinsic = max(0, K - forward)
        
    # If price is at intrinsic value, return 0 volatility
    if abs(price - intrinsic) < tolerance:
        return 0.0
        
    def bs_price(sigma: float) -> float:
        if sigma < tolerance:
            return intrinsic
            
        d1 = (np.log(S/K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
        d2 = d1 - sigma * np.sqrt(T)
        
        if option_type.lower() == 'call':
            return S * np.exp(-r * T) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
        else:
            return K * np.exp(-r * T) * norm.cdf(-d2) - S * np.exp(-r * T) * norm.cdf(-d1)
    
    try:
        # Try increasingly wider ranges
        ranges = [(0.0001, 1.0), (0.0001, 2.0), (0.00001, 3.0)]
        for low, high in ranges:
            try:
                return brentq(lambda x: bs_price(x) - price, low, high, rtol=tolerance)
            except ValueError:
                continue
        return None
    except Exception as e:
        warnings.warn(f"Failed to compute implied vol: {str(e)}")
        return None
In [11]:
def generate_vol_surface(
    pricing_func: Callable,
    S: float,
    params: dict,
    min_strike_mult: float = 0.5,
    max_strike_mult: float = 1.5,
    num_strikes: int = 300,
    clean_nans: bool = True
) -> Tuple[np.ndarray, np.ndarray]:
    """
    Generate implied volatility surface with robust error handling.
    
    Args:
        pricing_func: Function that prices options
        S: Current stock price
        params: Dictionary of parameters for pricing_func
        min_strike_mult: Minimum strike price as multiple of S
        max_strike_mult: Maximum strike price as multiple of S
        num_strikes: Number of strike prices to evaluate
        clean_nans: If True, interpolate over NaN values
        
    Returns:
        Tuple of (strikes, implied_vols)
    """
    strikes = np.linspace(S * min_strike_mult, S * max_strike_mult, num_strikes)
    
    # calculate the forward price 
    baseline_mean, baseline_std, event_mean, event_std, p_event = params.values()
    forward = S * np.exp(baseline_mean) * (1-p_event) + S * np.exp(baseline_mean + event_mean) * p_event 

    implied_vols = []
    
    option_type = "put"
    for K in strikes:
        try:
            price = pricing_func(S, K, option_type=option_type, **params)
            # Handle zero or negative prices
            if price <= 0:
                implied_vols.append(0.0)
                continue
                
            vol = calc_implied_vol(price, S, K, option_type=option_type)
            implied_vols.append(vol if vol is not None else np.nan)
        except Exception as e:
            warnings.warn(f"Error at strike {K}: {str(e)}")
            implied_vols.append(np.nan)
    
    vols = np.array(implied_vols)
    
    # Clean up NaN values through interpolation if requested
    if clean_nans and np.any(np.isnan(vols)):
        valid_mask = ~np.isnan(vols)
        if np.any(valid_mask):
            x = np.arange(len(vols))
            valid_indices = x[valid_mask]
            valid_vols = vols[valid_mask]
            vols = np.interp(x, valid_indices, valid_vols)
    
    return strikes, vols
In [12]:
def plot_vol_smile(
    S: float,
    baseline_mean: float,
    baseline_std: float,
    event_mean: float,
    event_std: float,
    p_event: float
) -> None:
    """
    Create plot of implied volatility smile with robust axis scaling.
    
    Args:
        S: Current stock price
        baseline_mean: Mean of baseline distribution
        baseline_std: Std dev of baseline distribution
        event_mean: Additional mean if event occurs
        event_std: Additional std if event occurs
        p_event: Probability of event occurring
    """
    params = {
        'baseline_mean': baseline_mean,
        'baseline_std': baseline_std,
        'event_mean': event_mean,
        'event_std': event_std,
        'p_event': p_event
    }
    
    try:
        strikes, vols = generate_vol_surface(price_option, S, params)
        
        # Find ATM strike for reference line
        forward_strike = S * np.exp(baseline_mean) * (1-p_event) + S * np.exp(baseline_mean + event_mean) * p_event
        
        # Clean any remaining NaN values for max calculation
        clean_vols = vols[~np.isnan(vols)]
        if len(clean_vols) == 0:
            raise ValueError("No valid volatility values found")
            
        # Calculate axis limits
        max_vol = np.max(clean_vols)
        min_moneyness = min(strikes) / S
        max_moneyness = max(strikes) / S
        
        plt.figure(figsize=(10, 6))
        
        # Plot implied volatility line
        plt.plot(strikes/S, vols, 'b-', label='Implied Volatility', linewidth=2)
        
        # Add FW reference line
        plt.axvline(x=forward_strike/S, color='g', linestyle=':', 
                   label=f'Forward (K={forward_strike:.1f})', alpha=0.5)
        
        # Add baseline std as reference line if it's a pure baseline case
        if p_event == 0:
            plt.axhline(y=baseline_std, color='r', linestyle='--', 
                       label=f'Baseline σ={baseline_std:.2f}', linewidth=1.5)
        
        # Set axis limits and labels
        plt.xlim(min_moneyness, max_moneyness)
        plt.ylim(0, max(max_vol * 1.5, baseline_std * 1.5))
        
        plt.xlabel('Moneyness (K/S)')
        plt.ylabel('Implied Volatility')
        plt.title(f'Implied Volatility Smile (p_event={p_event:.2f})')
        
        # Enhance grid and legend
        plt.grid(True, alpha=0.3)
        plt.legend(loc='best')
        
        # Add horizontal grid lines at round numbers
        max_y = plt.ylim()[1]
        grid_steps = np.arange(0, max_y + 0.1, 0.1)
        plt.hlines(grid_steps, min_moneyness, max_moneyness, 
                  colors='gray', linestyles=':', alpha=0.2)
        
        plt.show()
        
    except Exception as e:
        plt.close()  # Clean up any partial plot
        warnings.warn(f"Failed to generate plot: {str(e)}")
        raise
In [13]:
def create_interactive_plot() -> None:
    """
    Create interactive widget controls with reasonable bounds and steps.
    """
    return interact(
        plot_vol_smile,
        S=FloatSlider(value=100.0, min=50.0, max=150.0, step=1.0, 
                      description='Stock Price'),
        baseline_mean=FloatSlider(value=0.0, min=-0.2, max=0.2, step=0.01, 
                                description='Base Mean'),
        baseline_std=FloatSlider(value=0.2, min=0.0, max=0.5, step=0.01, 
                               description='Base Std'),
        event_mean=FloatSlider(value=-0.05, min=-0.2, max=0.2, step=0.01, 
                             description='Event Mean'),
        event_std=FloatSlider(value=0.3, min=0.0, max=1.0, step=0.01, 
                            description='Event Std'),
        p_event=FloatSlider(value=0.1, min=0.0, max=1.0, step=0.01, 
                           description='Event Prob')
    )

Baseline: Normally distributed returns, no chance of an event¶

In [14]:
plot_vol_smile(
    S = 100,
    baseline_mean = 0.0,
    baseline_std = .2,
    event_mean = 0.0,
    event_std = 0.0,
    p_event = 0.1
) 

Positive event probability, 0 mean¶

In [15]:
plot_vol_smile(
    S = 100,
    baseline_mean = 0.0,
    baseline_std = .2,
    event_mean = 0.0,
    event_std = 0.4,
    p_event = 0.1
)

Probability with an event with negative expected outcome¶

In [19]:
plot_vol_smile(
    S = 100,
    baseline_mean = 0.0,
    baseline_std = .2,
    event_mean = -0.2,
    event_std = 0.4,
    p_event = 0.5
)

Probability with an event with positive expected outcome¶

In [17]:
plot_vol_smile(
    S = 100,
    baseline_mean = 0.0,
    baseline_std = .2,
    event_mean = 0,
    event_std = 0.4,
    p_event = 0.3
)

Interactive Plot¶

In [18]:
create_interactive_plot()
interactive(children=(FloatSlider(value=100.0, description='Stock Price', max=150.0, min=50.0, step=1.0), Floa…
Out[18]:
<function __main__.plot_vol_smile(S: float, baseline_mean: float, baseline_std: float, event_mean: float, event_std: float, p_event: float) -> None>

Takeaways¶

As shown above, the existence of a volatile event in excess of a day's baseline volatility implies the existence of skew in the "fair" IV of options at different strike prices.

I don't think this model is useful for justifying whether call skew or put skew should be larger -- there are natural distortions in this simulation because a log-normal distribution cannot go below 0, and the avg price of the asset in time period 1 is affected by the mean return AND the standard deviation of the returns (this is also why phenomena like [Shannon's Demon] (https://portfoliocharts.com/2022/04/12/unexpected-returns-shannons-demon-the-rebalancing-bonus/) arise in portfolio management).

Notice that the skew of the volatility surface peaks when the probability of an event is 50%. After that point, a more probable event just becomes a new base state, and the extent of skew in the tails starts decreasing.

The mathematical intuition behind this skew is that, if the options in the tails become valuable, it is likely that the asset experienced an event that has dramatically widened the cone of possible prices upon expiry. To compensate for this risk, the options in the tail should have higher implied volatility.