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:
- 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
}
- 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
}
- 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 ...
}
- 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