Introduction
QuantSupport is a Rust library for building market data, pricing derivatives, measuring risk with automatic differentiation, simulating exposure, and computing XVA.
Everything is organised around one flow:
- Load observable quotes, fixings, and FX rates into
QuoteStore,FixingStore, andFxStore. - Describe curves, credit curves, volatility surfaces/cubes, and simulations with serialisable configuration structs (
CurveConfiguration,CreditCurveConfiguration,VolatilitySurfaceConfiguration,VolatilityCubeConfiguration,SimulationConfiguration). - Put them into a
PricingContextand callinitialize(), which bootstraps and builds every object in dependency order. - Build an instrument with a
Make*builder and wrap it in a trade (SwapTrade,FxForwardTrade, …) that carries notional and side. - Ask a pricer (
DiscountedCashflowPricer,ClosedFormBlackCapPricer,FxOptionPricer, …) forRequest::Value,FairRate,Cashflows, orSensitivities. - Reuse the same market for scenarios (
Scenario), scripted payoffs (ScriptEngine), simulation (LgmMarketModel,HullWhite), and XVA (XvaEngine).
To allow sensitivity computations via automatic differentiation (AAD), generic scalar parameter T: Scalar is available in curves, instruments and pricers. With T = f64 you get plain numbers; with T = DualFwd (reverse-mode tape over a second-order forward type) every price is differentiable with respect to the quotes that built the market.
Crate layout
| Module | Contents |
|---|---|
ad | Tape, Dual<T>, Fwd<T>, DualFwd and the Scalar trait |
core | PricingContext, ConstructedElementStore, Request, EvaluationResults, Trade, Side, Pricer, Evaluator, discount policies |
currencies, indices | Currency and MarketIndex enums |
quotes | Quote, QuoteDetails, QuoteInstrument, QuoteStore, Scenario, FixingStore, FxStore |
rates | DiscountTermStructure, FlatForwardTermStructure, RateDefinition, MultiCurveBootstrapper, CreditCurveBootstrapper, curve configurations |
volatility | Surfaces, cubes, VolatilityType, SmileType, Strike, volatility sources |
instruments | Instruments and Make* builders |
pricers | Concrete pricers |
models | HullWhite, LGM components and LgmMarketModel, Brownian motion, Monte Carlo engine |
simulations | SimulationConfiguration, SimulationBuilder, GeneratedMonteCarloSimulation |
scripting | Payoff language, ScriptEngine, ScriptedProduct |
xva | Contingent claims, netting sets, CSA, XvaEngine, aggregators |
time, math, utils | Date, Period, Calendar, schedules, interpolation, solvers, errors |
quantsupport::prelude::* re-exports the types used in this book.
What the book covers
- Getting Started installs the crate and prices a first swap in Rust and Python.
- Core Concepts explains the market-data model,
PricingContext::initialize, and how instruments, trades, pricers and results relate. - Curves and Market Data covers term structures, multi-curve bootstrapping with dependencies, FX-implied collateral curves, and volatility objects.
- Pricing documents each pricer: which
Requests it supports, the formulas it implements, and the builder fields it needs. - Risk describes the AD machinery, quote-pillar sensitivities, and scenario shocks.
- Scripting documents the payoff language, event streams,
ScriptEngine, and scripted products in XVA. - Models and Simulation covers Hull-White and LGM calibration and Monte Carlo generation.
- XVA covers contingent-claim decomposition, netting sets and CSA terms, CVA/DVA/FVA, and AAD sensitivities of XVA measures.
- Reference lists the JSON schemas, the runnable examples, and a glossary.
Rust snippets marked rust,ignore are extracted from the library and the examples/ packages but are not compiled as doctests; the full programs are listed in Examples.
Continue with Installation.
Installation
Requirements
- Rust stable toolchain (edition 2021). Install with rustup.
Rust crate
Add the latest release to your project:
cargo add quantsupport
Or, from a checkout of the repository:
[dependencies]
quantsupport = { path = "../quantsupport" }
With plotting helpers:
cargo add quantsupport --features plot
Runtime dependencies pulled in by the crate: chrono (dates), rayon (parallel XVA and script evaluation), rand and sobol_burley (random numbers and Owen-scrambled Sobol sequences), nalgebra (linear algebra for the Newton solver and correlation matrices), num-complex (FFT), serde (configuration), thiserror (errors). serde_json is a dev-dependency only; add it to your own project to load the JSON files described in Configuration.
Building the repository
The repository is a Cargo workspace whose members are the library, benchmarks, bindings/python, and one package per example:
git clone https://github.com/jmelo11/quantsupport
cd quantsupport
cargo build -p quantsupport # library only
cargo test -p quantsupport # unit tests + doctests
cargo build --workspace # everything, including examples and bindings
The crate is compiled with missing_docs = "forbid" and Clippy pedantic, nursery and cargo lint groups at deny, with unwrap_used and expect_used denied. If you contribute, run cargo clippy --all-targets before opening a pull request.
Run an example:
cargo run -p valuation
cargo run -p bootstrap
cargo run -p scripting-examples --bin valuation
See Examples for the full list.
Documentation
- API docs: https://docs.rs/quantsupport
- This book: https://jmelo11.github.io/quantsupport/ (published from
mainby.github/workflows/pages.yml) - Local build:
cargo install mdbook --locked && mdbook serve --open
Your First Swap
This chapter introduces the QuantSupport pricing workflow through a five-year USD SOFR swap. The values are specific to the example, but the workflow applies to other products:
- Define the instrument’s contractual economics.
- Wrap the instrument in a trade.
- Assemble the market state for an evaluation date.
- Select a compatible pricer and request specific calculations.
- Read the requested values from
EvaluationResults.
The complete program is in examples/valuation/src/main.rs. Run it from the workspace root with cargo run -p valuation.
Choosing a scalar type
Most numerical types in QuantSupport are generic over T: Scalar. The scalar determines whether a calculation carries only values or also automatic derivatives:
f64is appropriate for value-only calculations where the relevant market data and pricer support it.DualFwdcarries automatic-differentiation information used by the current pricing and sensitivity infrastructure.
The instrument, curves, and pricer must use compatible scalar types. This example requests curve sensitivities, so it uses DualFwd throughout.
1. Define the instrument
An instrument describes a financial product contractual economics: schedules, rates, indices, currencies, and payoff direction. QuantSupport constructs instruments with Make* builders (builder pattern). A builder collects inputs, applies documented defaults, and validates required fields in build(). For a vanilla fixed-versus-floating swap, MakeSwap<T> creates a fixed leg and a floating leg, with the given parameters.
In this example
The contract receives a 3% fixed rate and pays six-month SOFR on USD 10 million from 15 January 2024 to 15 January 2029:
use std::{cell::RefCell, rc::Rc};
use quantsupport::prelude::*;
let start_date = Date::new(2024, 1, 15);
let maturity_date = Date::new(2029, 1, 15);
let notional = 10_000_000.0;
let rate_definition = RateDefinition::new(
DayCounter::Actual360,
Compounding::Simple,
Frequency::Semiannual,
);
let swap = MakeSwap::<DualFwd>::default()
.with_identifier("USD_IRS_5Y".to_string())
.with_start_date(start_date)
.with_maturity_date(maturity_date)
.with_fixed_rate(0.030)
.with_notional(notional)
.with_rate_definition(rate_definition)
.with_currency(Currency::USD)
.with_market_index(MarketIndex::SOFR)
.with_side(Side::LongReceive)
.with_fixed_leg_frequency(Frequency::Semiannual)
.with_floating_leg_frequency(Frequency::Semiannual)
.build()?;
build() returns QSError when a required field is absent or invalid. For MakeSwap, the required fields are the identifier (a string to identify this particular swap), dates, notional, fixed rate, rate definition, currency, and floating-rate index. The principal optional settings are:
| Builder method | Default |
|---|---|
with_spread(f64) | 0.0 on the floating leg |
with_side(Side) | Side::LongReceive |
with_fixed_leg_frequency(Frequency) | Frequency::Semiannual |
with_floating_leg_frequency(Frequency) | Frequency::Quarterly |
with_calendar(Calendar) | Calendar::NullCalendar (no holiday adjustment) |
with_business_day_convention(BusinessDayConvention) | Unadjusted |
with_date_generation_rule(DateGenerationRule) | Backward for bullet legs |
with_end_of_month(bool) | false |
Internally, legs are stored in a vector, where leg 0 is fixed and has the swap’s side and leg 1 is floating, references MarketIndex::SOFR, and has the opposite side. Both are bullet legs, so their notionals do not amortize. In this example we choose to override the floating-leg frequency from its quarterly default to semiannual.
2. Add the trade layer
An instrument defines what pays; a trade adds position-level metadata such as trade date, notional, and side. This separation lets pricing and portfolio workflows operate on positions without putting lifecycle metadata into every product definition. Pricers generally accept trades rather than bare instruments.
In this example
let trade = SwapTrade::new(swap, start_date, notional, Side::LongReceive);
LongReceive means receive the fixed leg and pay the floating leg; PayShort reverses those signs.
3. Assemble the market
Pricing needs a market state (a set of market variables) as of an evaluation date. QuantSupport separates that state into three layers:
- Raw stores contain observations such as quotes, historical fixings, and FX rates.
ConstructedElementStorecontains derived objects such as discount and credit curves, volatility objects, and simulations.PricingContextowns those stores and implementsMarketDataProvider, the interface through which pricers request only the data they need.
In a configuration-driven workflow, the user should populate quotes and configurations and call PricingContext::initialize(), as this will intialize all elements required for pricing, such as discount curves and volatility surfaces. All risk factors or elements are keyed by a MarketIndex, so for example a SOFR leg can resolve against the SOFR curve already registered in the context. If a product references a MarketIndex not available in the context, an error is returned.
In this example
In this example, we create a flat SOFR curve. FlatForwardTermStructure represents a constant rate interpreted using its RateDefinition; here the input is 3% with continuous compounding. As we want to obtain sensitivities to this curve, the pillar label is required for sensitivity reporting.
let evaluation_date = Date::new(2024, 1, 15);
let discount_curve = FlatForwardTermStructure::new(
evaluation_date,
DualFwd::from(0.03),
RateDefinition::new(
DayCounter::Actual360,
Compounding::Continuous,
Frequency::Annual,
),
)
.with_pillar_label("SOFR_flat".to_string());
let mut constructed_elements = ConstructedElementStore::default();
constructed_elements.discount_curves_mut().insert(
MarketIndex::SOFR,
DiscountCurveElement::new(
MarketIndex::SOFR,
Rc::new(RefCell::new(discount_curve)),
),
);
let context = PricingContext::new()
.with_quote_store(QuoteStore::new(evaluation_date))
.with_fixing_store(FixingStore::default())
.with_base_currency(Currency::USD)
.with_constructed_elements(constructed_elements);
The curve is wrapped in Rc<RefCell<_>>, allowing constructed elements to be shared and updated by calibration workflows. The fixing store is empty because the swap starts on the evaluation date. The example does not call initialize() because its required curve has already been constructed and inserted.
4. Select a pricer and requests
A pricer connects a trade to market data in order to get different Requests. Its market_data_request() declares the required curves, fixings, FX rates, and volatility objects it needs to evaluate the product, and the market data provider resolves that declaration. An user can separately choose outputs with different Request, avoiding calculations that are not needed.
| Request | Meaning |
|---|---|
Request::Value | Present value or NPV |
Request::Cashflows | Coupon and payment details |
Request::Sensitivities | Derivatives with respect to labelled market pillars |
Request::FairRate | Rate that makes the instrument NPV equal to zero |
Request support is pricer-specific, as not all pricer and product share the same variables.
In this example
let pricer = DiscountedCashflowPricer::<Swap<DualFwd>, SwapTrade<DualFwd>>::new();
let requests = vec![Request::Value, Request::Cashflows, Request::Sensitivities];
let results = pricer.evaluate(&trade, &requests, &context)?;
The generic parameters of DiscountedCashflowPricer<I, T> identify its instrument and trade types. It supports value, fair rate, cashflows, and sensitivities for leg-based products; YieldToMaturity and ModifiedDuration are not populated by this pricer. Value, cashflows, and sensitivities share one prepared valuation state during this evaluate() call.
5. Interpret the results
EvaluationResults is an envelope of optional outputs. A getter returns Some(...) when the corresponding result was produced and None otherwise. Callers should read the fields associated with the requests they submitted rather than assume every field is present.
In this example
if let Some(price) = results.price() {
println!("Swap NPV = {price:.2}");
}
if let Some(sensitivities) = results.sensitivities() {
for (key, exposure) in sensitivities
.instrument_keys()
.iter()
.zip(sensitivities.exposure())
{
println!(" {key}: {exposure:.4}");
}
}
if let Some(cashflows) = results.cashflows() {
let dates = cashflows.payment_dates();
let types = cashflows.cashflow_types();
let amounts = cashflows.amounts();
let currencies = cashflows.currencies();
for i in 0..dates.len() {
println!(
"{:<12} {:<22} {:>14.2} {:>6}",
dates[i], types[i], amounts[i], currencies[i]
);
}
}
SensitivityMap contains parallel instrument_keys() and exposure() vectors. Each exposure is the derivative of NPV with respect to the labelled market pillar. This flat-curve example has one pillar, SOFR_flat, so it reports one value for (\partial\mathrm{NPV}/\partial r). A bootstrapped curve instead reports sensitivities against its quote labels, such as OIS_USD_SOFR_5Y.
CashflowsTable is column-oriented. In addition to the columns printed above, it exposes fixing(), accrual_periods(), leg_indices(), and optional caplet/floorlet strikes. Leg index 0 identifies fixed-leg rows and index 1 identifies floating-leg rows.
What to read next
- Rust API summarizes the traits behind the objects used above.
- Pricing Context explains configuration-driven market construction and
initialize(). - Interest Rate Swaps covers fair rates, spreads, fixings, and basis swaps.
Rust API
QuantSupport separates product definition, market construction, pricing, and results. Most applications follow the same path regardless of asset class:
quotes and fixings -> PricingContext -> Pricer -> EvaluationResults
^ ^
| |
market requests instrument + trade
The easiest entry point is quantsupport::prelude::*, which re-exports the types used by normal pricing workflows. Lower-level modules remain useful when implementing a new instrument, pricer, curve, or simulation model.
This chapter is a map of those responsibilities. It is not an exhaustive API reference; the generated Rust documentation remains the source for every method and trait bound.
The pricing pipeline
A typical valuation has five steps:
- Build an instrument containing contractual economics.
- Wrap it in a trade containing position metadata.
- Prepare a pricing context containing market data as of one date.
- Select a pricer and the outputs to calculate.
- Read those outputs from evaluation results.
use quantsupport::prelude::*;
let instrument = MakeSwap::<DualFwd>::default().build()?; // contract fields
let trade = SwapTrade::new(instrument, trade_date, notional, side);
let context = PricingContext::new(); // quotes, fixings, curves, volatility, and configuration
let pricer = DiscountedCashflowPricer::<Swap<DualFwd>, SwapTrade<DualFwd>>::new();
let results = pricer.evaluate(
&trade,
&[Request::Value, Request::Sensitivities],
&context,
)?;
The first swap chapter develops a complete example of this flow.
Numerical scalar types
Curves, instruments, and models commonly use a generic T: Scalar. The scalar supplies arithmetic and mathematical operations while allowing the same financial logic to run with different numeric representations.
pub trait Scalar: Copy + PartialOrd {
fn scalar(value: f64) -> Self;
fn value(&self) -> f64;
fn zero() -> Self;
fn one() -> Self;
// arithmetic and elementary functions
}
The principal choices are:
| Scalar | Use |
|---|---|
f64 | Value-only calculations and simulation paths where the surrounding API supports plain values |
Fwd<T> | Forward-mode automatic differentiation |
Dual<T> | Reverse-mode automatic differentiation |
DualFwd | The library’s standard nested AD scalar for pricing and market sensitivities |
Scalar types must agree across connected objects. For example, a Swap<DualFwd> is valued against curves that produce DualFwd. The current constructed-market and standard pricing-context infrastructure is AD-oriented, so DualFwd is the normal choice for direct pricing. Some simulations and standalone numerical components use f64.
Use .value() when a scalar calculation reaches a reporting boundary. Do not convert intermediate values to f64, because doing so discards derivative information.
See Automatic Differentiation for tape and sensitivity behavior.
Instruments and trades
An instrument describes contractual economics. The base trait intentionally guarantees only an identifier:
pub trait Instrument: Send + Sync {
fn identifier(&self) -> String;
}
Product-specific traits expose additional capabilities. Examples include leg access, currency, discounting index, strike, or maturity. Pricers use these narrower capability traits rather than placing every possible property on Instrument.
Instruments are normally created with Make* builders:
let swap = MakeSwap::<DualFwd>::default()
.with_identifier("USD_IRS_5Y".to_string())
.with_start_date(start_date)
.with_maturity_date(maturity_date)
.with_notional(10_000_000.0)
.with_fixed_rate(0.03)
.with_currency(Currency::USD)
.with_market_index(MarketIndex::SOFR)
.with_rate_definition(rate_definition)
.build()?;
Builders collect required and optional fields, apply defaults, construct schedules and legs, and return QSError for invalid or missing inputs. This is preferable to calling long positional constructors in application code.
A trade adds position-level information:
pub trait Trade<I: Instrument>: Send + Sync {
fn instrument(&self) -> &I;
fn trade_date(&self) -> Date;
fn side(&self) -> Side;
}
pub enum Side {
PayShort, // sign = -1
LongReceive, // sign = +1
}
The exact meaning of the side follows the product. For a vanilla swap, LongReceive receives the fixed leg and pays the floating leg. Pricers generally accept the trade type, not the bare instrument.
Market data and the pricing context
Market data is split between raw observations and constructed valuation objects.
| Layer | Main types | Responsibility |
|---|---|---|
| Raw data | QuoteStore, FixingStore, FxStore | Quotes, historical fixings, and spot FX observations |
| Configuration | Curve, volatility, credit, and simulation configurations | Instructions for constructing market objects |
| Constructed data | ConstructedElementStore | Discount, dividend, and credit curves; volatility surfaces and cubes; simulations |
| Orchestration | PricingContext | Owns the market state and serves pricer requests |
MarketIndex is the key connecting products to market objects. A SOFR floating leg requests data under MarketIndex::SOFR; the context must contain or construct the corresponding curve.
There are two common ways to prepare a context.
Configuration-driven construction
Provide quotes and configurations, then initialize once:
let mut context = PricingContext::new()
.with_quote_store(quotes)
.with_fixing_store(fixings)
.with_curve_configurations(curve_configurations)
.with_base_currency(Currency::USD)
.with_base_index(MarketIndex::SOFR);
context.initialize()?;
initialize() applies scenarios and builds configured curves, credit curves, volatility objects, and simulations in dependency order.
Direct construction
Small applications and tests can create market objects themselves and insert them into a ConstructedElementStore. In that case, initialize() is unnecessary because the objects already exist. The first swap uses this route.
Request and response boundary
Pricers do not traverse PricingContext directly. They declare a MarketDataRequest, and a MarketDataProvider returns the requested subset as MarketData:
pub trait MarketDataProvider {
fn handle_request(&self, request: &MarketDataRequest) -> Result<MarketData>;
fn evaluation_date(&self) -> Date;
}
This boundary keeps pricing logic independent of how the market was assembled. It also makes focused tests possible with a small provider that returns hand-built data.
Pricers and calculation requests
A pricer binds one trade type to one pricing methodology:
pub trait Pricer: Send + Sync {
type Item;
type Policy: ?Sized + Send + Sync;
fn evaluate(
&self,
trade: &Self::Item,
requests: &[Request],
context: &impl MarketDataProvider,
) -> Result<EvaluationResults>;
fn market_data_request(&self, trade: &Self::Item) -> Option<MarketDataRequest>;
fn set_discount_policy(&mut self, policy: Box<Self::Policy>);
fn discount_policy(&self) -> Option<&Self::Policy>;
}
There are two different request concepts:
MarketDataRequestis produced by the pricer and describes required market inputs.Requestis supplied by the caller and describes desired outputs.
pub enum Request {
Value,
YieldToMaturity,
ModifiedDuration,
Sensitivities,
Cashflows,
FairRate,
}
Support is pricer-specific. Request only outputs listed for that pricer in the pricing overview. Depending on the implementation, an unsupported request may be ignored rather than producing a populated result.
When several outputs share valuation work, pricers can calculate the common state once. For example, DiscountedCashflowPricer prepares value state once for value, cashflow, and sensitivity requests submitted in the same call.
Results
EvaluationResults is a non-generic reporting envelope. Its fields are optional because the caller chooses which calculations to request:
let results = pricer.evaluate(
&trade,
&[Request::Value, Request::Cashflows],
&context,
)?;
if let Some(npv) = results.price() {
println!("NPV: {npv:.2}");
}
if let Some(cashflows) = results.cashflows() {
for (date, amount) in cashflows
.payment_dates()
.iter()
.zip(cashflows.amounts())
{
println!("{date}: {amount:.2}");
}
}
The main result types are:
| Type | Contents |
|---|---|
EvaluationResults | Optional price, fair rate, sensitivities, and cashflows exposed through public getters |
SensitivityMap | Parallel market-pillar labels and NPV derivatives |
CashflowsTable | Column-oriented payment dates, types, amounts, fixings, accrual periods, currencies, leg indices, and optional strikes |
Always check the relevant Option; creating an EvaluationResults value does not imply that every calculation was performed.
Discount policies
Discounting is a policy decision rather than an intrinsic property of every payoff. A DiscountPolicy maps a discountable object to the curve index that should discount it:
pub trait DiscountPolicy: Send + Sync {
fn accept(&self, target: &dyn Discountable) -> Result<MarketIndex>;
fn discount_indices(&self) -> Vec<MarketIndex>;
}
Important implementations include:
SingleCurveCSADiscountPolicy, for collateralized discounting under one remuneration index and currency.FixedIncomeDiscountPolicy, which can prefer an instrument’s own index or use a configured risk-free index by currency.
Without an explicit policy, DiscountedCashflowPricer uses its default curve-resolution rules. NettingSet also owns a discount policy so exposure and XVA preprocessing can resolve discount curves consistently.
Curves, volatility, and models
The common rate-curve abstraction is InterestRatesTermStructure<T>. It provides discount factors, forward rates, dates, nodes, and day-count information. Principal implementations are:
FlatForwardTermStructure<T>for a constant rate.DiscountTermStructure<T>for an interpolated discount-factor curve.
Volatility surfaces resolve an expiry and strike coordinate; volatility cubes add tenor. Model and simulation APIs consume constructed curves and volatility objects rather than raw quote strings.
Use the dedicated chapters for domain behavior:
Static and dynamic dispatch
Direct use of a concrete pricer gives compile-time type checking and is the simplest option:
let pricer = DiscountedCashflowPricer::<Swap<DualFwd>, SwapTrade<DualFwd>>::new();
let results = pricer.evaluate(&trade, &[Request::Value], &context)?;
Applications pricing heterogeneous portfolios can register pricers in Evaluator. It stores ErasedPricer implementations by the trade’s TypeId and performs the downcast at runtime:
use std::{any::{Any, TypeId}, collections::HashMap};
use quantsupport::core::{evaluator::Evaluator, pricer::ErasedPricer};
let mut pricers: HashMap<TypeId, Box<dyn ErasedPricer>> = HashMap::new();
pricers.insert(
TypeId::of::<SwapTrade<DualFwd>>(),
Box::new(DiscountedCashflowPricer::<Swap<DualFwd>, SwapTrade<DualFwd>>::new()),
);
let evaluator = Evaluator::new(pricers);
let results = evaluator.evaluate(
&trade as &dyn Any,
&[Request::Value],
&context,
)?;
Use direct dispatch for isolated pricing and generic library code. Use Evaluator when the trade type is known only at runtime.
Errors
Public fallible APIs return the crate alias:
pub type Result<T> = std::result::Result<T, QSError>;
Common error categories are:
| Category | Typical cause |
|---|---|
ValueNotSetErr | A required builder input is absent |
NotFoundErr | A required curve, fixing, quote, model, or pricer is unavailable |
InvalidValueErr | Inputs are present but inconsistent or outside the accepted domain |
InterpolationErr / NodeError | Curve or surface construction/evaluation failed |
SolverErr | Calibration or root finding failed |
TapeError / DualFwdError | Automatic-differentiation state is invalid |
| Parsing and serialization errors | External identifiers or data could not be decoded |
Use ? to propagate errors and add application context at system boundaries. Avoid treating a missing optional result as an error unless that result was required by the workflow.
Time conventions
Time types are shared across instruments, curves, and models:
| Type | Role |
|---|---|
Date | Calendar date and date arithmetic |
Period / TimeUnit | Relative terms such as three months or five years |
DayCounter | Converts date intervals to year fractions |
Frequency | Coupon, compounding, or schedule frequency |
Calendar | Holiday and business-day rules |
BusinessDayConvention | Adjustment rule when a date is not a business day |
DateGenerationRule | Forward, backward, IMM, CDS, and related schedule rules |
MakeSchedule | Builds explicit date schedules |
These conventions are part of valuation inputs. A coupon’s day count, its payment frequency, and a curve’s compounding convention are separate choices and should not be assumed to match.
Extending the library
Add functionality at the narrowest suitable boundary:
- New contract: implement
Instrumentand normally provide aMake*builder and trade wrapper. - New pricing method: implement
Pricerand declare market dependencies inmarket_data_request(). - New curve: implement
InterestRatesTermStructure<T>and the pillar traits required by its use case. - New discounting convention: implement
DiscountPolicy. - New portfolio dispatch entry: register the pricer with
Evaluatorunder the trade’sTypeId.
Keep economic definitions in instruments, market lookup in providers, numerical valuation in pricers, and presentation outside EvaluationResults. Maintaining those boundaries is what lets the same products participate in direct pricing, calibration, simulation, and XVA workflows.
Architecture
QuantSupport separates observable inputs, constructed market objects, instruments and trades, and pricers. This chapter explains why the layers exist and how data flows between them.
flowchart LR
subgraph Inputs
Q[QuoteStore]
F[FixingStore]
X[FxStore]
S[Scenarios]
end
subgraph Configuration
CC[CurveConfiguration]
CR[CreditCurveConfiguration]
VS[VolatilitySurfaceConfiguration]
VC[VolatilityCubeConfiguration]
SC[SimulationConfiguration]
end
PC[PricingContext::initialize]
subgraph Constructed
DC[Discount curves]
CU[Credit curves]
SU[Vol surfaces / cubes]
SI[Monte Carlo simulations]
end
Q & F & X & S --> PC
CC & CR & VS & VC & SC --> PC
PC --> DC --> CU --> SU --> SI
T[Trade] --> P[Pricer]
DC & CU & SU --> P
P --> R[EvaluationResults]
DC --> XVA[XvaEngine / ScriptEngine]
Layer 1 – observable inputs
QuoteStore, FixingStore and FxStore hold what the market actually publishes: par swap rates, deposit rates, basis spreads, caplet and swaption volatilities, FX forwards, CDS spreads, past index fixings and spot FX. Quotes are strings-plus-numbers; nothing has been interpolated or bootstrapped yet. Scenarios (Scenario) act on this layer only, which is what makes shocked valuations consistent: every downstream object is rebuilt from the shocked quotes.
Layer 2 – configuration
Configuration structs say how to turn quotes into objects: which quotes belong to the SOFR curve, which interpolator to use, which caplet quotes form the vol surface, what model drives a simulation. They are plain Serialize/Deserialize data, so the same JSON can drive Rust and Python. See Configuration for schemas.
Layer 3 – constructed elements
PricingContext::initialize() produces the ConstructedElementStore, a set of HashMap<MarketIndex, *Element>:
| Accessor | Element | Holds |
|---|---|---|
discount_curves() / discount_curve(&idx) | DiscountCurveElement | Rc<RefCell<dyn InterestRatesTermStructure<DualFwd>>> |
dividend_curves() / dividend_curve(&idx) | DividendCurveElement | dividend yield curve for equity indices |
credit_curves() / credit_curve(&idx) | CreditCurveElement | survival-probability curve from CDS |
volatility_surfaces() / volatility_surface(&idx) | VolatilitySurfaceElement | expiry × key surface |
fx_volatility_surface(&FxPair) | OrientedFxVolSurface | surface oriented for a pair, inverting if only the reciprocal pair exists |
volatility_cubes() / volatility_cube(&idx) | VolatilityCubeElement | expiry × tenor × key cube |
simulations() | MonteCarloSimulationElement | generated paths |
Each accessor has a _mut twin so bootstrappers and hand-built markets can insert objects. The order of construction inside initialize() is fixed: scenarios → discount curves (MultiCurveBootstrapper) → credit curves (CreditCurveBootstrapper, which discounts CDS legs on the curves just built) → volatility surfaces → volatility cubes → simulations (SimulationBuilder, which may calibrate models to the surfaces/cubes).
Layer 4 – instruments and trades
An instrument (Swap, CapFloor, FxForward, …) describes cashflows: legs, coupons, indices, dates, strikes. It is built with a Make* builder and knows nothing about who owns it. A trade (SwapTrade, FxForwardTrade, …) adds trade_date, notional and Side. Pricers and the XVA engine consume trades; IntoContingentClaims is implemented on trades.
Instruments implement Discountable (asset class, currency, optional own discount index) so DiscountPolicy objects can decide which curve discounts them.
Layer 5 – pricers and results
A Pricer maps (trade, requests, market) → EvaluationResults. Before pricing, market_data_request(trade) declares the curves, fixings, FX rates and vol objects the pricer needs; the PricingContext (as MarketDataProvider) resolves them through handle_request, returning a MarketData bundle. This indirection is what allows the same pricer to run against a full context, a hand-built store, or a shocked copy.
Evaluator offers dynamic dispatch by trade TypeId when a portfolio mixes instrument types.
Layer 6 – simulation, scripting and XVA
LgmMarketModel and HullWhite read the constructed curves (and calibrate to constructed vol objects); ScriptEngine evaluates payoffs on a MarketModel<DualFwd>; XvaEngine decomposes trades into ContingentClaims and prices them on the simulated paths. Because these layers consume the same DiscountCurveElements that the deterministic pricers use, NPV at \(t_0\) from the exposure engine equals the pricer NPV, and AAD sensitivities flow back to the same quote pillars.
The AD thread running through everything
All constructed elements are built in DualFwd. Quote values become tape leaves during bootstrapping; discount factors, forwards and vols are tape nodes derived from them. Any result computed from the market—an NPV, a CVA, a scripted payoff—can be back-propagated to those leaves. The Automatic Differentiation chapter explains the tape API; the practical consequence is that Request::Sensitivities costs roughly one extra evaluation regardless of the number of quotes.
Market Data
Market data enters the library through three stores: QuoteStore (prices), FixingStore (historical index fixings) and FxStore (spot FX). This chapter documents the quote identifier grammar that ties quotes to instruments, and the API of each store.
Quote identifiers
Every quote is identified by an underscore-separated string that is parsed by QuoteDetails::from_str into a QuoteInstrument. The identifier is what curve configurations, vol configurations and scenarios refer to, and it doubles as the pillar label in sensitivity reports.
| Instrument | Pattern | Example |
|---|---|---|
| Overnight deposit / cash | FixedRateDeposit_<CCY>_<Index>_<Tenor> | FixedRateDeposit_USD_SOFR_1D |
| OIS / fixed–float swap | OIS_<CCY>_<Index>_<Tenor>[_<FixedFreq>_<FloatFreq>] | OIS_USD_SOFR_5Y, OIS_CLP_ICP_6M |
| Tenor basis swap | BasisSwap_<CCY>_<PayIndex>_<RecvIndex>_<Tenor>[_<PayFreq>_<RecvFreq>] | BasisSwap_USD_SOFR_TermSOFR3m_2Y |
| Fix–float cross-currency swap | FixFloatCrossCurrencySwap_<FixedCCY>_<FloatIndex>_<FloatCCY>_<Tenor>[..] | FixFloatCrossCurrencySwap_CLP_SOFR_USD_5Y |
| Float–float cross-currency swap | FloatFloatCrossCurrencySwap_<DomCCY>_<DomIndex>_<ForIndex>_<ForCCY>_<Tenor>[..] | FloatFloatCrossCurrencySwap_USD_SOFR_ESTR_EUR_5Y |
| FX forward points | FxForwardPoints_<PAIR>_<Tenor> | FxForwardPoints_USDCLP_3M |
| FX outright forward | FxOutrightForward_<PAIR>_<Tenor> | FxOutrightForward_EURUSD_1Y |
| Rate future | Future_<CCY>_<Index>_<IMM> | Future_USD_SOFR_H26 |
| Convexity adjustment | ConvexityAdjustment_<CCY>_<Index>_<IMM> | |
| Cap / floor volatility | CapFloor_<CCY>_<Index>_<Tenor>_<Freq>_<StrikeKind>_<Strike>_<VolType> | CapFloor_USD_SOFR_5Y_Quarterly_Absolute_0.04_Black |
| Caplet / floorlet volatility | CapletFloorlet_<CCY>_<Index>_<IndexTenor>_<Expiry>_<StrikeKind>_<Strike>_<Strategy>_<VolType> | CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black |
| Swaption volatility | Swaption_<CCY>_<Index>_<Expiry>_<SwapTenor>[_<FixedFreq>_<FloatFreq>]_<StrikeKind>_<Strike>_<VolType> | Swaption_CLP_ICP_1Y_1Y_Absolute_0.045_Black, Swaption_USD_SOFR_3M_2Y_Semiannual_Semiannual_Absolute_0.04_Black |
| Equity option | EquityCall_<CCY>_<Index>_<Tenor>_<StrikeKind>_<Strike>, EquityPut_... | EquityCall_USD_AAPL_6M_Absolute_150 |
| FX option | FxCall_<PAIR>_<Tenor>_<StrikeKind>_<Strike>, FxPut_... | FxPut_USDCLP_3M_Absolute_950 |
| Credit default swap | Cds_<Entity>_<CCY>_<Tenor> | Cds_ACME_USD_5Y |
<Tenor>and<Expiry>arePeriodstrings (1D,3M,5Y,1Y6M).<StrikeKind>isAbsolute(strike follows as a decimal),Atm, orRelative(offset from ATM).<VolType>isBlackorNormal;<Strategy>for caplets isCap,FloororStraddle.- Currency pairs are concatenated ISO codes (
USDCLP= price of 1 USD in CLP).
QuoteInstrument has one variant per row (Ois, FixedRateDeposit, BasisSwap, FixFloatCrossCurrencySwap, FloatFloatCrossCurrencySwap, FxForwardPoints, FxOutrightForward, Future, ConvexityAdjustment, CapFloor, CapletFloorlet, Swaption, EquityOption, FxOption, Cds) carrying the parsed fields. Bootstrappers call CurveConfiguration::instruments() to turn these into instruments at the quoted levels.
QuoteStore
let mut store = QuoteStore::new(Date::new(2026, 2, 24));
let details = QuoteDetails::from_str("OIS_USD_SOFR_5Y")?;
store.add_quote(Quote::new(details, QuoteLevels::with_mid(0.0407677739)));
store.add_quote(Quote::new(
QuoteDetails::from_str("FxForwardPoints_USDCLP_3M")?,
QuoteLevels::new(Some(5.30), Some(5.20), Some(5.40)), // mid, bid, ask
));
store.reference_date(); // Date
store.quote("OIS_USD_SOFR_5Y"); // Option<&Quote>
store.quotes(); // &HashMap<String, Quote>
let mid = store.quote("OIS_USD_SOFR_5Y").and_then(|q| q.levels().mid());
QuoteLevels::with_mid(mid) sets only the mid; QuoteLevels::new(mid, bid, ask) takes three Option<f64>; levels.value(Level::Mid | Bid | Ask) returns Result<f64> and fails if that level was not supplied. Bootstrappers and builders take a Level argument, so one store can produce a mid curve and a bid/ask pair.
QuoteStore implements QuoteSelector, the trait bootstrappers read from. PricingContext::quote_store() returns the shocked copy when scenarios are attached and the base store otherwise (base_quote_store() always returns the original).
JSON
The examples use this schema (examples/bootstrap/data/quotes.json):
{
"reference_date": "2026-02-24",
"quotes": [
{ "identifier": "FixedRateDeposit_USD_SOFR_1D", "mid": 0.045 },
{ "identifier": "OIS_USD_SOFR_1Y", "mid": 0.0483664339 },
{ "identifier": "BasisSwap_USD_SOFR_TermSOFR3m_1Y", "mid": 0.00028 },
{ "identifier": "FxForwardPoints_USDCLP_3M", "mid": 5.3 },
{ "identifier": "FixFloatCrossCurrencySwap_CLP_SOFR_USD_5Y", "mid": 0.0512 }
]
}
The loader in examples/bootstrap/src/main.rs is ten lines:
#[derive(Deserialize)] struct QuoteRecord { identifier: String, mid: f64 }
#[derive(Deserialize)] struct JsonQuotes { reference_date: Date, quotes: Vec<QuoteRecord> }
let json: JsonQuotes = serde_json::from_reader(BufReader::new(File::open(path)?))?;
let mut store = QuoteStore::new(json.reference_date);
for rec in json.quotes {
store.add_quote(Quote::new(QuoteDetails::from_str(&rec.identifier)?, QuoteLevels::with_mid(rec.mid)));
}
The Python binding QuoteStore.from_json reads the same file.
FixingStore
let mut fixings = FixingStore::default();
fixings.add_fixing(&MarketIndex::SOFR, Date::new(2025, 5, 12), 0.0428);
fixings.fixing(&MarketIndex::SOFR, Date::new(2025, 5, 12))?; // Result<f64>, NotFoundErr if missing
fixings.fixings(&MarketIndex::SOFR)?; // Result<&BTreeMap<Date, f64>>
fixings.fill_missing_fixings(Interpolator::Linear)?; // fill every calendar day between first and last fixing
Fixings are needed for any floating coupon whose fixing date is on or before the valuation date. DiscountedCashflowPricer reads them through MarketDataRequest; the XVA FixingPreprocessor uses them to set realized_fixing / partial_fixing on claims (compounding daily fixings for in-arrears indices such as SOFR). JSON schema used by the examples:
{
"SOFR": [
{ "date": "2025-05-12", "rate": 0.0428 },
{ "date": "2025-05-13", "rate": 0.0429 }
]
}
FxStore
let mut fx = FxStore::new();
fx.add_fx_rate(Currency::USD, Currency::CLP, DualFwd::new(935.0)); // 1 USD = 935 CLP
fx.get_fx_rate(Currency::CLP, Currency::USD)?; // 1/935, inverted automatically
fx.get_fx_rate(Currency::EUR, Currency::CLP)?; // triangulated via USD if EURUSD is stored
let fx = FxStore::from_records(vec![FxRateRecord { base: Currency::CLP, quote: Currency::USD, rate: 1.0 / 900.0 }]);
get_fx_rate returns DualFwd::one() for identical currencies, a direct lookup if the pair is stored, and otherwise breadth-first triangulation over stored pairs (multiplying along base→quote edges and dividing along reversed ones); it fails with NotFoundErr when the currencies are disconnected. FxStore implements Pillars<DualFwd> (labels "USD/CLP"), so put_pillars_on_tape() turns every stored rate into a tape leaf and FX-spot sensitivities appear next to curve pillars. from_records stores rates with DualFwd::from, i.e. off-tape until you call put_pillars_on_tape(). The bootstrapper uses the store to build MarketIndex::Collateral(CLP, USD) curves from cross-currency quotes; FxForwardPricer and FxOptionPricer read spot from it.
Currencies and indices
Currency variants: USD, EUR, JPY, ZAR, CLP, CLF, CHF, BRL, COP, MXN, AUD, CAD, CNY, GBP, NZD, NOK, SEK, PEN, CNH, INR, TWD, HKD, KRW, DKK, IDR, with as_str(), name(), symbol(), precision(), numeric_code() and Currency::try_from("USD").
MarketIndex variants: SOFR, SOFRCompounded, TermSOFR1m, TermSOFR3m, TermSOFR6m, TermSOFR12m, ESTR, EURIBOR1m, EURIBOR3m, EURIBOR6m, EURIBOR12m, SONIA, TONAR, TIBOR3m, TIBOR6m, SARON, CORRA, AONIA, NZONIA, NOWA, SWESTR, ICP, VIX, Equity(String), FxPair(FxPair), Collateral(Currency, Currency), Credit(String), Other(String). Rate indices know their currency, tenor and day counter (MarketIndex::SOFR.currency() == Currency::USD); Collateral(CLP, USD) names the curve that discounts CLP cashflows collateralised in USD. In JSON, unit variants serialise as strings ("SOFR") and tuple variants as objects ({"Collateral": ["CLP", "USD"]}, {"Equity": "AAPL"}).
Pricing Context
PricingContext is the single object most users interact with. It collects raw market data and configuration, builds every derived market object once in initialize(), and then acts as the MarketDataProvider that pricers, the scripting engine and the XVA engine query.
Building a context
use quantsupport::prelude::*;
let mut ctx = PricingContext::new()
.with_quote_store(quote_store) // required: prices + reference date
.with_fixing_store(fixings) // optional: historical fixings
.with_fx_store(fx_store) // optional: spot FX (needed for Collateral curves)
.with_base_currency(Currency::USD) // CSA currency, default USD
.with_base_index(MarketIndex::SOFR) // CSA discount index, default SOFR
.with_curve_configurations(curve_specs) // Vec<CurveConfiguration>
.with_credit_curve_configurations(credit_specs) // Vec<CreditCurveConfiguration>
.with_volatility_surface_configurations(surface_specs) // Vec<VolatilitySurfaceConfiguration>
.with_volatility_cube_configurations(cube_specs) // Vec<VolatilityCubeConfiguration>
.with_simulation_configurations(sim_specs) // Vec<SimulationConfiguration>
.with_scenarios(vec![Scenario::new("SOFR", 0.0001, ScenarioType::Absolute)]);
ctx.initialize()?;
All with_* methods consume and return Self. The evaluation date is not set separately: evaluation_date() returns quote_store.reference_date().
Read accessors
| Method | Returns |
|---|---|
quote_store() | shocked store if scenarios are attached, otherwise the base store |
base_quote_store() | the unshocked store |
fixing_store(), fx_store() | the stores as given |
scenarios() | &Vec<Scenario> |
base_currency(), base_index() | CSA currency / index |
curve_configurations(), credit_curve_configurations(), volatility_surface_configurations(), volatility_cube_configurations(), simulation_configurations() | the configuration vectors |
constructed_elements() / constructed_elements_mut() | the ConstructedElementStore populated by initialize() |
evaluation_date() | reference date of the quote store |
What initialize() does
pub fn initialize(&mut self) -> Result<()>
- Scenarios. If
scenariosis non-empty, clone the quote store and apply eachScenarioin order. A scenario that matches no quote is an error. Everything below reads the shocked copy. - Discount curves.
MultiCurveBootstrapper::new(curve_configurations, BootstrapDiscountPolicy::new(base_index, base_currency)).with_fx_store(fx_store).bootstrap(quote_store, Level::Mid). Each resultingDiscountCurveElementis inserted under itsMarketIndex. - Credit curves.
CreditCurveBootstrapper::bootstrap(quote_store, Level::Mid, discount_curves)— CDS premium and protection legs are discounted on the curves from step 2. - Volatility surfaces.
VolatilitySurfaceBuilder::build(quote_store, Level::Mid). - Volatility cubes.
VolatilityCubeBuilder::build(quote_store, Level::Mid). - Simulations.
SimulationBuilder::build(constructed_elements, quote_store, fixing_store, Level::Mid)— runs last so models can calibrate to the surfaces/cubes and diffuse the bootstrapped curves.
Steps 3–6 are skipped when the corresponding configuration vector is empty. Calling initialize() twice rebuilds everything from the (possibly shocked) quotes.
Serving market data to pricers
PricingContext implements MarketDataProvider:
pub trait MarketDataProvider {
fn evaluation_date(&self) -> Date;
fn handle_request(&self, request: &MarketDataRequest) -> Result<MarketData>;
}
A pricer first calls market_data_request(&trade) to describe what it needs, then the context resolves it:
pub struct MarketDataRequest { // all fields optional
constructed_elements_request: Option<Vec<ConstructedElementRequest>>,
fixings_request: Option<Vec<FixingRequest>>,
fx_request: Option<Vec<FxRequest>>,
}
pub enum ConstructedElementRequest {
DiscountCurve { market_index }, DividendCurve { market_index }, CreditCurve { market_index },
VolatilitySurface { market_index }, VolatilityCube { market_index }, Simulation { market_index },
}
handle_request copies the requested elements into a fresh ConstructedElementStore, gathers the fixings (MarketData::fixings() is a HashMap<MarketIndex, BTreeMap<Date, f64>>), and attaches the FxStore. A missing element produces QSError::NotFoundErr("Discount curve not found for index …") — the most common error when a trade references an index without a curve configuration.
Because pricers only see MarketData, you can bypass the context entirely in tests by constructing MarketData::new(fixings, constructed_elements).with_fx_store(fx) and implementing MarketDataProvider on a small struct, or by populating constructed_elements_mut() directly with hand-built curves as the scripting example does:
let mut store = ConstructedElementStore::default();
store.discount_curves_mut().insert(
MarketIndex::SOFR,
DiscountCurveElement::new(MarketIndex::SOFR, Rc::new(RefCell::new(curve))),
);
let ctx = PricingContext::new()
.with_quote_store(QuoteStore::new(ref_date))
.with_fixing_store(FixingStore::default())
.with_constructed_elements(store)
.with_base_currency(Currency::USD)
.with_base_index(MarketIndex::SOFR);
// no initialize(): the curves are already there
Scenarios
Scenario::new(target, shock, ScenarioType::{Absolute, Relative}) shocks quotes before bootstrapping:
Absoluteaddsshockto the quote (0.0001= 1 bp);Relativemultiplies by1 + shock.targetis a full identifier ("OIS_USD_SOFR_5Y", one key-rate bump) or a segment selector: every underscore-separated segment of the target must appear among the identifier’s segments."SOFR"shocks all SOFR quotes (parallel shift),"OIS_USD_SOFR"all USD SOFR OIS pillars,"Swaption_USD"the USD swaption vol cube,"CapletFloorlet_USD_SOFR"the caplet surface.scenario.apply(&mut store)returns the number of quotes shocked and errors when zero matched.
Since all curves, vols and simulations are rebuilt from the shocked quotes, a scenario valuation is a full repricing, not a curve-level approximation. Use AAD sensitivities (Request::Sensitivities) for first-order risk and scenarios for stress tests, bump-and-reprice validation of AAD, or non-linear moves. See Scenarios.
Evaluating trades
The chapter Rust API shows the pricer-based flow (DiscountedCashflowPricer::new().evaluate(&trade, &[Request::Value, Request::Sensitivities], &ctx)), and Pricing Overview lists which pricer handles which trade type and the Evaluator for heterogeneous portfolios. The XVA engine takes the same context: XvaEngine::new(&ctx, config)?.run(&mut netting_sets).
Python
The binding exposes the same object with keyword arguments matching the builders: PricingContext(quotes, curves, fixings=None, fx=None, volatility_surfaces=None, volatility_cubes=None, simulations=None, discounting=None, scenarios=None); initialize() runs on construction, and the object is a context manager that clears the AD tape on exit. See Python API.
Instruments and Trades
QuantSupport models a contract at three levels: cashflows grouped into legs, an instrument that owns one or more legs (or an option payoff), and a trade that adds the economic position. This chapter documents the building blocks and lists every instrument in the library with its builder and trade type.
Cashflows
pub trait Cashflow<T: Scalar> {
fn amount(&self) -> Result<T>;
fn payment_date(&self) -> Date;
}
pub enum CashflowType<T: Scalar> {
FixedRateCoupon(FixedRateCoupon<T>), // notional × (compound(rate, accrual) − 1)
FloatingRateCoupon(FloatingRateCoupon<T>), // notional × (fixing + spread) × accrual
OptionEmbeddedCoupon(OptionEmbeddedCoupon<T>), // floating coupon with caplet/floorlet strikes
Redemption(SimpleCashflow<f64>), // principal repayment
Disbursement(SimpleCashflow<f64>), // principal paid out at start (loans/bonds)
ConstantAmount(SimpleCashflow<f64>),
OptionEmbeddedCashflow(OptionEmbeddedCashflow<T>),
}
FixedRateCoupon carries an InterestRate<T> (rate + RateDefinition = day counter, compounding, frequency); its amount() is notional × (compound_factor − 1). FloatingRateCoupon stores fixing/accrual dates, the forward index and a spread; its amount is undefined until a fixing is supplied by the pricer, which is why amount() returns Result. CashflowType<f64> and CashflowType<DualFwd> convert into each other with .into(), so instruments built in f64 can be priced with AAD.
Legs
Leg<T> groups cashflows that share a currency, side and set of indices:
| Field | Meaning |
|---|---|
id: usize | leg identifier used by pricers (CashflowsTable rows, XVA leg_id) |
cashflows: Vec<CashflowType<T>> | ordered cashflows |
currency: Currency | payment currency |
discount_index: Option<MarketIndex> | explicit discount curve override |
forward_index: Option<MarketIndex> | index fixing floating coupons |
spread: Option<T>, interest_rate: Option<InterestRate<T>> | floating spread / fixed rate |
side: Side | PayShort or LongReceive |
is_linear: bool | false when option-embedded coupons are present |
asset_class: AssetClass | FixedIncome, InterestRate, Equity, Fx, Credit, Other |
first_payment_date, last_payment_date | used by bootstrappers to order pillars |
MakeLeg
Every multi-leg instrument builder delegates to MakeLeg<T>:
let leg = MakeLeg::<DualFwd>::default()
.with_start_date(Date::new(2024, 1, 1))
.with_end_date(Date::new(2025, 1, 1)) // or .with_tenor(Period::from_str("1Y")?)
.with_notional(100_000.0)
.with_rate(InterestRate::from_rate_definition(DualFwd::new(0.05),
RateDefinition::new(DayCounter::Actual360, Compounding::Simple, Frequency::Annual)))
.with_rate_type(RateType::Fixed) // or RateType::Floating + with_forward_index / with_spread
.with_side(Side::PayShort)
.with_currency(Currency::USD)
.with_payment_frequency(Frequency::Semiannual)
.with_calendar(Some(Calendar::NullCalendar))
.with_business_day_convention(Some(BusinessDayConvention::ModifiedFollowing))
.with_date_generation_rule(Some(DateGenerationRule::Backward))
.with_discount_index(Some(MarketIndex::SOFR))
.bullet()
.build()?;
Payment structures (PaymentStructure):
| Method | Structure | Notes |
|---|---|---|
.bullet() | coupons + single redemption at maturity | default for swaps |
.equal_redemptions() | principal amortised in equal amounts, coupons on outstanding notional | |
.equal_payments() | constant coupon + principal instalments | fixed legs only (error on floating) |
.zero() | one payment at maturity | forces Frequency::Once |
.other() | custom with_disbursements(HashMap<Date,f64>) / with_redemptions(HashMap<Date,f64>) | forces Frequency::OtherFrequency |
Optional extras: with_first_coupon_date, with_end_of_month, with_leg_id, with_asset_class, with_caplet_strike/with_floorlet_strike (turns a floating leg into option-embedded coupons; not allowed on fixed legs). build() fails with ValueNotSetErr("Rate type") and similar messages when a required field is missing, and with InvalidValueErr for inconsistent combinations.
Instruments and trades
An instrument holds the legs and static terms; a trade wraps it with trade_date, notional and Side:
pub struct Swap<T: Scalar> { fixed_leg, floating_leg, forward_index, currency, ... }
impl<T: Scalar> Swap<T> {
pub fn fixed_leg(&self) -> &Leg<T>;
pub fn floating_leg(&self) -> &Leg<T>;
pub fn forward_index(&self) -> MarketIndex;
pub const fn currency(&self) -> Currency;
}
pub struct SwapTrade<T: Scalar> { instrument: Swap<T>, trade_date: Date, notional: f64, side: Side }
impl<T: Scalar> SwapTrade<T> {
pub const fn new(instrument: Swap<T>, trade_date: Date, notional: f64, side: Side) -> Self;
pub const fn notional(&self) -> f64;
}
Side::LongReceive means the trade receives the fixed leg (for swaps) / owns the instrument; Side::PayShort is the mirror. Side::sign() returns +1.0/-1.0 and pricers multiply by it. Trades implement Instrument (identifier()), Discountable (asset class, currency, optional discount index) and, for exposure simulation, IntoContingentClaims.
Catalogue
| Asset class | Instrument | Builder | Trade | Deterministic pricer |
|---|---|---|---|---|
| Rates | Swap (fixed vs float) | MakeSwap | SwapTrade | DiscountedCashflowPricer |
| Rates | BasisSwap (float vs float) | MakeBasisSwap | BasisSwapTrade | DiscountedCashflowPricer |
| Rates | FixFloatCrossCurrencySwap | MakeFixFloatCrossCurrencySwap | FixFloatCrossCurrencySwapTrade | DiscountedCashflowPricer |
| Rates | FloatFloatCrossCurrencySwap | MakeFloatFloatCrossCurrencySwap | FloatFloatCrossCurrencySwapTrade | DiscountedCashflowPricer |
| Rates | CapFloor | MakeCapFloor | CapFloorTrade | ClosedFormBlackCapPricer, ClosedFormHullWhiteCapPricer |
| Rates | CapletFloorlet | — (from quotes) | CapletFloorletTrade | ClosedFormBlackCapletPricer, ClosedFormHullWhiteCapletPricer |
| Rates | EuropeanSwaption | MakeSwaption | EuropeanSwaptionTrade<DualFwd> | ClosedFormHullWhiteSwaptionPricer |
| Rates | RateFutures | MakeRateFutures | RateFuturesTrade | RateFuturesPricer |
| Fixed income | FixedRateBond | MakeFixedRateBond | FixedRateBondTrade | DiscountedCashflowPricer |
| Fixed income | FloatingRateNote | MakeFloatingRateNote | FloatingRateNoteTrade | DiscountedCashflowPricer |
| Fixed income | FixedRateDeposit | MakeFixedRateDeposit | FixedRateDepositTrade | DiscountedCashflowPricer |
| FX | FxForward | MakeFxForward | FxForwardTrade | FxForwardPricer |
| FX | FxOption | MakeFxOption | FxOptionTrade | FxOptionPricer (Garman–Kohlhagen) |
| Equity | EquityForward | MakeEquityForward | EquityForwardTrade | — (claims / scripting) |
| Equity | EquityEuropeanOption | — | EquityEuropeanOptionTrade | BlackEuropeanOptionPricer, BlackMCEuropeanOptionPricer |
| Equity | Futures | MakeFutures | FuturesTrade | — (claims / scripting) |
| Credit | CreditDefaultSwap | — | CdsTrade | CdsPricer |
| Any | ScriptedProduct | script text | — | ScriptEngine (Monte Carlo) |
The generic parameter T on rate/fixed-income instruments is f64 or DualFwd; build in f64 when you do not need rate sensitivities and convert with .into() when you do. FX, equity, cap/floor and credit instruments are non-generic and always price in DualFwd internally.
Builder conventions
All Make* builders follow the same pattern as MakeSwap (see Your First Swap):
Make*::new()/default()then chainedwith_*setters that take ownership.build()returnsResult<Instrument>; missing mandatory fields produceQSError::ValueNotSetErr("<Field>").- Defaults are conservative:
Calendar::NullCalendar,BusinessDayConvention::Unadjusted,DateGenerationRule::Backward,spread = 0.0,Side::LongReceive. - Cross-currency builders take two currencies, two notionals (or an FX rate to derive one) and per-leg indices;
MakeFxForwardandMakeFxOptiontake acurrency pair, strike/forward rate and settlement date;MakeCapFloortakes an index, strike, cap/floor flag and schedule parameters;MakeSwaptionwraps aMakeSwapplus expiry and settlement type.
The per-product chapters under Pricing show each builder with its required fields and the requests its pricer supports.
Contingent claims
For simulation-based pricing every trade is decomposed into ContingentClaims—atomic payments with a payment date, currency, leg id, side and a ClaimEvaluationStrategy (fixed amount, forward-rate coupon, option payoff, scripted payoff, …). IntoContingentClaims::into_contingent_claims(&self, trade_id: &str) -> Result<Vec<ContingentClaim>> performs the decomposition; the XVA engine, the scripting engine and LgmMarketModel all consume claims rather than instruments. See Exposure.
Curves Overview
A curve in QuantSupport is any object implementing InterestRatesTermStructure<T>. Curves are usually produced by the bootstrapper from quotes, but the same trait is implemented by simple hand-built structures that are useful for tests, toy models and the scripting example.
The trait
pub trait InterestRatesTermStructure<T: Scalar> {
fn reference_date(&self) -> Date;
fn discount_factor(&self, date: Date) -> Result<T>;
fn forward_rate(&self, start: Date, end: Date, comp: Compounding, freq: Frequency) -> Result<T>;
fn nodes(&self) -> Option<Vec<(Date, T)>>;
fn day_counter(&self) -> Option<DayCounter>;
fn discount_factor_from_time(&self, t: f64) -> Result<T>;
fn forward_rate_from_time(&self, start: f64, end: f64) -> Result<T>;
}
T is f64 or DualFwd. Forward rates are always derived from discount factors through InterestRate::implied_rate, so any compounding convention is consistent with the curve’s discount factors:
\[ P(t_1,t_2)=\frac{P(0,t_2)}{P(0,t_1)},\qquad F_{\text{simple}}=\frac{1}{\tau}\left(\frac{1}{P(t_1,t_2)}-1\right),\qquad F_{\text{cont}}=-\frac{\ln P(t_1,t_2)}{\tau}. \]
Constructed curves are wrapped as Rc<RefCell<dyn ADCurveElement>> inside a DiscountCurveElement, where ADCurveElement = InterestRatesTermStructure<DualFwd> + Pillars<DualFwd>. element.curve() returns a borrow of the underlying curve.
Implementations
DiscountTermStructure<T>
The workhorse: a set of pillar dates with discount factors, interpolated on year fractions.
let curve = DiscountTermStructure::<DualFwd>::new(
vec![ref_date, ref_date + Period::from_str("3M")?, ref_date + Period::from_str("1Y")?],
vec![DualFwd::new(1.0), DualFwd::new(0.99), DualFwd::new(0.957)],
DayCounter::Actual360,
Interpolator::LogLinear,
true, // enable_extrapolation
)?
.with_pillar_labels(vec!["SOFR.0M".into(), "SOFR.3M".into(), "SOFR.12M".into()])?; // Result<Self>
new(dates, discount_factors, day_counter, interpolator, enable_extrapolation) -> Result<Self>: the first date is the reference date and must carry DF = 1; lengths must match.- Accessors:
dates(),discount_factors(),day_counter(),interpolator(),enable_extrapolation(). with_pillar_labels(Vec<String>) -> Result<Self>names the pillars for sensitivity reporting;with_pillar_values(Vec<T>) -> Result<Self>overrides the values exposed throughPillars(the bootstrapper stores the quotes here, so sensitivities are reported per quote, not per DF);with_ift_sensitivities(Vec<Vec<f64>>)stores the Jacobian used to rebuild AD links (see Bootstrapping).- Interpolation is done on year fractions with the chosen
Interpolatorapplied to the discount factors themselves;LogLineartherefore gives piecewise-constant forward rates.
FlatForwardTermStructure<T>
FlatForwardTermStructure::new(reference_date, rate: T, RateDefinition) – a single rate compounded with the given RateDefinition (day counter, compounding, frequency). with_pillar_label(String) exposes the rate as one pillar. Use it in unit tests and quick what-ifs.
SpreadTermStructure<T> and CompositeTermStructure<T>
SpreadTermStructure::new(reference_date, year_fractions, spreads, day_counter, interpolator) stores continuously compounded zero spreads
\(s(ti) = -\ln\!\big(P{\text{target}}(ti)/P{\text{base}}(t_i)\big)/t_i\) and returns \(P_s(t)=e^{-s(t)t}\). CompositeTermStructure::new(spread_curve, base_curve) multiplies discount factors, \(P(t)=P_s(t)\,P_b(t)\), taking the reference date from the base. Together they express “base curve plus spread” (funding curves, CSA adjustments) with sensitivities to the spread pillars and the base pillars kept separate.
Interpolators
Interpolator::{Linear, LogLinear, CubicSpline} (serialised as strings). The Interpolate trait provides interpolate(x, xs, ys, enable_extrapolation); extrapolation past the last pillar is flat-forward for LogLinear and linear for the others, and is an error when disabled.
Pillars<T>
pub trait Pillars<T> {
fn pillar_labels(&self) -> Option<Vec<String>>;
fn pillars(&self) -> Option<Vec<(String, &T)>>; // label → tape value
fn put_pillars_on_tape(&mut self);
}
Every curve (and FxStore) implements Pillars<DualFwd>. Pricers use it to produce named sensitivities: after Tape::backward() they iterate pillars() and read value.adjoint(). put_pillars_on_tape() must be called after Tape::start_recording_fwd() and before pricing when the curve was built outside the current tape; the bootstrapper’s curves are rebuilt from quotes through the IFT matrices so sensitivities are w.r.t. quotes rather than discount factors.
Rate conventions
Compounding::{Simple, Compounded, Continuous, SimpleThenCompounded, CompoundedThenSimple}.RateDefinition::new(day_counter, compounding, frequency);InterestRate::from_rate_definition(rate, def),InterestRate::new(rate, compounding, frequency, day_counter),compound_factor(t),discount_factor(t),implied_rate(compound, dc, comp, freq, t).- Day counters:
DayCounter::{Actual360, Actual365, Thirty360, Thirty360US, ActualActual, Business252}withyear_fraction(d1, d2)andday_count(d1, d2).
The next chapters cover how curves are produced: Bootstrapping for single curves, Multi-Curve Framework for dependent curves and collateral, and Volatility Surfaces for option markets.
Bootstrapping
Bootstrapping turns a CurveConfiguration (a list of quote identifiers) into a DiscountTermStructure<DualFwd> whose pillars are the market quotes. The implementation is a global Newton solve per curve followed by an implicit-function-theorem (IFT) step that connects the discount factors to the quotes on the AD tape.
CurveConfiguration
pub struct CurveConfiguration {
market_index: MarketIndex, // required
day_counter: DayCounter, // default Actual360
interpolator: Interpolator, // default LogLinear
enable_extrapolation: bool, // default true
quotes: Vec<String>, // pillar quote identifiers
}
CurveConfiguration::new(market_index, day_counter, interpolator, enable_extrapolation, quotes)
JSON (all optional fields may be omitted):
{
"market_index": "SOFR",
"day_counter": "Actual360",
"interpolator": "LogLinear",
"enable_extrapolation": true,
"quotes": [
"FixedRateDeposit_USD_SOFR_1D",
"OIS_USD_SOFR_1Y",
"OIS_USD_SOFR_2Y",
"OIS_USD_SOFR_3Y",
"OIS_USD_SOFR_5Y",
"OIS_USD_SOFR_7Y",
"OIS_USD_SOFR_10Y",
"OIS_USD_SOFR_30Y"
]
}
resolve(selector, level, fx_spot) looks every identifier up in the QuoteSelector, builds the calibration instrument at the requested Level (Mid, Bid, Ask), computes its pillar date and sorts the instruments by pillar date. Missing quotes produce NotFoundErr("Quote … not found in quotes."). After resolution instruments(), pillar_dates(), pillar_labels() (the identifiers) and quote_values() are available.
Supported pillar instruments and residuals
Each quote becomes a CalibrationInstrumentType and contributes one residual \(F_i(x)\) to the solver:
| Quote type | Instrument | Residual |
|---|---|---|
FixedRateDeposit | zero-coupon deposit | NPV of the deposit legs |
OIS | fixed vs overnight swap | NPV (fixed − floating) |
BasisSwap | float vs float + spread | NPV |
FixFloatCrossCurrencySwap, FloatFloatCrossCurrencySwap | two-currency swap with notional exchange | NPV in the collateral currency |
Future | rate future | implied forward − market rate (convexity-adjusted if a ConvexityAdjustment quote exists) |
FxForwardPoints, FxOutrightForward | FX forward | implied FX forward − market forward |
Instruments whose floating leg references another index (e.g. a BasisSwap_USD_SOFR_TermSOFR3m_* pillar in the TermSOFR3m curve) project the other index from the already-solved curve, and all legs are discounted according to the BootstrapDiscountPolicy.
MultiCurveBootstrapper
let policy = BootstrapDiscountPolicy::new(MarketIndex::SOFR, Currency::USD);
let mut fx_store = FxStore::new();
fx_store.add_fx_rate(Currency::USD, Currency::CLP, DualFwd::new(935.0));
let curves: HashMap<MarketIndex, DiscountCurveElement> =
MultiCurveBootstrapper::new(curve_specs, policy)
.with_fx_store(fx_store) // required when any spec uses Collateral(..) or FX pillars
.bootstrap("e_store, Level::Mid)?;
bootstrap proceeds in four steps:
- Resolve every configuration. For
MarketIndex::Collateral(ccy, coll_ccy)specs the FX spotcoll_ccy→ccyis passed so cross-currency notionals are FX-consistent at inception. - Order the curves topologically with
dependency_order. A curve depends on every curve its pillar instruments need for projection or discounting. A dependency without configuration fails withNotFoundErr("Curve X requires Y for discounting but no curve configuration was provided for it …"); cycles fail withInvalidValueErr("Circular dependency detected …"). - Solve each curve in order with
bootstrap_next_curve. - Wrap the result as
DiscountTermStructure<DualFwd>with pillar labels, pillar values (the quotes) and IFT matrices, inside aDiscountCurveElement.
The Newton solve
For a curve with \(n\) pillars the unknowns are the discount factors \(x = (P_1,\dots,P_n)\) at the pillar dates, with \(P_0 = 1\) fixed. The trial curve is a DiscountTermStructure with the configured interpolator, so all instruments are repriced on the whole curve at every iteration—this is a global fit rather than a sequential strip, and it handles overlapping and non-monotone pillars.
- Initial guess \(x_0 = 0.99\) for every pillar.
VectorNewton::new(1e-12, 200): tolerance \(10^{-12}\) on the residual norm, at most 200 iterations; failure returnsSolverErr.- The Jacobian \(J = \partial F/\partial x\) is computed by central finite differences with a relative bump of \(10^{-6}\) (floored at \(10^{-8}\)) and reused for the IFT step.
Implicit-function-theorem sensitivities
At the solution \(F(x^{\ast}, q, z) = 0\), where \(q\) are the curve’s own quotes and \(z\) the discount factors of parent curves. Differentiating gives
\[ \frac{\partial x}{\partial q} = -J^{-1}\,\frac{\partial F}{\partial q},\qquad \frac{\partial x}{\partial z} = -J^{-1}\,\frac{\partial F}{\partial z}. \]
Because quote \(q_i\) enters only residual \(F_i\), \(\partial F/\partial q\) is diagonal and its entries are computed analytically (compute_quote_sensitivities). \(\partial F/\partial z\) is computed by bumping each parent discount factor. The resulting matrices are stored with the curve (with_ift_sensitivities, CrossCurveDep) and used by put_pillars_on_tape() to rebuild each discount factor as
\[ P_i = P_i^{\ast} + \sum_j \frac{\partial P_i}{\partial q_j}\,(q_j - q_j^{\ast}) + \sum_k \frac{\partial P_i}{\partial z_k}\,(z_k - z_k^{\ast}), \]
with \(q_j\) as tape leaves. Consequently, when a pricer back-propagates through a curve, sensitivities land on the quotes—OIS_USD_SOFR_5Y, BasisSwap_USD_SOFR_TermSOFR3m_2Y, …—including chained effects such as a TermSOFR3m swap’s exposure to the SOFR OIS quotes used for discounting.
Reading a bootstrapped curve
let elem = &curves[&MarketIndex::SOFR];
let curve = elem.curve(); // Ref<dyn ADCurveElement>
let df = curve.discount_factor(rd + Period::from_str("4Y")?)?.value();
if let Some(pillars) = curve.pillars() {
for (label, quote) in pillars { // label = quote identifier, value = quote level
println!("{label:<40} {:>10.4}%", quote.value() * 100.0);
}
}
let zero = -df.ln() / DayCounter::Actual360.year_fraction(rd, date);
examples/bootstrap (cargo run -p bootstrap) prints, for each of SOFR, TermSOFR3m, ICP and Collateral(CLP, USD), the pillar quotes, discount factors, zero rates, and interpolated DFs at 6M/4Y/15Y/20Y.
Credit curves
CreditCurveBootstrapper::new(Vec<CreditCurveConfiguration>).bootstrap("e_store, Level::Mid, &discount_curves) strips piecewise-constant hazard rates from CDS par spreads. The result is a CreditCurveElement wrapping a DiscountTermStructure whose “discount factor” is the survival probability \(Q(t)\).
pub struct CreditCurveConfiguration {
market_index: MarketIndex, // MarketIndex::Credit("ACME")
currency: Currency,
discount_index: MarketIndex, // curve discounting premium & protection legs, e.g. SOFR
recovery: f64, // e.g. 0.4
day_counter: DayCounter, // default Actual360
premium_frequency: Frequency, // default Quarterly
interpolator: Interpolator, // default LogLinear (on survival probabilities)
enable_extrapolation: bool, // default true
quotes: Vec<String>, // "Cds_ACME_USD_1Y", "Cds_ACME_USD_5Y", ...
}
{
"market_index": { "Credit": "ACME" },
"currency": "USD",
"discount_index": "SOFR",
"recovery": 0.4,
"quotes": ["Cds_ACME_USD_1Y", "Cds_ACME_USD_5Y", "Cds_ACME_USD_10Y"]
}
For each maturity in order, the hazard rate on the last interval is solved by bisection (bounds \(10^{-12}\) to 20, 200 iterations) so that the CDS prices to par given the previously stripped intervals. A finite-difference IFT Jacobian (spread bump \(10^{-6}\)) is attached, so CdsPricer sensitivities are reported per CDS quote exactly like rate sensitivities. Duplicate maturities or empty quote lists are configuration errors.
Interpreting failures
| Error | Typical cause |
|---|---|
NotFoundErr("Quote … not found in quotes.") | identifier typo or missing quote in the store |
NotFoundErr("Curve X requires Y …") | pillar instrument references an index (projection or collateral) without configuration |
SolverErr after 200 iterations | inconsistent quotes (e.g. deposit and OIS at the same pillar with very different levels), wrong day counter, or an FX spot inconsistent with forward points |
InvalidValueErr("Curve configuration not resolved") | instruments()/reference_date() called before bootstrap |
Multi-Curve Framework
Since the move to OIS discounting, a single currency needs several curves—one to discount collateralised cashflows and one per projected index—and each foreign currency collateralised in the base currency needs a cross-currency-adjusted curve. QuantSupport encodes this with MarketIndex naming, discount policies and bootstrapper dependency resolution.
Curve roles
| Role | MarketIndex | Built from | Used for |
|---|---|---|---|
| CSA / discount curve | e.g. SOFR | deposits + OIS | discounting all collateralised USD cashflows; projecting SOFR coupons |
| Projection curve | e.g. TermSOFR3m, EURIBOR6m | deposit + basis swaps vs the OIS index (or fixed–float swaps) | forward rates for coupons fixing on that index |
| Collateral-adjusted curve | Collateral(CLP, USD) | FX forwards + cross-currency swaps | discounting CLP cashflows under a USD CSA |
| Local OIS curve | e.g. ICP | CLP deposits + OIS | projecting ICP coupons |
Nothing in the code is hard-wired to these names: PricingContext::with_base_index / with_base_currency (defaults SOFR / USD) decide which curve is the CSA curve.
Discount policies
pub trait Discountable {
fn asset_class(&self) -> AssetClass;
fn discount_index(&self) -> Option<MarketIndex> { None }
fn currency(&self) -> Currency;
}
pub trait DiscountPolicy: Send + Sync {
fn accept(&self, target: &dyn Discountable) -> Result<MarketIndex>;
fn discount_indices(&self) -> Vec<MarketIndex>;
}
Leg, instruments and trades implement Discountable. Two policies ship with the library:
SingleCurveCSADiscountPolicy::new(discount_index, currency)– returnsdiscount_indexwhen the target’s currency equals the CSA currency, andMarketIndex::Collateral(target_ccy, csa_ccy)otherwise. This is the derivative (AssetClass::InterestRate,Fx) rule.FixedIncomeDiscountPolicy::new(prefer_instrument_index).with_risk_free_index(ccy, idx)– forAssetClass::FixedIncomeonly. Ifprefer_instrument_indexand the instrument declares its owndiscount_index(issuer curve), that wins; otherwise the per-currency risk-free index; otherwiseInvalidValueErr("No risk-free index configured for currency …"). Any other asset class is an error.
BootstrapDiscountPolicy::new(csa_index, csa_currency) combines both for the bootstrapper: discount_index(&Leg<f64>) dispatches on the leg’s asset class (FixedIncome → fixed-income policy with prefer_instrument_index = true; InterestRate/Fx → CSA policy), and discount_index_for_currency(ccy) resolves a bare currency, honouring per-currency collateral overrides first.
DiscountedCashflowPricer::set_discount_policy(Box<dyn DiscountPolicy>) installs the same kind of policy at pricing time. Without a policy the pricer falls back to a heuristic: a leg with floating coupons is discounted on the unique curve whose rate index is in the leg currency (an error if there are zero or several), otherwise the leg’s discount_index, otherwise its forward_index. Always set a policy in multi-curve setups.
Dependency resolution
CurveConfiguration::dependencies(&policy) inspects each pillar instrument’s legs: the forward index of floating legs and the discount index returned by the policy. dependency_order performs a Kahn topological sort. For the standard example configuration:
flowchart LR
SOFR --> TermSOFR3m
SOFR --> COLL["Collateral(CLP, USD)"]
ICP --> COLL
TermSOFR3mpillars are basis swaps vs SOFR: the SOFR leg is projected and discounted on the solved SOFR curve, and only the TermSOFR3m projection is unknown.Collateral(CLP, USD)pillars areFixFloatCrossCurrencySwap_CLP_SOFR_USD_*(fixed CLP vs float SOFR USD) andFxForwardPoints_USDCLP_*. The USD leg is discounted and projected on SOFR; the CLP leg’s discount curve is the unknown, so the solve produces the CLP-under-USD-collateral curve directly. FX spot (FxStore) converts the two notionals.ICPis independent; it projects ICP coupons in CLP swaps priced under USD collateral (discounting on the Collateral curve).
Missing pieces are reported explicitly: bootstrapping TermSOFR3m without a SOFR configuration fails with “Curve TermSOFR3m requires SOFR for discounting but no curve configuration was provided for it”.
Cross-curve sensitivities
Because the IFT step records \(\partial P^{\text{child}}/\partial P^{\text{parent}}\) for every parent (CrossCurveDep), risk flows through the dependency graph: a CLP swap discounted on Collateral(CLP, USD) reports sensitivities to the cross-currency swap quotes, the FX forward points and the SOFR OIS quotes. See Sensitivities for the output format.
Pricing a cross-currency portfolio
let mut ctx = PricingContext::new()
.with_quote_store(quotes)
.with_fx_store(fx) // USD/CLP spot
.with_base_currency(Currency::USD)
.with_base_index(MarketIndex::SOFR)
.with_curve_configurations(vec![sofr, term_sofr_3m, collateral_clp_usd, icp]);
ctx.initialize()?;
// CLP fixed vs ICP swap: projection on ICP, discounting on Collateral(CLP, USD)
let clp_swap = MakeSwap::<f64>::new()
.with_currency(Currency::CLP)
.with_market_index(MarketIndex::ICP)
.with_notional(1_000_000_000.0)
.with_fixed_rate(0.055)
.with_start_date(rd)
.with_maturity_date(rd + Period::from_str("5Y")?)
.build()?;
let trade = SwapTrade::new(clp_swap, rd, 1_000_000_000.0, Side::LongReceive);
let mut pricer = DiscountedCashflowPricer::<Swap<f64>, SwapTrade<f64>>::new();
pricer.set_discount_policy(Box::new(SingleCurveCSADiscountPolicy::new(MarketIndex::SOFR, Currency::USD)));
let res = pricer.evaluate(&trade, &[Request::Value, Request::Sensitivities], &ctx)?;
When the policy resolves a Collateral(leg_ccy, coll_ccy) curve, each cashflow is converted at spot and discounted on that curve, \(PV = CF*{\text{leg}}\times S*{\text{leg}\to\text{coll}}\times P_{\text{Collateral}}(T)\), so the Value of a CLP swap under a USD CSA is reported in USD. Legs in the CSA currency are discounted on the CSA curve without conversion.
Configuration checklist
- One
CurveConfigurationper index that any instrument projects or discounts on. - The CSA curve (
base_index) configured with deposits/OIS in thebase_currency. - For every foreign currency with collateralised trades, a
Collateral(ccy, base_ccy)configuration with FX forward and/or cross-currency swap pillars, plus the FX spot in theFxStore. - Fixings for every projected index with coupons already fixed.
Volatility Surfaces
Volatility objects are built from quotes exactly like curves: a configuration lists quote identifiers, a builder resolves them and produces an interpolated object stored in the ConstructedElementStore. Source: src/volatility/.
Configurations
pub struct VolatilitySurfaceConfiguration {
market_index: MarketIndex, // required
volatility_type: VolatilityType, // default Black
smile_type: SmileType, // default Strike
quotes: Vec<String>, // expiry × strike pillars
}
pub struct VolatilityCubeConfiguration { /* same fields; quotes are expiry × tenor × strike */ }
VolatilitySurfaceConfiguration::new(market_index, volatility_type, smile_type, quotes)
VolatilityCubeConfiguration::new(market_index, volatility_type, smile_type, quotes)
{
"market_index": "SOFR",
"volatility_type": "Black",
"smile_type": "Strike",
"quotes": [
"CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.035_Straddle_Black",
"CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.045_Straddle_Black",
"CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black"
]
}
| Enum | Variants | Meaning |
|---|---|---|
VolatilityType | Black, Normal | lognormal (Black-76) or Bachelier quoting |
SmileType | Strike, Delta, LogMoneyness | what the second axis (key) means |
Strike | Absolute(f64), Atm, Relative(f64) | resolve(forward) returns K, F, or F + spread |
Quote identifiers and axes
| Identifier | Axes |
|---|---|
CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.045_Straddle_Black | index tenor 3M, expiry 6M, strike 0.045, strategy Straddle, vol type Black |
Swaption_CLP_ICP_1Y_2Y_Absolute_0.045_Black | expiry 1Y, swap tenor 2Y, strike 0.045 (optional PayFreq_RecvFreq segments before the strike) |
FxCall_USDCLP_6M_Absolute_950, FxPut_... | expiry, strike (FX surfaces) |
EquityCall_USD_AAPL_1Y_Absolute_150 | expiry, strike (equity surfaces) |
Caplet quotes populate a surface (expiry × strike); swaption quotes populate a cube (expiry × tenor × strike).
Builders
let surfaces: HashMap<MarketIndex, VolatilitySurfaceElement> =
VolatilitySurfaceBuilder::new(surface_specs).build("e_store, Level::Mid)?;
let cubes: HashMap<MarketIndex, VolatilityCubeElement> =
VolatilityCubeBuilder::new(cube_specs).build("e_store, Level::Mid)?;
Inside PricingContext::initialize() the same builders run after the curves (with_volatility_surface_configurations, with_volatility_cube_configurations). Each quote value becomes a DualFwd leaf, so option sensitivities are reported per volatility quote identifier.
Querying
InterpolatedVolatilitySurface<T> implements the VolatilitySurface trait:
| Method | Notes |
|---|---|
volatility_from_period(expiry: Period, key: f64) -> Result<T> | bilinear in (expiry year fraction, key), flat extrapolation |
volatility_from_date(date: Date, key: f64) -> Result<T> | converts the date to a period first |
volatility_type(), smile_type(), market_index(), reference_date() |
InterpolatedVolatilityCube<T> adds the tenor axis: volatility_from_period(expiry, tenor, key) (trilinear).
let elem = &surfaces[&MarketIndex::SOFR];
let vol = elem.surface().volatility_from_period(Period::from_str("9M")?, 0.0325)?;
println!("9M / 3.25% Black vol = {:.4}", vol.value());
examples/volatilitysurface (cargo run -p volatilitysurface) builds the SOFR caplet surface from examples/volatilitysurface/data and prints interpolated vols for a grid of expiry/strike points plus the volatility and smile types.
FX orientation
FX surfaces are stored for one pair direction. OrientedFxVolSurface::new(&element, inverted: bool) exposes volatility_from_date(expiry, strike) and, when inverted, maps the strike as \(K \to 1/K\) so the same surface serves both USDCLP and CLPUSD trades. FxOptionPricer chooses the orientation from the trade’s pair.
Volatility sources for models
Models and simulations do not take raw surfaces; they take a VolatilitySourceConfiguration:
pub enum VolatilitySourceConfiguration {
Constant { value: f64 },
Surface { market_index: MarketIndex, key: f64 },
Cube { market_index: MarketIndex, tenor: Period, key: f64 },
Calibrated(ModelCalibrationConfiguration),
}
pub struct ModelCalibrationConfiguration {
source: CalibrationSource, // Surface { market_index } | Cube { market_index }
quote_ids: Vec<String>, // instruments to fit
strike: Option<Strike>, // e.g. "Atm" overrides the quoted strike
alpha: f64, // mean reversion used while fitting
}
{ "Constant": { "value": 0.2 } }
{ "Surface": { "market_index": "SOFR", "key": 0.03 } }
{ "Cube": { "market_index": "ICP", "tenor": "1Y", "key": 0.045 } }
{ "Calibrated": { "source": { "Surface": { "market_index": "SOFR" } },
"quote_ids": ["CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black"],
"strike": "Atm", "alpha": 0.1 } }
bootstrap_black_term_volatility(&config, &store, reference_date, day_counter) -> Result<PiecewiseConstantVolatility<f64>> reads the implied vol \(\sigma_i\) at each calibration quote and strips a piecewise-constant forward volatility so that \(\int_0^{T_i}\sigma(s)^2\,ds = \sigma_i^2 T_i\) at every pillar; negative forward variance is rejected as arbitrageable. PiecewiseConstantVolatility::new(schedule) requires a non-empty, strictly increasing (year_fraction, sigma) schedule and implements TimeDependentVolatility::vol(t).
Hull-White and LGM use the Calibrated variant to fit their short-rate sigma schedule instead; see Hull-White.
Pricing Overview
Every pricer implements the Pricer trait from src/core:
pub trait Pricer {
type Item; // the trade type
type Policy: ?Sized; // usually dyn DiscountPolicy
fn evaluate(&self, trade: &Self::Item, requests: &[Request], ctx: &impl MarketDataProvider) -> Result<EvaluationResults>;
fn market_data_request(&self, trade: &Self::Item) -> Option<MarketDataRequest>;
fn set_discount_policy(&mut self, policy: Box<Self::Policy>);
fn discount_policy(&self) -> Option<&Self::Policy>;
}
evaluate asks the PricingContext (a MarketDataProvider) for exactly the elements listed by market_data_request — discount curves per index, volatility surfaces/cubes, FX pairs, fixings — then prices on the AD tape. All results for one call share one forward pass.
Pricer catalogue
| Pricer | Trade type | Requests | Market data | Model |
|---|---|---|---|---|
DiscountedCashflowPricer<I, T>::new() | any T: LegsProvider<DualFwd> + Trade<I> (swaps, basis swaps, XCCY swaps, bonds, FRNs, deposits, FX forwards via legs) | Value, FairRate, Cashflows, Sensitivities | discount curve per leg index, FX for cross-currency legs, fixings | \(\sum_i CF_i\,P(T_i)\) |
CdsPricer::new() | CdsTrade | Value, FairRate, Sensitivities | credit curve MarketIndex::Credit(name), discount curve | premium/protection legs on survival curve |
BlackEuropeanOptionPricer::new() | EquityEuropeanOptionTrade | Value, Sensitivities | spot, equity surface, discount curve, dividend | Black-Scholes |
BlackMCEuropeanOptionPricer::new() | EquityEuropeanOptionTrade | Value, Sensitivities | a SimulationConfiguration-generated path set | \(P(T)\,\mathbb E[\text{payoff}(S_T)]\) |
FxForwardPricer::new() | FxForwardTrade | Value, FairRate, Sensitivities | base/quote discount curves, spot | \(F = S\,P*{base}/P*{quote}\) |
FxOptionPricer::new() | FxOptionTrade | Value, Sensitivities | base/quote curves, spot, FX surface | Garman-Kohlhagen |
ClosedFormBlackCapletPricer::new() | CapletFloorletTrade | Value, Sensitivities | forward curve, surface at (fixing, strike) | Black-76 |
ClosedFormBlackCapPricer::new() | CapFloorTrade | Value, Sensitivities | same | sum of Black-76 caplets |
ClosedFormHullWhiteCapletPricer::new(alpha, sigma) | CapletFloorletTrade | Value, Sensitivities | discount curve | bond-put representation |
ClosedFormHullWhiteCapPricer::new(alpha, sigma) | CapFloorTrade | Value, Sensitivities | discount curve | sum of HW caplets |
ClosedFormHullWhiteSwaptionPricer::new(alpha, sigma) | EuropeanSwaptionTrade<T> | Value, Sensitivities | discount curve | Jamshidian |
RateFuturesPricer::new() | RateFuturesTrade | Value, Sensitivities | curve of market_index | \(Q = 100 - 100F\) |
Request::YieldToMaturity and Request::ModifiedDuration exist in the enum but no public pricer currently fills them.
Discount policies
A pricer discounts each leg with the curve returned by its DiscountPolicy:
pub trait DiscountPolicy {
fn accept(&self, target: &dyn Discountable) -> Result<MarketIndex>;
fn discount_indices(&self) -> Vec<MarketIndex>;
}
| Policy | Behaviour |
|---|---|
SingleCurveCSADiscountPolicy::new(discount_index, currency) | legs in currency discount on discount_index; legs in another currency discount on MarketIndex::Collateral(leg_ccy, currency) — the FX-implied collateral curve |
FixedIncomeDiscountPolicy::new(prefer_instrument_index).with_risk_free_index(ccy, index) | bonds/deposits use their own discount_index when prefer_instrument_index and one is set, otherwise the risk-free index registered for their currency |
Without a policy DiscountedCashflowPricer discounts every leg on its own forward index. With a Collateral(..) index the pricer converts the cashflow to the collateral currency with the context FX store and discounts on the collateral curve.
let mut pricer = DiscountedCashflowPricer::<Swap<DualFwd>, SwapTrade<DualFwd>>::new();
pricer.set_discount_policy(Box::new(SingleCurveCSADiscountPolicy::new(MarketIndex::SOFR, Currency::USD)));
Results
EvaluationResults collects price(), fair_rate(), sensitivities(), cashflows(). Sensitivities are computed by one reverse sweep from the price to the quote leaves of every curve/surface used, then labelled with the quote identifiers (OIS_USD_SOFR_5Y, CapletFloorlet_..._Black). Duplicate labels coming from chained curves are merged with SensitivityMap::aggregate().
Type-erased dispatch
When a portfolio mixes trade types, register pricers in an Evaluator:
let mut pricers: HashMap<TypeId, Box<dyn ErasedPricer>> = HashMap::new();
pricers.insert(TypeId::of::<SwapTrade<DualFwd>>(), Box::new(DiscountedCashflowPricer::<Swap<DualFwd>, SwapTrade<DualFwd>>::new()));
pricers.insert(TypeId::of::<FxOptionTrade>(), Box::new(FxOptionPricer::new()));
let evaluator = Evaluator::new(pricers);
let results = evaluator.evaluate(&trade as &dyn Any, &[Request::Value], &context)?;
examples/evaluator (cargo run -p evaluator) shows this pattern.
Interest Rate Swaps
Swap<T> is two Legs: leg 0 fixed, leg 1 floating. Build it with MakeSwap (Your First Swap lists every builder field and default), wrap it in SwapTrade::new(swap, trade_date, notional, side) and price with DiscountedCashflowPricer::<Swap<T>, SwapTrade<T>>::new().
Valuation
For each coupon the pricer computes
\[ \text{NPV} = \sum_{\text{legs}} \text{sign}(\text{leg}) \sum_i N\,r_i\,\tau_i\,P_{d}(T_i) \]
- Fixed coupons: \(r_i\) from the
RateDefinition(day counter, compounding, frequency). - Floating coupons: if
accrual_start < evaluation_datethe rate is read from theFixingStore(state.get_fixing(index, accrual_start)), otherwise it is projected from the forward curve of the leg’smarket_indexwithforward_rate(start, end, Simple, frequency); thespreadis added afterwards. - \(P_d\) is the discount factor of the curve selected by the discount policy (defaults to the leg’s own index).
Request::FairRate returns the fixed rate that sets NPV to zero:
\[ K^{\ast} = \frac{\text{PV}_{\text{float}}}{\text{Annuity}},\qquad \text{Annuity}=\sum_i N\,\tau_i\,P_d(T_i). \]
Request::Cashflows returns the CashflowsTable with one row per coupon (leg_indices() distinguishes fixed/floating).
Fixings
let mut fixings = FixingStore::default();
fixings.add_fixing(&MarketIndex::SOFR, Date::new(2025, 5, 12), 0.0428);
fixings.fill_missing_fixings(Interpolator::Linear)?; // optional gap filling
let ctx = PricingContext::new().with_fixing_store(fixings) /* ... */;
JSON: {"SOFR": [{"date": "2025-05-12", "rate": 0.0428}, ...]}. A seasoned swap whose current coupon started before the evaluation date fails with NotFoundErr if the fixing is missing.
Multi-curve swaps
examples/sensitivity (cargo run -p sensitivity) bootstraps SOFR, TermSOFR3m, ICP and the CLP collateral curve, then prices:
- a SOFR OIS swap,
- a Term SOFR swap projected on
TermSOFR3mand discounted on SOFR throughSingleCurveCSADiscountPolicy::new(MarketIndex::SOFR, Currency::USD), - an ICP (CLP) swap and cross-currency swaps.
The sensitivity table for the Term SOFR swap contains both BasisSwap_USD_SOFR_TermSOFR3m_* and OIS_USD_SOFR_* rows because the basis curve depends on the SOFR curve through the IFT link described in Curve Bootstrapping.
Basis swaps
let basis = MakeBasisSwap::<DualFwd>::default()
.with_identifier("USD_SOFR_TSOFR3M_2Y".into())
.with_start_date(rd).with_maturity_date(rd + Period::from_str("2Y")?)
.with_notional(10_000_000.0)
.with_currency(Currency::USD)
.with_pay_market_index(MarketIndex::SOFR)
.with_receive_market_index(MarketIndex::TermSOFR3m)
.with_pay_spread(0.0).with_receive_spread(-0.0012)
.with_pay_leg_frequency(Frequency::Quarterly)
.with_receive_leg_frequency(Frequency::Quarterly)
.build()?;
let trade = BasisSwapTrade::new(basis, rd, 10_000_000.0, Side::LongReceive);
Required: notional, start_date, maturity_date, currency, pay_market_index, receive_market_index, identifier. Defaults: spreads 0.0, both frequencies Quarterly, side LongReceive. Priced with DiscountedCashflowPricer::<BasisSwap<T>, BasisSwapTrade<T>>.
Fixed-income instruments
The same pricer handles the fixed-income builders:
| Builder | Required | Defaults |
|---|---|---|
MakeFixedRateBond<T> | notional, start_date, maturity_date, rate, rate_definition, currency, identifier | units 100, side LongReceive, frequency Semiannual, PaymentStructure::Bullet |
MakeFloatingRateNote<T> | notional, start_date, maturity_date, forward_index, currency, identifier | spread 0, units 100, frequency Quarterly, Bullet |
MakeFixedRateDeposit<T> | notional, start_date, maturity_date, rate, rate_definition, currency, identifier | units 100, single payment |
MakeRateFutures | identifier, market_index, start_date, end_date, futures_price | contract_size 2500, rate definition from the index; priced by RateFuturesPricer |
PaymentStructure variants: Bullet, EqualPayments, EqualRedemptions, Zero, Other. Bonds may carry their own discount_index, honoured by FixedIncomeDiscountPolicy.
Cross-Currency Swaps
Two instruments cover cross-currency swaps, both with initial and final notional exchange and priced by DiscountedCashflowPricer.
Builders
let xccy = MakeFloatFloatCrossCurrencySwap::<f64>::default()
.with_identifier("CLPUSD_XCCY_5Y".to_string())
.with_start_date(rd)
.with_maturity_date(rd.advance(5, TimeUnit::Years))
.with_domestic_notional(10_000_000.0) // USD
.with_foreign_notional(10_000_000.0 * fx_clpusd) // CLP
.with_foreign_spread(0.002)
.with_domestic_currency(Currency::USD)
.with_foreign_currency(Currency::CLP)
.with_domestic_market_index(MarketIndex::SOFR)
.with_foreign_market_index(MarketIndex::ICP)
.build()?;
let trade = FloatFloatCrossCurrencySwapTrade::new(xccy, rd, 10_000_000.0, Side::LongReceive);
| Builder | Required | Defaults |
|---|---|---|
MakeFixFloatCrossCurrencySwap<T> | start_date, maturity_date, domestic_notional, foreign_notional, fixed_rate, identifier, domestic_currency, foreign_currency, floating_market_index | spread 0, side LongReceive; with_domestic_leg_frequency, with_foreign_leg_frequency |
MakeFloatFloatCrossCurrencySwap<T> | start_date, maturity_date, domestic_notional, foreign_notional, identifier, domestic_currency, foreign_currency, domestic_market_index, foreign_market_index | domestic/foreign spread 0, side LongReceive |
Trades: FixFloatCrossCurrencySwapTrade<T>::new(..), FloatFloatCrossCurrencySwapTrade<T>::new(..).
Discounting and FX
Each leg is priced in its own currency, converted to the reporting currency with the FxStore (get_fx_rate triangulates through intermediate currencies with a BFS when the direct pair is absent) and discounted on the curve chosen by the discount policy. Under a USD CSA:
pricer.set_discount_policy(Box::new(SingleCurveCSADiscountPolicy::new(MarketIndex::SOFR, Currency::USD)));
- USD leg → discounted on
SOFR. - CLP leg → discounted on
MarketIndex::Collateral(Currency::CLP, Currency::USD), the CLP curve implied by USD collateral. That curve must be bootstrapped from cross-currency basis quotes:
{
"market_index": { "Collateral": ["CLP", "USD"] },
"quotes": [
"FloatFloatCrossCurrencySwap_USD_SOFR_ICP_CLP_1Y",
"FloatFloatCrossCurrencySwap_USD_SOFR_ICP_CLP_2Y",
"FloatFloatCrossCurrencySwap_USD_SOFR_ICP_CLP_5Y"
]
}
MultiCurveBootstrapper needs with_fx_store(fx) for such specs so the notionals are FX-consistent at inception. examples/bootstrap and examples/sensitivity do this for USD/CLP; examples/cva runs the same trade through XVA.
Sensitivities
With DualFwd the sensitivity table for the swap above contains rows for OIS_USD_SOFR_* (discounting), OIS_CLP_ICP_* (projection of the CLP leg) and FloatFloatCrossCurrencySwap_USD_SOFR_ICP_CLP_* (collateral curve). Sensitivity to the FX spot is exposed if the spot is registered as a DualFwd::new leaf in the FxStore (add_fx_rate(base, quote, DualFwd)).
FX forwards
MakeFxForward (with_identifier, with_delivery_date, with_base_currency, with_quote_currency, and either with_forward_price/with_forward_rate or with_forward_points; as_deliverable() default or as_ndf(fixing_date, settlement_ccy); with_day_counter default Actual360) produces an FxForward, wrapped by FxForwardTrade::new. FxForwardPricer::new() supports Value, FairRate and Sensitivities with
\[ F = S\,\frac{P_{quote}(T)}{P_{base}(T)},\qquad \text{NPV} = N\,(F-K)\,P_{quote}(T). \]
Request::FairRate returns \(F\).
Caps and Floors
Instruments
let cap = MakeCapFloor::default()
.with_identifier("USD_SOFR_CAP_2Y".to_string())
.with_start_date(rd)
.with_maturity_date(rd + Period::from_str("2Y")?)
.with_notional(10_000_000.0)
.with_strike(0.045)
.with_cap_floor_type(CapFloorType::Cap)
.with_currency(Currency::USD)
.with_market_index(MarketIndex::SOFR)
.with_frequency(Frequency::Quarterly) // default
.build()?;
let trade = CapFloorTrade::new(cap, rd, 10_000_000.0, Side::LongReceive);
Required: notional, start_date, maturity_date, strike, currency, market_index, identifier, cap_floor_type. Defaults: side LongReceive, frequency Quarterly. CapFloorType::{Cap, Floor}; a single period is a CapletFloorlet (CapletFloorletType::{Caplet, Floorlet}) with trade CapletFloorletTrade::new(..).
Black-76 pricers
ClosedFormBlackCapletPricer::new() and ClosedFormBlackCapPricer::new() handle Request::Value and Request::Sensitivities. For each caplet with fixing \(T\), accrual \([T,S]\), \(\tau=S-T\):
\[ F = \frac{1}{\tau}\left(\frac{P(T)}{P(S)}-1\right),\qquad \text{Caplet} = N\,\tau\,Pd(S)\,[F\,\Phi(d_1) - K\,\Phi(d_2)],\quad d{1,2}=\frac{\ln(F/K)\pm\tfrac12\sigma^2 T}{\sigma\sqrt T}. \]
- The forward comes from the curve of
market_index; the discount factor from the discount policy (dual-curve when aSingleCurveCSADiscountPolicyis set). - The strike is a
Strike(Absolute,Atm,Relative) resolved against \(F\). - \(\sigma\) is read from the
VolatilitySurfaceElementformarket_indexat(fixing_date, strike)throughvolatility_from_date;VolatilityType::Normalsurfaces switch to the Bachelier formula. - A cap is the sum of its caplets; floors use the put formula.
Market data requested: the discount curve(s) and the volatility surface of the index. Sensitivities are labelled with the OIS quotes and the CapletFloorlet_* quotes that define the surface.
Hull-White pricers
ClosedFormHullWhiteCapletPricer::new(alpha, sigma) and ClosedFormHullWhiteCapPricer::new(alpha, sigma) price the same trades without a surface, using the one-factor Hull-White model with constant \(\sigma\):
\[ \text{Caplet} = N\,(1+\tau K)\;\text{BondPut}\bigl(T, S, X\bigr),\qquad X=\frac{1}{1+\tau K}, \]
where the zero-coupon bond option uses the volatility
\[ \sigma_P = \sigma\,B(T,S)\sqrt{\frac{1-e^{-2\alpha T}}{2\alpha}},\qquad B(t,T)=\frac{1-e^{-\alpha(T-t)}}{\alpha}. \]
They are useful to cross-check a calibrated model (HullWhite::calibrate_with_configuration, see Hull-White) against the Black surface it was fitted to.
Sensitivities
Both pricer families run the reverse sweep from the option value; results.sensitivities() therefore contains curve pillars (OIS_USD_SOFR_*) and, for Black pricers, one row per volatility quote (CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black), i.e. a vega ladder on the quoted grid.
Swaptions
Instrument
let swaption = MakeSwaption::<DualFwd>::default()
.with_identifier("USD_SOFR_1Y5Y_PAYER".to_string())
.with_expiry(rd + Period::from_str("1Y")?)
.with_swap_tenor_date(rd + Period::from_str("6Y")?) // underlying swap maturity
.with_strike(0.04)
.with_notional(10_000_000.0)
.with_currency(Currency::USD)
.with_market_index(MarketIndex::SOFR)
.with_swaption_type(SwaptionType::Payer) // default
.build()?;
let trade = EuropeanSwaptionTrade::new(swaption, rd, 10_000_000.0, Side::LongReceive);
Required: strike, expiry, identifier, market_index, currency, swap_tenor_date, notional. SwaptionType::{Payer, Receiver}. The underlying swap’s fixed-leg coupons (payment_time, accrual_fraction) are derived from the swaption’s frequency settings.
ClosedFormHullWhiteSwaptionPricer
let pricer = ClosedFormHullWhiteSwaptionPricer::new(alpha, sigma);
let results = pricer.evaluate(&trade, &[Request::Value, Request::Sensitivities], &ctx)?;
Handles Request::Value and Request::Sensitivities; requests the discount curve of market_index (and the policy’s discount index if different). The price is Jamshidian’s decomposition:
- Zero-coupon bond prices in Hull-White are affine, \(P(t,T\mid r_t)=A(t,T)\,e^{-B(t,T)r_t}\), with \(B(t,T)=\frac{1-e^{-\alpha(T-t)}}{\alpha}\) and \(A\) fitted to the initial curve.
- Find the critical short rate \(r^{\ast}\) such that the underlying swap’s fixed leg (coupons \(c_i\) plus final notional) is worth par at expiry: \(\sum_i c_i P(T_0,T_i\mid r^{\ast}) = 1\). The solve is a bisection with up to 200 iterations.
- Strikes \(X_i = P(T_0,T_i\mid r^{\ast})\) turn the swaption into a portfolio of zero-coupon bond options: a payer swaption is \(\sum_i c_i\,\text{BondPut}(T_0,T_i,X_i)\), a receiver the corresponding calls, each priced with the bond volatility \(\sigma\,B(T_0,T_i)\sqrt{(1-e^{-2\alpha T_0})/(2\alpha)}\).
The implicit solve is handled inside the AD framework, so Request::Sensitivities returns exact derivatives with respect to the curve quotes without bumping.
Volatility cubes
Market swaption volatilities live in a VolatilityCubeConfiguration built from Swaption_CCY_Index_Expiry_Tenor_[PayFreq_RecvFreq]_Strike_val_VolType quotes (see Volatility Surfaces). The cube is used to calibrate LGM/Hull-White sigma schedules for simulation (VolatilitySourceConfiguration::Calibrated with CalibrationSource::Cube, as in examples/cva/data/xva_config.json for ICP); pair it with the Hull-White swaption pricer to verify that the calibrated model reprices the calibration instruments.
FX and Equity Options
FX options
let opt = MakeFxOption::default()
.with_identifier("USDCLP_CALL_6M".to_string())
.with_expiry_date(rd + Period::from_str("6M")?)
.with_strike(950.0)
.with_option_type(FxOptionType::Call)
.with_base_currency(Currency::USD)
.with_quote_currency(Currency::CLP)
.with_pair("USDCLP".to_string())
.build()?;
let trade = FxOptionTrade::new(opt, rd, 1_000_000.0, Side::LongReceive);
let results = FxOptionPricer::new().evaluate(&trade, &[Request::Value, Request::Sensitivities], &ctx)?;
Required: identifier, expiry_date, strike, option_type (FxOptionType::{Call, Put}), base_currency, quote_currency, pair. Default day counter Actual360.
FxOptionPricer requests both discount curves, the spot from the FxStore and the FX volatility surface registered for the pair. It prices with Garman-Kohlhagen:
\[ F = S\,\frac{P*{base}(T)}{P*{quote}(T)},\qquad C = P*{quote}(T)\,[F\Phi(d_1)-K\Phi(d_2)],\qquad d*{1,2}=\frac{\ln(F/K)\pm\frac12\sigma^2T}{\sigma\sqrt T}. \]
The volatility is read through OrientedFxVolSurface, which inverts the strike when the surface is quoted for the reverse pair. Sensitivities cover the two curves, the vol quotes and (if registered as a DualFwd leaf) the spot.
Equity European options
let option = EquityEuropeanOption::new(/* identifier, market_index, strike, expiry, EuroOptionType::Call, currency */);
let trade = EquityEuropeanOptionTrade::new(option, notional, rd, Side::LongReceive); // note argument order
Two pricers share the trade type:
| Pricer | Requests | Method |
|---|---|---|
BlackEuropeanOptionPricer::new() | Value, Sensitivities | Black-Scholes with spot, dividend yield, discount curve and the equity surface at (expiry, strike) |
BlackMCEuropeanOptionPricer::new() | Value, Sensitivities | reads a pre-generated simulation for market_index from the context (with_simulation_configurations, ModelConfiguration::BrownianMotion) and returns \(P(T)\,\frac1n\sum_p \text{payoff}(S^p_T)\) |
The Monte Carlo pricer keeps paths as DualFwd, so sensitivities to the spot/vol/curve leaves flow through the simulation. BrownianMotion::closed_form_price / delta / vega / rho / theta(fwd, strike, vol, tau, is_call) give reference values for tests.
Equity forwards
MakeEquityForward (required identifier, market_index, delivery_date, strike, currency; defaults Actual360, LongReceive) creates an EquityForward priced as a cashflow-based trade (EquityForwardTrade). FuturesTrade wraps a generic listed future.
Vol quotes
FX surfaces: FxCall_USDCLP_6M_Absolute_950-style identifiers; equity surfaces: EquityCall_USD_AAPL_1Y_Absolute_150. Both feed VolatilitySurfaceConfiguration with smile_type Strike, Delta or LogMoneyness — the pricer passes the key consistent with the configured smile type.
Automatic Differentiation
All sensitivities in quantsupport are computed by algorithmic differentiation, not bumping. The implementation lives in src/ad/.
Scalar types
| Type | Mode | Use |
|---|---|---|
f64 | none | fastest pricing, no risk |
Fwd1..Fwd4 (Fwd<N>) | forward, N-th order tangents | second-order Greeks, tests |
Dual<T> | reverse (tape) over an inner scalar T | full curve sensitivities |
DualFwd = Dual<Fwd2> | reverse over forward | the default AD type: exact first derivatives to every quote and second-order information for IFT |
ADForward = Fwd2 | alias used by curve code |
Every pricer, curve and instrument is generic over T: Scalar; DualFwd::scalar(x), DualFwd::zero(), DualFwd::one(), DualFwd::from(x) create constants.
Tape
Tape is a thread-local recorder. Operations on Dual values push nodes only while recording:
Tape::start_recording_fwd();
let x = DualFwd::new(0.04); // leaf (recorded)
let c = DualFwd::scalar(2.0); // constant (not recorded)
let y = (x * c).exp();
y.backward(); // reverse sweep from y
let dy_dx = x.adjoint()?; // 2·exp(0.08)
Tape::stop_recording_fwd();
| API | Purpose |
|---|---|
Tape::start_recording_fwd() / stop_recording_fwd() / is_active() | control recording (start_recording etc. for Dual<f64>) |
Tape::set_mark_fwd() / rewind_to_mark_fwd() | keep the market-data part of the tape and discard trade-level nodes between evaluations |
Tape::rewind_to_init_fwd(), propagate_mark_to_start_fwd(), reset_mark_fwd() | full reset / propagate adjoints from mark to start |
Dual::new(f64) | leaf variable; constant(f64) non-differentiable |
value(), inner(), adjoint() -> Result<T> | read primal / inner forward value / gradient |
backward(), backward_to_mark(), backward_mark_to_start() | reverse sweeps over different tape ranges |
put_on_tape(), ensure_on_tape(), is_on_tape() | register a value created off-tape |
PricingContext::initialize() starts recording, bootstraps curves and surfaces (quotes become leaves), then sets a mark. Each evaluate call records the pricing nodes after the mark, runs backward_to_mark() and propagate_mark_to_start_fwd() to reach the quote leaves, reads their adjoints, and rewinds to the mark so the next trade starts from a clean tape. This is what makes portfolio-wide sensitivities cost roughly one extra pricing per trade.
Curves and pillars
curve.put_pillars_on_tape() marks pillar discount factors as leaves; curve.pillars() -> Option<Vec<(String, DualFwd)>> returns them labelled with the quote identifier. The bootstrapper uses the implicit function theorem to convert pillar adjoints into quote adjoints (see Curve Bootstrapping), so the labels in SensitivityMap are the original quotes (OIS_USD_SOFR_5Y), not internal pillars.
Forward mode
Fwd<N> carries the value and up to N tangents:
let x = Fwd2::var(1.5); // seed tangent 1
let y = x * x;
y.value(); // 2.25
y.first_derivative(); // 3.0
y.second_derivative(); // 2.0
Fwd::constant(x) has zero tangents. Inside DualFwd, the forward component propagates through the reverse sweep, which is how the bootstrapper obtains the Jacobian needed for the IFT without a second pass.
Costs and caveats
- Recording allocates: keep
Tape::start_recording_fwd()scoped and rewind between trades. - Functions with branches (
max,if) are differentiated along the taken branch; digital payoffs need smoothing (see the scriptingFuzzyEvaluator). - Sensitivities are exact derivatives of the implemented formulas, so bisection solvers in Hull-White pricers are differentiated via IFT at the converged root.
Sensitivities
Requesting
let results = pricer.evaluate(&trade, &[Request::Value, Request::Sensitivities], &ctx)?;
let sens = results.sensitivities().ok_or(/* ... */)?;
for (key, dv) in sens.instrument_keys().iter().zip(sens.exposure()) {
println!("{key:40} {dv:12.2}");
}
Sensitivities are only available when the context and trade use DualFwd. Values are \(\partial \text{NPV} / \partial q\) for each quote \(q\) in its own units (rate quotes in absolute rate: multiply by 1e-4 for a DV01 per basis point).
SensitivityMap
pub struct SensitivityMap { instrument_key: Vec<String>, exposure: Vec<f64> }
impl SensitivityMap {
pub fn instrument_keys(&self) -> &[String];
pub fn exposure(&self) -> &[f64];
pub fn with_instrument_keys(self, keys: &[String]) -> Self;
pub fn with_exposure(self, exposure: &[f64]) -> Self;
pub fn aggregate(self) -> Self; // sums duplicate keys, keeps first-occurrence order
}
aggregate() is applied by the pricers: when a child curve (a basis or collateral curve) depends on a parent curve, the IFT produces contributions to the parent quotes from both curves; they are summed under one label.
What appears in the table
| Market element | Labels |
|---|---|
| Discount/projection curves | the quotes in the CurveConfiguration (OIS_USD_SOFR_1Y, Swap_CLP_ICP_5Y, Deposit_USD_SOFR_1W, BasisSwap_*, FloatFloatCrossCurrencySwap_*) |
| Credit curves | Cds_* quotes |
| Volatility surfaces/cubes | CapletFloorlet_*, Swaption_*, FxCall_* quotes (vega ladder) |
| FX spot | only if the spot was added to the FxStore as DualFwd::new |
| Model parameters | Hull-White/LGM sigma pillars when built with HullWhiteTimeDependentVolatility::with_pillar_labels().with_ift_sensitivities() |
Quotes that do not influence the price are omitted (zero adjoint).
Portfolio aggregation
Sum maps across trades keyed by label:
let mut total: BTreeMap<String, f64> = BTreeMap::new();
for r in results {
if let Some(s) = r.sensitivities() {
for (k, v) in s.instrument_keys().iter().zip(s.exposure()) {
*total.entry(k.clone()).or_default() += v;
}
}
}
Because all trades share the same quote leaves, the summed ladder is the exact portfolio sensitivity.
Example
cargo run -p sensitivity prices SOFR, Term SOFR, ICP and USD/CLP cross-currency swaps and prints, per trade, the NPV followed by a table of quote identifier and exposure. The Term SOFR swap shows both BasisSwap_USD_SOFR_TermSOFR3m_* and OIS_USD_SOFR_* rows; the cross-currency swap adds OIS_CLP_ICP_* and FloatFloatCrossCurrencySwap_USD_SOFR_ICP_CLP_*.
Verifying against bumps
For a check, shock a quote with a Scenario and reprice:
let base = ctx.evaluate(&trade, &[Request::Value])?.price();
let mut bumped = base_ctx.with_scenarios(vec![Scenario::new("OIS_USD_SOFR_5Y", 1e-4, ScenarioType::Absolute)]);
bumped.initialize()?;
let fd = (bumped.evaluate(&trade, &[Request::Value])?.price() - base) / 1e-4;
fd should match the OIS_USD_SOFR_5Y entry to first order.
Scenarios
Scenarios shock quotes before bootstrapping, so every curve, surface and simulation that depends on them is rebuilt consistently. Source: src/quotes/scenario.rs, src/core/pricingcontext.rs.
Scenario
pub enum ScenarioType { Absolute, Relative }
pub struct Scenario { target: String, shock: f64, scenario_type: ScenarioType }
impl Scenario {
pub fn new(target: impl Into<String>, shock: f64, scenario_type: ScenarioType) -> Self;
pub fn matches(&self, identifier: &str) -> bool;
pub fn shocked_value(&self, value: f64) -> f64; // Absolute: v + shock; Relative: v · (1 + shock)
pub fn apply(&self, store: &mut QuoteStore) -> Result<usize>; // number of quotes shocked; error if none
}
Matching splits both strings on _ and requires every segment of target to appear among the identifier’s segments:
| Target | Matches |
|---|---|
OIS_USD_SOFR_5Y | exactly that quote |
SOFR | every quote containing the SOFR segment (OIS_USD_SOFR_*, BasisSwap_USD_SOFR_TermSOFR3m_*, CapletFloorlet_USD_SOFR_*) |
USD_OIS | all USD OIS quotes regardless of tenor |
CapletFloorlet_Black | all Black caplet vol quotes |
Both bid and ask are shocked.
Using with PricingContext
let mut ctx = PricingContext::new()
.with_quote_store(quotes)
.with_curve_configurations(curve_specs)
.with_scenarios(vec![
Scenario::new("OIS_USD_SOFR", 0.0001, ScenarioType::Absolute), // +1bp parallel SOFR
Scenario::new("CapletFloorlet_USD_SOFR", 0.10, ScenarioType::Relative), // vols ×1.10
]);
ctx.initialize()?;
On initialize() the context clones the base store, applies the scenarios in order, and stores the result as the shocked store. ctx.quote_store() returns the shocked store when scenarios exist (otherwise the base), and ctx.base_quote_store() always returns the unshocked quotes. Curves, volatility surfaces and simulations are all bootstrapped from quote_store().
Patterns
- Parallel shift: one scenario with a partial target, e.g.
"OIS_USD_SOFR". - Key-rate ladder: build one context per pillar target (
OIS_USD_SOFR_1Y,_2Y, …) and difference the NPVs — useful to validate the AD ladder from Sensitivities. - Stress: combine several scenarios (rates, vols, cross-currency basis) in one list; each is applied sequentially to the same store.
- Relative FX moves: target the FX spot quote (
FxSpot_USDCLP-style identifiers) withScenarioType::Relative.
Because scenarios act on quotes, all downstream consistency (multi-curve links, collateral curves, calibrated model vols) is preserved automatically, unlike bumping a curve node in isolation.
Scripting Overview
The quantsupport::scripting module lets you describe a payoff as a dated sequence of small scripts instead of implementing a new Instrument and pricer in Rust. Scripts are parsed once, statically analysed, and then evaluated over Monte Carlo paths produced by any MarketModel<DualFwd> (in practice the LGM market model). Because evaluation runs on the same AD tape as the rest of the library, a scripted payoff yields NPV, per-pillar sensitivities, and expected cashflows without any extra code, and it can enter the XVA engine as a set of ordinary contingent claims.
Everything you need is re-exported from the prelude:
#![allow(unused)]
fn main() {
use quantsupport::prelude::{
CodedEvent, Event, EventStream, // dated scripts
ScriptEngine, ScriptModelSetup, // evaluation
ScriptModelCallback, ParallelScriptEvaluation, ExpectedCashflow,
ScriptedProduct, // XVA integration
SimulationDataRequest, ScriptingError, ScriptValue,
};
}
Pipeline
Vec<CodedEvent> ──TryFrom──▶ EventStream (parsed AST per event)
│
▼
ScriptEngine::new(events, ref_date, ccy, discount_index)
│ VarIndexer → variable slots + SimulationDataRequest per event
│ IfConditionTransform / IfProcessor / DomainProcessor
▼
evaluate(&mut model, Some("swap")) → HashMap<String, f64>
evaluate_with_cashflows(&mut model, ..) → (values, Vec<ExpectedCashflow>)
evaluate_parallel(&setup, Some("swap")) → ParallelScriptEvaluation
│
▼
ScriptedProduct::new(..).contingent_claims() → Vec<ContingentClaim> for XvaEngine
Module layout (src/scripting/):
| Path | Responsibility |
|---|---|
parsing/lexer.rs, parsing/parser.rs | Tokenizer and recursive-descent parser producing Node trees |
nodes/node.rs | The Node enum (arithmetic, comparison, If, ForEach, Pays, Spot, Df, RateIndex, …) and per-node metadata used by the analysers |
nodes/event.rs | CodedEvent (date + source), Event (date + AST), EventStream |
visitors/varindexer.rs | Assigns variable slots, collects SimulationDataRequests |
visitors/ifconditiontransform.rs, ifprocessor.rs, domainprocessor.rs | Static passes preparing conditionals for smoothing and nested-if variable stores |
visitors/evaluator.rs | SingleScenarioEvaluator: exact path evaluation |
visitors/fuzzyevaluator.rs | FuzzyEvaluator: smoothed conditionals for stable AAD on digital payoffs |
request.rs | SimulationDataRequest (discounts, forwards, FX, spots, numeraire flag) |
runtime.rs | ScriptEngine, ScriptModelSetup, ParallelScriptEvaluation, ExpectedCashflow |
product.rs | ScriptedProduct, ScriptedPayoff and the IntoContingentClaims bridge to XVA |
utils/errors.rs | ScriptingError |
The numeric type used inside scripts is NumericType = DualFwd, so every script variable is differentiable with respect to curve pillars and model parameters that were put on the tape before evaluation.
A complete example
The scripting-examples package prices a one-year receive-fixed SOFR swap twice: once with MakeSwap + DiscountedCashflowPricer, once as four scripted events. The script for each accrual period (examples/scripting/src/lib.rs) is:
swap = 0; fixed_rate = 0.035; # first event only
accrual = cvg("2025-01-01", "2025-04-01", "Actual360");
floating_rate = RateIndex("SOFR", "2025-01-01", "2025-04-01");
swap pays 10000000 * (fixed_rate - floating_rate) * accrual on "2025-04-01";
and the driver (examples/scripting/src/bin/valuation.rs) evaluates it against an LGM model with zero volatility so the comparison is exact:
Tape::start_recording_fwd();
curve.put_pillars_on_tape();
let rate_model = LgmRateModel::new(DualFwd::scalar(0.03), DualFwd::zero(), &curve);
let mut model = LgmMarketModel::new(Currency::USD, MarketIndex::SOFR, reference_date(), DayCounter::Actual360)
.with_n_paths(1)
.with_seed(42);
model.add_curve_model(MarketIndex::SOFR, rate_model);
let script = ScriptEngine::new(scripted_swap_events()?, reference_date(), Currency::USD, MarketIndex::SOFR)?;
let results = script.evaluate(&mut model, Some("swap"))?;
let npv = results["swap"];
// d(NPV)/d(pillar) is now available through pillar.adjoint() for every curve pillar.
Tape::stop_recording_fwd();
Run it with:
cargo run -p scripting-examples --bin valuation # NPV + pillar sensitivities vs native swap
cargo run -p scripting-examples --bin xva # EPE profile + CVA/FVA sensitivities vs native swap
Both binaries assert agreement with the native implementation to 1e-8 (NPV, EPE) and 1e-6 (sensitivities).
When to use scripting
- Structured coupons, digitals, range accruals, autocallables, and other payoffs that are not worth a dedicated Rust instrument.
- Products whose term sheet changes frequently: the script is data (a
Vec<CodedEvent>isSerialize/Deserialize), so it can be stored and versioned alongside market data. - Getting an XVA exposure profile for a bespoke product without writing a claim decomposition.
Prefer native instruments and pricers when a closed form exists (Black caplets, Garman–Kohlhagen FX options, Hull–White swaptions) or when you need Request::FairRate or cashflow tables in the EvaluationResults format.
Script Language
Scripts are small imperative programs. Each event holds one script; statements are separated by ;, blocks use { }, and #-style comments are not supported (keep comments in the surrounding Rust or JSON). The grammar is defined in src/scripting/parsing/lexer.rs and parser.rs.
Statements
x = 0.035; # assignment
x += 1; x -= 1; x *= 2; x /= 2;
acc pays amount on "2027-06-09" in "USD";
if cond { ... } else { ... }
for i in range(1, 3) { ... }
for s in [1, 2, 3] { ... }
Identifiers are case-sensitive and must not collide with the reserved words if else and or not for true false pays on in or with the built-in function names below. Every variable that is read must have been assigned in the same or an earlier event; variables persist across events on the same path.
Literals and operators
| Category | Syntax |
|---|---|
| Numbers | 0.035, 10000000, 1e-4 |
| Booleans | true, false |
| Strings | "2027-06-09", "USD", "Actual360" (dates, currencies, day counters, index names) |
| Arithmetic | + - * /, ** (power), unary +/- |
| Comparison | == != < <= > >= |
| Logic | and, or, not |
| Arrays | [1, 2, 3], range(1, 3), vals[0], vals.append(x), vals.mean(), vals.std() |
Comparisons produce booleans that can only be used in if conditions or combined with and/or/not; they cannot be assigned to numeric variables. Use fif (below) when you need a differentiable numeric indicator.
Built-in functions
| Function | Meaning |
|---|---|
exp(x), ln(x), pow(x, y) | Elementary functions |
min(a, b, ...), max(a, b, ...) | Variadic (2 to 100 arguments) min/max, differentiable almost everywhere |
cvg("start", "end", "DayCounter") | Year fraction between two dates; day counter names follow the DayCounter enum: Actual360, Actual365, Thirty360, Thirty360US, ActualActual, Business252 |
fif(x, a, b, eps) | Smoothed “functional if”: returns a where x > eps/2, b where x < -eps/2, and interpolates linearly in between (a call spread of width eps), so the derivative with respect to x is finite |
range(a, b) | Integer range a..b for for loops |
Market data access
Market observations are resolved on the event date unless a date argument is given. Each call becomes an entry in that event’s SimulationDataRequest; the market model must be able to serve it.
| Expression | Request created | Notes |
|---|---|---|
RateIndex("SOFR", "start", "end") | ForwardRateRequest | Simple forward rate of MarketIndex::SOFR for the period; on the fixing date this is the realised rate |
Df("2027-06-09") | DiscountRequest on the engine’s local discount index | Discount factor from the event date to the given date |
Df("2027-06-09", "TermSOFR3m") | DiscountRequest on a named curve | |
Spot("AAPL") | SpotRequest for MarketIndex::Equity("AAPL") | Equity spot on the event date |
Spot("USD", "CLP") | FxRequest | Price of one USD in CLP |
Spot("USD", "CLP", "2024-12-31") | FxRequest with explicit observation date |
Index names are parsed with MarketIndex::from_str, currencies with Currency::try_from, so the spelling must match the enum variants (SOFR, TermSOFR3m, ICP, ESTR, …).
Payments
acc pays <amount> [on "<date>"] [in "<CCY>"];
accis the accumulator variable; the engine adds the discounted, numeraire-deflated value of the payment to it. Never multiply the amount by a discount factor yourself.ondefaults to the event date;indefaults to the engine’s local currency. Payments in another currency are converted with the simulated FX rate on the payment date.- Every
paysstatement gets a payment id during indexing.ScriptedProductturns each id into oneContingentClaim, andevaluate_with_cashflowsreports each id’s expected amount and present value. paysmay also appear inside an expression, e.g.call = pays max(Spot("CLP", "USD") - 900.0, 0);, which assigns the discounted payment tocall.- An
EventStreammust contain at least one event, and aScriptedProductmust contain at least onepaysexpression with no payment before the reference date.
Conditionals and smoothing
if Spot("AAPL") <= trigger {
deal pays 100000 on "2027-09-09";
} else {
deal pays 0 on "2027-09-09";
}
A plain if is a discontinuous function of Spot("AAPL"), so its pathwise derivative is zero almost everywhere and infinite at the barrier. The FuzzyEvaluator (visitors/fuzzyevaluator.rs) first lets IfConditionTransform rewrite every comparison into the canonical form \((\text{lhs}-\text{rhs}) > 0\), then replaces the hard branch selection by a truth degree \(d_t\in[0,1]\) computed with a call spread of width \(\varepsilon\):
\[ d_t(x) = \begin{cases} 0 & x < -\varepsilon/2 \ \dfrac{x + \varepsilon/2}{\varepsilon} & |x| \le \varepsilon/2 \ 1 & x > \varepsilon/2 \end{cases}, \qquad \text{result} = d_t\cdot\text{then} + (1-d_t)\cdot\text{else}. \]
Equality tests use a butterfly instead of a call spread, and and/or/not are implemented as products and complements of truth degrees. The width is chosen per comparison: with automatic scaling (the default in ScriptEngine) it is
\[ \varepsilon = \max\bigl(0.02\cdot\max(|\text{lhs}|,|\text{rhs}|),\;10^{-8}\bigr) \]
(constants AUTO_SMOOTHING_RELATIVE_WIDTH and AUTO_SMOOTHING_MIN_WIDTH), so a rate barrier at 4% is smoothed over roughly 8 bp while an equity barrier at 150 is smoothed over 3 price units. FuzzyEvaluator::with_eps overrides the fallback width used when auto scaling is off. Nested ifs are supported; the IfProcessor pass computes the maximum nesting depth and pre-allocates one variable snapshot per level so both branches can be evaluated and blended.
Use fif when a single expression is more readable than an if block, for example a digital coupon paying 5% when SOFR fixes above 4%, smoothed over 10 bp:
coupon = fif(RateIndex("SOFR", "2027-03-09", "2027-06-09") - 0.04, 0.05, 0.0, 0.001);
Worked payoffs
Capped floating coupon (per accrual period event):
a1 = cvg("2027-03-09", "2027-06-09", "Actual360");
r1 = RateIndex("SOFR", "2027-03-09", "2027-06-09");
deal pays 10000000 * (0.0385 - min(r1, 0.06)) * a1 on "2027-06-09";
European equity put settled in cash:
payoff pays 10000 * max(strike - Spot("AAPL"), 0) / strike on "2027-09-09";
Fixed-rate note (single event, explicit currency):
note = 0; note pays 1052500 on "2028-09-09" in "USD";
Errors
Parsing and evaluation return ScriptingError:
| Variant | Raised when |
|---|---|
InvalidSyntax(String) | Grammar violation or reserved-word misuse; the message includes line and column |
UnexpectedToken(String) | Token sequence does not match the expected production |
InvalidToken(String) | Lexer could not classify a character sequence |
ParsingError(ParseFloatError) | Malformed numeric literal |
EvaluationError(String) | Runtime failure, e.g. the requested result variable is not defined |
NotFoundError(String) | A market response slot was missing |
InvalidOperation(String) | Structural rule broken, e.g. no events, unordered events, payment before the reference date |
QuantSupport(QSError) | Wrapped library error (date parsing, unknown index or currency, …) |
Events and Scripted Products
A scripted product is a dated sequence of scripts. Each script runs once per Monte Carlo path on its event date, with access to the market state simulated up to that date and to every variable assigned by earlier events. The types live in src/scripting/nodes/event.rs and src/scripting/product.rs.
CodedEvent
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CodedEvent { event_date: Date, script: String }
impl CodedEvent {
pub fn new(event_date: Date, script: String) -> Self;
pub fn event_date(&self) -> Date;
pub fn script(&self) -> &String;
}
CodedEvent is the storage format: it is plain data and derives Serde, so a product can be persisted as JSON:
[
{
"event_date": "2025-01-01",
"script": "swap = 0; fixed_rate = 0.035; accrual = cvg(\"2025-01-01\", \"2025-04-01\", \"Actual360\"); floating_rate = RateIndex(\"SOFR\", \"2025-01-01\", \"2025-04-01\"); swap pays 10000000 * (fixed_rate - floating_rate) * accrual on \"2025-04-01\";"
},
{
"event_date": "2025-04-01",
"script": "accrual = cvg(\"2025-04-01\", \"2025-07-01\", \"Actual360\"); floating_rate = RateIndex(\"SOFR\", \"2025-04-01\", \"2025-07-01\"); swap pays 10000000 * (fixed_rate - floating_rate) * accrual on \"2025-07-01\";"
}
]
Dates use the library’s Date serialisation (YYYY-MM-DD).
Event and EventStream
Event::try_from(CodedEvent) parses the source into a Node tree; a syntax error is reported as ScriptingError::InvalidSyntax("<message> (event date: <date>)"), so you always know which event failed.
pub struct Event { event_date: Date, expr: Node }
impl Event {
pub fn new(event_date: Date, expr: Node) -> Self;
pub fn event_date(&self) -> Date;
pub fn expr(&self) -> &Node;
pub fn mut_expr(&mut self) -> &mut Node;
}
#[derive(Default)]
pub struct EventStream { id: Option<usize>, events: Vec<Event> }
impl EventStream {
pub fn new() -> Self;
pub fn with_id(self, id: usize) -> Self;
pub fn with_events(self, events: Vec<Event>) -> Self;
pub fn add_event(&mut self, event: Event);
pub fn events(&self) -> &[Event];
pub fn mut_events(&mut self) -> &mut Vec<Event>;
pub fn event_dates(&self) -> Vec<Date>;
}
impl TryFrom<Vec<CodedEvent>> for EventStream { type Error = ScriptingError; }
The usual way to build a stream is EventStream::try_from(coded_events), exactly as scripted_swap_events() does in examples/scripting/src/lib.rs:
pub fn scripted_swap_events() -> Result<EventStream, ScriptingError> {
let events: Vec<CodedEvent> = accrual_periods()
.into_iter()
.enumerate()
.map(|(period, (start, end))| {
let initialization = if period == 0 {
format!("swap = 0; fixed_rate = {FIXED_RATE};")
} else {
String::new()
};
let source = format!(
r#"
{initialization}
accrual = cvg("{start}", "{end}", "Actual360");
floating_rate = RateIndex("SOFR", "{start}", "{end}");
swap pays {NOTIONAL} * (fixed_rate - floating_rate) * accrual on "{end}";
"#
);
CodedEvent::new(start, source)
})
.collect();
EventStream::try_from(events)
}
Note the pattern: the event date is the fixing date (start), the rate is observed on that date, and the payment is deferred with on "{end}". The engine discounts from the payment date back to the reference date on every path.
Validation performed by ScriptEngine::new
| Check | Error |
|---|---|
| Stream has no events | InvalidOperation("a script must contain at least one event") |
An event date precedes reference_date | InvalidOperation("scripted event dates cannot precede the reference date") |
| Events are not sorted by date | InvalidOperation("scripted events must be ordered by date") |
Two events may share a date; they are executed in order.
ScriptedProduct
pub struct ScriptedProduct { id: String, engine: Arc<ScriptEngine>, payments: Vec<ScriptPayment> }
impl ScriptedProduct {
pub fn new(
id: impl Into<String>,
events: EventStream,
reference_date: Date,
local_currency: Currency,
local_discount_index: MarketIndex,
) -> Result<Self, ScriptingError>;
pub fn id(&self) -> &str;
pub fn maturity(&self) -> Date; // latest payment date
pub fn contingent_claims(&self) -> QSResult<Vec<ContingentClaim>>;
}
impl IntoContingentClaims for ScriptedProduct {
fn into_contingent_claims(&self, trade_id: &str) -> QSResult<Vec<ContingentClaim>>;
}
ScriptedProduct::new compiles the stream through ScriptEngine::new, walks every event’s AST (including if branches, for bodies and indexed expressions) and records one ScriptPayment { id, date, currency } per pays node. Two extra checks apply: at least one pays must exist, and no payment date may precede the reference date. The default date of a payment is its event date; the default currency is local_currency.
Each payment becomes one ContingentClaim built with MakeContingentClaim:
| Claim field | Value |
|---|---|
trade_id | The id passed to new (or to into_contingent_claims) |
leg_id | The payment id assigned during indexing |
payment_date | on date or event date |
currency | in currency or local_currency |
notional | 1.0 (the script amount already includes the notional) |
side | Side::LongReceive (sign lives in the script expression) |
evaluation_strategy | ClaimEvaluationStrategy::Scripted { payoff: ScriptedPayoff } |
ScriptedPayoff holds an Arc<ScriptEngine> and the payment id. When the XVA exposure evaluator reaches a valuation date it calls ScriptedPayoff::evaluate(valuation_date, responses), which replays the script on the path’s SimulationResponses and returns only the value of that payment. The engine shares one compiled script between all claims, so a product with 40 coupons parses once.
Reading scripts from files
Because CodedEvent is Deserialize, loading a product is a one-liner with serde_json:
let coded: Vec<CodedEvent> = serde_json::from_reader(File::open("product.json")?)?;
let product = ScriptedProduct::new("STRUCTURED_NOTE", EventStream::try_from(coded)?, ref_date, Currency::USD, MarketIndex::SOFR)?;
Pair this with the JSON QuoteStore, CurveConfiguration and XvaEngineConfig described in Configuration to keep an entire pricing job in data.
Script Engine
ScriptEngine (src/scripting/runtime.rs) turns an EventStream into an executable program, derives the market data it needs, and evaluates it over Monte Carlo paths while keeping the AD tape footprint bounded.
Construction
pub fn new(
events: EventStream,
reference_date: Date,
local_currency: Currency,
local_discount_index: MarketIndex,
) -> Result<ScriptEngine, ScriptingError>
new performs the whole static analysis pipeline:
- Validation – non-empty, no event before
reference_date, events sorted by date. VarIndexer– assigns a slot to each variable, numbers everypays,Df,RateIndexandSpotnode, and produces oneSimulationDataRequestper event holding theDiscountRequests,ForwardRateRequests,FxRequests andSpotRequests that event needs. Default discounting useslocal_discount_index; default currency islocal_currency.- Request flattening – the per-event requests are flattened into the
Vec<SimulationRequest>format consumed byMarketModel::set_requests, along with the observation date of each request and a map back to the script nodes. IfConditionTransform– rewrites every comparison into the canonical(lhs - rhs) > 0form used by the smoothing evaluator.IfProcessor– computes the maximumifnesting depth (max_nested_ifs) and the set of variables written inside each branch.DomainProcessor– propagates value domains through the tree so constants are folded and impossible branches are dropped.
Accessors
| Method | Returns |
|---|---|
events() -> &EventStream | The indexed and transformed stream |
requests() -> &[SimulationDataRequest] | One request bundle per event (dfs(), fwds(), fxs(), spots(), requires_numeraire()) |
model_requests() -> &[SimulationRequest] | Flattened requests for a MarketModel |
reference_date() -> Date | |
maturity() -> Date | Latest of all event dates and all requested observation/payment dates |
has_variable(&str) -> bool | Whether the script defines a variable, useful to validate a result_variable before running |
local_currency() -> Currency |
Evaluation on a single tape
pub fn evaluate(
&self,
model: &mut dyn MarketModel<DualFwd>,
result_variable: Option<&str>,
) -> Result<HashMap<String, f64>>
pub fn evaluate_with_cashflows(
&self,
model: &mut dyn MarketModel<DualFwd>,
result_variable: Option<&str>,
) -> Result<(HashMap<String, f64>, Vec<ExpectedCashflow>)>
Both methods:
- call
model.set_evaluation_dates(event_dates)andmodel.set_requests(model_requests)so the model simulates exactly the dates and observables the script uses; - read the numeraire at every event date and pre-compute control-variate expectations (see below);
- iterate over
model.n_paths()paths. For each path:Tape::rewind_to_mark_fwd(),model.generate_path(i), build aScenariofrom the responses, run the evaluator, accumulatevalue / n_pathsfor every numeric variable; - when
result_variableisSome, back-propagateresult / n_pathsto the tape mark on each path, and after the loop propagate the accumulated adjoints from the mark to the start of the tape.
The result is that any DualFwd leaf recorded before evaluate (curve pillars via curve.put_pillars_on_tape(), model parameters created with DualFwd::scalar) exposes d(mean result)/d(leaf) through .adjoint(). Do not run your own backward() afterwards. Peak tape memory is one path, independent of n_paths (the same mark/rewind pattern as the XVA exposure evaluator).
evaluate_with_cashflows additionally captures every executed pays and returns path-averaged ExpectedCashflows sorted by date:
pub struct ExpectedCashflow {
pub date: Date,
pub currency: Currency, // payment currency (local currency when not named)
pub amount: f64, // path-averaged undiscounted amount in `currency`
pub present_value: f64, // path-averaged discounted, numeraire-deflated value in local currency
}
Summing present_value over the vector reproduces the script price, so the vector is a per-date decomposition of the NPV.
Choice of evaluator
ScriptEngine picks the evaluator per scenario:
max_nested_ifs == 0→SingleScenarioEvaluator: exact evaluation, no smoothing needed.- otherwise →
FuzzyEvaluator::new(n_variables, max_nested_ifs)with automatic comparison scaling, so digital payoffs get finite, stable pathwise sensitivities (see Script Language).
Control variates
When result_variable is given, n_paths >= 16, and the script requests any discount factor or forward rate, the engine fits two martingale control coefficients on a pilot set of min(n_paths, 64) extra paths (indices n_paths..n_paths+pilot, so they are disjoint from the reported set): discounted zero-coupon bonds and discounted forward payoffs, whose expectations are known exactly from the curve. The main pass then subtracts β·(control − E[control]) from the payoff. The betas are treated as constants, so the AAD pass is not differentiated through the regression. This anchors the linear-rate component of the payoff to the curve and is why the scripted swap in the examples matches the analytic swap to 1e-8 with a single path at zero volatility and with tight error at 1 000 paths in the XVA run.
Parallel evaluation
pub trait ScriptModelSetup: Send + Sync {
fn n_paths(&self) -> usize;
fn with_model<R>(&self, callback: &mut ScriptModelCallback<'_, R>) -> Result<R>;
}
pub type ScriptModelCallback<'a, R> =
dyn FnMut(&mut dyn MarketModel<DualFwd>, &[(String, DualFwd)]) -> Result<R> + 'a;
pub fn evaluate_parallel<S: ScriptModelSetup>(
&self,
setup: &S,
result_variable: Option<&str>,
) -> Result<ParallelScriptEvaluation>
pub struct ParallelScriptEvaluation {
pub values: HashMap<String, f64>, // path-averaged script variables
pub sensitivities: Vec<(String, f64)>, // adjoints of the leaves supplied by `with_model`, sorted by label
pub cashflows: Vec<ExpectedCashflow>,
}
A DualFwd holds a pointer into a thread-local tape, so a model built on the caller’s thread cannot be shared with Rayon workers. ScriptModelSetup::with_model is your factory: on each worker it must rebuild the curves and model, put the pillars/parameters you want sensitivities for on that worker’s tape, and pass them as labelled leaves. The engine then:
- splits
0..n_pathsintorayon::current_num_threads()contiguous ranges; - on each worker resets and starts a fresh tape, calls
with_model, configures the model, evaluates the range, and readsleaf.adjoint()for each supplied leaf; - sums values, adjoints and cashflows across workers. Normalisation always uses the total path count, so results are independent of the number of threads and deterministic for a fixed seed.
Sketch of a setup:
struct SofrSetup { ref_date: Date, dfs: Vec<(Date, f64, String)>, n_paths: usize }
impl ScriptModelSetup for SofrSetup {
fn n_paths(&self) -> usize { self.n_paths }
fn with_model<R>(&self, callback: &mut ScriptModelCallback<'_, R>) -> Result<R> {
let mut curve = DiscountTermStructure::<DualFwd>::new(
self.dfs.iter().map(|(d, _, _)| *d).collect(),
self.dfs.iter().map(|(_, df, _)| DualFwd::from(*df)).collect(),
DayCounter::Actual360, Interpolator::LogLinear, true,
)?.with_pillar_labels(self.dfs.iter().map(|(_, _, l)| l.clone()).collect())?;
curve.put_pillars_on_tape();
let leaves: Vec<(String, DualFwd)> = curve.pillars().unwrap_or_default();
let rate_model = LgmRateModel::new(DualFwd::scalar(0.05), DualFwd::scalar(0.01), &curve);
let mut model = LgmMarketModel::new(Currency::USD, MarketIndex::SOFR, self.ref_date, DayCounter::Actual360)
.with_n_paths(self.n_paths)
.with_seed(42);
model.add_curve_model(MarketIndex::SOFR, rate_model);
callback(&mut model, &leaves)
}
}
let result = engine.evaluate_parallel(&setup, Some("swap"))?;
println!("NPV = {}", result.values["swap"]);
for (pillar, dv) in &result.sensitivities { println!("{pillar}: {dv}"); }
Errors
evaluate* return ScriptingError::EvaluationError when the result variable is not defined ("result variable 'x' is not defined by the script"), when the model exposes zero paths, when a path cannot be generated, or when a SimulationResponse lacks a value the script requested. Model failures are wrapped as ScriptingError::QuantSupport(QSError).
Scripted Products in XVA
ScriptedProduct implements IntoContingentClaims, so a script drops into the XVA engine exactly like a SwapTrade or a cross-currency swap. Nothing in XvaEngine, NettingSet or the aggregators knows about scripts; they only see ContingentClaims whose evaluation_strategy is ClaimEvaluationStrategy::Scripted { payoff }.
From script to claims
let scripted_claims = ScriptedProduct::new(
"scripted_swap",
scripted_swap_events()?,
reference_date(),
Currency::USD,
MarketIndex::SOFR,
)?
.contingent_claims()?;
Each pays statement becomes one claim (notional = 1.0, side = LongReceive, leg_id = payment id). The claims share a single Arc<ScriptEngine>, so the script is parsed and indexed once regardless of how many coupons it generates.
How the exposure evaluator prices a scripted claim
During XvaEngine::run:
PreprocessorExecutorcollectsSimulationRequests from every claim. For scripted claims these areScriptedPayoff::simulation_requests(), i.e.ScriptEngine::model_requests()— the discount factors, forward rates, FX rates and spots the script observes.- The LGM market model simulates those observables on every path and evaluation date.
- At each valuation date \(t_k\) the exposure evaluator asks every live claim for its value. For a scripted claim it calls
ScriptedPayoff::evaluate(valuation_date, responses), which replays the compiled script on that path’s responses and returns the numeraire-deflated value of that payment only. Payments already settled before \(t_k\) are excluded automatically, so the exposure profile rolls off correctly. - The per-path NPVs are aggregated into
NpvCubes and then into CVA/DVA/FVA by the configured aggregators.
Because the payoff is evaluated in DualFwd, the engine’s AAD pass produces XVA sensitivities to curve pillars and model parameters for scripted claims with no extra work.
Worked comparison
examples/scripting/src/bin/xva.rs runs the native and scripted swap through the same engine and asserts identical results:
fn csa_terms() -> CsaTerms {
CsaTerms {
collateral_index: MarketIndex::SOFR,
collateral_currency: Currency::USD,
credit_spread: 0.01,
recovery: 0.4,
funding_spread: 0.005,
funding_spread_curve: None,
funding_index: None,
credit_index: None,
}
}
let config = XvaEngineConfig {
model_configs: vec![LgmModelConfig {
market_index: MarketIndex::SOFR,
lambda: Some(0.05),
sigma: Some(0.01),
volatility: None,
driver: None,
}],
fx_configs: Vec::new(),
n_paths: 1_000,
seed: 42,
frequency: Frequency::Quarterly,
};
let native_claims = native_swap()?.into_contingent_claims()?;
let scripted_claims = ScriptedProduct::new("scripted_swap", scripted_swap_events()?, ref_date, Currency::USD, MarketIndex::SOFR)?
.contingent_claims()?;
let mut netting_sets = HashMap::from([("swap".to_string(), NettingSet::with_csa_terms(claims, csa_terms()))]);
let result: ExposureResult = XvaEngine::new(&context, config)?.run(&mut netting_sets)?;
let epe = result.cubes.iter().find(|c| c.trade_id == "swap").map(NpvCube::epe);
let sensitivities: BTreeMap<String, f64> = result.sensitivities.unwrap().into_iter().collect();
The binary prints the EPE vector for both routes and a table of combined CVA/FVA sensitivities per risk factor (SOFR.3M, SOFR.6M, … plus model parameters), then checks that the maximum EPE difference and the maximum sensitivity difference are below 1e-8:
cargo run -p scripting-examples --bin xva
Mixing scripted and native trades
A netting set is just Vec<ContingentClaim>, so you can concatenate claims from different sources:
let mut claims = irs_trade.into_contingent_claims()?;
claims.extend(xccy_trade.into_contingent_claims()?);
claims.extend(structured_note.contingent_claims()?); // ScriptedProduct
netting_sets.insert("counterparty_A".into(), NettingSet::with_csa_terms(claims, csa));
Trade ids are preserved in the resulting NpvCubes, so result.cubes still lets you separate the exposure contribution of the scripted note from the vanilla swaps while CVA/DVA/FVA are computed on the netted total.
Limitations
- Scripted claims are always
Side::LongReceivewith unit notional; express direction and notional inside the script. - The market model must be able to serve every request the script makes:
RateIndex("X", …)needs a curve model forMarketIndex::X,Spot("USD","CLP")needs an FX model for CLP,Spot("AAPL")an equity model. - Scripting is currently Rust-only; the Python bindings expose the XVA engine for native trades but not
ScriptedProduct.
Monte Carlo Framework
Two simulation layers exist:
- Single-index path sets (
SimulationConfiguration→SimulationBuilder→GeneratedMonteCarloSimulation) stored in thePricingContextand consumed by pricers such asBlackMCEuropeanOptionPricer. - Multi-asset market models (
LgmMarketModel, theMarketModel<T>trait) that drive exposure and XVA engines and the scripting engine (Scripting).
SimulationConfiguration
pub struct SimulationConfiguration {
market_index: MarketIndex,
model: ModelConfiguration,
n_paths: usize, // default 1000
seed: u64, // default 42
horizon: Period,
frequency: Frequency, // default Monthly
day_counter: DayCounter, // default Actual365
}
SimulationConfiguration::new(market_index, model, n_paths, seed, horizon, frequency)
{
"market_index": "SOFR",
"model": {
"HullWhite": {
"alpha": 0.1,
"volatility": { "Constant": { "value": 0.01 } }
}
},
"n_paths": 2000,
"seed": 7,
"horizon": "5Y",
"frequency": "Monthly"
}
ModelConfiguration
| Variant | Fields | Dynamics |
|---|---|---|
HullWhite { alpha, volatility } | mean reversion, VolatilitySourceConfiguration | \(dr = (\theta(t)-\alpha r)dt + \sigma(t)dW\) |
BrownianMotion { volatility, dividend_rate } | vol source, optional yield | \(dS = (r-q)S\,dt + \sigma(t)S\,dW\) |
Lgm { lambda, volatility } | mean reversion (0 = none), vol source | see LGM |
The volatility source may be Constant, a point on a Surface/Cube, or Calibrated (fits the sigma schedule to caplets/swaptions, see Hull-White).
Building
let sims: HashMap<MarketIndex, MonteCarloSimulationElement> =
SimulationBuilder::new(specs).build(&constructed_store, "e_store, &fixing_store, Level::Mid)?;
PricingContext::with_simulation_configurations(specs) runs this in initialize() after curves and surfaces so calibrated models can see them. The dates grid is reference_date + k·frequency up to horizon.
GeneratedMonteCarloSimulation
pub fn new(market_index: MarketIndex, dates: Vec<Date>, paths: Vec<Vec<f64>>, dt: f64) -> Self;
fn path(&self) -> &Vec<Vec<DualFwd>>; // paths[path][date]
fn n_paths(&self) -> i64;
fn dates(&self) -> &[Date];
fn dt(&self) -> f64; // average step in years
fn market_index(&self) -> MarketIndex;
Paths are stored as DualFwd, so a pricer averaging payoffs over paths still yields AD sensitivities to spot, curve and volatility leaves.
BrownianMotion
BrownianMotion::new(spot, rate, Box<dyn TimeDependentVolatility<T>>, dividend_rate: Option<T>)
Exact log-Euler stepping \(S_{t+\Delta} = S_t\exp\bigl((r-q-\tfrac12\sigma^2)\Delta + \sigma\sqrt\Delta Z\bigr)\). Static helpers closed_form_price, delta, vega, rho, theta ((fwd, strike, vol, tau, is_call)) provide analytic references.
Random numbers
Single-index simulations use rand with the configured seed. LgmMarketModel uses Owen-scrambled Sobol sequences (sobol_burley) with antithetic pairing (n_paths must be even) and a Cholesky factor of the user correlation matrix; the same seed reproduces the same paths.
MarketModel<T> trait
pub trait MarketModel<T: Scalar> {
fn n_paths(&self) -> usize;
fn set_evaluation_dates(&mut self, dates: Vec<Date>);
fn set_requests(&mut self, requests: Vec<SimulationRequest>);
fn generate_path(&self, index: usize) -> Option<PathScenario<T>>;
fn resolve_request(&self, eval_date: Date, request: &SimulationRequest) -> SimulationResponse<T>;
}
SimulationResponse carries discounts, forward_rates, fx_rates, spots, path_dependent_observations and the numeraire at each evaluation date; exposure engines call resolve_request per claim rather than reading raw states. The ScriptEngine and XvaEngine accept any implementor.
Hull-White Model
One-factor Gaussian short-rate model, src/models/hullwhite/:
\[ dr_t = \bigl(\theta(t) - \alpha r_t\bigr)dt + \sigma(t)\,dW_t . \]
API
pub struct HullWhite<'a, T: Scalar> {
alpha: T,
curve: &'a dyn InterestRatesTermStructure<T>,
calibration_quality: Option<HullWhiteCalibrationQuality>,
vol_func: Option<HullWhiteTimeDependentVolatility<T>>,
}
let mut hw = HullWhite::new(alpha, &sofr_curve).with_constant_volatility(0.01);
| Method | Formula |
|---|---|
B(t, T) | \(\frac{1-e^{-\alpha(T-t)}}{\alpha}\) |
A(t, T, sigma, curve) | \(\frac{P(0,T)}{P(0,t)}\exp\!\bigl(B\,f(0,t) - \frac{\sigma^2}{4\alpha}(1-e^{-2\alpha t})B^2\bigr)\) |
zcb_price(r_t, t, T, sigma, curve) | \(A(t,T)\,e^{-B(t,T)r_t}\) |
zcb_price_volatility(sigma, t, T) | \(\sigma B(t,T)\sqrt{\frac{1-e^{-2\alpha t}}{2\alpha}}\) |
theta(t, sigma, curve) | drift fitted to the initial curve |
caplet_price(strike, t, S, sigma, curve) | \((1+\tau K)\,\text{BondPut}(t,S,\frac1{1+\tau K})\) |
swaption_price(strike, t_option, &[(pay_time, accrual)], sigma, curve) | Jamshidian decomposition |
bond_put_price, bond_call_price | zero-coupon bond options |
The closed forms are shared with ClosedFormHullWhiteCapletPricer, ClosedFormHullWhiteCapPricer and ClosedFormHullWhiteSwaptionPricer (Caps and Floors, Swaptions).
Calibration
hw.calibrate("e_ids, "e_store, &curve, Level::Mid)?;
hw.calibrate_with_configuration(&config, &constructed_store, "e_store, &curve, Level::Mid)?;
ModelCalibrationConfiguration (JSON in examples/hullwhite/data/hw_calibration.json):
{
"source": { "Surface": { "market_index": "SOFR" } },
"quote_ids": [
"CapletFloorlet_USD_SOFR_3M_3M_Absolute_0.045_Straddle_Black",
"CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.045_Straddle_Black",
"CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black"
],
"strike": "Atm",
"alpha": 0.1
}
Algorithm, per calibration quote in expiry order:
- Parse the identifier to get expiry \(T_i\), index tenor and strike;
strike: "Atm"replaces the quoted strike with the forward. - Read the Black (or Normal) vol from the surface/cube and compute the market caplet/swaption price.
- Solve by bisection for the piecewise-constant \(\sigmai\) on \([T{i-1},T_i]\) such that the Hull-White price matches, keeping earlier pillars fixed.
Results are kept in HullWhiteCalibrationQuality { records: Vec<HullWhiteCalibrationRecord> }, each record holding identifier, expiry, t, big_t, market_vol, market_price, model_price, calibrated_sigma, forward_rate, effective_strike. HullWhiteTimeDependentVolatility::new(schedule).with_pillar_labels().with_ift_sensitivities() exposes the sigma pillars as labelled AD leaves so downstream prices carry sensitivities to the calibration quotes.
cargo run -p hullwhite bootstraps SOFR, builds the caplet surface, calibrates and prints a quality table (expiry, t, market vol, model implied vol, market price, model price, error) followed by ATM cap prices built from the calibrated model, then simulates paths using examples/hullwhite/data/simulation.json.
Simulation
{
"market_index": "SOFR",
"model": {
"HullWhite": {
"alpha": 0.1,
"volatility": {
"Calibrated": {
"source": { "Surface": { "market_index": "SOFR" } },
"quote_ids": ["..."],
"strike": "Atm",
"alpha": 0.1
}
}
}
},
"n_paths": 1000,
"seed": 42,
"horizon": "5Y",
"frequency": "Monthly"
}
SimulationBuilder calibrates (if Calibrated), then evolves \(r\) exactly on the date grid with the Gaussian transition \(r_{t+\Delta} = r_t e^{-\alpha\Delta} + \int\theta + \sigma\sqrt{\frac{1-e^{-2\alpha\Delta}}{2\alpha}}Z\). Discount factors along a path are zcb_price(r_t, t, T). In the XVA engine the same calibrated schedule is transferred to an LGM model via LgmRateModel::calibrated (LGM).
Linear Gaussian Markov (LGM)
The multi-currency simulation engine (src/models/lgm/) is built from LGM rate components plus lognormal FX and equity components, all driven under the domestic risk-neutral measure.
LgmRateModel
pub struct LgmRateModel<'a, T: Scalar> {
lambda: T, // mean reversion (1/years); 0 = none
sigma_schedule: Vec<(f64, T)>,// piecewise-constant σ(t)
discount_curve: &'a dyn InterestRatesTermStructure<T>,
}
LgmRateModel::new(lambda, sigma, &curve)
LgmRateModel::new_piecewise(lambda, schedule, &curve)? // schedule non-empty, increasing
LgmRateModel::calibrated(lambda, &curve, &calibration_config, &store, "es, Level::Mid)?
calibrated runs the Hull-White caplet/swaption bootstrap (Hull-White) and transfers the sigma schedule.
State variable \(z_t\) with \(z_0 = 0\):
| Method | Formula |
|---|---|
H(t) | \(\frac{1-e^{-\lambda t}}{\lambda}\) (\(= t\) when \(\lambda\approx 0\)) |
H_dot(t) | \(e^{-\lambda t}\) |
alpha(t) | \(\sigma(t)e^{\lambda t}\) |
zeta(t) | \(\int_0^t\alpha(s)^2ds\) |
P_discount(t, T, z) | \(\frac{P(0,T)}{P(0,t)}\exp\!\bigl(-(H(T)-H(t))z - \tfrac12(H(T)^2-H(t)^2)\zeta(t)\bigr)\) |
numeraire(t, z) | \(\exp\!\bigl(H(t)z + \tfrac12H(t)^2\zeta(t)\bigr)/P(0,t)\) |
instantaneous_forward_rate(t, T, z) | \(f(0,T) + H’(T)H(T)\zeta(t) + H’(T)z\) |
short_rate(t, z) | \(f(t,t\mid z)\) |
self_drift(t) | 0 (domestic factor is driftless) |
gamma_under_domestic_measure(t, &dom, fx_vol, rho_zx, rho_zz) | \(\rho*{zz}\alpha_i\alpha_d H_d - \alpha_i^2 H_i - \rho*{zx}\sigma_X\alpha_i\) |
evolve_domestic_factor_euler(t, z, dt, dw) | \(z + \alpha(t)\,dW\) |
evolve_foreign_factor_under_domestic_measure_euler(..) | \(z + \gamma\,dt + \alpha(t)\,dW\) |
FX and equity components
LgmFxModel::new(&domestic_rates, &foreign_rates, fx_vol, spot_0, rho_zx_dom_fx) // spot = domestic per foreign
LgmEquityModel::new(&domestic_rates, equity_vol, spot_0, dividend_yield: Option<f64>, rho_zs_dom)
LgmMarketModel
let mut model = LgmMarketModel::new(Currency::USD, MarketIndex::SOFR, reference_date, DayCounter::Actual365)
.with_n_paths(2000) // must be even (antithetic)
.with_seed(42)
.with_correlation_matrix(corr); // ordered as the state vector below
model.add_curve_model(MarketIndex::SOFR, sofr_lgm);
model.add_curve_model(MarketIndex::ICP, icp_lgm);
model.add_fx_model(Currency::CLP, clp_fx);
model.add_equity_model("AAPL".into(), aapl);
model.set_curve_driver(MarketIndex::TermSOFR3m, MarketIndex::SOFR); // index simulated off another factor
model.set_evaluation_dates(dates);
model.set_requests(requests);
State vector \(Y(t) = [z_d, z_{f_1},\dots,z_{f_F}, \log X_1,\dots,\log X_F, \log S_1,\dots,\log S_E]\) with dynamics under the domestic measure
\[ \begin{aligned} dz_d &= \alpha_d\,dW_d, & dz_{f_i} &= \gamma_i\,dt + \alpha_i\,dW_{f_i},\ d\log X_i &= (r_d - r_i + \rho_{d,X_i}\alpha_d H_d\sigma_{X_i} - \tfrac12\sigma_{X_i}^2)dt + \sigma_{X_i}dW_{X_i},\ d\log S_j &= (r_d - q_j + \rho_{d,S_j}\alpha_d H_d\sigma_{S_j} - \tfrac12\sigma_{S_j}^2)dt + \sigma_{S_j}dW_{S_j}. \end{aligned} \]
Path generation: Owen-scrambled Sobol draws → antithetic pairs → Cholesky-correlated increments → Euler steps between consecutive evaluation dates → resolve_request answers each SimulationRequest (discount factor, forward rate, FX, spot, numeraire) from the state. Discount factors within a path are P_discount, forward rates come from instantaneous_forward_rate/P_discount ratios, and the numeraire is used to deflate cashflows in exposure and pricing engines.
JSON configuration
ModelConfiguration::Lgm { lambda, volatility } in a SimulationConfiguration, or LgmModelConfig inside XvaEngineConfig (XVA Overview):
{
"market_index": "SOFR",
"lambda": 0.05,
"volatility": {
"Calibrated": {
"source": { "Surface": { "market_index": "SOFR" } },
"quote_ids": [
"CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black"
],
"strike": "Atm",
"alpha": 0.05
}
}
}
Provide either sigma (constant) or volatility; driver lets an index reuse another index’s factor.
Example
cargo run -p pfe builds a USD SOFR swap and an EUR/USD FX forward, bootstraps SOFR and ESTR, loads an LGM configuration, simulates, and prints per-trade NPV, the exposure profile through PfeAggregator (quantiles by date) — see Exposure Simulation.
Exposure Simulation
Exposure is computed by projecting trades into ContingentClaims, evaluating them along MarketModel paths, and aggregating the resulting NPV cube. Source: src/xva/.
Contingent claims
pub struct ContingentClaim {
trade_id: String, leg_id: String, idx: usize,
payment_date: Date, fixing_date: Option<Date>,
accrual_start: Option<Date>, accrual_end: Option<Date>,
currency: Currency, foreign_currency: Option<Currency>,
notional: f64, side: Side,
evaluation_strategy: ClaimEvaluationStrategy,
index: Option<MarketIndex>,
realized_fixing: Option<f64>, partial_fixing: Option<f64>,
}
ClaimEvaluationStrategy | Meaning |
|---|---|
Deterministic { amount } | fixed coupon / notional exchange |
LinearRate { spread, day_counter } | floating coupon \(N\,(L+s)\,\tau\) |
NonLinearRate { payoff_ops, strike, spread, day_counter } | caplet/floorlet-style payoff on the rate |
SpotPayoff { payoff_ops, strike, observation_date } | FX/equity option payoff on a spot |
PathDependent { observation_dates, aggregator, payoff_ops, strike } | Asian/lookback-style payoff |
ExerciseContingent { exercise_date, exercise_group, inner } | claim alive only if the group is exercised |
Scripted { payoff } | payoff from a ScriptedProduct (Scripting) |
Trades implement IntoContingentClaims (swaps, cross-currency swaps, caps, FX forwards/options, scripted products); MakeContingentClaim builds claims by hand. Claims are the common language of the exposure engine, so any trade type reduces to the same evaluation loop.
Preprocessing
let claims = PreprocessorExecutor::new()
.with_preprocessor(Box::new(FixingPreprocessor::new(reference_date, DayCounter::Actual360, &fixing_store)))
.with_compression()
.visit(claims)?;
FixingPreprocessor fills realized_fixing for coupons whose fixing date is in the past; with_compression() merges deterministic claims paying on the same date and currency to shrink the cube.
NPV cube
For each evaluation date \(t_k\) on the frequency grid and each path \(p\), the engine values every claim with payment date after \(t_k\) using the path’s discount factors and numeraire, converts to the netting-set currency with the simulated FX and stores
\[ \text{NPV}{p,k} = \sum{\text{claims}} \text{side}\cdot\text{payoff}_p\,\frac{P_p(t_k,T)}{1}. \]
pub struct NpvCube { trade_id: String, dates: Vec<Date>, npvs: Matrix<f64> /* [path][date] */ }
impl NpvCube {
pub fn epe(&self) -> Vec<f64>; // mean(max(NPV,0)) per date
pub fn ene(&self) -> Vec<f64>; // mean(min(NPV,0)) per date
pub fn ee(&self) -> Vec<f64>; // mean(NPV) per date
}
Aggregators
| Type | Output |
|---|---|
PfeAggregator / PfeAggregatorFactory | quantile of positive exposure per date (e.g. 97.5%) |
CvaAggregator { lgd, hazard } | \(\sumk \text{EPE}_k\,\text{LGD}\,(S(t{k-1})-S(t_k))\) with \(S(t)=e^{-\lambda t}\) |
AggregatorBundle | runs several aggregators over one cube |
CvaFactory, DvaFactory, FvaFactory, CreditCurveCvaFactory, FundingCurveFvaFactory | build aggregators from CsaTerms (flat spreads or bootstrapped credit/funding curves) |
Running
The high-level entry point is XvaEngine (XVA Overview); the low-level flow used by examples/pfe is:
- Build claims from trades and preprocess.
- Build an
LgmMarketModel(or anyMarketModel) withset_evaluation_datesand the claims’SimulationRequests. - Evaluate claims path by path into an
NpvCube. - Apply aggregators.
cargo run -p pfe prints the trades’ NPVs, then a table of date, EE, EPE and PFE quantile for the netted portfolio. For scripted payoffs the same machinery is reused by ScriptEngine::evaluate_with_cashflows and ExpectedCashflow, so exotic products can be included in the netting set (Scripting and XVA).
XVA Overview
XvaEngine (src/xva/engine.rs) turns a PricingContext plus a set of NettingSets into exposure cubes, CVA/FVA values and AD sensitivities in one run.
Pipeline
flowchart LR
T[Trades] -->|IntoContingentClaims| C[ContingentClaims]
C --> N[NettingSet + CsaTerms]
N --> E[XvaEngine::run]
E --> P[FixingPreprocessor]
P --> S[LgmMarketModel paths]
S --> Q[NpvCube per trade]
Q --> A[CVA / FVA aggregators]
A --> R[ExposureResult]
run performs, in order:
- Preprocess claims (
FixingPreprocessorfills realized fixings) and collect theSimulationRequests each claim needs. - Validate that every discount index selected by a netting set’s discount policy has an
LgmModelConfig. - Build the evaluation grid with
MakeSchedule::new(reference_date, max_payment_date).with_frequency(frequency). - Compute system discount factors \(P(0,t_k)\) from the domestic curve; XVA values are reported in present value on that curve (deterministic, no rate sensitivity through this term).
- For each netting set build a CVA aggregator (
CreditCurveCvaFactoryifcredit_indexis set, else flatCvaFactoryfromcredit_spread/recovery) and an FVA aggregator (FundingCurveFvaFactoryfromfunding_indexorfunding_spread_curve, else flatFvaFactoryfromfunding_spread). - Build the LGM market model from the configs (calibrating sigma schedules if
volatilityisCalibrated), simulate, evaluate claims intoNpvCubes, aggregate, and back-propagate adjoints to the labelled leaves.
Configuration
pub struct XvaEngineConfig {
model_configs: Vec<LgmModelConfig>, // one per simulated curve
fx_configs: Vec<FxModelConfig>, // one per foreign currency
n_paths: usize,
seed: u64,
frequency: Frequency,
}
pub struct LgmModelConfig { market_index, lambda: Option<f64>, sigma: Option<f64>,
volatility: Option<VolatilitySourceConfiguration>, driver: Option<MarketIndex> }
pub struct FxModelConfig { foreign_currency: Currency, fx_vol: f64, rho: f64 }
examples/cva/data/xva_config.json:
{
"model_configs": [
{
"market_index": "SOFR",
"lambda": 0.05,
"volatility": {
"Calibrated": {
"source": { "Surface": { "market_index": "SOFR" } },
"quote_ids": [
"CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black"
],
"strike": "Atm",
"alpha": 0.05
}
}
},
{
"market_index": "ICP",
"lambda": 0.05,
"volatility": {
"Calibrated": {
"source": { "Cube": { "market_index": "ICP" } },
"quote_ids": ["Swaption_CLP_ICP_1Y_2Y_Absolute_0.045_Black"],
"alpha": 0.05
}
}
}
],
"fx_configs": [{ "foreign_currency": "CLP", "fx_vol": 0.12, "rho": 0.0 }],
"n_paths": 2000,
"seed": 42,
"frequency": "Monthly"
}
Running
let config: XvaEngineConfig = serde_json::from_str(&fs::read_to_string("data/xva_config.json")?)?;
let mut engine = XvaEngine::new(&ctx, config)?;
let csa: CsaTerms = serde_json::from_str(&fs::read_to_string("data/csa_terms.json")?)?;
let mut sets = HashMap::new();
sets.insert("CLIENT_A".to_string(),
NettingSet::with_csa_terms(vec![swap.into_claims()?, xccy.into_claims()?].concat(), csa));
let result = engine.run(&mut sets)?;
for cube in &result.cubes { println!("{} EPE(1Y) = {:.0}", cube.trade_id, cube.epe()[12]); }
for v in result.xva_values.unwrap_or_default() { println!("{} {} {:.2}", v.netting_set, v.measure, v.value); }
for (label, dv) in result.sensitivities.unwrap_or_default() { println!("{label:40} {dv:12.4}"); }
ExposureResult { cubes: Vec<NpvCube>, xva_values: Option<Vec<XvaValue { netting_set, measure, value }>>, sensitivities: Option<Vec<(String, f64)>> }. measure is "CVA" or "FVA" (a DvaAggregator exists for own-credit calculations built manually).
cargo run -p cva runs this on a 5Y USD SOFR swap (10M, receive 3.78%) and a 5Y USD/CLP float-float cross-currency swap and prints netting set, measure and value.
Chapters: Netting Sets and CSA, CVA, DVA and FVA, XVA Sensitivities.
Netting Sets and CSA
NettingSet
NettingSet::new(claims: Vec<ContingentClaim>, policy: Box<dyn DiscountPolicy>)
NettingSet::with_csa_terms(claims: Vec<ContingentClaim>, csa: CsaTerms)
ns.claims() -> &[ContingentClaim]
ns.csa_terms() -> Option<&CsaTerms>
ns.discount_policy() -> &dyn DiscountPolicy
All claims in a netting set are summed per path and date before taking positive/negative parts, so netting benefit is captured. XvaEngine::run requires with_csa_terms; NettingSet::new is for exposure-only runs with a custom policy.
CsaTerms
pub struct CsaTerms {
collateral_index: MarketIndex, // discount curve for collateralised cashflows
collateral_currency: Currency, // currency of collateral
credit_spread: f64, // flat counterparty hazard rate (if no credit_index)
recovery: f64, // LGD = 1 - recovery
funding_spread: f64, // flat funding spread (fallback)
funding_spread_curve: Option<FundingSpreadCurve { dates: Vec<Date>, spreads: Vec<f64> }>,
funding_index: Option<MarketIndex>, // bootstrapped funding curve
credit_index: Option<MarketIndex>, // bootstrapped credit curve, e.g. Credit("CLIENT_A")
}
examples/cva/data/csa_terms.json:
{
"collateral_index": "SOFR",
"collateral_currency": "USD",
"credit_spread": 0.01,
"recovery": 0.4,
"funding_index": "TermSOFR3m",
"funding_spread_curve": {
"dates": ["2026-11-11", "2028-11-11", "2030-11-11"],
"spreads": [0.004, 0.005, 0.006]
}
}
The CSA implies a SingleCurveCSADiscountPolicy::new(collateral_index, collateral_currency): claims in the collateral currency discount on collateral_index; claims in other currencies discount on MarketIndex::Collateral(ccy, collateral_currency), which therefore must have both a bootstrapped curve and an LgmModelConfig.
Selection rules inside the engine:
| Field set | Aggregator |
|---|---|
credit_index | CreditCurveCvaFactory with pillar survivals from the bootstrapped credit curve (sensitivities labelled <index>.pillar_i) |
| otherwise | CvaFactory with \(S(t)=e^{-\text{credit\_spread}\cdot t}\) |
funding_index | FundingCurveFvaFactory using the spread between the funding curve and the system curve (labels <funding_index>.<date>) |
funding_spread_curve | FundingCurveFvaFactory with the explicit term structure (labels funding_spread.<date>) |
| otherwise | FvaFactory with the flat funding_spread |
Building claims
let claims: Vec<ContingentClaim> = swap_trade.into_claims()?; // IntoContingentClaims
let claim = MakeContingentClaim::default()
.with_trade_id("MANUAL_1").with_leg_id("fixed").with_payment_date(d)
.with_currency(Currency::USD).with_notional(1e6).with_side(Side::LongReceive)
.with_evaluation_strategy(ClaimEvaluationStrategy::Deterministic { amount: 25_000.0 })
.build()?;
Multiple trades — swaps, cross-currency swaps, FX forwards, options and ScriptedProducts — can share a netting set as long as their currencies are covered by fx_configs.
Multiple netting sets
run(&mut HashMap<String, NettingSet>) simulates a single market model for all sets and produces per-set XvaValues, so counterparties sharing the same market factors are evaluated on identical paths.
CVA, DVA and FVA
Aggregators (src/xva/aggregator.rs) consume the netted NpvCube of a netting set and produce a single number plus AD adjoints. Each implements name() -> &'static str ("CVA", "DVA", "FVA").
Definitions
Let \(V_k^p\) be the netted NPV on path \(p\) at grid date \(t_k\), \(n\) the number of paths, \(P(0,t_k)\) the system discount factor, and
\[ \text{EPE}_k=\frac1n\sum_p \max(V_k^p,0),\qquad \text{ENE}_k=\frac1n\sum_p \min(V_k^p,0). \]
| Aggregator | Formula | Inputs |
|---|---|---|
CvaAggregator { lgd, ... } | \(\text{CVA}=\text{LGD}\sumk P(0,t_k)\,\text{EPE}_k\,[S(t{k-1})-S(t_k)]\) | counterparty survival \(S\); LGD \(=1-\text{recovery}\) |
DvaAggregator | \(\text{DVA}=\text{LGD}{own}\sum_k P(0,t_k)\,(-\text{ENE}_k)\,[S{own}(t*{k-1})-S*{own}(t_k)]\) | own survival curve |
FvaAggregator | \(\text{FVA}=\sum_k P(0,t_k)\,\text{EPE}_k\,s_f(t_k)\,\Delta t_k\) | funding spread \(s_f\) (flat, term structure or from a funding curve) |
Survival with a flat spread is \(S(t)=e^{-\lambda t}\) with \(\lambda=\)credit_spread; with credit_index it is interpolated from the bootstrapped credit curve pillars (CreditCurveCvaFactory). Positive exposure is taken after netting, so the collateral policy and FX conversion applied in the exposure evaluator directly affect these values.
Factories
PfeAggregatorFactory implementations create one aggregator per netting set:
| Factory | Fields |
|---|---|
CvaFactory | credit_spread, recovery, n_paths, system_dfs |
CreditCurveCvaFactory | pillar_dates, pillar_survivals, pillar_labels, recovery, n_paths, day_counter, system_dfs |
FvaFactory | flat funding_spread |
FundingCurveFvaFactory | dated spreads (from funding_spread_curve or funding_index minus the system curve) |
DvaFactory | own-credit inputs; not wired by XvaEngine::run, use it directly with AggregatorBundle |
Reading results
let result = engine.run(&mut netting_sets)?;
for v in result.xva_values.iter().flatten() {
println!("{:<10} {:<4} {:>14.2}", v.netting_set, v.measure, v.value);
}
cargo run -p cva output shape:
netting_set measure value
CLIENT_A CVA 12345.67
CLIENT_A FVA 4567.89
Credit curves for CVA
Bootstrapped from CDS quotes with a CurveConfiguration whose market_index is {"Credit": "CLIENT_A"} and quotes like Cds_USD_CLIENT_A_1Y; the bootstrapper solves piecewise-constant hazard rates by bisection (see Curve Bootstrapping). Set credit_index in CsaTerms to use it, and the CVA sensitivities then include one entry per credit pillar.
Exposure metrics
NpvCube::epe(), ene(), ee() return per-date vectors; PfeAggregator gives the quantile profile used for limit monitoring (examples/pfe). These are available in result.cubes regardless of CSA fields.
XVA Sensitivities
XvaEngine::run computes sensitivities of every XVA value with the same tape-based AD used for pricing: the market model is built from DualFwd leaves, paths are simulated as DualFwd, aggregators are differentiated, and one reverse sweep returns the gradient with respect to every registered leaf. No bumping and re-simulation is required.
Labels
result.sensitivities: Option<Vec<(String, f64)>> pairs a label with \(\partial\text{XVA}/\partial\text{leaf}\):
| Label | Leaf |
|---|---|
curve quote identifiers (OIS_USD_SOFR_5Y, OIS_CLP_ICP_2Y, FloatFloatCrossCurrencySwap_USD_SOFR_ICP_CLP_5Y) | curve pillars mapped back to quotes through the IFT |
volatility pillar labels from HullWhiteTimeDependentVolatility::with_pillar_labels() (calibration quote identifiers such as CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black) | calibrated LGM sigma pillars |
FX.<pair>.spot, FX.<pair>.vol (e.g. FX.CLPUSD.spot) | FX spot and lognormal FX volatility per FxModelConfig |
<credit_index>.pillar_<i> | survival pillars of a bootstrapped credit curve |
funding_spread.<date> or <funding_index>.<date> | funding spread term structure |
Values are aggregated across all netting sets in the run. To obtain per-set sensitivities run the engine once per netting set.
Example
let result = engine.run(&mut netting_sets)?;
let mut sens = result.sensitivities.unwrap_or_default();
sens.sort_by(|a, b| b.1.abs().partial_cmp(&a.1.abs()).unwrap_or(std::cmp::Ordering::Equal));
for (label, value) in sens.iter().take(10) {
println!("{label:<55} {value:>12.4}");
}
Typical top rows for the examples/cva portfolio are the long-dated SOFR OIS quotes (through both the exposure and the system discounting), the cross-currency basis quotes and FX.CLPUSD.vol.
Notes
- The deterministic system-curve discounting step (\(P(0,t_k)\) from the domestic curve) is not differentiated, so curve sensitivities exclude that term.
- Path noise is common to value and gradient: since sensitivities come from the same paths, they are consistent with the reported XVA (no bump-noise), but they still carry Monte Carlo error that decreases with
n_paths. - Sensitivities to
lambda,rhoand constantsigma/fx_volconfiguration values are available where those values are leaves (FX.<pair>.vol);lambdaandrhoare treated as constants. - Validate by rerunning with a
Scenarioon the base quotes (see Scenarios) and the sameseed.
Configuration Files
All configuration structs derive serde::Deserialize, so the same shapes work from JSON files or built in code. Dates are YYYY-MM-DD, periods are 1W, 3M, 5Y, enums use their variant names.
quotes.json
Array of quotes; identifiers encode instrument, currency, index and tenor (see Market Data).
[
{ "identifier": "OIS_USD_SOFR_1Y", "bid": 0.0421, "ask": 0.0423 },
{
"identifier": "CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black",
"bid": 0.31,
"ask": 0.33
},
{
"identifier": "Swaption_CLP_ICP_1Y_2Y_Absolute_0.045_Black",
"bid": 0.25,
"ask": 0.27
},
{ "identifier": "Cds_USD_CLIENT_A_5Y", "bid": 0.0095, "ask": 0.0105 }
]
Loaded with QuoteStore::from_json / serde_json; values are read at Level::Bid | Mid | Ask.
fixings.json
{
"SOFR": [{ "date": "2025-05-12", "rate": 0.0428 }],
"ICP": [{ "date": "2025-05-12", "rate": 0.0575 }]
}
curve_specs.json — Vec<CurveConfiguration>
[
{
"market_index": "SOFR",
"currency": "USD",
"day_counter": "Actual360",
"interpolator": "LogLinear",
"quotes": ["Deposit_USD_SOFR_1W", "OIS_USD_SOFR_1Y", "OIS_USD_SOFR_5Y"]
},
{
"market_index": "TermSOFR3m",
"currency": "USD",
"quotes": ["BasisSwap_USD_SOFR_TermSOFR3m_1Y"]
},
{
"market_index": { "Collateral": ["CLP", "USD"] },
"currency": "CLP",
"quotes": ["FloatFloatCrossCurrencySwap_USD_SOFR_ICP_CLP_1Y"]
},
{
"market_index": { "Credit": "CLIENT_A" },
"currency": "USD",
"quotes": ["Cds_USD_CLIENT_A_1Y", "Cds_USD_CLIENT_A_5Y"]
}
]
Full field list and defaults in Curve Bootstrapping.
vol_specs.json
{
"volatility_surfaces": [
{
"market_index": "SOFR",
"volatility_type": "Black",
"smile_type": "Strike",
"quotes": ["CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.035_Straddle_Black"]
}
],
"volatility_cubes": [
{
"market_index": "ICP",
"volatility_type": "Black",
"smile_type": "Strike",
"quotes": ["Swaption_CLP_ICP_1Y_2Y_Absolute_0.045_Black"]
}
]
}
hw_calibration.json — ModelCalibrationConfiguration
{
"source": { "Surface": { "market_index": "SOFR" } },
"quote_ids": ["CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black"],
"strike": "Atm",
"alpha": 0.1
}
simulation.json — SimulationConfiguration
{
"market_index": "SOFR",
"model": {
"HullWhite": {
"alpha": 0.1,
"volatility": { "Constant": { "value": 0.01 } }
}
},
"n_paths": 1000,
"seed": 42,
"horizon": "5Y",
"frequency": "Monthly",
"day_counter": "Actual365"
}
Model variants: HullWhite { alpha, volatility }, BrownianMotion { volatility, dividend_rate? }, Lgm { lambda, volatility }. Volatility sources: Constant { value }, Surface { market_index, key }, Cube { market_index, tenor, key }, Calibrated { ... }.
xva_config.json — XvaEngineConfig
{
"model_configs": [
{ "market_index": "SOFR", "lambda": 0.05, "sigma": 0.01 },
{ "market_index": "TermSOFR3m", "driver": "SOFR" }
],
"fx_configs": [{ "foreign_currency": "CLP", "fx_vol": 0.12, "rho": 0.0 }],
"n_paths": 2000,
"seed": 42,
"frequency": "Monthly"
}
csa_terms.json — CsaTerms
{
"collateral_index": "SOFR",
"collateral_currency": "USD",
"credit_spread": 0.01,
"recovery": 0.4,
"funding_spread": 0.0,
"funding_index": "TermSOFR3m",
"funding_spread_curve": {
"dates": ["2026-11-11", "2028-11-11"],
"spreads": [0.004, 0.005]
},
"credit_index": { "Credit": "CLIENT_A" }
}
Scripted products — Vec<CodedEvent>
[
{
"id": "fix1",
"date": "2026-06-15",
"code": "libor = RateIndex(SOFR, 2026-06-15, 2026-12-15)"
},
{
"id": "pay1",
"date": "2026-12-15",
"code": "coupon pays max(libor - 0.03, 0) * 0.5"
}
]
Grammar and validation rules in Events and Products.
MarketIndex spelling
Plain indices are bare strings ("SOFR", "ICP", "TermSOFR3m", "ESTR"); structured ones are objects: {"Collateral": ["CLP", "USD"]} (curve of CLP under USD collateral), {"Credit": "NAME"}, {"Equity": "AAPL"}.
Examples
Each example is a workspace member under examples/ with its own data/ folder. Run from the repository root.
| Command | What it shows |
|---|---|
cargo run -p bootstrap | Loads quotes.json and curve_specs.json, bootstraps SOFR, TermSOFR3m, ICP and the CLP-under-USD collateral curve with MultiCurveBootstrapper, prints pillar dates, discount factors and zero rates |
cargo run -p valuation | Builds swaps with MakeSwap, evaluates Value, FairRate and Cashflows through PricingContext, prints the cashflow table |
cargo run -p sensitivity | Prices SOFR, Term SOFR, ICP and USD/CLP cross-currency swaps with DualFwd and prints per-quote sensitivity ladders |
cargo run -p evaluator | Registers several pricers in an Evaluator keyed by TypeId and prices a heterogeneous portfolio via &dyn Any |
cargo run -p volatilitysurface | Builds a SOFR caplet Black surface and prints interpolated vols on an expiry × strike grid |
cargo run -p hullwhite | Calibrates Hull-White to caplets (hw_calibration.json), prints the calibration quality table and ATM cap prices, simulates paths from simulation.json |
cargo run -p pfe | Builds claims for a swap and an FX forward, simulates with LgmMarketModel, prints EE/EPE/PFE profiles |
cargo run -p cva | Runs XvaEngine on a netting set (5Y SOFR swap + 5Y USD/CLP XCCY) with csa_terms.json and xva_config.json, prints CVA/FVA and sensitivities |
cargo run -p scripting-examples --bin valuation | Parses a scripted payoff, builds a ScriptEngine and prints value and expected cashflows |
cargo run -p scripting-examples --bin xva | Wraps a ScriptedProduct as contingent claims and runs it through the exposure engine next to a vanilla swap |
Common structure
let quotes: Vec<Quote> = serde_json::from_str(&fs::read_to_string("examples/<name>/data/quotes.json")?)?;
let curve_specs: Vec<CurveConfiguration> = serde_json::from_str(&fs::read_to_string(".../curve_specs.json")?)?;
let mut ctx = PricingContext::new()
.with_reference_date(reference_date)
.with_quote_store(QuoteStore::from_quotes(quotes))
.with_curve_configurations(curve_specs)
.with_fixing_store(fixings);
ctx.initialize()?;
let results = ctx.evaluate(&trade, &[Request::Value, Request::Sensitivities])?;
Python
bindings/python mirrors the pricing and XVA examples (PricingContext(...), ctx.evaluate(trade, requests), ctx.run_xva(config, netting_sets)), returning pandas DataFrames; see Python API. Scripting is Rust-only.
Tests and benchmarks
cargo testruns unit tests, integration tests and doctests (cargo test --doc -p quantsupport).cargo bench -p benchmarksruns Criterion benchmarks for bootstrapping and pricing; reports land intarget/criterion.
Glossary
| Term | Meaning in quantsupport |
|---|---|
| AAD / AD | Algorithmic (adjoint) differentiation. Dual<T> records a tape; one reverse sweep yields all sensitivities. |
| Aggregator | PfeAggregator implementor turning an NpvCube into a scalar measure (CVA, DVA, FVA, PFE quantile). |
| Annuity | \(\sum_i N\tau_i P(T_i)\) over fixed coupons; denominator of the fair swap rate. |
Claim (ContingentClaim) | Atomic future cashflow with an evaluation strategy; the unit of exposure simulation. |
| Collateral curve | MarketIndex::Collateral(ccy, coll_ccy): discount curve for ccy cashflows under coll_ccy collateral, bootstrapped from cross-currency basis quotes. |
CSA (CsaTerms) | Credit Support Annex parameters: collateral index/currency, credit spread or credit curve, recovery, funding spread or curve. |
| CVA / DVA / FVA | Credit, debit and funding valuation adjustments computed from EPE/ENE profiles. |
| Discount policy | DiscountPolicy trait selecting the discount curve per leg (SingleCurveCSADiscountPolicy, FixedIncomeDiscountPolicy). |
| DualFwd | Dual<Fwd2>: default AD scalar (reverse over second-order forward). |
| EE / EPE / ENE | Expected exposure, expected positive/negative exposure per date from an NpvCube. |
| Event / EventStream | Dated script code blocks (CodedEvent) and their parsed, validated sequence. |
| Evaluator | Type-erased dispatcher from TypeId to ErasedPricer. |
| Fixing | Historical index observation stored in FixingStore; used for coupons whose accrual already started. |
| FuzzyEvaluator | Script evaluator that smooths if conditions with call spreads so payoffs are differentiable. |
| Hull-White | One-factor Gaussian short-rate model \(dr=(\theta-\alpha r)dt+\sigma dW\); closed forms for ZCBs, caplets, swaptions. |
| IFT | Implicit function theorem; converts pillar sensitivities into quote sensitivities after bootstrapping. |
| LGM | Linear Gaussian Markov model; state \(z_t\), functions \(H(t)\), \(\zeta(t)\); basis of LgmMarketModel. |
| Level | Bid, Mid, Ask — which side of a quote to use. |
| MarketIndex | Curve identifier: SOFR, ICP, TermSOFR3m, Collateral(..), Credit(..), Equity(..). |
| Netting set | Claims valued together under one CSA; positive exposure is taken on the netted sum. |
| NpvCube | npvs[path][date] matrix per trade produced by the exposure evaluator. |
| Numeraire | Bank-account value along a path used to deflate cashflows in LGM. |
| Pillar | Curve node created by one quote; sensitivities are reported per pillar quote identifier. |
| PricingContext | Owner of quotes, configurations, bootstrapped elements and the AD tape; entry point for evaluation. |
| Quote identifier | Underscore-separated string such as OIS_USD_SOFR_5Y parsed into QuoteDetails. |
| Request | Value, FairRate, Cashflows, Sensitivities (plus unimplemented YieldToMaturity, ModifiedDuration). |
| Scenario | Quote shock (Absolute/Relative) applied before bootstrapping; segment-based target matching. |
| ScriptEngine | Compiles an EventStream into an evaluable product and prices it on a MarketModel. |
| Side | LongReceive / PayShort sign convention for trades and claims. |
| Strike | Absolute(K), Atm, Relative(spread) resolved against the forward. |
| Tape | Thread-local recorder of Dual operations; supports marks and rewinds between trades. |
| Vol surface / cube | Bilinear (expiry × strike) or trilinear (expiry × tenor × strike) interpolated implied volatilities. |