Esc

    The API is under testing and may change before release.

    FX volatility surfaces

    This page lists what you need to build and read an FX volatility surface: its constructor's arguments, the reads it answers, the delta conventions, and examples you can run. An FX surface holds a currency pair's implied vols as one smile per expiry, each stated the way FX desks quote it, and every FX option in Helixio reads its vol from one.

    Quick example

    This builds a EURUSD surface on 21 May 2026 with smiles at one month, three months and one year, each from an at-the-money vol, a 25-delta risk reversal and a 25-delta butterfly under a spot delta. It reads the three-month smile back, places each node at the strike its delta names at a forward of 1.10 and a base discount factor of 0.98, reads vols between and beyond the wings, and pins the surface on a market.

    Rust
    use chrono::NaiveDate;
    use helixrs::{
        Currency, CurrencyPair, DayCountFraction, DeltaConvention, DeltaQuote, FxVolSmile,
        FxVolSurface, MarketContext, atm_delta_neutral_strike, strike_from_delta,
    };
    
    type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
    
    // The pair's forward to delivery and the base currency's discount factor: the
    // two market inputs that place a delta quote on the strike axis.
    const FORWARD: f64 = 1.10;
    const DF_BASE: f64 = 0.98;
    const SPOT_DELTA: DeltaConvention = DeltaConvention::Spot;
    
    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
    }
    
    fn main() -> Outcome<()> {
        let reference = date(2026, 5, 21);
        let pair = CurrencyPair::new(Currency::new("EUR")?, Currency::new("USD")?);
        // (expiry, at-the-money, 25-delta risk reversal, 25-delta butterfly)
        let quotes = [
            (date(2026, 6, 22), 0.080, 0.006, 0.0020),
            (date(2026, 8, 21), 0.085, 0.008, 0.0025),
            (date(2027, 5, 21), 0.090, 0.010, 0.0030),
        ];
        let mut smiles = Vec::new();
        for &(expiry, atm, rr, bf) in &quotes {
            smiles.push((expiry, FxVolSmile::from_quotes(atm, rr, bf)?));
        }
        let surface = FxVolSurface::new(pair, SPOT_DELTA, reference, smiles)?
            .with_day_count(DayCountFraction::Act365Fixed);
        let view = surface.view::<f64>();
    
        // The three-month smile: the butterfly lifts both wings and the risk reversal
        // splits them, so here the put wing ends below at-the-money and the call above.
        let three_month = quotes[1].0;
        let smile = surface.smile(three_month).expect("a stored smile");
        for (node, vol) in smile.quotes().iter().zip(smile.vols()) {
            println!("{:<4} vol {:7.4}%", node.to_string(), vol * 100.0);
        }
    
        // A quote names an option, not a strike. Each node sits at the strike where
        // an option of its kind has its delta, at the node's own vol; the
        // at-the-money node is the delta-neutral straddle. The surface reads each
        // node's vol back there.
        let expiry = f64::from((three_month - reference).num_days() as i32) / 365.0;
        let mut strikes = Vec::new();
        for (node, &vol) in smile.quotes().iter().zip(smile.vols()) {
            let strike = match node.signed_delta() {
                None => atm_delta_neutral_strike(FORWARD, vol, expiry, SPOT_DELTA),
                Some(signed) => strike_from_delta(signed, FORWARD, vol, expiry, DF_BASE, SPOT_DELTA)?,
            };
            strikes.push(strike);
            let read = view.vol_at_strike(three_month, strike, FORWARD, DF_BASE)?;
            println!("{:<4} strike {strike:.6}   vol {:7.4}%", node.to_string(), read * 100.0);
        }
    
        // Between node strikes the vol is linear in log-moneyness; past the outer
        // nodes it is held at the wing's vol.
        let mut reads = Vec::new();
        for strike in [1.00, 1.06, 1.08, 1.10, 1.12, 1.14, 1.20] {
            let vol = view.vol_at_strike(three_month, strike, FORWARD, DF_BASE)?;
            reads.push((strike, vol));
            println!("strike {strike:.2}   vol {:7.4}%", vol * 100.0);
        }
    
        // A market holds one surface per pair, and an option on that pair reads it.
        let market = MarketContext::as_of(reference).with_fx_vols(surface.clone());
        let pinned = market.fx_vols(pair)?;
        let read_back = pinned.view::<f64>().vol_at_quote(three_month, "ATM".parse::<DeltaQuote>()?)?;
        println!("expiries on the pinned surface   {}", pinned.expiries().len());
        println!("day count                        {}", pinned.day_count().code());
        println!("3M ATM read back                 {:7.4}%", read_back * 100.0);
    
        Ok(())
    }
    Python
    import datetime as dt
    
    import helixio as hlx
    
    REFERENCE = dt.date(2026, 5, 21)
    # (expiry, at-the-money, 25-delta risk reversal, 25-delta butterfly)
    QUOTES = [
        (dt.date(2026, 6, 22), 0.080, 0.006, 0.0020),
        (dt.date(2026, 8, 21), 0.085, 0.008, 0.0025),
        (dt.date(2027, 5, 21), 0.090, 0.010, 0.0030),
    ]
    # The pair's forward to delivery and the base currency's discount factor: the
    # two market inputs that place a delta quote on the strike axis.
    FORWARD, DF_BASE = 1.10, 0.98
    SPOT_DELTA = hlx.vols.DeltaConvention.Spot
    
    surface = hlx.vols.FxVolSurface("EUR", "USD", REFERENCE, QUOTES, "spot", "ACT/365.FIXED")
    
    # The three-month smile: the butterfly lifts both wings and the risk reversal
    # splits them, so here the put wing ends below at-the-money and the call above.
    three_month = QUOTES[1][0]
    smile = surface.smile(three_month)
    for quote, vol in zip(smile.quotes(), smile.vols()):
        print(f"{quote:<4} vol {vol * 100.0:7.4f}%")
    
    # A quote names an option, not a strike. Each node sits at the strike where an
    # option of its kind has its delta, at the node's own vol; the at-the-money node
    # is the delta-neutral straddle. The surface reads each node's vol back there.
    expiry = (three_month - REFERENCE).days / 365.0
    strikes = []
    for quote, vol in zip(smile.quotes(), smile.vols()):
        if quote == "ATM":
            strike = hlx.vols.atm_delta_neutral_strike(FORWARD, vol, expiry, SPOT_DELTA)
        else:
            signed = -0.25 if quote == "25P" else 0.25
            strike = hlx.vols.strike_from_delta(signed, FORWARD, vol, expiry, DF_BASE, SPOT_DELTA)
        strikes.append(strike)
        read = surface.vol_at_strike(three_month, strike, FORWARD, DF_BASE)
        print(f"{quote:<4} strike {strike:.6f}   vol {read * 100.0:7.4f}%")
    
    # Between node strikes the vol is linear in log-moneyness; past the outer
    # nodes it is held at the wing's vol.
    reads = {}
    for strike in (1.00, 1.06, 1.08, 1.10, 1.12, 1.14, 1.20):
        reads[strike] = surface.vol_at_strike(three_month, strike, FORWARD, DF_BASE)
        print(f"strike {strike:.2f}   vol {reads[strike] * 100.0:7.4f}%")
    
    # A market holds one surface per pair, and an option on that pair reads it.
    market = hlx.MarketContext(REFERENCE).with_fx_vols(surface)
    pinned = market.fx_vols("EUR", "USD")
    read_back = pinned.vol_at_quote(three_month, "ATM")
    print(f"expiries on the pinned surface   {len(pinned.expiries())}")
    print(f"day count                        {pinned.day_count()}")
    print(f"3M ATM read back                 {read_back * 100.0:7.4f}%")
    25P  vol  8.3500%
    ATM  vol  8.5000%
    25C  vol  9.1500%
    25P  strike 1.070989   vol  8.3500%
    ATM  strike 1.101002   vol  8.5000%
    25C  strike 1.134981   vol  9.1500%
    strike 1.00   vol  8.3500%
    strike 1.06   vol  8.3500%
    strike 1.08   vol  8.3955%
    strike 1.10   vol  8.4951%
    strike 1.12   vol  8.8659%
    strike 1.14   vol  9.1500%
    strike 1.20   vol  9.1500%
    expiries on the pinned surface   3
    day count                        ACT/365.FIXED
    3M ATM read back                  8.5000%

    The call wing sits above the put wing because the risk reversal is positive. Each node's own strike reads that node's vol back. Below the put strike and above the call strike the vol is held at the wing's value.

    API

    Argument Type Default Meaning
    vols.FxVolSurface(base, quote, reference, smiles) str, str, date, list required the pair, the date expiries are counted from, and one smile per expiry; Rust FxVolSurface::new(pair, convention, reference, smiles)
    smiles list of (expiry, atm, rr25, bf25) or (expiry, FxVolSmile) required each expiry's smile, from its three quotes or as nodes; Rust takes (NaiveDate, FxVolSmile)
    delta_convention DeltaConvention or str "spot" which delta places the nodes: "spot", "forward", or premium-adjusted "spot_premium", "forward_premium"; Rust DeltaConvention::{Spot, Fwd, PaSpot, PaFwd}
    day_count str or DayCountFraction "ACT/365.FIXED" what time to expiry is counted in; Rust with_day_count
    vols.FxVolSmile.from_quotes(atm, risk_reversal, butterfly) float ×3 required a three-node smile, 25P, ATM and 25C
    vols.FxVolSmile(nodes) list of (quote, vol) required a smile from its nodes, puts first, such as ("10P", 0.12)
    Read Returns Meaning
    smile(expiry) FxVolSmile or None the smile stored at exactly that expiry
    quotes(), expiries(), day_count() lists, str what the surface holds
    vol_at_quote(expiry, quote) float a node's vol, blended in total variance between stored expiries
    vol_at_strike(expiry, strike, forward, df_base) float the vol at a strike, given the forward and the base discount factor
    strike_at_delta(expiry, delta, forward, df_base) float the strike whose delta, at the smile's own vol there, is delta
    vols.strike_from_delta(target, forward, vol, expiry, df_base, convention) float the strike with a signed delta at one fixed vol
    vols.atm_delta_neutral_strike(forward, vol, expiry, convention) float the delta-neutral straddle's strike
    vols.fx_delta(forward, strike, vol, expiry, df_base, convention, kind) float an option's signed delta

    In Rust the reads sit on FxVolSurface::view::<f64>(), and a market holds the surface with MarketContext::with_fx_vols and returns it with fx_vols(pair).

    Defaults and limits

    • Node placement: a node's strike depends on the forward and the base discount factor, so a strike read takes both. The surface moves with the forward.
    • Beyond the data: past the outer node strikes and outside the stored expiries, the vol is held flat. Nothing is extrapolated.
    • Across expiries: a read between two expiries is linear in total variance. A day count that scales every time alike leaves it unchanged.
    • Smile moves: a scenario moves the risk reversal or butterfly in absolute vol points; a relative smile move is refused.
    • One surface per pair: a market holds one surface per currency pair.
    • Conventions: each pair's spot lag, calendars and settlement are in FX market conventions.

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

    More examples

    The same surface, read across expiries in total variance, with the delta behind each node's strike, under two day counts, and moved by a risk-reversal and a butterfly scenario.

    Rust
    use chrono::NaiveDate;
    use helixrs::{
        Currency, CurrencyPair, DayCountFraction, DeltaConvention, DeltaQuote, FxVolSmile,
        FxVolSurface, MarketContext, OptionKind, Scenario, Selector, Shift, SmileComponent,
        atm_delta_neutral_strike, delta, strike_from_delta,
    };
    
    type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
    
    const FORWARD: f64 = 1.10;
    const DF_BASE: f64 = 0.98;
    const SPOT_DELTA: DeltaConvention = DeltaConvention::Spot;
    
    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
    }
    
    fn main() -> Outcome<()> {
        let reference = date(2026, 5, 21);
        let pair = CurrencyPair::new(Currency::new("EUR")?, Currency::new("USD")?);
        // (expiry, at-the-money, 25-delta risk reversal, 25-delta butterfly)
        let quotes = [
            (date(2026, 6, 22), 0.080, 0.006, 0.0020),
            (date(2026, 8, 21), 0.085, 0.008, 0.0025),
            (date(2027, 5, 21), 0.090, 0.010, 0.0030),
        ];
        let build = |day_count: DayCountFraction| -> Outcome<FxVolSurface> {
            let mut smiles = Vec::new();
            for &(expiry, atm, rr, bf) in &quotes {
                smiles.push((expiry, FxVolSmile::from_quotes(atm, rr, bf)?));
            }
            Ok(FxVolSurface::new(pair, SPOT_DELTA, reference, smiles)?.with_day_count(day_count))
        };
        let surface = build(DayCountFraction::Act365Fixed)?;
        let view = surface.view::<f64>();
        let (three_month, one_year) = (quotes[1].0, quotes[2].0);
        let tau = |at: NaiveDate| f64::from((at - reference).num_days() as i32) / 365.0;
    
        // Between three months and a year, each quote's vol is linear in total
        // variance sigma^2 tau. Interpolating the vol itself would give a
        // different number.
        let between = date(2026, 11, 23);
        let (t0, t1, t) = (tau(three_month), tau(one_year), tau(between));
        let w = (t - t0) / (t1 - t0);
        for &node in surface.quotes() {
            let (v0, v1) = (view.vol_at_quote(three_month, node)?, view.vol_at_quote(one_year, node)?);
            let vol = view.vol_at_quote(between, node)?;
            println!(
                "{:<4} total variance {:7.4}%   linear in vol {:7.4}%",
                node.to_string(),
                vol * 100.0,
                (v0 + (v1 - v0) * w) * 100.0
            );
        }
    
        // Before the first expiry and after the last, the vol is held flat.
        let quote = |spelling: &str| spelling.parse::<DeltaQuote>();
        let early = view.vol_at_quote(date(2026, 6, 1), quote("25P")?)?;
        let late = view.vol_at_quote(date(2028, 5, 22), quote("25C")?)?;
        println!("before the first expiry   {:7.4}%", early * 100.0);
        println!("after the last expiry     {:7.4}%", late * 100.0);
    
        // A day count that scales every tau alike leaves an interpolated vol in
        // place: the weights use a ratio of times. An option still counts its life
        // on it.
        let on_365 = view.vol_at_quote(between, quote("ATM")?)?;
        let on_360 = build(DayCountFraction::Act360)?.view::<f64>().vol_at_quote(between, quote("ATM")?)?;
        println!("ATM between, ACT/365F     {:7.4}%", on_365 * 100.0);
        println!("ATM between, ACT/360      {:7.4}%", on_360 * 100.0);
    
        // The delta behind a strike: a node's strike carries the delta its quote
        // names, and the convention decides which delta that is. The delta-neutral
        // straddle's call has a forward delta of one half, and a spot delta of
        // DF_base / 2.
        let expiry = t0;
        let smile = surface.smile(three_month).expect("a stored smile");
        let (put_vol, atm_vol, call_vol) = (smile.vols()[0], smile.vols()[1], smile.vols()[2]);
        let put_25 = strike_from_delta(-0.25, FORWARD, put_vol, expiry, DF_BASE, SPOT_DELTA)?;
        let call_25 = strike_from_delta(0.25, FORWARD, call_vol, expiry, DF_BASE, SPOT_DELTA)?;
        let dns = atm_delta_neutral_strike(FORWARD, atm_vol, expiry, SPOT_DELTA);
        let put_delta = delta(FORWARD, put_25, put_vol, expiry, DF_BASE, SPOT_DELTA, OptionKind::Put);
        let call_delta =
            delta(FORWARD, call_25, call_vol, expiry, DF_BASE, SPOT_DELTA, OptionKind::Call);
        let forward_delta =
            delta(FORWARD, dns, atm_vol, expiry, DF_BASE, DeltaConvention::Fwd, OptionKind::Call);
        let spot_delta = delta(FORWARD, dns, atm_vol, expiry, DF_BASE, SPOT_DELTA, OptionKind::Call);
        println!("25P strike   {put_25:.6}   spot delta {put_delta:+.4}");
        println!(
            "ATM strike   {dns:.6}   forward delta {forward_delta:+.4}   spot delta {spot_delta:+.4}"
        );
        println!("25C strike   {call_25:.6}   spot delta {call_delta:+.4}");
        // A trade labelled 25 delta resolves on the smile itself: the strike whose
        // own smile vol gives it that delta. At a stored node that is the node's strike.
        let call_25_on_smile = view.strike_at_delta(three_month, 0.25, FORWARD, DF_BASE)?;
        println!("25C strike on the smile {call_25_on_smile:.6}");
    
        // A scenario moves a smile the way it is quoted, in vol points. A risk
        // reversal move adds half to every call quote and takes half off every put
        // quote; a butterfly move adds to both. At-the-money stays put under both.
        let market = MarketContext::as_of(reference).with_fx_vols(surface.clone());
        let smile_after = |label: &str, component: SmileComponent, shift: f64| -> Outcome<Vec<f64>> {
            let selector = Selector::FxSmile { pair, component };
            let shocked = market.under(&Scenario::new(label).with(selector, Shift::Parallel(shift)))?;
            Ok(shocked.fx_vols(pair)?.smile(three_month).expect("a stored smile").vols().to_vec())
        };
        let steeper_vols = smile_after("steeper skew", SmileComponent::RiskReversal, 0.0020)?;
        let wider_vols = smile_after("wider wings", SmileComponent::Butterfly, 0.0010)?;
        for (label, vols) in [("risk reversal +0.20", &steeper_vols), ("butterfly +0.10", &wider_vols)]
        {
            println!(
                "{label:<20}  put {:7.4}%   atm {:7.4}%   call {:7.4}%",
                vols[0] * 100.0,
                vols[1] * 100.0,
                vols[2] * 100.0
            );
        }
    
        Ok(())
    }
    Python
    import datetime as dt
    
    import helixio as hlx
    
    REFERENCE = dt.date(2026, 5, 21)
    # (expiry, at-the-money, 25-delta risk reversal, 25-delta butterfly)
    QUOTES = [
        (dt.date(2026, 6, 22), 0.080, 0.006, 0.0020),
        (dt.date(2026, 8, 21), 0.085, 0.008, 0.0025),
        (dt.date(2027, 5, 21), 0.090, 0.010, 0.0030),
    ]
    FORWARD, DF_BASE = 1.10, 0.98
    SPOT_DELTA = hlx.vols.DeltaConvention.Spot
    
    surface = hlx.vols.FxVolSurface("EUR", "USD", REFERENCE, QUOTES, "spot", "ACT/365.FIXED")
    _, three_month, one_year = (q[0] for q in QUOTES)
    
    # Between three months and a year, each quote's vol is linear in total variance
    # sigma^2 tau. Interpolating the vol itself would give a different number.
    between = dt.date(2026, 11, 23)
    t0, t1, t = ((d - REFERENCE).days / 365.0 for d in (three_month, one_year, between))
    w = (t - t0) / (t1 - t0)
    for quote in surface.quotes():
        v0, v1 = surface.vol_at_quote(three_month, quote), surface.vol_at_quote(one_year, quote)
        vol = surface.vol_at_quote(between, quote)
        print(f"{quote:<4} total variance {vol * 100.0:7.4f}%   linear in vol {(v0 + (v1 - v0) * w) * 100.0:7.4f}%")
    
    # Before the first expiry and after the last, the vol is held flat.
    early = surface.vol_at_quote(dt.date(2026, 6, 1), "25P")
    late = surface.vol_at_quote(dt.date(2028, 5, 22), "25C")
    print(f"before the first expiry   {early * 100.0:7.4f}%")
    print(f"after the last expiry     {late * 100.0:7.4f}%")
    
    # A day count that scales every tau alike leaves an interpolated vol in place:
    # the weights use a ratio of times. An option still counts its life on it.
    on_365 = surface.vol_at_quote(between, "ATM")
    on_360 = hlx.vols.FxVolSurface("EUR", "USD", REFERENCE, QUOTES, "spot", "ACT/360").vol_at_quote(between, "ATM")
    print(f"ATM between, ACT/365F     {on_365 * 100.0:7.4f}%")
    print(f"ATM between, ACT/360      {on_360 * 100.0:7.4f}%")
    
    # The delta behind a strike: a node's strike carries the delta its quote names,
    # and the convention decides which delta that is. The delta-neutral straddle's
    # call has a forward delta of one half, and a spot delta of DF_base / 2.
    expiry = t0
    put_vol, atm_vol, call_vol = surface.smile(three_month).vols()
    put_25 = hlx.vols.strike_from_delta(-0.25, FORWARD, put_vol, expiry, DF_BASE, SPOT_DELTA)
    call_25 = hlx.vols.strike_from_delta(0.25, FORWARD, call_vol, expiry, DF_BASE, SPOT_DELTA)
    dns = hlx.vols.atm_delta_neutral_strike(FORWARD, atm_vol, expiry, SPOT_DELTA)
    put_delta = hlx.vols.fx_delta(FORWARD, put_25, put_vol, expiry, DF_BASE, SPOT_DELTA, "put")
    call_delta = hlx.vols.fx_delta(FORWARD, call_25, call_vol, expiry, DF_BASE, SPOT_DELTA, "call")
    forward_delta = hlx.vols.fx_delta(FORWARD, dns, atm_vol, expiry, DF_BASE, "forward", "call")
    spot_delta = hlx.vols.fx_delta(FORWARD, dns, atm_vol, expiry, DF_BASE, SPOT_DELTA, "call")
    print(f"25P strike   {put_25:.6f}   spot delta {put_delta:+.4f}")
    print(f"ATM strike   {dns:.6f}   forward delta {forward_delta:+.4f}   spot delta {spot_delta:+.4f}")
    print(f"25C strike   {call_25:.6f}   spot delta {call_delta:+.4f}")
    # A trade labelled 25 delta resolves on the smile itself: the strike whose own
    # smile vol gives it that delta. At a stored node that is the node's strike.
    call_25_on_smile = surface.strike_at_delta(three_month, 0.25, FORWARD, DF_BASE)
    print(f"25C strike on the smile {call_25_on_smile:.6f}")
    
    # A scenario moves a smile the way it is quoted, in vol points. A risk reversal
    # move adds half to every call quote and takes half off every put quote; a
    # butterfly move adds to both. At-the-money stays put under both.
    market = hlx.MarketContext(REFERENCE).with_fx_vols(surface)
    steeper = market.under(hlx.Scenario("steeper skew").shift("fx_rr(EUR/USD)", 0.0020))
    wider = market.under(hlx.Scenario("wider wings").shift("fx_bf(EUR/USD)", 0.0010))
    steeper_vols = steeper.fx_vols("EUR", "USD").smile(three_month).vols()
    wider_vols = wider.fx_vols("EUR", "USD").smile(three_month).vols()
    for label, vols in (("risk reversal +0.20", steeper_vols), ("butterfly +0.10", wider_vols)):
        print(f"{label:<20}  put {vols[0] * 100.0:7.4f}%   atm {vols[1] * 100.0:7.4f}%   call {vols[2] * 100.0:7.4f}%")
    25P  total variance  8.6566%   linear in vol  8.5049%
    ATM  total variance  8.8409%   linear in vol  8.6722%
    25C  total variance  9.5940%   linear in vol  9.3738%
    before the first expiry    7.9000%
    after the last expiry      9.8000%
    ATM between, ACT/365F      8.8409%
    ATM between, ACT/360       8.8409%
    25P strike   1.070989   spot delta -0.2500
    ATM strike   1.101002   forward delta +0.5000   spot delta +0.4900
    25C strike   1.134981   spot delta +0.2500
    25C strike on the smile 1.134981
    risk reversal +0.20   put  8.2500%   atm  8.5000%   call  9.2500%
    butterfly +0.10       put  8.4500%   atm  8.5000%   call  9.2500%

    Interpolating the vol itself would read lower than total variance at every node. A node's strike carries exactly the delta its quote names, and both smile moves leave at-the-money where it was.

    See also

    • Source: crates/helixrs/src/marketdata/fx_vol.rs, crates/helixrs/src/models/fx_option.rs, and crates/helixrs/src/trading.rs for the scenario selectors
    • Related pages:
      • How vol surfaces work, the ideas behind the reads.
      • FX options, which price off this surface.
      • Price an FX option from a delta quote, step by step.
      • Rate vol surfaces, for the same expiry rule on a rates grid.

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