Esc

    The API is under testing and may change before release.

    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.

    Rust
    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(())
    }
    Python
    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 −0.25-0.25.

    How it's priced

    Under Garman-Kohlhagen, with FF the forward to delivery, σ\sigma the vol the smile reads at the strike KK, τ\tau the time to expiry and PquoteP_{\text{quote}} the quote currency's discount factor to delivery, a call on NN units of base is worth

    C=N Pquote [F Φ(d1)−K Φ(d2)],d1,2=ln⁡(F/K)±12σ2τστ.C = N\,P_{\text{quote}}\,\big[F\,\Phi(d_1) - K\,\Phi(d_2)\big], \qquad d_{1,2} = \frac{\ln(F/K) \pm \tfrac12 \sigma^2 \tau}{\sigma\sqrt{\tau}}.

    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
    σ(K,T)\sigma(K, T) 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
    σ(K)\sigma(K) the vol at a strike FxVolSurface::view then vol_at_strike vols.FxVolSurface.vol_at_strike
    C,PC, P the option's value FxOption::new(…, Strike::Absolute(k)), then AnyInstrument::npv products.fx_option(…, strike=k), then Instrument.npv
    F,σ,Δ,KF, \sigma, \Delta, K 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, ATM and 25C, 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::priced does that in one call, as Python's Instrument.diagnostics does.
    • 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.