FX options
This page lists what you need to price a European FX option: the surface it reads, the constructor's arguments, how it's priced, and an example you can run. An FX option is the right, not the obligation, to exchange two currencies at a fixed rate on a future date, the way a company caps the cost of a future payment while keeping the upside.
Quick example
The example reads its quotes from the desk sheets; load them once and it runs as shown.
This builds a EUR/USD surface from the three quotes an FX desk sees at each expiry: the at-the-money vol, the 25-delta risk reversal and the 25-delta butterfly. It prices a one-year call and put at 1.13 on a million euros and checks put-call parity. Then it strikes a put by its delta, reads the vol the smile gives at a strike, and shows that an option is valued at zero from its expiry date.
use chrono::NaiveDate;
use helixio_docs_sheets::{EUR_CLOSE, SOFR_CLOSE_DATE, USD_SOFR_CLOSE};
use helixrs::{
AnyInstrument, CollateralSpec, Currency, CurrencyPair, DeltaConvention, DeltaQuote, FxOption,
FxRates, FxVolSmile, FxVolSurface, MarketContext, OptionKind, PricingRiskFactors, QuoteSheet,
Strike, YieldCurve,
};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let spot = 1.1000;
let (expiry, delivery) = (date((2027, 10, 1)), date((2027, 10, 5)));
let notional = 1_000_000.0;
let (eur, usd): (Currency, Currency) = ("EUR".parse()?, "USD".parse()?);
// One smile per expiry, from the three quotes an FX desk sees: the at-the-money
// vol, the 25-delta risk reversal and the 25-delta butterfly.
let surface = FxVolSurface::new(
CurrencyPair::new(eur, usd),
DeltaConvention::Spot,
close,
vec![
(date((2027, 1, 4)), FxVolSmile::from_quotes(0.075, -0.006, 0.0020)?),
(date((2027, 4, 1)), FxVolSmile::from_quotes(0.078, -0.007, 0.0022)?),
(expiry, FxVolSmile::from_quotes(0.082, -0.008, 0.0025)?),
],
)?;
let market = MarketContext::as_of(close)
.with_recipe_quotes(&CollateralSpec::Currency(usd), &QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?)?
.with_recipe_quotes(&CollateralSpec::Currency(eur), &QuoteSheet::from_rows(EUR_CLOSE.iter().copied())?)?
.with_fx_rates(FxRates::from_quotes(&[(CurrencyPair::new(eur, usd), spot)])?)
.with_fx_vols(surface.clone());
for at in [date((2027, 1, 4)), date((2027, 4, 1)), expiry] {
println!(
"{at} 25P {:.4} ATM {:.4}",
surface.view::<f64>().vol_at_quote(at, "25P".parse::<DeltaQuote>()?)?,
surface.view::<f64>().vol_at_quote(at, DeltaQuote::Atm)?
);
}
// A call and a put on a million euros at 1.13, one year out. Long the call and
// short the put is a forward bought at the strike.
let option = |kind, strike| FxOption::new(eur, usd, notional, kind, expiry, delivery, strike);
let call = option(OptionKind::Call, Strike::Absolute(1.13));
let put = option(OptionKind::Put, Strike::Absolute(1.13));
let diagnostics = market.priced(&call.risk_factors()?, |v| call.diagnostics(v))?;
let read = |key: &str| -> Outcome<f64> {
let (curve, _) = market.curve(&key.parse()?).expect("a pinned curve");
Ok(curve.view::<f64>().discount(delivery)?)
};
let (p_usd, p_eur) = (read("discount(USD)")?, read("discount(EUR)")?);
let forward = spot * p_eur / p_usd;
let call_npv = AnyInstrument::FxOption(call.clone()).npv(&market)?;
let put_npv = AnyInstrument::FxOption(put).npv(&market)?;
println!("forward {:.6}", diagnostics.forward);
println!("vol {:.6}", diagnostics.vol);
println!("call {call_npv:.2}");
let by_parity = (forward - 1.13) * notional * p_usd;
println!("put {put_npv:.2}");
println!("call - put {:.2}", call_npv - put_npv);
println!("by parity {by_parity:.2}");
// A strike named by its delta: `delta=0.25` on a put is the 25-delta put, the
// strike where the put's spot delta is -0.25 at the vol the smile reads there.
// On a smile that quotes the 25-delta put, that is the 25P node: its strike
// and its vol, to the tolerance the strike-vol fixed point is solved to.
let wing_put = option(OptionKind::Put, Strike::AtDelta(0.25));
let wing = market.priced(&wing_put.risk_factors()?, |v| wing_put.diagnostics(v))?;
println!("strike {:.6}", wing.strike);
println!("vol {:.6}", wing.vol);
println!("delta {:.6}", wing.delta);
let put_node = surface.view::<f64>().vol_at_quote(expiry, "25P".parse::<DeltaQuote>()?)?;
println!("25P node {put_node:.6}");
// The other way round: the vol the smile reads at a strike, with the nodes
// placed at the option's forward and the euro discount factor to delivery.
let at = surface.view::<f64>().vol_at_strike(expiry, 1.13, diagnostics.forward, p_eur)?;
println!("vol at 1.13 {at:.6}");
// From its expiry date an option is valued at zero; this one is out of the money.
let expired = AnyInstrument::FxOption(FxOption::new(
eur,
usd,
notional,
OptionKind::Call,
close,
date((2026, 10, 5)),
Strike::Absolute(1.30),
));
println!("expired {:.2}", expired.npv(&market)?);
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import EUR_CLOSE, SOFR_CLOSE_DATE, USD_SOFR_CLOSE
SPOT = 1.1000
EXPIRY, DELIVERY = dt.date(2027, 10, 1), dt.date(2027, 10, 5)
NOTIONAL = 1_000_000.0
# One smile per expiry, from the three quotes an FX desk sees: the at-the-money
# vol, the 25-delta risk reversal and the 25-delta butterfly.
surface = hlx.vols.FxVolSurface(
"EUR",
"USD",
SOFR_CLOSE_DATE,
[
(dt.date(2027, 1, 4), 0.075, -0.006, 0.0020),
(dt.date(2027, 4, 1), 0.078, -0.007, 0.0022),
(EXPIRY, 0.082, -0.008, 0.0025),
],
)
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("USD", USD_SOFR_CLOSE)
.with_recipe_quotes("EUR", EUR_CLOSE)
.with_fx_rates([("EUR", "USD", SPOT)])
.with_fx_vols(surface)
)
for expiry in surface.expiries():
print(f"{expiry} 25P {surface.vol_at_quote(expiry, '25P'):.4f} ATM {surface.vol_at_quote(expiry, 'ATM'):.4f}")
# A call and a put on a million euros at 1.13, one year out. Long the call and
# short the put is a forward bought at the strike.
call = hlx.products.fx_option("EUR", "USD", notional_base=NOTIONAL, kind="call", expiry=EXPIRY, delivery=DELIVERY, strike=1.13)
put = hlx.products.fx_option("EUR", "USD", notional_base=NOTIONAL, kind="put", expiry=EXPIRY, delivery=DELIVERY, strike=1.13)
diagnostics = call.diagnostics(market)
p_usd = market.curve("discount(USD)").discount(DELIVERY)
p_eur = market.curve("discount(EUR)").discount(DELIVERY)
forward = SPOT * p_eur / p_usd
call_npv, put_npv = call.npv(market), put.npv(market)
by_parity = (forward - 1.13) * NOTIONAL * p_usd
print(f"forward {diagnostics.forward():.6f}")
print(f"vol {diagnostics.vol():.6f}")
print(f"call {call_npv:.2f}")
print(f"put {put_npv:.2f}")
print(f"call - put {call_npv - put_npv:.2f}")
print(f"by parity {by_parity:.2f}")
# A strike named by its delta: `delta=0.25` on a put is the 25-delta put, the
# strike where the put's spot delta is -0.25 at the vol the smile reads there.
# On a smile that quotes the 25-delta put, that is the 25P node: its strike and
# its vol, to the tolerance the strike-vol fixed point is solved to.
wing = hlx.products.fx_option("EUR", "USD", notional_base=NOTIONAL, kind="put", expiry=EXPIRY, delivery=DELIVERY, delta=0.25)
wing_diagnostics = wing.diagnostics(market)
print(f"strike {wing_diagnostics.strike():.6f}")
print(f"vol {wing_diagnostics.vol():.6f}")
print(f"delta {wing_diagnostics.delta():.6f}")
print(f"25P node {surface.vol_at_quote(EXPIRY, '25P'):.6f}")
# The other way round: the vol the smile reads at a strike, with the nodes
# placed at the option's forward and the euro discount factor to delivery.
at = surface.vol_at_strike(EXPIRY, 1.13, diagnostics.forward(), p_eur)
print(f"vol at 1.13 {at:.6f}")
# From its expiry date an option is valued at zero; this one is out of the money.
expired = hlx.products.fx_option(
"EUR", "USD", notional_base=NOTIONAL, kind="call", expiry=SOFR_CLOSE_DATE, delivery=dt.date(2026, 10, 5), strike=1.30
)
print(f"expired {expired.npv(market):.2f}")2027-01-04 25P 0.0800 ATM 0.0750
2027-04-01 25P 0.0837 ATM 0.0780
2027-10-01 25P 0.0885 ATM 0.0820
forward 1.126932
vol 0.082072
call 33900.12
put 36835.97
call - put -2935.85
by parity -2935.85
strike 1.067274
vol 0.088500
delta -0.250000
25P node 0.088500
vol at 1.13 0.082072
expired 0.00
The call less the put equals a forward bought at 1.13, to the cent. The put struck by delta lands
on the smile's 25P node: its vol is the node's vol, and its delta is exactly .
How it's priced
Under Garman-Kohlhagen, with the forward to delivery, the vol the smile reads at the strike , the time to expiry and the quote currency's discount factor to delivery, a call on units of base is worth
How the surface is quoted, put-call parity and strikes named by delta are covered in how FX products work.
API
| Argument | Type | Default | Meaning |
|---|---|---|---|
products.fx_option(base, quote, ...) |
str, str |
required | the pair; Rust FxOption::new(base, quote, notional, kind, expiry, delivery, strike) |
notional_base |
float |
required | units of base the option exchanges; negative is the short side |
kind |
"call" or "put" |
required | a call buys the base at the strike, a put sells it; Rust OptionKind |
expiry |
date |
required | the day the option is exercised or lapses |
delivery |
date |
required | the day an exercised option exchanges the currencies |
strike |
float |
None |
an absolute strike, quote per base; Rust Strike::Absolute(k) |
delta |
float |
None |
a strike named by the option's own unsigned delta, under the surface's convention; Rust Strike::AtDelta(d) |
quote_amount |
float |
None |
the strike as the quote amount exchanged for notional_base |
settlement_index, fixing_date, settlement_currency |
str, date, str |
None |
given together, a non-deliverable option; see NDO |
vols.FxVolSurface(base, quote, reference, smiles) |
str, str, date, list of (expiry, atm, rr25, bf25) |
required | one smile per expiry from its three quotes; Rust FxVolSurface::new with FxVolSmile::from_quotes |
delta_convention, day_count |
str |
"spot", "ACT/365.FIXED" |
how the surface's deltas are quoted, and what its total variance is counted in; Rust DeltaConvention |
Give exactly one of strike, delta and quote_amount.
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| the pair's surface | FxVolSurface::new, pinned by MarketContext::with_fx_vols |
vols.FxVolSurface, pinned by MarketContext.with_fx_vols |
|
| vol at a quote | a node of the surface | FxVolSurface::view then vol_at_quote |
vols.FxVolSurface.vol_at_quote |
| the vol at a strike | FxVolSurface::view then vol_at_strike |
vols.FxVolSurface.vol_at_strike |
|
| the option's value | FxOption::new(…, Strike::Absolute(k)), then AnyInstrument::npv |
products.fx_option(…, strike=k), then Instrument.npv |
|
| what the option resolved to | MarketContext::priced(&option.risk_factors()?, |v| option.diagnostics(v)) |
Instrument.diagnostics |
Defaults and limits
- The market: a discount curve for each currency, the pair's spot, and the pair's surface,
pinned with
with_fx_vols. - The surface: each expiry's smile has three nodes,
25P,ATMand25C, placed at the strikes their deltas name. Across expiries the surface is linear in total variance. - Delta strikes: a strike named by delta is solved together with the vol the smile reads there. On a smile quoting that delta, it's the node itself.
- Expired options: from its expiry date an option is valued at zero, even when it was exercised and its delivery is still ahead. Book an exercised option as an FX forward at the strike for its delivery date.
- Currency of the value: the npv is in the quote currency.
- Diagnostics: in Rust, an option's diagnostics are read on the view the market resolves for its
risk factors;
MarketContext::priceddoes that in one call, as Python'sInstrument.diagnosticsdoes. - Conventions: how each pair quotes its deltas, spot or forward, premium-adjusted or not, is in the FX vol surface catalog.
See also
- Source:
crates/helixrs/src/instruments/fx.rs,crates/helixrs/src/marketdata/fx_vol.rs,crates/helixrs/src/models/fx_option.rs - Related pages:
- How FX products work, the ideas behind the price.
- Price an FX option from a delta quote, step by step.
- FX forwards, for the forward the option is priced on.
- NDO, for the option settled in cash.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.