Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Load observable quotes, fixings, and FX rates into QuoteStore, FixingStore, and FxStore.
  2. Describe curves, credit curves, volatility surfaces/cubes, and simulations with serialisable configuration structs (CurveConfiguration, CreditCurveConfiguration, VolatilitySurfaceConfiguration, VolatilityCubeConfiguration, SimulationConfiguration).
  3. Put them into a PricingContext and call initialize(), which bootstraps and builds every object in dependency order.
  4. Build an instrument with a Make* builder and wrap it in a trade (SwapTrade, FxForwardTrade, …) that carries notional and side.
  5. Ask a pricer (DiscountedCashflowPricer, ClosedFormBlackCapPricer, FxOptionPricer, …) for Request::Value, FairRate, Cashflows, or Sensitivities.
  6. 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

ModuleContents
adTape, Dual<T>, Fwd<T>, DualFwd and the Scalar trait
corePricingContext, ConstructedElementStore, Request, EvaluationResults, Trade, Side, Pricer, Evaluator, discount policies
currencies, indicesCurrency and MarketIndex enums
quotesQuote, QuoteDetails, QuoteInstrument, QuoteStore, Scenario, FixingStore, FxStore
ratesDiscountTermStructure, FlatForwardTermStructure, RateDefinition, MultiCurveBootstrapper, CreditCurveBootstrapper, curve configurations
volatilitySurfaces, cubes, VolatilityType, SmileType, Strike, volatility sources
instrumentsInstruments and Make* builders
pricersConcrete pricers
modelsHullWhite, LGM components and LgmMarketModel, Brownian motion, Monte Carlo engine
simulationsSimulationConfiguration, SimulationBuilder, GeneratedMonteCarloSimulation
scriptingPayoff language, ScriptEngine, ScriptedProduct
xvaContingent claims, netting sets, CSA, XvaEngine, aggregators
time, math, utilsDate, 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

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:

  1. Define the instrument’s contractual economics.
  2. Wrap the instrument in a trade.
  3. Assemble the market state for an evaluation date.
  4. Select a compatible pricer and request specific calculations.
  5. 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:

  • f64 is appropriate for value-only calculations where the relevant market data and pricer support it.
  • DualFwd carries 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 methodDefault
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.
  • ConstructedElementStore contains derived objects such as discount and credit curves, volatility objects, and simulations.
  • PricingContext owns those stores and implements MarketDataProvider, 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.

RequestMeaning
Request::ValuePresent value or NPV
Request::CashflowsCoupon and payment details
Request::SensitivitiesDerivatives with respect to labelled market pillars
Request::FairRateRate 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.

  • 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:

  1. Build an instrument containing contractual economics.
  2. Wrap it in a trade containing position metadata.
  3. Prepare a pricing context containing market data as of one date.
  4. Select a pricer and the outputs to calculate.
  5. 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:

ScalarUse
f64Value-only calculations and simulation paths where the surrounding API supports plain values
Fwd<T>Forward-mode automatic differentiation
Dual<T>Reverse-mode automatic differentiation
DualFwdThe 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.

LayerMain typesResponsibility
Raw dataQuoteStore, FixingStore, FxStoreQuotes, historical fixings, and spot FX observations
ConfigurationCurve, volatility, credit, and simulation configurationsInstructions for constructing market objects
Constructed dataConstructedElementStoreDiscount, dividend, and credit curves; volatility surfaces and cubes; simulations
OrchestrationPricingContextOwns 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:

  • MarketDataRequest is produced by the pricer and describes required market inputs.
  • Request is 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:

TypeContents
EvaluationResultsOptional price, fair rate, sensitivities, and cashflows exposed through public getters
SensitivityMapParallel market-pillar labels and NPV derivatives
CashflowsTableColumn-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:

CategoryTypical cause
ValueNotSetErrA required builder input is absent
NotFoundErrA required curve, fixing, quote, model, or pricer is unavailable
InvalidValueErrInputs are present but inconsistent or outside the accepted domain
InterpolationErr / NodeErrorCurve or surface construction/evaluation failed
SolverErrCalibration or root finding failed
TapeError / DualFwdErrorAutomatic-differentiation state is invalid
Parsing and serialization errorsExternal 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:

