NDO
This page lists what you need to draft and value a non-deliverable option (NDO): its templates, the arguments they take, the forward it reads, and an example you can run. An NDO is an FX option on a currency that can't be delivered offshore, settled in cash in dollars. It's to an FX option what an NDF is to a forward.
Quick example
The example reads its dollar quotes from the desk sheets; load them once and it runs as shown.
This marks the same won market as the NDF page, adds a USD/KRW smile per expiry, and
drafts a six-month dollar call against won at 1380 from the KRW-USD-NDO template. It reads the
fixing the option expires on and the forward it prices on, compares it with a deliverable call on
the same dates, and checks that a call less a put at 1370 is the NDF at 1370.
use chrono::NaiveDate;
use helixio_docs_sheets::{SOFR_CLOSE_DATE, Sheet, USD_SOFR_CLOSE};
use helixrs::{
AnyInstrument, CollateralSpec, Currency, CurrencyPair, DeltaConvention, FxOption, FxRates,
FxVolSmile, FxVolSurface, MarketContext, OptionKind, Period, PricingRiskFactors, QuoteSheet,
Strike, YieldCurve, template,
};
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 (usd, krw): (Currency, Currency) = ("USD".parse()?, "KRW".parse()?);
let won: Sheet = &[("KRW-KOFR-OIS", "1Y", 0.0255), ("KRW-KOFR-OIS", "2Y", 0.0262), ("KRW-KOFR-OIS", "5Y", 0.0275)];
let outrights: Sheet = &[
("KRW-USD-NDF", "1M", 1388.5),
("KRW-USD-NDF", "3M", 1385.0),
("KRW-USD-NDF", "6M", 1380.0),
("KRW-USD-NDF", "1Y", 1371.0),
("KRW-USD-NDF", "2Y", 1355.0),
];
let six_months: Period = "6M".parse()?;
// The NDF market — dollars, won onshore and won offshore from NDF outrights —
// and a won smile per expiry from its three quotes.
let surface = FxVolSurface::new(
CurrencyPair::new(usd, krw),
DeltaConvention::Spot,
close,
vec![
(date((2027, 1, 4)), FxVolSmile::from_quotes(0.090, 0.010, 0.0030)?),
(date((2027, 4, 1)), FxVolSmile::from_quotes(0.095, 0.011, 0.0032)?),
(date((2027, 10, 1)), FxVolSmile::from_quotes(0.100, 0.012, 0.0035)?),
],
)?;
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(krw), &QuoteSheet::from_rows(won.iter().copied())?)?
.with_fx_rates(FxRates::from_quotes(&[(CurrencyPair::new(usd, krw), 1390.0)])?)
.with_recipe_quotes(&"KRW-CSA-USD".parse()?, &QuoteSheet::from_rows(outrights.iter().copied())?)?
.with_fx_vols(surface);
// A six-month dollar call against won at 1380, drafted from its template. It
// expires on the fixing the NDF terms give for six months.
let ndo_template = template("KRW-USD-NDO")?;
let call_terms = ndo_template.ndo(close, six_months, 1_000_000.0, 1380.0, OptionKind::Call)?;
let call = AnyInstrument::FxOption(call_terms.clone());
let needed = call.fixings_needed(date((2027, 4, 5)));
let (fixing_key, fixing_date) = &needed[0];
let diagnostics = market.priced(&call_terms.risk_factors()?, |v| call_terms.diagnostics(v))?;
println!("fixes on {fixing_key} {fixing_date}");
println!("forward {:.4}", diagnostics.forward);
println!("vol {:.6}", diagnostics.vol);
let ndo_npv = call.npv(&market)?;
println!("npv {ndo_npv:.2} {}", call.currency());
// The option's forward is the offshore forward to the value date. A deliverable
// call on the same dates exchanges won, so it reads the onshore forward and is
// worth something else.
let delivery = date((2027, 4, 6));
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 = read("discount(USD)")?;
let offshore = 1390.0 * p_usd / read("discount(KRW-CSA-USD)")?;
let onshore = 1390.0 * p_usd / read("discount(KRW)")?;
let deliverable_terms = FxOption::new(
usd,
krw,
1_000_000.0,
OptionKind::Call,
*fixing_date,
delivery,
Strike::Absolute(1380.0),
);
let deliverable_forward =
market.priced(&deliverable_terms.risk_factors()?, |v| deliverable_terms.diagnostics(v))?.forward;
let deliverable = AnyInstrument::FxOption(deliverable_terms);
println!("offshore {offshore:.4}");
println!("onshore {onshore:.4}");
println!("deliverable reads {deliverable_forward:.4}");
println!("deliverable {:.0} {}", deliverable.npv(&market)?, deliverable.currency());
// A call less a put at one strike is the NDF at that strike. All three value
// in dollars, the settlement currency, so the two sides compare as they are.
// At 1370, below the offshore forward, both are positive.
let call = AnyInstrument::FxOption(ndo_template.ndo(close, six_months, 1_000_000.0, 1370.0, OptionKind::Call)?);
let put = AnyInstrument::FxOption(ndo_template.ndo(close, six_months, 1_000_000.0, 1370.0, OptionKind::Put)?);
let ndf = AnyInstrument::FxForward(template("KRW-USD-NDF")?.ndf(close, six_months, 1_000_000.0, 1370.0, false)?);
let difference = call.npv(&market)? - put.npv(&market)?;
println!("call - put {difference:.2} {}", call.currency());
println!("ndf {:.2} {}", ndf.npv(&market)?, ndf.currency());
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE
# The NDF market — dollars, won onshore and won offshore from NDF outrights —
# and a won smile per expiry from its three quotes.
surface = hlx.vols.FxVolSurface(
"USD",
"KRW",
SOFR_CLOSE_DATE,
[
(dt.date(2027, 1, 4), 0.090, 0.010, 0.0030),
(dt.date(2027, 4, 1), 0.095, 0.011, 0.0032),
(dt.date(2027, 10, 1), 0.100, 0.012, 0.0035),
],
)
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("USD", USD_SOFR_CLOSE)
.with_recipe_quotes("KRW", {"KRW-KOFR-OIS": {"1Y": 0.0255, "2Y": 0.0262, "5Y": 0.0275}})
.with_fx_rates([("USD", "KRW", 1390.0)])
.with_recipe_quotes("KRW-CSA-USD", {"KRW-USD-NDF": {"1M": 1388.5, "3M": 1385.0, "6M": 1380.0, "1Y": 1371.0, "2Y": 1355.0}})
.with_fx_vols(surface)
)
# A six-month dollar call against won at 1380, drafted from its template. It
# expires on the fixing the NDF terms give for six months.
call = hlx.templates.get("KRW-USD-NDO").ndo(SOFR_CLOSE_DATE, "6M", notional=1_000_000.0, strike=1380.0, kind="call")
[(fixing_key, fixing_date)] = call.fixings_needed(dt.date(2027, 4, 5))
diagnostics = call.diagnostics(market)
print(f"fixes on {fixing_key} {fixing_date}")
print(f"forward {diagnostics.forward():.4f}")
print(f"vol {diagnostics.vol():.6f}")
ndo_npv = call.npv(market)
print(f"npv {ndo_npv:.2f} {call.npv_ccy()}")
# The option's forward is the offshore forward to the value date. A deliverable
# call on the same dates exchanges won, so it reads the onshore forward and is
# worth something else.
delivery = dt.date(2027, 4, 6)
p_usd = market.curve("discount(USD)").discount(delivery)
offshore = 1390.0 * p_usd / market.curve("discount(KRW-CSA-USD)").discount(delivery)
onshore = 1390.0 * p_usd / market.curve("discount(KRW)").discount(delivery)
deliverable = hlx.products.fx_option(
"USD", "KRW", notional_base=1_000_000.0, kind="call", expiry=fixing_date, delivery=delivery, strike=1380.0
)
deliverable_forward = deliverable.diagnostics(market).forward()
print(f"offshore {offshore:.4f}")
print(f"onshore {onshore:.4f}")
print(f"deliverable reads {deliverable_forward:.4f}")
print(f"deliverable {deliverable.npv(market):.0f} {deliverable.npv_ccy()}")
# A call less a put at one strike is the NDF at that strike. All three value
# in dollars, the settlement currency, so the two sides compare as they are.
# At 1370, below the offshore forward, both are positive.
call = hlx.templates.get("KRW-USD-NDO").ndo(SOFR_CLOSE_DATE, "6M", notional=1_000_000.0, strike=1370.0, kind="call")
put = hlx.templates.get("KRW-USD-NDO").ndo(SOFR_CLOSE_DATE, "6M", notional=1_000_000.0, strike=1370.0, kind="put")
ndf = hlx.templates.get("KRW-USD-NDF").ndf(SOFR_CLOSE_DATE, "6M", notional=1_000_000.0, forward_rate=1370.0)
print(f"call - put {call.npv(market) - put.npv(market):.2f} {call.npv_ccy()}")
print(f"ndf {ndf.npv(market):.2f} {ndf.npv_ccy()}")fixes on FX-USDKRW 2027-04-02
forward 1380.0000
vol 0.094935
npv 26235.50 USD
offshore 1380.0000
onshore 1377.9203
deliverable reads 1377.9203
deliverable 35508453 KRW
call - put 7090.46 USD
ndf 7090.46 USD
The NDO reads the offshore forward and values in dollars. The deliverable call exchanges won, so it reads the onshore forward and values in won: a different trade with a different price. The call less the put at 1370 matches the NDF to the cent, both in dollars.
How it's priced
The NDO is priced by Garman-Kohlhagen on the pair's surface, as an FX option is, expiring on the fixing date and paying on the value date. The forward it reads is the offshore one,
and a call less a put at one strike is the NDF at that strike, . Why the payoff keeps its shape when it's paid in dollars is covered in how FX products work.
API
| Argument | Type | Default | Meaning |
|---|---|---|---|
templates.get(code) |
str |
required | the market's NDO terms: KRW-USD-NDO, INR-USD-NDO and the rest; Rust template(code)?, an AnyTemplate::Ndo |
Template.ndo(trade_date, tenor, ...) |
date, str |
required | drafts the option, expiring on the fixing the NDF terms give for the tenor; Rust AnyTemplate::ndo(trade_date, tenor, notional, strike, kind) |
notional |
float |
required | the settlement currency the option is on |
strike |
float |
required | an absolute strike, local per settlement currency |
kind |
"call" or "put" |
required | a call on the settlement currency, or a put; Rust OptionKind |
A non-deliverable option can also be built directly with products.fx_option(..., settlement_index=, fixing_date=, settlement_currency=); see FX options.
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| expiry | the NDF fixing for the tenor | AnyInstrument::fixings_needed |
Instrument.fixings_needed |
| its value, in dollars | AnyInstrument::npv |
Instrument.npv |
|
| the npv currency | dollars, the settlement currency | AnyInstrument::currency |
Instrument.npv_ccy |
| the forward it reads | market.priced(&option.risk_factors()?, |v| option.diagnostics(v)), .forward |
option.diagnostics(market).forward() |
|
| the deliverable option | the vanilla on the same dates, read onshore | FxOption::new |
products.fx_option |
Defaults and limits
- The market: the NDF market, the dollar curve, the pair's spot and the offshore curve under
KRW-CSA-USD, plus the pair's surface pinned withwith_fx_vols. - Exercise: European only, on the fixing date. The payoff is converted at that fixing and paid on the value date.
- Settlement: the npv is in the settlement currency, dollars. A deliverable option on the same pair values in the quote currency, won, and reads the onshore forward.
- Vols: quoted as for any FX option; see the FX vol surface catalog. Each market's NDO terms are in the non-deliverable conventions catalog.
See also
- Source:
crates/helixrs/src/statics/templates/ndo.rs,crates/helixrs/src/instruments/fx.rs - Related pages:
- How FX products work, the ideas behind the option.
- FX options, for the deliverable option and the surface.
- NDF, for the forward on the same fixing.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.