Esc

    The API is under testing and may change before release.

    Rate volatility surfaces

    This page lists what you need to build and read a rate volatility surface: its three constructors, their arguments, the read, and an example you can run. A rate surface holds implied vols on a grid of option expiries and underlying tenors, optionally with a strike axis, and swaptions, caps and floors, and bond options all read their vol from one.

    A market can also hold a SABR smile in the same place, with with_swaption_vols or with_cap_vols: vols.SabrVolSurface keeps the ATM vol, ρ and ν at each expiry and tenor node and implies α at each read. SABR lists that model; this page covers the grid.

    Quick example

    This builds a normal-vol at-the-money grid with expiries of 1, 2 and 5 years and tenors of 2, 5 and 10 years, and reads it at a node, between nodes and beyond its edges. Then it reads a cap smile at absolute strikes, a swaption cube at a spread to the forward, and pins a swaption surface and a cap surface on one market.

    Rust
    use chrono::NaiveDate;
    use helixrs::{Currency, MarketContext, RateVolSurface, StrikeAxis, VolConvention};
    
    type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
    
    fn main() -> Outcome<()> {
        let expiries = vec![1.0, 2.0, 5.0];
        let tenors = vec![2.0, 5.0, 10.0];
        // Normal (basis-point) vols, one row per expiry, in tenor order.
        let rows = vec![
            vec![0.0090, 0.0085, 0.0080],
            vec![0.0095, 0.0090, 0.0084],
            vec![0.0100, 0.0094, 0.0088],
        ];
        let atm = RateVolSurface::new(expiries.clone(), tenors.clone(), &rows, VolConvention::Normal)?;
        let view = atm.view::<f64>();
    
        // At a node the read is the quote itself. The same numbers under a
        // lognormal convention are a different surface: the convention picks the
        // pricing formula.
        let node = view.vol(2.0, 5.0);
        let lognormal =
            RateVolSurface::new(expiries.clone(), tenors.clone(), &rows, VolConvention::Lognormal)?;
        println!("expiries {:?}   tenors {:?}", atm.expiries(), atm.tenors());
        println!("2Y x 5Y node          {:6.2} bp", node * 10_000.0);
        println!("convention            {}", atm.convention().code());
        println!("same grid, relabelled {}", lognormal.convention().code());
    
        // Between nodes: linear in vol across tenor, then linear in total variance
        // across expiry.
        let between = view.vol(3.0, 7.0);
        println!("3Y x 7Y               {:6.2} bp", between * 10_000.0);
    
        // Short of the first expiry, past the last, and outside the tenor axis, the
        // read is the edge node's vol.
        let probes = [(0.25, 5.0), (10.0, 5.0), (2.0, 1.0), (2.0, 30.0), (0.25, 30.0)];
        let edges: Vec<f64> = probes.iter().map(|&(expiry, tenor)| view.vol(expiry, tenor)).collect();
        for ((expiry, tenor), vol) in probes.iter().zip(&edges) {
            println!("{expiry:5.2}Y x {tenor:4.1}Y   {:6.2} bp", vol * 10_000.0);
        }
    
        // A cap smile: one index tenor, absolute strikes, one row per expiry. A
        // strike is read linearly in vol, flat beyond both edges.
        let strikes = vec![0.02, 0.03, 0.04];
        let smile_rows = vec![vec![0.0110, 0.0100, 0.0105], vec![0.0105, 0.0098, 0.0102]];
        let smile =
            RateVolSurface::smile(vec![1.0, 3.0], 0.25, strikes.clone(), &smile_rows, VolConvention::Normal)?;
        let smiled = smile.view::<f64>();
        let mut smile_reads = Vec::new();
        for strike in [0.01, 0.025, 0.035, 0.06] {
            let vol = smiled.vol_at_strike(1.0, 0.25, strike)?;
            smile_reads.push((strike, vol));
            println!("1Y at {:4.2}%   {:6.2} bp", strike * 100.0, vol * 10_000.0);
        }
        let at_2y = smiled.vol_at_strike(2.0, 0.25, 0.035)?;
        println!("2Y at 3.50%   {:6.2} bp", at_2y * 10_000.0);
    
        // A swaption cube is quoted at spreads to the at-the-money forward. The
        // surface records its strike axis, because the numbers alone can't tell a
        // 1% strike from a 100 bp spread. Rust reads a spread with `vol_at_spread`
        // and refuses an absolute-strike read on a spread axis.
        let spreads = vec![-0.01, 0.0, 0.01];
        let cube_tenors = vec![2.0, 10.0];
        let cells = vec![
            vec![vec![0.0100, 0.0090, 0.0096], vec![0.0092, 0.0084, 0.0089]],
            vec![vec![0.0104, 0.0095, 0.0099], vec![0.0096, 0.0088, 0.0092]],
        ];
        let cube = RateVolSurface::cube(
            vec![1.0, 5.0],
            cube_tenors.clone(),
            spreads.clone(),
            &cells,
            VolConvention::Normal,
        )?
        .over_the_forward();
        let cube_view = cube.view::<f64>();
        let at_spread = cube_view.vol_at_spread(1.0, 6.0, 0.005)?;
        let wrong_axis = if cube_view.vol_at_strike(1.0, 6.0, 0.035).is_err() { "refused" } else { "read" };
        println!("strike axis                 {}", cube.strike_axis().code());
        println!("1Y x 6Y at +50 bp           {:6.2} bp", at_spread * 10_000.0);
        println!("a read the axis can't take  {wrong_axis}");
    
        // A currency carries a swaption surface and a cap surface side by side;
        // each product reads its own.
        let usd = Currency::new("USD")?;
        let reference = NaiveDate::from_ymd_opt(2026, 5, 21).expect("a real calendar date");
        let market = MarketContext::as_of(reference)
            .with_swaption_vols(usd, atm.clone())
            .with_cap_vols(usd, smile.clone());
        let swaption_read = market.swaption_vols(usd).expect("a pinned swaption surface").view::<f64>().vol(3.0, 7.0);
        let cap_read = market.cap_vols(usd).expect("a pinned cap surface").view::<f64>().vol_at_strike(2.0, 0.25, 0.035)?;
        println!("swaption surface, 3Y x 7Y   {:6.2} bp", swaption_read * 10_000.0);
        println!("cap surface, 2Y at 3.50%    {:6.2} bp", cap_read * 10_000.0);
    
        Ok(())
    }
    Python
    import datetime as dt
    
    import helixio as hlx
    
    EXPIRIES = [1.0, 2.0, 5.0]
    TENORS = [2.0, 5.0, 10.0]
    # Normal (basis-point) vols, one row per expiry, in tenor order.
    ROWS = [
        [0.0090, 0.0085, 0.0080],
        [0.0095, 0.0090, 0.0084],
        [0.0100, 0.0094, 0.0088],
    ]
    atm = hlx.vols.RateVolSurface(EXPIRIES, TENORS, ROWS, hlx.vols.VolConvention.Normal)
    
    # At a node the read is the quote itself. The same numbers under a lognormal
    # convention are a different surface: the convention picks the pricing formula.
    node = atm.vol(2.0, 5.0)
    lognormal = hlx.vols.RateVolSurface(EXPIRIES, TENORS, ROWS, "lognormal")
    print(f"expiries {atm.expiries()}   tenors {atm.tenors()}")
    print(f"2Y x 5Y node          {node * 10_000.0:6.2f} bp")
    print(f"convention            {atm.vol_convention()}")
    print(f"same grid, relabelled {lognormal.vol_convention()}")
    
    # Between nodes: linear in vol across tenor, then linear in total variance
    # across expiry.
    between = atm.vol(3.0, 7.0)
    print(f"3Y x 7Y               {between * 10_000.0:6.2f} bp")
    
    # Short of the first expiry, past the last, and outside the tenor axis, the
    # read is the edge node's vol.
    PROBES = [(0.25, 5.0), (10.0, 5.0), (2.0, 1.0), (2.0, 30.0), (0.25, 30.0)]
    edges = [atm.vol(expiry, tenor) for expiry, tenor in PROBES]
    for (expiry, tenor), vol in zip(PROBES, edges):
        print(f"{expiry:5.2f}Y x {tenor:4.1f}Y   {vol * 10_000.0:6.2f} bp")
    
    # A cap smile: one index tenor, absolute strikes, one row per expiry. A strike
    # is read linearly in vol, flat beyond both edges.
    STRIKES = [0.02, 0.03, 0.04]
    SMILE_ROWS = [[0.0110, 0.0100, 0.0105], [0.0105, 0.0098, 0.0102]]
    smile = hlx.vols.RateVolSurface.smile([1.0, 3.0], 0.25, STRIKES, SMILE_ROWS, "normal")
    smile_reads = {strike: smile.vol(1.0, 0.25, strike) for strike in (0.01, 0.025, 0.035, 0.06)}
    for strike, vol in smile_reads.items():
        print(f"1Y at {strike * 100.0:4.2f}%   {vol * 10_000.0:6.2f} bp")
    at_2y = smile.vol(2.0, 0.25, 0.035)
    print(f"2Y at 3.50%   {at_2y * 10_000.0:6.2f} bp")
    
    # A swaption cube is quoted at spreads to the at-the-money forward. The surface
    # records its strike axis, because the numbers alone can't tell a 1% strike
    # from a 100 bp spread. Python reads the one strike coordinate on the axis the
    # surface carries, and refuses a read that names no strike at all.
    SPREADS = [-0.01, 0.0, 0.01]
    CUBE_TENORS = [2.0, 10.0]
    CELLS = [
        [[0.0100, 0.0090, 0.0096], [0.0092, 0.0084, 0.0089]],
        [[0.0104, 0.0095, 0.0099], [0.0096, 0.0088, 0.0092]],
    ]
    cube = hlx.vols.RateVolSurface.cube(
        [1.0, 5.0], CUBE_TENORS, SPREADS, CELLS, "normal", hlx.vols.StrikeAxis.SpreadToAtm
    )
    at_spread = cube.vol(1.0, 6.0, 0.005)
    try:
        cube.vol(1.0, 6.0)
        no_strike = "read"
    except ValueError:
        no_strike = "refused"
    print(f"strike axis                 {cube.strike_axis()}")
    print(f"1Y x 6Y at +50 bp           {at_spread * 10_000.0:6.2f} bp")
    print(f"a read the axis can't take  {no_strike}")
    
    # A currency carries a swaption surface and a cap surface side by side; each
    # product reads its own.
    market = (
        hlx.MarketContext(dt.date(2026, 5, 21))
        .with_swaption_vols("USD", atm)
        .with_cap_vols("USD", smile)
    )
    swaption_read = market.swaption_vols("USD").vol(3.0, 7.0)
    cap_read = market.cap_vols("USD").vol(2.0, 0.25, 0.035)
    print(f"swaption surface, 3Y x 7Y   {swaption_read * 10_000.0:6.2f} bp")
    print(f"cap surface, 2Y at 3.50%    {cap_read * 10_000.0:6.2f} bp")
    expiries [1.0, 2.0, 5.0]   tenors [2.0, 5.0, 10.0]
    2Y x 5Y node           90.00 bp
    convention            normal
    same grid, relabelled lognormal
    3Y x 7Y                89.84 bp
     0.25Y x  5.0Y    85.00 bp
    10.00Y x  5.0Y    94.00 bp
     2.00Y x  1.0Y    95.00 bp
     2.00Y x 30.0Y    84.00 bp
     0.25Y x 30.0Y    80.00 bp
    1Y at 1.00%   110.00 bp
    1Y at 2.50%   105.00 bp
    1Y at 3.50%   102.50 bp
    1Y at 6.00%   105.00 bp
    2Y at 3.50%   100.63 bp
    strike axis                 spread_to_atm
    1Y x 6Y at +50 bp            89.75 bp
    a read the axis can't take  refused
    swaption surface, 3Y x 7Y    89.84 bp
    cap surface, 2Y at 3.50%    100.63 bp

    A node reads its own quote. Every probe outside the grid reads the nearest edge node. Each product on the market reads its own surface: the swaption surface at 3Y × 7Y, the cap surface at 3.50%.

    API

    Argument Type Default Meaning
    vols.RateVolSurface(expiries, tenors, vols) list[float] ×2, list[list[float]] required an at-the-money grid, in years, one row of vols per expiry in tenor order; Rust RateVolSurface::new
    vol_convention VolConvention or str "normal" "normal" (basis points, Bachelier) or "lognormal" (percent of the rate, Black-76)
    day_count str or DayCountFraction "ACT/365.FIXED" RateVolSurface(...) only: the basis a reader counts years to an expiry on; smile and cube take none
    vols.RateVolSurface.smile(expiries, tenor_years, strikes, vols) lists, float required one tenor with a strike axis, one row per expiry in strike order: a cap smile
    vols.RateVolSurface.cube(expiries, tenors, strikes, vols) lists required all three axes, vols[expiry][tenor][strike]: a swaption cube
    strike_axis StrikeAxis or str "absolute" for smile and cube: "absolute" rates or "spread_to_atm"; Rust over_the_forward()
    Read Returns Meaning
    vol(expiry_years, tenor_years, strike=None) float the vol at a point; a smiled surface must be given a strike, read on the axis it carries
    expiries(), tenors(), strikes() lists the grid's axes; strikes() is None without a strike axis
    vol_convention(), strike_axis(), day_count() str what the surface carries

    In Rust the reads sit on RateVolSurface::view::<f64>(): vol at the money, vol_at_strike on an absolute axis and vol_at_spread on a spread axis. A market holds a currency's surfaces with with_swaption_vols and with_cap_vols, and returns them with swaption_vols and cap_vols.

    Defaults and limits

    • The read: linear in vol across strikes and tenors, linear in total variance across expiries.
    • Beyond the data: outside every edge the read is the edge node's vol. Nothing is extrapolated.
    • The convention: the surface carries it, and each pricer takes its formula from it, so the same numbers under the other convention price differently.
    • Strike axes: a read naming the wrong kind of coordinate is refused. In Python a smiled surface read with no strike raises ValueError; in Rust an absolute-strike read on a spread axis returns an error.
    • One of each per currency: a market holds one swaption surface and one cap surface per currency.

    The ideas behind these rules are in how vol surfaces work.

    See also

    • Source: crates/helixrs/src/marketdata/rate_vol.rs
    • Related pages:
      • How vol surfaces work, the ideas behind the read.
      • Swaptions, which read the at-the-money grid or the cube.
      • Caps and floors, which read the cap smile.
      • Bond options, which read the tenor axis at a duration.
      • FX vol surfaces, for the same expiry rule on a delta smile.

    From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.