TypeRole
DateCalendar date and date arithmetic
Period / TimeUnitRelative terms such as three months or five years
DayCounterConverts date intervals to year fractions
FrequencyCoupon, compounding, or schedule frequency
CalendarHoliday and business-day rules
BusinessDayConventionAdjustment rule when a date is not a business day
DateGenerationRuleForward, backward, IMM, CDS, and related schedule rules
MakeScheduleBuilds 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 Instrument and normally provide a Make* builder and trade wrapper.
  • New pricing method: implement Pricer and declare market dependencies in market_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 Evaluator under the trade’s TypeId.

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>:

AccessorElementHolds
discount_curves() / discount_curve(&idx)DiscountCurveElementRc<RefCell<dyn InterestRatesTermStructure<DualFwd>>>
dividend_curves() / dividend_curve(&idx)DividendCurveElementdividend yield curve for equity indices
credit_curves() / credit_curve(&idx)CreditCurveElementsurvival-probability curve from CDS
volatility_surfaces() / volatility_surface(&idx)VolatilitySurfaceElementexpiry × key surface
fx_volatility_surface(&FxPair)OrientedFxVolSurfacesurface oriented for a pair, inverting if only the reciprocal pair exists
volatility_cubes() / volatility_cube(&idx)VolatilityCubeElementexpiry × tenor × key cube
simulations()MonteCarloSimulationElementgenerated 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.

InstrumentPatternExample
Overnight deposit / cashFixedRateDeposit_<CCY>_<Index>_<Tenor>FixedRateDeposit_USD_SOFR_1D
OIS / fixed–float swapOIS_<CCY>_<Index>_<Tenor>[_<FixedFreq>_<FloatFreq>]OIS_USD_SOFR_5Y, OIS_CLP_ICP_6M
Tenor basis swapBasisSwap_<CCY>_<PayIndex>_<RecvIndex>_<Tenor>[_<PayFreq>_<RecvFreq>]BasisSwap_USD_SOFR_TermSOFR3m_2Y
Fix–float cross-currency swapFixFloatCrossCurrencySwap_<FixedCCY>_<FloatIndex>_<FloatCCY>_<Tenor>[..]FixFloatCrossCurrencySwap_CLP_SOFR_USD_5Y
Float–float cross-currency swapFloatFloatCrossCurrencySwap_<DomCCY>_<DomIndex>_<ForIndex>_<ForCCY>_<Tenor>[..]FloatFloatCrossCurrencySwap_USD_SOFR_ESTR_EUR_5Y
FX forward pointsFxForwardPoints_<PAIR>_<Tenor>FxForwardPoints_USDCLP_3M
FX outright forwardFxOutrightForward_<PAIR>_<Tenor>FxOutrightForward_EURUSD_1Y
Rate futureFuture_<CCY>_<Index>_<IMM>Future_USD_SOFR_H26
Convexity adjustmentConvexityAdjustment_<CCY>_<Index>_<IMM>
Cap / floor volatilityCapFloor_<CCY>_<Index>_<Tenor>_<Freq>_<StrikeKind>_<Strike>_<VolType>CapFloor_USD_SOFR_5Y_Quarterly_Absolute_0.04_Black
Caplet / floorlet volatilityCapletFloorlet_<CCY>_<Index>_<IndexTenor>_<Expiry>_<StrikeKind>_<Strike>_<Strategy>_<VolType>CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black
Swaption volatilitySwaption_<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 optionEquityCall_<CCY>_<Index>_<Tenor>_<StrikeKind>_<Strike>, EquityPut_...EquityCall_USD_AAPL_6M_Absolute_150
FX optionFxCall_<PAIR>_<Tenor>_<StrikeKind>_<Strike>, FxPut_...FxPut_USDCLP_3M_Absolute_950
Credit default swapCds_<Entity>_<CCY>_<Tenor>Cds_ACME_USD_5Y
  • <Tenor> and <Expiry> are Period strings (1D, 3M, 5Y, 1Y6M).
  • <StrikeKind> is Absolute (strike follows as a decimal), Atm, or Relative (offset from ATM).
  • <VolType> is Black or Normal; <Strategy> for caplets is Cap, Floor or Straddle.
  • 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

