Day 2 - From Fixed to Dynamic: Building an IV Surface for Better Exposure Calculations

Improved option exposure calculations by implementing a per-strike implied volatility surface with smoothing and convexity enforcement

Written by Chris on December 2nd, 2025

This post is part of the 'The 12 Days of Full Stack Dev' series, an adventure through full stack fintech app development. Join me through the first 12 days of December as we develop a variety of new features, fixes, and components within the Full Stack Craft fintech family.

For the full list of days and topics in this series, see the original post: The 12 Days of Full Stack Dev.

If you're interested in the more trader-focused explanation of this feature, check out this article on VannaCharm's blog.

The Problem: One Size Doesn't Fit All

When calculating option Greeks (gamma, vanna, charm) for exposure analysis, we previously used a single volatility value for all strikes and expirations. This was the "underlying volatility" - essentially the default volatility assumed by the Black-Scholes model for the underlying asset.

// Old approach: one volatility for everything - use it directly
callGreeks := blackscholes.GetOptionGreeks(callOption, divYield, mark, interestRate, volatility)
putGreeks := blackscholes.GetOptionGreeks(putOption, divYield, mark, interestRate, volatility)

To spare you market jargon, the reality is that implied volatility varies significantly across strikes and expirations. This phenomenon is known as the "volatility smile" or "volatility skew."

The Solution: Per-Strike Implied Volatility

The fix is conceptually simple: calculate the implied volatility for each individual given strike and expiry, then use that IV when computing Greeks for exposure. Instead of one number for the entire chain, we build a surface - a grid of IVs organized by expiration date, option type (call/put), and strike price.

// New approach: before calculating exposure, specific IV for each strike
callIVAtStrike := volatility.GetIVForOptionExpiryTypeAndStrike(ivSurfaces, expiration, "CALL", callOption.Strike)
putIVAtStrike := volatility.GetIVForOptionExpiryTypeAndStrike(ivSurfaces, expiration, "PUT", putOption.Strike)

callGreeks := blackscholes.GetOptionGreeks(callOption, divYield, mark, interestRate, callIVAtStrike)
putGreeks := blackscholes.GetOptionGreeks(putOption, divYield, mark, interestRate, putIVAtStrike)

Architecture: Separation of Concerns

Rather than cramming this logic into the exposure calculation code, I created a new volatility package with clear responsibilities:

Package Structure

volatility/
├── volatility.go      # IV surface construction and lookup
├── smoothing.go       # Total variance smoothing algorithms
└── types/
    └── volatility_types.go  # IVSurface data structure

The IVSurface Type

The core data structure is elegantly simple:

type IVSurface struct {
    ExpirationDate int64
    PutCall        string
    Strikes        []float64
    RawIVs         []float64
    SmoothedIVs    []float64
}

Each surface represents one side (call or put) of one expiration. The strikes are sorted in ascending order, and we keep both raw and smoothed IV values.

Building the Surface

The GetIVSurfaces() function orchestrates the entire process:

func GetIVSurfaces(
    volatilityModel string,    // "blackscholes", "svm", "garch" (future)
    smoothingModel string,      // "totalvariance", "none"
    underlyingPrice float64,
    interestRate float64,
    dividendYield float64,
    options *[]types.OptionContract,
    expirations []int64,
) []volatilityTypes.IVSurface

Step 1: Calculate Implied Volatility

For each option in the chain, we reverse-engineer the IV from the market price:

if volatilityModel == "blackscholes" {
    expirationUnixMillis := option.ExpirationDate.UnixMilli()
    timeToExpirationInMilliseconds := blackscholes.GetMillisecondsToExpirationTime(expirationUnixMillis)
    timeToExpirationInYears := float64(timeToExpirationInMilliseconds) / blackscholes.MILLISECONDS_PER_YEAR
    isCall := option.PutCall == "CALL"
    
    iv := blackscholes.CalculateIV(
        option.Mark,
        underlyingPrice,
        option.Strike,
        interestRate/100.0,
        dividendYield/100.0,
        timeToExpirationInYears,
        isCall,
    )
}

