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.
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 "es {
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(())
}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.
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 "es {
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(())
}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, andcrates/helixrs/src/trading.rsfor 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.