MethodReturns
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<()>
  1. Scenarios. If scenarios is non-empty, clone the quote store and apply each Scenario in order. A scenario that matches no quote is an error. Everything below reads the shocked copy.
  2. Discount curves. MultiCurveBootstrapper::new(curve_configurations, BootstrapDiscountPolicy::new(base_index, base_currency)).with_fx_store(fx_store).bootstrap(quote_store, Level::Mid). Each resulting DiscountCurveElement is inserted under its MarketIndex.
  3. Credit curves. CreditCurveBootstrapper::bootstrap(quote_store, Level::Mid, discount_curves) — CDS premium and protection legs are discounted on the curves from step 2.
  4. Volatility surfaces. VolatilitySurfaceBuilder::build(quote_store, Level::Mid).
  5. Volatility cubes. VolatilityCubeBuilder::build(quote_store, Level::Mid).
  6. 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:

  • Absolute adds shock to the quote (0.0001 = 1 bp); Relative multiplies by 1 + shock.
  • target is 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:

FieldMeaning
id: usizeleg identifier used by pricers (CashflowsTable rows, XVA leg_id)
cashflows: Vec<CashflowType<T>>ordered cashflows
currency: Currencypayment 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: SidePayShort or LongReceive
is_linear: boolfalse when option-embedded coupons are present
asset_class: AssetClassFixedIncome, InterestRate, Equity, Fx, Credit, Other
first_payment_date, last_payment_dateused 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):

MethodStructureNotes
.bullet()coupons + single redemption at maturitydefault for swaps
.equal_redemptions()principal amortised in equal amounts, coupons on outstanding notional
.equal_payments()constant coupon + principal instalmentsfixed legs only (error on floating)
.zero()one payment at maturityforces 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 classInstrumentBuilderTradeDeterministic pricer
RatesSwap (fixed vs float)MakeSwapSwapTradeDiscountedCashflowPricer
RatesBasisSwap (float vs float)MakeBasisSwapBasisSwapTradeDiscountedCashflowPricer
RatesFixFloatCrossCurrencySwapMakeFixFloatCrossCurrencySwapFixFloatCrossCurrencySwapTradeDiscountedCashflowPricer
RatesFloatFloatCrossCurrencySwapMakeFloatFloatCrossCurrencySwapFloatFloatCrossCurrencySwapTradeDiscountedCashflowPricer
RatesCapFloorMakeCapFloorCapFloorTradeClosedFormBlackCapPricer, ClosedFormHullWhiteCapPricer
RatesCapletFloorlet— (from quotes)CapletFloorletTradeClosedFormBlackCapletPricer, ClosedFormHullWhiteCapletPricer
RatesEuropeanSwaptionMakeSwaptionEuropeanSwaptionTrade<DualFwd>ClosedFormHullWhiteSwaptionPricer
RatesRateFuturesMakeRateFuturesRateFuturesTradeRateFuturesPricer
Fixed incomeFixedRateBondMakeFixedRateBondFixedRateBondTradeDiscountedCashflowPricer
Fixed incomeFloatingRateNoteMakeFloatingRateNoteFloatingRateNoteTradeDiscountedCashflowPricer
Fixed incomeFixedRateDepositMakeFixedRateDepositFixedRateDepositTradeDiscountedCashflowPricer
FXFxForwardMakeFxForwardFxForwardTradeFxForwardPricer
FXFxOptionMakeFxOptionFxOptionTradeFxOptionPricer (Garman–Kohlhagen)
EquityEquityForwardMakeEquityForwardEquityForwardTrade— (claims / scripting)
EquityEquityEuropeanOptionEquityEuropeanOptionTradeBlackEuropeanOptionPricer, BlackMCEuropeanOptionPricer
EquityFuturesMakeFuturesFuturesTrade— (claims / scripting)
CreditCreditDefaultSwapCdsTradeCdsPricer
AnyScriptedProductscript textScriptEngine (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 chained with_* setters that take ownership.
  • build() returns Result<Instrument>; missing mandatory fields produce QSError::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; MakeFxForward and MakeFxOption take a currency pair, strike/forward rate and settlement date; MakeCapFloor takes an index, strike, cap/floor flag and schedule parameters; MakeSwaption wraps a MakeSwap plus 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 through Pillars (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 Interpolator applied to the discount factors themselves; LogLinear therefore 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} with year_fraction(d1, d2) and day_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 typeInstrumentResidual
FixedRateDepositzero-coupon depositNPV of the deposit legs
OISfixed vs overnight swapNPV (fixed − floating)
BasisSwapfloat vs float + spreadNPV
FixFloatCrossCurrencySwap, FloatFloatCrossCurrencySwaptwo-currency swap with notional exchangeNPV in the collateral currency
Futurerate futureimplied forward − market rate (convexity-adjusted if a ConvexityAdjustment quote exists)
FxForwardPoints, FxOutrightForwardFX forwardimplied 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(&quote_store, Level::Mid)?;

bootstrap proceeds in four steps:

  1. Resolve every configuration. For MarketIndex::Collateral(ccy, coll_ccy) specs the FX spot coll_ccy→ccy is passed so cross-currency notionals are FX-consistent at inception.
  2. 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 with NotFoundErr("Curve X requires Y for discounting but no curve configuration was provided for it …"); cycles fail with InvalidValueErr("Circular dependency detected …").
  3. Solve each curve in order with bootstrap_next_curve.
  4. Wrap the result as DiscountTermStructure<DualFwd> with pillar labels, pillar values (the quotes) and IFT matrices, inside a DiscountCurveElement.

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 returns SolverErr.
  • 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 quotesOIS_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(&quote_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

ErrorTypical 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 iterationsinconsistent 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

RoleMarketIndexBuilt fromUsed for
CSA / discount curvee.g. SOFRdeposits + OISdiscounting all collateralised USD cashflows; projecting SOFR coupons
Projection curvee.g. TermSOFR3m, EURIBOR6mdeposit + basis swaps vs the OIS index (or fixed–float swaps)forward rates for coupons fixing on that index
Collateral-adjusted curveCollateral(CLP, USD)FX forwards + cross-currency swapsdiscounting CLP cashflows under a USD CSA
Local OIS curvee.g. ICPCLP deposits + OISprojecting 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) – returns discount_index when the target’s currency equals the CSA currency, and MarketIndex::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) – for AssetClass::FixedIncome only. If prefer_instrument_index and the instrument declares its own discount_index (issuer curve), that wins; otherwise the per-currency risk-free index; otherwise InvalidValueErr("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
  • TermSOFR3m pillars 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 are FixFloatCrossCurrencySwap_CLP_SOFR_USD_* (fixed CLP vs float SOFR USD) and FxForwardPoints_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.
  • ICP is 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

  1. One CurveConfiguration per index that any instrument projects or discounts on.
  2. The CSA curve (base_index) configured with deposits/OIS in the base_currency.
  3. 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 the FxStore.
  4. 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"
  ]
}
EnumVariantsMeaning
VolatilityTypeBlack, Normallognormal (Black-76) or Bachelier quoting
SmileTypeStrike, Delta, LogMoneynesswhat the second axis (key) means
StrikeAbsolute(f64), Atm, Relative(f64)resolve(forward) returns K, F, or F + spread