This uses a bisection search to find the volatility that makes the Black-Scholes theoretical price match the observed market price:

Step 2: Organize by Expiration and Type

We bucket the IVs into separate arrays for calls and puts at each expiration:

if isCall {
    callStrikes = append(callStrikes, option.Strike)
    callIVs = append(callIVs, iv)
} else {
    putStrikes = append(putStrikes, option.Strike)
    putIVs = append(putIVs, iv)
}

Step 3: Sort by Strike

Before smoothing, we ensure strikes are in ascending order - this is critical for the interpolation algorithms:

type strikeIVPair struct {
    strike float64
    iv     float64
}

pairs := make([]strikeIVPair, len(strikes))
for i := range strikes {
    pairs[i] = strikeIVPair{strike: strikes[i], iv: ivs[i]}
}
sort.Slice(pairs, func(i, j int) bool {
    return pairs[i].strike < pairs[j].strike
})

The IV Smoothing Algorithm

Our current algorithm implements a three-step process:

  1. Convert to Total Variance
w := make([]float64, n)
for i := 0; i < n; i++ {
    vol := ivs[i] / 100.0 // percent → decimal
    w[i] = vol * vol * T  // variance * time remaining = total variance
}
  1. Cubic Spline Interpolation

We fit a cubic spline through the variance points. Cubic splines are smooth (continuous first and second derivatives) and pass through all data points:

type cubicSpline struct {
    x []float64  // strikes
    a []float64  // spline coefficients
    b []float64
    c []float64
    d []float64
}

func (s *cubicSpline) eval(x float64) float64 {
    // Find interval
    i := sort.Search(len(s.x)-1, func(i int) bool { 
        return s.x[i+1] >= x 
    })
    
    // Evaluate cubic polynomial
    dx := x - s.x[i]
    return s.a[i] + s.b[i]*dx + s.c[i]*dx*dx + s.d[i]*dx*dx*dx
}
  1. Enforce Convexity

To prevent arbitrage opportunities, total variance must be convex in strike. We use the convex hull algorithm to ensure this:

func enforceConvexity(x, w []float64) {
    // Build lower convex hull of (strike, variance) points
    hull := make([]point, 0, len(x))
    
    for i := 0; i < len(x); i++ {
        p := point{x[i], w[i]}
        
        // Remove points that break convexity
        for len(hull) >= 2 {
            h1 := hull[len(hull)-1]
            h2 := hull[len(hull)-2]
            
            // Cross product for convexity check
            cross := (h1.x-h2.x)*(p.w-h2.w) - (h1.w-h2.w)*(p.x-h2.x)
            if cross >= 0 {
                break // convex, keep point
            }
            hull = hull[:len(hull)-1] // remove, breaks convexity
        }
        hull = append(hull, p)
    }
    
    // Interpolate hull back to original strikes
    // ... linear interpolation code ...
}
  1. Convert Back to IV

Finally, we transform total variance back to implied volatility:

for i := 0; i < n; i++ {
    if smoothedW[i] <= 0 {
        smoothedIV[i] = ivs[i] // if negative, fallback to original
    } else {
        smoothedIV[i] = math.Sqrt(smoothedW[i]/T) * 100.0
    }
}

Integration with Exposure Calculations

This part is easy - once we've tabulated the surface for all calls and puts at all expiries, at exposure calculation time, we look up the specific IV when computing Greeks, instead of using the fixed volatility amount of the underlying.

That's It!

I'm really enjoying this '12 days of full stack dev' series! Let's see what other fixes, features, and frontend components I can build over the remaining 10 days!

The Wheel Screener provides sophisticated options analytics for systematic traders. Learn more at wheelscreener.com

More posts:

footer-frame