use std::collections::HashMap;
use sobol_burley::sample as sobol_sample;
use crate::{
ad::scalar::Scalar,
core::marketdatahandling::{
discountrequest::DiscountRequest, forwardraterequest::ForwardRateRequest,
fxrequest::FxRequest, pathdependentrequest::PathDependentRequest, spotrequest::SpotRequest,
},
currencies::currency::Currency,
indices::marketindex::MarketIndex,
math::linalg::cholesky,
math::solvers::solvertraits::Matrix,
models::lgm::lgmcomponents::{LgmEquityModel, LgmFxModel, LgmRateModel},
time::{date::Date, daycounter::DayCounter},
utils::errors::{QSError, Result},
xva::visitors::{
marketmodel::{MarketModel, PathScenario, SimulationResponse},
preprocessorexecutor::SimulationRequest,
},
};
#[derive(Default)]
struct LgmMarketModelState {
rates: HashMap<MarketIndex, HashMap<Date, f64>>,
fx: HashMap<Currency, HashMap<Date, f64>>,
}
pub struct LgmMarketModel<'a, T: Scalar> {
domestic_currency: Currency,
domestic_index: MarketIndex,
curve_models: HashMap<MarketIndex, LgmRateModel<'a, T>>,
fx_models: HashMap<Currency, LgmFxModel<'a, T>>,
equity_models: HashMap<String, LgmEquityModel<'a, T>>,
currency_to_index: HashMap<Currency, MarketIndex>,
fx_spot_indices: HashMap<MarketIndex, Currency>,
curve_drivers: HashMap<MarketIndex, MarketIndex>,
dates: Vec<Date>,
requests: Vec<SimulationRequest>,
request_dates: Vec<Option<Date>>,
compact_dated_requests: bool,
path_times: Vec<f64>,
path_rate_indices: Vec<MarketIndex>,
path_fx_currencies: Vec<Currency>,
path_equity_names: Vec<String>,
path_cholesky_l: Vec<Vec<f64>>,
path_n_factors: usize,
request_indices_by_step: Vec<Vec<usize>>,
reference_date: Date,
day_counter: DayCounter,
n_paths: usize,
seed: u64,
correlation_matrix: Option<Matrix<f64>>,
state: LgmMarketModelState,
}
impl<'a, T: Scalar> LgmMarketModel<'a, T> {
#[must_use]
pub fn new(
domestic_currency: Currency,
domestic_index: MarketIndex,
reference_date: Date,
day_counter: DayCounter,
) -> Self {
Self {
domestic_currency,
domestic_index,
curve_models: HashMap::new(),
fx_models: HashMap::new(),
equity_models: HashMap::new(),
currency_to_index: HashMap::new(),
fx_spot_indices: HashMap::new(),
curve_drivers: HashMap::new(),
dates: Vec::new(),
requests: Vec::new(),
request_dates: Vec::new(),
compact_dated_requests: false,
path_times: Vec::new(),
path_rate_indices: Vec::new(),
path_fx_currencies: Vec::new(),
path_equity_names: Vec::new(),
path_cholesky_l: Vec::new(),
path_n_factors: 0,
request_indices_by_step: Vec::new(),
reference_date,
day_counter,
n_paths: 1000,
seed: 42,
correlation_matrix: None,
state: LgmMarketModelState::default(),
}
}
#[must_use]
pub const fn with_n_paths(mut self, n: usize) -> Self {
self.n_paths = n;
self
}
#[must_use]
pub const fn with_seed(mut self, seed: u64) -> Self {
self.seed = seed;
self
}
#[must_use]
pub fn with_correlation_matrix(mut self, corr: Matrix<f64>) -> Self {
self.correlation_matrix = Some(corr);
self
}
pub fn add_curve_model(&mut self, market_index: MarketIndex, model: LgmRateModel<'a, T>) {
if let Ok(details) = market_index.rate_index_details() {
self.currency_to_index
.insert(details.currency(), market_index.clone());
}
self.curve_models.insert(market_index, model);
self.rebuild_path_layout();
}
pub fn add_fx_model(&mut self, currency: Currency, model: LgmFxModel<'a, T>) {
self.fx_models.insert(currency, model);
self.rebuild_path_layout();
}
pub fn add_equity_model(&mut self, name: String, model: LgmEquityModel<'a, T>) {
self.equity_models.insert(name, model);
self.rebuild_path_layout();
}
pub fn set_curve_driver(&mut self, index: MarketIndex, driver: MarketIndex) {
self.curve_drivers.insert(index, driver);
}
fn factor_index<'b>(&'b self, index: &'b MarketIndex) -> &'b MarketIndex {
self.curve_drivers.get(index).unwrap_or(index)
}
pub fn register_fx_spot_index(&mut self, index: MarketIndex, currency: Currency) {
self.fx_spot_indices.insert(index, currency);
}
pub fn set_requests(&mut self, requests: Vec<SimulationRequest>) {
self.request_dates = vec![None; requests.len()];
self.compact_dated_requests = false;
self.requests = requests;
self.rebuild_request_layout();
}
fn time_from_date(&self, date: Date) -> f64 {
self.day_counter.year_fraction(self.reference_date, date)
}
fn rate_index_for_currency(&self, ccy: Currency) -> Option<MarketIndex> {
self.currency_to_index.get(&ccy).cloned()
}
fn build_factor_ordering(&self) -> (Vec<MarketIndex>, Vec<Currency>, Vec<String>) {
let mut rate_indices = vec![self.domestic_index.clone()];
let mut fx_currencies: Vec<Currency> = self.fx_models.keys().copied().collect();
fx_currencies.sort_by_key(ToString::to_string);
for currency in &fx_currencies {
if let Some(index) = self.rate_index_for_currency(*currency) {
if !rate_indices.contains(&index) {
rate_indices.push(index);
}
}
}
let mut equity_names: Vec<String> = self.equity_models.keys().cloned().collect();
equity_names.sort();
(rate_indices, fx_currencies, equity_names)
}
fn rebuild_path_layout(&mut self) {
self.path_times.clear();
self.path_times.reserve(self.dates.len() + 1);
self.path_times.push(0.0);
for date in &self.dates {
self.path_times
.push(self.day_counter.year_fraction(self.reference_date, *date));
}
let (rate_indices, fx_currencies, equity_names) = self.build_factor_ordering();
self.path_rate_indices = rate_indices;
self.path_fx_currencies = fx_currencies;
self.path_equity_names = equity_names;
self.path_n_factors = self.path_rate_indices.len()
+ self.path_fx_currencies.len()
+ self.path_equity_names.len();
self.path_cholesky_l = self.correlation_matrix.as_ref().map_or_else(
|| {
let mut identity = vec![vec![0.0; self.path_n_factors]; self.path_n_factors];
for (index, row) in identity.iter_mut().enumerate() {
row[index] = 1.0;
}
identity
},
|correlation| cholesky(correlation),
);
self.rebuild_request_layout();
}
fn rebuild_request_layout(&mut self) {
self.request_indices_by_step = self
.dates
.iter()
.map(|event_date| {
self.requests
.iter()
.enumerate()
.filter_map(|(index, _)| {
(!self.compact_dated_requests
|| self.request_dates[index].is_none_or(|date| date == *event_date))
.then_some(index)
})
.collect()
})
.collect();
}
}
const SOBOL_DIMENSIONS: usize = 256;
const SOBOL_SEQUENCE_LENGTH: usize = 1 << 16;
const fn fold_seed(seed: u64) -> u32 {
let bytes = seed.to_le_bytes();
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
^ u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]])
}
fn sobol_uniform(sample_index: u32, raw_dimension: usize, sequence_block: usize, seed: u64) -> f64 {
let dimension = u32::try_from(raw_dimension % SOBOL_DIMENSIONS).unwrap_or(0);
let dimension_block = u32::try_from(raw_dimension / SOBOL_DIMENSIONS).unwrap_or(u32::MAX);
let sequence_block = u32::try_from(sequence_block).unwrap_or(u32::MAX);
let scramble = fold_seed(seed)
.wrapping_add(dimension_block.wrapping_mul(0x9e37_79b9))
.wrapping_add(sequence_block.wrapping_mul(0x85eb_ca6b));
f64::from(sobol_sample(sample_index, dimension, scramble)).clamp(1.0e-12, 1.0 - 1.0e-12)
}
fn sobol_normal(
sample_index: u32,
normal_dimension: usize,
sequence_block: usize,
seed: u64,
antithetic_sign: f64,
) -> f64 {
let uniform_dimension = normal_dimension.saturating_mul(2);
let u1 = sobol_uniform(sample_index, uniform_dimension, sequence_block, seed);
let u2 = sobol_uniform(
sample_index,
uniform_dimension.saturating_add(1),
sequence_block,
seed,
);
antithetic_sign * (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
struct LgmPathContext<'a, T: Scalar> {
model: &'a LgmMarketModel<'a, T>,
times: &'a [f64],
rate_indices: &'a [MarketIndex],
fx_currencies: &'a [Currency],
equity_names: &'a [String],
cholesky_l: &'a [Vec<f64>],
n_factors: usize,
request_indices_by_step: &'a [Vec<usize>],
}
unsafe impl<T: Scalar> Sync for LgmPathContext<'_, T> {}
unsafe impl<T: Scalar> Send for LgmPathContext<'_, T> {}
impl<'a, T: Scalar> LgmPathContext<'a, T> {
fn new(model: &'a LgmMarketModel<'a, T>) -> Self {
Self {
model,
times: &model.path_times,
rate_indices: &model.path_rate_indices,
fx_currencies: &model.path_fx_currencies,
equity_names: &model.path_equity_names,
cholesky_l: &model.path_cholesky_l,
n_factors: model.path_n_factors,
request_indices_by_step: &model.request_indices_by_step,
}
}
fn correlated_increments(
&self,
step: usize,
sample_index: u32,
sequence_block: usize,
antithetic_sign: f64,
sqrt_time_step: f64,
) -> Vec<f64> {
let independent_normals: Vec<f64> = (0..self.n_factors)
.map(|factor| {
let normal_dimension = step.saturating_mul(self.n_factors).saturating_add(factor);
sobol_normal(
sample_index,
normal_dimension,
sequence_block,
self.model.seed,
antithetic_sign,
)
})
.collect();
(0..self.n_factors)
.map(|factor| {
self.cholesky_l[factor]
.iter()
.zip(&independent_normals)
.take(factor + 1)
.map(|(loading, normal)| loading * normal)
.sum::<f64>()
* sqrt_time_step
})
.collect()
}
fn evolve_market_state(
&self,
time: f64,
time_step: f64,
increments: &[f64],
rate_factors: &mut [T],
fx_spots: &mut HashMap<Currency, T>,
equity_spots: &mut HashMap<String, T>,
) -> Result<()> {
if time_step <= 1e-14 {
return Ok(());
}
let rate_count = self.rate_indices.len();
let domestic_model = &self.model.curve_models[&self.rate_indices[0]];
rate_factors[0] = domestic_model.evolve_domestic_factor_euler(
time,
rate_factors[0],
time_step,
increments[0],
);
for (foreign_position, currency) in self.fx_currencies.iter().enumerate() {
let fx_model = &self.model.fx_models[currency];
let foreign_index = self
.model
.rate_index_for_currency(*currency)
.ok_or_else(|| {
QSError::NotFoundErr(format!("Rate index for currency {currency}"))
})?;
let rate_position = self
.rate_indices
.iter()
.position(|index| *index == foreign_index)
.ok_or_else(|| {
QSError::NotFoundErr(format!("Rate position for {foreign_index}"))
})?;
let fx_position = rate_count + foreign_position;
let foreign_model = &self.model.curve_models[&foreign_index];
let (rate_correlation, fx_correlation) =
self.model
.correlation_matrix
.as_ref()
.map_or((0.0, 0.0), |correlation| {
(
correlation[rate_position][0],
correlation[rate_position][fx_position],
)
});
rate_factors[rate_position] = foreign_model
.evolve_foreign_factor_under_domestic_measure_euler(
time,
rate_factors[rate_position],
time_step,
increments[rate_position],
domestic_model,
fx_model.fx_vol().value(),
fx_correlation,
rate_correlation,
);
let current_spot = fx_spots[currency];
let evolved_spot = fx_model.evolve_fx_spot_log_euler(
time,
current_spot,
rate_factors[0],
rate_factors[rate_position],
time_step,
increments[fx_position],
)?;
fx_spots.insert(*currency, evolved_spot);
}
for (equity_position, name) in self.equity_names.iter().enumerate() {
let equity_model = &self.model.equity_models[name];
let factor_position = rate_count + self.fx_currencies.len() + equity_position;
let evolved_spot = equity_model.evolve_spot_log_euler(
time,
equity_spots[name],
rate_factors[0],
time_step,
increments[factor_position],
)?;
equity_spots.insert(name.clone(), evolved_spot);
}
Ok(())
}
fn responses_for_step(
&self,
step: usize,
next_time: f64,
rate_factors: &[T],
fx_spots: &HashMap<Currency, T>,
) -> Result<Vec<SimulationResponse<T>>> {
let evaluation_date = self.model.dates[step];
let domestic_model = &self.model.curve_models[&self.rate_indices[0]];
let mut responses = Vec::with_capacity(self.request_indices_by_step[step].len());
for &request_index in &self.request_indices_by_step[step] {
let request = &self.model.requests[request_index];
let mut response = SimulationResponse::new();
if let Some(forward_request) = &request.forward_rate_request {
let index = forward_request.market_index();
if let Some(curve_model) = self.model.curve_models.get(&index) {
let factor_index = self.model.factor_index(&index);
let rate_position = self
.rate_indices
.iter()
.position(|rate_index| rate_index == factor_index)
.unwrap_or(0);
let rate_factor = rate_factors[rate_position];
let start = forward_request
.start_date()
.unwrap_or_else(|| forward_request.fixing_date());
let end = forward_request
.end_date()
.unwrap_or_else(|| forward_request.fixing_date());
let evaluation_time = self.model.time_from_date(evaluation_date);
let start_time = self.model.time_from_date(start);
let end_time = self.model.time_from_date(end);
response.forward_rates = if (end_time - start_time).abs() < 1e-14 {
Some(curve_model.instantaneous_forward_rate(
evaluation_time,
start_time,
rate_factor,
)?)
} else {
let start_discount =
curve_model.P_discount(evaluation_time, start_time, rate_factor)?;
let end_discount =
curve_model.P_discount(evaluation_time, end_time, rate_factor)?;
Some(
start_discount
.div_val(end_discount)
.sub_val(T::one())
.div_val(T::scalar(end_time - start_time)),
)
};
}
}
if let Some(fx_request) = &request.fx_request {
let base = fx_request.base();
response.fx_rates = if base == self.model.domestic_currency {
Some(T::one())
} else {
Some(fx_spots.get(&base).copied().unwrap_or_else(T::one))
};
}
if let Some(discount_request) = &request.discount_request {
let index = discount_request.market_index();
if let Some(curve_model) = self.model.curve_models.get(&index) {
let factor_index = self.model.factor_index(&index);
let rate_position = self
.rate_indices
.iter()
.position(|rate_index| rate_index == factor_index)
.unwrap_or(0);
let evaluation_time = self.model.time_from_date(evaluation_date);
let payment_time = self.model.time_from_date(discount_request.date());
response.discounts = if payment_time > evaluation_time {
curve_model
.P_discount(evaluation_time, payment_time, rate_factors[rate_position])
.ok()
} else {
Some(T::one())
};
}
}
response.numeraire = Some(domestic_model.numeraire(next_time, rate_factors[0])?);
if request.path_dependent_request.is_some() {
response.path_dependent_observations = None;
}
responses.push(response);
}
Ok(responses)
}
fn populate_spot_responses(
&self,
scenario: &mut PathScenario<T>,
fx_history: &[HashMap<Currency, T>],
equity_history: &[HashMap<String, T>],
) {
for (step, step_responses) in scenario.iter_mut().enumerate() {
for (local_index, &request_index) in
self.request_indices_by_step[step].iter().enumerate()
{
let Some(spot_request) = &self.model.requests[request_index].spot_request else {
continue;
};
let index = spot_request.market_index();
let observation_step = self
.model
.dates
.iter()
.rposition(|date| *date <= spot_request.date())
.unwrap_or(step)
.min(self.model.dates.len() - 1);
if let Some(currency) = self.model.fx_spot_indices.get(&index) {
if let Some(&spot) = fx_history[observation_step].get(currency) {
step_responses[local_index].spots = Some(spot);
}
} else if let MarketIndex::Equity(name) = &index {
if let Some(&spot) = equity_history[observation_step].get(name) {
step_responses[local_index].spots = Some(spot);
}
}
}
}
}
fn generate_path(
&self,
sample_index: u32,
sequence_block: usize,
antithetic_sign: f64,
) -> Result<PathScenario<T>> {
let n_dates = self.model.dates.len();
let mut rate_factors = vec![T::zero(); self.rate_indices.len()];
let mut fx_spots: HashMap<Currency, T> = self
.fx_currencies
.iter()
.map(|currency| {
let spot = self.model.fx_models[currency].initial_spot();
(*currency, spot)
})
.collect();
let mut equity_spots: HashMap<String, T> = self
.equity_names
.iter()
.map(|name| (name.clone(), self.model.equity_models[name].initial_spot()))
.collect();
let mut fx_history = Vec::with_capacity(n_dates);
let mut equity_history = Vec::with_capacity(n_dates);
let mut scenario = Vec::with_capacity(n_dates);
for step in 0..n_dates {
let time = self.times[step];
let next_time = self.times[step + 1];
let time_step = next_time - time;
let increments = self.correlated_increments(
step,
sample_index,
sequence_block,
antithetic_sign,
time_step.sqrt(),
);
self.evolve_market_state(
time,
time_step,
&increments,
&mut rate_factors,
&mut fx_spots,
&mut equity_spots,
)?;
fx_history.push(fx_spots.clone());
equity_history.push(equity_spots.clone());
scenario.push(self.responses_for_step(step, next_time, &rate_factors, &fx_spots)?);
}
self.populate_spot_responses(&mut scenario, &fx_history, &equity_history);
Ok(scenario)
}
}
unsafe impl<T: Scalar> Sync for LgmMarketModel<'_, T> {}
#[allow(clippy::non_send_fields_in_send_ty)]
unsafe impl<T: Scalar> Send for LgmMarketModel<'_, T> {}
impl<T: Scalar + 'static> MarketModel<T> for LgmMarketModel<'_, T> {
fn n_paths(&self) -> usize {
self.n_paths
}
fn generate_path(&self, index: usize) -> Option<PathScenario<T>> {
let ctx = LgmPathContext::new(self);
let pair_index = index / 2;
let sample_index = u32::try_from(pair_index % SOBOL_SEQUENCE_LENGTH).ok()?;
let sequence_block = pair_index / SOBOL_SEQUENCE_LENGTH;
let antithetic_sign = if index.is_multiple_of(2) { 1.0 } else { -1.0 };
ctx.generate_path(sample_index, sequence_block, antithetic_sign)
.ok()
}
fn set_evaluation_dates(&mut self, dates: Vec<Date>) {
self.dates = dates;
self.rebuild_path_layout();
}
fn set_requests(&mut self, requests: Vec<SimulationRequest>) {
self.request_dates = vec![None; requests.len()];
self.compact_dated_requests = false;
self.requests = requests;
self.rebuild_request_layout();
}
fn set_request_dates(&mut self, request_dates: Vec<Option<Date>>) {
if request_dates.len() == self.requests.len() {
self.request_dates = request_dates;
self.compact_dated_requests = true;
self.rebuild_request_layout();
}
}
fn uses_compact_dated_requests(&self) -> bool {
self.compact_dated_requests
}
fn resolve_discount_request(&self, eval_date: Date, request: &DiscountRequest) -> Result<T> {
let idx = request.market_index();
let curve_model = self
.curve_models
.get(&idx)
.ok_or_else(|| QSError::NotFoundErr(format!("Curve model for {idx}")))?;
let t_eval = self.time_from_date(eval_date);
let t_pay = self.time_from_date(request.date());
let z_t = T::scalar(self.state_z(&idx, eval_date).unwrap_or(0.0));
curve_model.P_discount(t_eval, t_pay, z_t)
}
fn resolve_forward_rate_request(
&self,
eval_date: Date,
request: &ForwardRateRequest,
) -> Result<T> {
let idx = request.market_index();
let curve_model = self
.curve_models
.get(&idx)
.ok_or_else(|| QSError::NotFoundErr(format!("Curve model for {idx}")))?;
let t_eval = self.time_from_date(eval_date);
let z_t = T::scalar(self.state_z(&idx, eval_date).unwrap_or(0.0));
let start = request
.start_date()
.unwrap_or_else(|| request.fixing_date());
let end = request.end_date().unwrap_or_else(|| request.fixing_date());
let t_start = self.time_from_date(start);
let t_end = self.time_from_date(end);
if (t_end - t_start).abs() < 1e-14 {
curve_model.instantaneous_forward_rate(t_eval, t_start, z_t)
} else {
let p_s = curve_model.P_discount(t_eval, t_start, z_t)?;
let p_e = curve_model.P_discount(t_eval, t_end, z_t)?;
let tau = t_end - t_start;
Ok(p_s.div_val(p_e).sub_val(T::one()).div_val(T::scalar(tau)))
}
}
fn resolve_fx_request(&self, eval_date: Date, request: &FxRequest) -> Result<T> {
let base = request.base();
if base == self.domestic_currency {
return Ok(T::one());
}
self.state_fx(base, eval_date)
.map(|v| T::scalar(v))
.ok_or_else(|| QSError::NotFoundErr(format!("FX state for {base} at {eval_date}")))
}
fn resolve_spot_request(&self, eval_date: Date, request: &SpotRequest) -> Result<T> {
let idx = request.market_index();
self.fx_spot_indices.get(&idx).map_or_else(
|| {
Err(QSError::NotImplementedErr(
"Spot request not supported in LGM rate model".into(),
))
},
|ccy| {
self.state_fx(*ccy, eval_date)
.map(|v| T::scalar(v))
.ok_or_else(|| {
QSError::NotFoundErr(format!("FX state for {ccy} at {eval_date}"))
})
},
)
}
fn resolve_path_dependent_request(
&self,
_eval_date: Date,
_request: &PathDependentRequest,
) -> Result<T> {
Err(QSError::NotImplementedErr(
"Path-dependent request not supported in LGM rate model".into(),
))
}
}
impl<T: Scalar> LgmMarketModel<'_, T> {
fn state_z(&self, index: &MarketIndex, date: Date) -> Option<f64> {
self.state
.rates
.get(self.factor_index(index))?
.get(&date)
.copied()
}
fn state_fx(&self, currency: Currency, date: Date) -> Option<f64> {
self.state.fx.get(¤cy)?.get(&date).copied()
}
}
#[cfg(test)]
mod sampling_tests {
use super::*;
use crate::{
rates::{
interestrate::RateDefinition,
yieldtermstructure::flatforwardtermstructure::FlatForwardTermStructure,
},
time::date::Date,
};
#[test]
fn sobol_normals_are_repeatable_antithetic_pairs() {
for dimension in 0..32 {
let positive = sobol_normal(17, dimension, 0, 42, 1.0);
let repeated = sobol_normal(17, dimension, 0, 42, 1.0);
let antithetic = sobol_normal(17, dimension, 0, 42, -1.0);
assert!(positive.is_finite());
assert_eq!(positive, repeated);
assert!((positive + antithetic).abs() < f64::EPSILON);
}
}
#[test]
fn factor_order_is_stable_and_sorted_by_currency() {
let reference_date = Date::new(2026, 9, 9);
let curve = FlatForwardTermStructure::new(reference_date, 0.03, RateDefinition::default());
let domestic_fx_rate = LgmRateModel::new(0.03, 0.01, &curve);
let eur_fx_rate = LgmRateModel::new(0.03, 0.01, &curve);
let jpy_fx_rate = LgmRateModel::new(0.03, 0.01, &curve);
let eur_fx = LgmFxModel::new(&domestic_fx_rate, &eur_fx_rate, 0.10, 1.15, 0.0);
let jpy_fx = LgmFxModel::new(&domestic_fx_rate, &jpy_fx_rate, 0.10, 0.007, 0.0);
let mut model = LgmMarketModel::new(
Currency::USD,
MarketIndex::SOFR,
reference_date,
DayCounter::Actual365,
);
model.add_curve_model(MarketIndex::SOFR, LgmRateModel::new(0.03, 0.01, &curve));
model.add_curve_model(MarketIndex::TONAR, LgmRateModel::new(0.03, 0.01, &curve));
model.add_curve_model(MarketIndex::ESTR, LgmRateModel::new(0.03, 0.01, &curve));
model.add_fx_model(Currency::JPY, jpy_fx);
model.add_fx_model(Currency::EUR, eur_fx);
let (rate_indices, fx_currencies, _) = model.build_factor_ordering();
assert_eq!(fx_currencies, vec![Currency::EUR, Currency::JPY]);
assert_eq!(
rate_indices,
vec![MarketIndex::SOFR, MarketIndex::ESTR, MarketIndex::TONAR]
);
}
}