Quote identifiers and axes

IdentifierAxes
CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.045_Straddle_Blackindex tenor 3M, expiry 6M, strike 0.045, strategy Straddle, vol type Black
Swaption_CLP_ICP_1Y_2Y_Absolute_0.045_Blackexpiry 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_150expiry, 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(&quote_store, Level::Mid)?;
let cubes: HashMap<MarketIndex, VolatilityCubeElement> =
    VolatilityCubeBuilder::new(cube_specs).build(&quote_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:

MethodNotes
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

PricerTrade typeRequestsMarket dataModel
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, Sensitivitiesdiscount curve per leg index, FX for cross-currency legs, fixings\(\sum_i CF_i\,P(T_i)\)
CdsPricer::new()CdsTradeValue, FairRate, Sensitivitiescredit curve MarketIndex::Credit(name), discount curvepremium/protection legs on survival curve
BlackEuropeanOptionPricer::new()EquityEuropeanOptionTradeValue, Sensitivitiesspot, equity surface, discount curve, dividendBlack-Scholes
BlackMCEuropeanOptionPricer::new()EquityEuropeanOptionTradeValue, Sensitivitiesa SimulationConfiguration-generated path set\(P(T)\,\mathbb E[\text{payoff}(S_T)]\)
FxForwardPricer::new()FxForwardTradeValue, FairRate, Sensitivitiesbase/quote discount curves, spot\(F = S\,P*{base}/P*{quote}\)
FxOptionPricer::new()FxOptionTradeValue, Sensitivitiesbase/quote curves, spot, FX surfaceGarman-Kohlhagen
ClosedFormBlackCapletPricer::new()CapletFloorletTradeValue, Sensitivitiesforward curve, surface at (fixing, strike)Black-76
ClosedFormBlackCapPricer::new()CapFloorTradeValue, Sensitivitiessamesum of Black-76 caplets
ClosedFormHullWhiteCapletPricer::new(alpha, sigma)CapletFloorletTradeValue, Sensitivitiesdiscount curvebond-put representation
ClosedFormHullWhiteCapPricer::new(alpha, sigma)CapFloorTradeValue, Sensitivitiesdiscount curvesum of HW caplets
ClosedFormHullWhiteSwaptionPricer::new(alpha, sigma)EuropeanSwaptionTrade<T>Value, Sensitivitiesdiscount curveJamshidian
RateFuturesPricer::new()RateFuturesTradeValue, Sensitivitiescurve 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>;
}
PolicyBehaviour
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_date the rate is read from the FixingStore (state.get_fixing(index, accrual_start)), otherwise it is projected from the forward curve of the leg’s market_index with forward_rate(start, end, Simple, frequency); the spread is 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 TermSOFR3m and discounted on SOFR through SingleCurveCSADiscountPolicy::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:

BuilderRequiredDefaults
MakeFixedRateBond<T>notional, start_date, maturity_date, rate, rate_definition, currency, identifierunits 100, side LongReceive, frequency Semiannual, PaymentStructure::Bullet
MakeFloatingRateNote<T>notional, start_date, maturity_date, forward_index, currency, identifierspread 0, units 100, frequency Quarterly, Bullet
MakeFixedRateDeposit<T>notional, start_date, maturity_date, rate, rate_definition, currency, identifierunits 100, single payment
MakeRateFuturesidentifier, market_index, start_date, end_date, futures_pricecontract_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);
BuilderRequiredDefaults
MakeFixFloatCrossCurrencySwap<T>start_date, maturity_date, domestic_notional, foreign_notional, fixed_rate, identifier, domestic_currency, foreign_currency, floating_market_indexspread 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_indexdomestic/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 a SingleCurveCSADiscountPolicy is set).
  • The strike is a Strike (Absolute, Atm, Relative) resolved against \(F\).
  • \(\sigma\) is read from the VolatilitySurfaceElement for market_index at (fixing_date, strike) through volatility_from_date; VolatilityType::Normal surfaces 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:

  1. 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.
  2. 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.
  3. 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:

PricerRequestsMethod
BlackEuropeanOptionPricer::new()Value, SensitivitiesBlack-Scholes with spot, dividend yield, discount curve and the equity surface at (expiry, strike)
BlackMCEuropeanOptionPricer::new()Value, Sensitivitiesreads 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

TypeModeUse
f64nonefastest pricing, no risk
Fwd1..Fwd4 (Fwd<N>)forward, N-th order tangentssecond-order Greeks, tests
Dual<T>reverse (tape) over an inner scalar Tfull curve sensitivities
DualFwd = Dual<Fwd2>reverse over forwardthe default AD type: exact first derivatives to every quote and second-order information for IFT
ADForward = Fwd2alias 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();
APIPurpose
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 scripting FuzzyEvaluator).
  • 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 elementLabels
Discount/projection curvesthe quotes in the CurveConfiguration (OIS_USD_SOFR_1Y, Swap_CLP_ICP_5Y, Deposit_USD_SOFR_1W, BasisSwap_*, FloatFloatCrossCurrencySwap_*)
Credit curvesCds_* quotes
Volatility surfaces/cubesCapletFloorlet_*, Swaption_*, FxCall_* quotes (vega ladder)
FX spotonly if the spot was added to the FxStore as DualFwd::new
Model parametersHull-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:

TargetMatches
OIS_USD_SOFR_5Yexactly that quote
SOFRevery quote containing the SOFR segment (OIS_USD_SOFR_*, BasisSwap_USD_SOFR_TermSOFR3m_*, CapletFloorlet_USD_SOFR_*)
USD_OISall USD OIS quotes regardless of tenor
CapletFloorlet_Blackall 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) with ScenarioType::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/):

PathResponsibility
parsing/lexer.rs, parsing/parser.rsTokenizer and recursive-descent parser producing Node trees
nodes/node.rsThe Node enum (arithmetic, comparison, If, ForEach, Pays, Spot, Df, RateIndex, …) and per-node metadata used by the analysers
nodes/event.rsCodedEvent (date + source), Event (date + AST), EventStream
visitors/varindexer.rsAssigns variable slots, collects SimulationDataRequests
visitors/ifconditiontransform.rs, ifprocessor.rs, domainprocessor.rsStatic passes preparing conditionals for smoothing and nested-if variable stores
visitors/evaluator.rsSingleScenarioEvaluator: exact path evaluation
visitors/fuzzyevaluator.rsFuzzyEvaluator: smoothed conditionals for stable AAD on digital payoffs
request.rsSimulationDataRequest (discounts, forwards, FX, spots, numeraire flag)
runtime.rsScriptEngine, ScriptModelSetup, ParallelScriptEvaluation, ExpectedCashflow
product.rsScriptedProduct, ScriptedPayoff and the IntoContingentClaims bridge to XVA
utils/errors.rsScriptingError

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> is Serialize/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

CategorySyntax
Numbers0.035, 10000000, 1e-4
Booleanstrue, false
Strings"2027-06-09", "USD", "Actual360" (dates, currencies, day counters, index names)
Arithmetic+ - * /, ** (power), unary +/-
Comparison== != < <= > >=
Logicand, 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

FunctionMeaning
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.

ExpressionRequest createdNotes
RateIndex("SOFR", "start", "end")ForwardRateRequestSimple 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 indexDiscount 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")FxRequestPrice 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>"];
  • acc is 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.
  • on defaults to the event date; in defaults to the engine’s local currency. Payments in another currency are converted with the simulated FX rate on the payment date.
  • Every pays statement gets a payment id during indexing. ScriptedProduct turns each id into one ContingentClaim, and evaluate_with_cashflows reports each id’s expected amount and present value.
  • pays may also appear inside an expression, e.g. call = pays max(Spot("CLP", "USD") - 900.0, 0);, which assigns the discounted payment to call.
  • An EventStream must contain at least one event, and a ScriptedProduct must contain at least one pays expression 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:

VariantRaised 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

CheckError
Stream has no eventsInvalidOperation("a script must contain at least one event")
An event date precedes reference_dateInvalidOperation("scripted event dates cannot precede the reference date")
Events are not sorted by dateInvalidOperation("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 fieldValue
trade_idThe id passed to new (or to into_contingent_claims)
leg_idThe payment id assigned during indexing
payment_dateon date or event date
currencyin currency or local_currency
notional1.0 (the script amount already includes the notional)
sideSide::LongReceive (sign lives in the script expression)
evaluation_strategyClaimEvaluationStrategy::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:

  1. Validation – non-empty, no event before reference_date, events sorted by date.
  2. VarIndexer – assigns a slot to each variable, numbers every pays, Df, RateIndex and Spot node, and produces one SimulationDataRequest per event holding the DiscountRequests, ForwardRateRequests, FxRequests and SpotRequests that event needs. Default discounting uses local_discount_index; default currency is local_currency.
  3. Request flattening – the per-event requests are flattened into the Vec<SimulationRequest> format consumed by MarketModel::set_requests, along with the observation date of each request and a map back to the script nodes.
  4. IfConditionTransform – rewrites every comparison into the canonical (lhs - rhs) > 0 form used by the smoothing evaluator.
  5. IfProcessor – computes the maximum if nesting depth (max_nested_ifs) and the set of variables written inside each branch.
  6. DomainProcessor – propagates value domains through the tree so constants are folded and impossible branches are dropped.

Accessors

MethodReturns
events() -> &EventStreamThe 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() -> DateLatest of all event dates and all requested observation/payment dates
has_variable(&str) -> boolWhether 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) and model.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 a Scenario from the responses, run the evaluator, accumulate value / n_paths for every numeric variable;
  • when result_variable is Some, back-propagate result / n_paths to 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 == 0SingleScenarioEvaluator: 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:

  1. splits 0..n_paths into rayon::current_num_threads() contiguous ranges;
  2. on each worker resets and starts a fresh tape, calls with_model, configures the model, evaluates the range, and reads leaf.adjoint() for each supplied leaf;
  3. 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:

  1. PreprocessorExecutor collects SimulationRequests from every claim. For scripted claims these are ScriptedPayoff::simulation_requests(), i.e. ScriptEngine::model_requests() — the discount factors, forward rates, FX rates and spots the script observes.
  2. The LGM market model simulates those observables on every path and evaluation date.
  3. 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.
  4. 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::LongReceive with 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 for MarketIndex::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:

  1. Single-index path sets (SimulationConfigurationSimulationBuilderGeneratedMonteCarloSimulation) stored in the PricingContext and consumed by pricers such as BlackMCEuropeanOptionPricer.
  2. Multi-asset market models (LgmMarketModel, the MarketModel<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

VariantFieldsDynamics
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 sourcesee 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, &quote_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);
MethodFormula
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_pricezero-coupon bond options

The closed forms are shared with ClosedFormHullWhiteCapletPricer, ClosedFormHullWhiteCapPricer and ClosedFormHullWhiteSwaptionPricer (Caps and Floors, Swaptions).

Calibration

hw.calibrate(&quote_ids, &quote_store, &curve, Level::Mid)?;
hw.calibrate_with_configuration(&config, &constructed_store, &quote_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:

  1. Parse the identifier to get expiry \(T_i\), index tenor and strike; strike: "Atm" replaces the quoted strike with the forward.
  2. Read the Black (or Normal) vol from the surface/cube and compute the market caplet/swaption price.
  3. 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, &quotes, 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\):

MethodFormula
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>,
}
ClaimEvaluationStrategyMeaning
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

TypeOutput
PfeAggregator / PfeAggregatorFactoryquantile 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}\)
AggregatorBundleruns several aggregators over one cube
CvaFactory, DvaFactory, FvaFactory, CreditCurveCvaFactory, FundingCurveFvaFactorybuild 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:

  1. Build claims from trades and preprocess.
  2. Build an LgmMarketModel (or any MarketModel) with set_evaluation_dates and the claims’ SimulationRequests.
  3. Evaluate claims path by path into an NpvCube.
  4. 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:

  1. Preprocess claims (FixingPreprocessor fills realized fixings) and collect the SimulationRequests each claim needs.
  2. Validate that every discount index selected by a netting set’s discount policy has an LgmModelConfig.
  3. Build the evaluation grid with MakeSchedule::new(reference_date, max_payment_date).with_frequency(frequency).
  4. 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).
  5. For each netting set build a CVA aggregator (CreditCurveCvaFactory if credit_index is set, else flat CvaFactory from credit_spread/recovery) and an FVA aggregator (FundingCurveFvaFactory from funding_index or funding_spread_curve, else flat FvaFactory from funding_spread).
  6. Build the LGM market model from the configs (calibrating sigma schedules if volatility is Calibrated), simulate, evaluate claims into NpvCubes, 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 setAggregator
credit_indexCreditCurveCvaFactory with pillar survivals from the bootstrapped credit curve (sensitivities labelled <index>.pillar_i)
otherwiseCvaFactory with \(S(t)=e^{-\text{credit\_spread}\cdot t}\)
funding_indexFundingCurveFvaFactory using the spread between the funding curve and the system curve (labels <funding_index>.<date>)
funding_spread_curveFundingCurveFvaFactory with the explicit term structure (labels funding_spread.<date>)
otherwiseFvaFactory 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). \]

AggregatorFormulaInputs
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:

FactoryFields
CvaFactorycredit_spread, recovery, n_paths, system_dfs
CreditCurveCvaFactorypillar_dates, pillar_survivals, pillar_labels, recovery, n_paths, day_counter, system_dfs
FvaFactoryflat funding_spread
FundingCurveFvaFactorydated spreads (from funding_spread_curve or funding_index minus the system curve)
DvaFactoryown-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}\):

LabelLeaf
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, rho and constant sigma/fx_vol configuration values are available where those values are leaves (FX.<pair>.vol); lambda and rho are treated as constants.
  • Validate by rerunning with a Scenario on the base quotes (see Scenarios) and the same seed.

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.jsonVec<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.jsonModelCalibrationConfiguration

{
  "source": { "Surface": { "market_index": "SOFR" } },
  "quote_ids": ["CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black"],
  "strike": "Atm",
  "alpha": 0.1
}

simulation.jsonSimulationConfiguration

{
  "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.jsonXvaEngineConfig

{
  "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.jsonCsaTerms

{
  "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.

CommandWhat it shows
cargo run -p bootstrapLoads 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 valuationBuilds swaps with MakeSwap, evaluates Value, FairRate and Cashflows through PricingContext, prints the cashflow table
cargo run -p sensitivityPrices SOFR, Term SOFR, ICP and USD/CLP cross-currency swaps with DualFwd and prints per-quote sensitivity ladders
cargo run -p evaluatorRegisters several pricers in an Evaluator keyed by TypeId and prices a heterogeneous portfolio via &dyn Any
cargo run -p volatilitysurfaceBuilds a SOFR caplet Black surface and prints interpolated vols on an expiry × strike grid
cargo run -p hullwhiteCalibrates Hull-White to caplets (hw_calibration.json), prints the calibration quality table and ATM cap prices, simulates paths from simulation.json
cargo run -p pfeBuilds claims for a swap and an FX forward, simulates with LgmMarketModel, prints EE/EPE/PFE profiles
cargo run -p cvaRuns 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 valuationParses a scripted payoff, builds a ScriptEngine and prints value and expected cashflows
cargo run -p scripting-examples --bin xvaWraps 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 test runs unit tests, integration tests and doctests (cargo test --doc -p quantsupport).
  • cargo bench -p benchmarks runs Criterion benchmarks for bootstrapping and pricing; reports land in target/criterion.

Glossary

TermMeaning in quantsupport
AAD / ADAlgorithmic (adjoint) differentiation. Dual<T> records a tape; one reverse sweep yields all sensitivities.
AggregatorPfeAggregator 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 curveMarketIndex::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 / FVACredit, debit and funding valuation adjustments computed from EPE/ENE profiles.
Discount policyDiscountPolicy trait selecting the discount curve per leg (SingleCurveCSADiscountPolicy, FixedIncomeDiscountPolicy).
DualFwdDual<Fwd2>: default AD scalar (reverse over second-order forward).
EE / EPE / ENEExpected exposure, expected positive/negative exposure per date from an NpvCube.
Event / EventStreamDated script code blocks (CodedEvent) and their parsed, validated sequence.
EvaluatorType-erased dispatcher from TypeId to ErasedPricer.
FixingHistorical index observation stored in FixingStore; used for coupons whose accrual already started.
FuzzyEvaluatorScript evaluator that smooths if conditions with call spreads so payoffs are differentiable.
Hull-WhiteOne-factor Gaussian short-rate model \(dr=(\theta-\alpha r)dt+\sigma dW\); closed forms for ZCBs, caplets, swaptions.
IFTImplicit function theorem; converts pillar sensitivities into quote sensitivities after bootstrapping.
LGMLinear Gaussian Markov model; state \(z_t\), functions \(H(t)\), \(\zeta(t)\); basis of LgmMarketModel.
LevelBid, Mid, Ask — which side of a quote to use.
MarketIndexCurve identifier: SOFR, ICP, TermSOFR3m, Collateral(..), Credit(..), Equity(..).
Netting setClaims valued together under one CSA; positive exposure is taken on the netted sum.
NpvCubenpvs[path][date] matrix per trade produced by the exposure evaluator.
NumeraireBank-account value along a path used to deflate cashflows in LGM.
PillarCurve node created by one quote; sensitivities are reported per pillar quote identifier.
PricingContextOwner of quotes, configurations, bootstrapped elements and the AD tape; entry point for evaluation.
Quote identifierUnderscore-separated string such as OIS_USD_SOFR_5Y parsed into QuoteDetails.
RequestValue, FairRate, Cashflows, Sensitivities (plus unimplemented YieldToMaturity, ModifiedDuration).
ScenarioQuote shock (Absolute/Relative) applied before bootstrapping; segment-based target matching.
ScriptEngineCompiles an EventStream into an evaluable product and prices it on a MarketModel.
SideLongReceive / PayShort sign convention for trades and claims.
StrikeAbsolute(K), Atm, Relative(spread) resolved against the forward.
TapeThread-local recorder of Dual operations; supports marks and rewinds between trades.
Vol surface / cubeBilinear (expiry × strike) or trilinear (expiry × tenor × strike) interpolated implied volatilities.