pub mod bootstrap;
pub mod portfolio;
pub mod volatility;
use std::collections::HashMap;
use std::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::contract::{Currency, FxRate};
use crate::finance::{year_fraction, Date, DayCountConvention, FinanceError, FixedRateBond};
use crate::option::{black_scholes_merton, BlackScholesInputs, OptionError, OptionType};
#[derive(Debug, Clone, PartialEq)]
pub enum ValuationContextError {
MissingDiscountCurve(Currency),
MissingForwardCurve(Currency),
MissingVolatilitySurface(String),
MissingFxRate { from: Currency, to: Currency },
TimeOutsideCurve,
InvalidCurve(&'static str),
Instrument(FinanceError),
Option(OptionError),
InvalidScenario(&'static str),
UnsupportedExercise(crate::option::OptionStyle),
OutsideSurfaceValidity,
}
impl fmt::Display for ValuationContextError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingDiscountCurve(currency) => {
write!(f, "no discount curve for {currency}")
}
Self::MissingForwardCurve(currency) => {
write!(f, "no forward curve for {currency}")
}
Self::MissingVolatilitySurface(symbol) => {
write!(f, "no volatility surface for {symbol}")
}
Self::MissingFxRate { from, to } => {
write!(f, "no fx rate from {from} to {to}")
}
Self::TimeOutsideCurve => f.write_str("date lies before the curve's reference date"),
Self::InvalidCurve(reason) => write!(f, "invalid curve: {reason}"),
Self::Instrument(err) => write!(f, "invalid instrument: {err}"),
Self::Option(err) => write!(f, "invalid option inputs: {err:?}"),
Self::InvalidScenario(reason) => write!(f, "invalid scenario: {reason}"),
Self::OutsideSurfaceValidity => {
f.write_str("strike or maturity lies outside the volatility surface's validity")
}
Self::UnsupportedExercise(style) => write!(
f,
"no pricing engine for {style:?} exercise; only European options are valued here"
),
}
}
}
impl std::error::Error for ValuationContextError {}
impl From<FinanceError> for ValuationContextError {
fn from(err: FinanceError) -> Self {
Self::Instrument(err)
}
}
impl From<OptionError> for ValuationContextError {
fn from(err: OptionError) -> Self {
Self::Option(err)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct YieldCurve {
reference: Date,
day_count: DayCountConvention,
nodes: Vec<(f64, f64)>,
}
impl YieldCurve {
pub fn flat(reference: Date, rate: f64, day_count: DayCountConvention) -> Self {
Self {
reference,
day_count,
nodes: vec![(0.0, rate)],
}
}
pub fn from_zero_rates(
reference: Date,
nodes: Vec<(f64, f64)>,
day_count: DayCountConvention,
) -> Result<Self, ValuationContextError> {
if nodes.is_empty() {
return Err(ValuationContextError::InvalidCurve(
"a curve needs at least one node",
));
}
if nodes
.iter()
.any(|(t, r)| !t.is_finite() || *t < 0.0 || !r.is_finite())
{
return Err(ValuationContextError::InvalidCurve(
"node times must be finite and non-negative, rates finite",
));
}
if nodes.windows(2).any(|w| w[0].0 >= w[1].0) {
return Err(ValuationContextError::InvalidCurve(
"node times must be strictly ascending",
));
}
Ok(Self {
reference,
day_count,
nodes,
})
}
pub fn reference_date(&self) -> Date {
self.reference
}
pub fn day_count(&self) -> DayCountConvention {
self.day_count
}
pub fn nodes(&self) -> &[(f64, f64)] {
&self.nodes
}
pub fn time_to(&self, date: Date) -> Result<f64, ValuationContextError> {
if date < self.reference {
return Err(ValuationContextError::TimeOutsideCurve);
}
Ok(year_fraction(self.reference, date, self.day_count))
}
pub fn zero_rate(&self, t: f64) -> Result<f64, ValuationContextError> {
if !t.is_finite() || t < 0.0 {
return Err(ValuationContextError::TimeOutsideCurve);
}
let first = self.nodes[0];
if t <= first.0 {
return Ok(first.1);
}
let last = self.nodes[self.nodes.len() - 1];
if t >= last.0 {
return Ok(last.1);
}
let index = self
.nodes
.partition_point(|(node_t, _)| *node_t <= t)
.max(1);
let (t0, r0) = self.nodes[index - 1];
let (t1, r1) = self.nodes[index];
let weight = (t - t0) / (t1 - t0);
Ok(r0 + weight * (r1 - r0))
}
pub fn discount_factor_at(&self, t: f64) -> Result<f64, ValuationContextError> {
Ok((-self.zero_rate(t)? * t).exp())
}
pub fn discount_factor(&self, date: Date) -> Result<f64, ValuationContextError> {
self.discount_factor_at(self.time_to(date)?)
}
pub fn shifted(&self, delta: f64) -> Self {
Self {
reference: self.reference,
day_count: self.day_count,
nodes: self
.nodes
.iter()
.map(|(t, rate)| (*t, rate + delta))
.collect(),
}
}
pub fn forward_rate(&self, t1: f64, t2: f64) -> Result<f64, ValuationContextError> {
if !t2.is_finite() || t2 <= t1 {
return Err(ValuationContextError::TimeOutsideCurve);
}
let (z1, z2) = (self.zero_rate(t1)?, self.zero_rate(t2)?);
Ok((z2 * t2 - z1 * t1) / (t2 - t1))
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DiscountCurve(YieldCurve);
impl DiscountCurve {
pub fn new(curve: YieldCurve) -> Self {
Self(curve)
}
pub fn curve(&self) -> &YieldCurve {
&self.0
}
pub fn discount_factor(&self, date: Date) -> Result<f64, ValuationContextError> {
self.0.discount_factor(date)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ForwardCurve(YieldCurve);
impl ForwardCurve {
pub fn new(curve: YieldCurve) -> Self {
Self(curve)
}
pub fn curve(&self) -> &YieldCurve {
&self.0
}
pub fn forward_rate(&self, t1: f64, t2: f64) -> Result<f64, ValuationContextError> {
self.0.forward_rate(t1, t2)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ValuationStamp {
pub valuation_date: Date,
pub as_of: i64,
pub data_version: String,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Valued<T> {
pub value: T,
pub stamp: ValuationStamp,
}
impl<T> Valued<T> {
pub fn into_inner(self) -> T {
self.value
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct BondCurveValuation {
pub dirty_price: f64,
pub clean_price: f64,
pub accrued_interest: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ValuationContext {
valuation_date: Date,
as_of: i64,
data_version: String,
discount_curves: HashMap<Currency, DiscountCurve>,
forward_curves: HashMap<Currency, ForwardCurve>,
volatility_surfaces: HashMap<String, volatility::VolatilitySurface>,
fx_rates: Vec<FxRate>,
}
impl ValuationContext {
pub fn new(valuation_date: Date, as_of: i64, data_version: impl Into<String>) -> Self {
Self {
valuation_date,
as_of,
data_version: data_version.into(),
discount_curves: HashMap::new(),
forward_curves: HashMap::new(),
volatility_surfaces: HashMap::new(),
fx_rates: Vec::new(),
}
}
pub fn with_discount_curve(mut self, currency: Currency, curve: DiscountCurve) -> Self {
self.discount_curves.insert(currency, curve);
self
}
pub fn with_forward_curve(mut self, currency: Currency, curve: ForwardCurve) -> Self {
self.forward_curves.insert(currency, curve);
self
}
pub fn with_volatility_surface(
mut self,
symbol: impl Into<String>,
surface: volatility::VolatilitySurface,
) -> Self {
self.volatility_surfaces.insert(symbol.into(), surface);
self
}
pub fn volatility_surface(
&self,
symbol: &str,
) -> Result<&volatility::VolatilitySurface, ValuationContextError> {
self.volatility_surfaces
.get(symbol)
.ok_or_else(|| ValuationContextError::MissingVolatilitySurface(symbol.to_string()))
}
pub fn with_fx_rate(mut self, rate: FxRate) -> Self {
self.fx_rates.push(rate);
self
}
pub fn valuation_date(&self) -> Date {
self.valuation_date
}
pub fn stamp(&self) -> ValuationStamp {
ValuationStamp {
valuation_date: self.valuation_date,
as_of: self.as_of,
data_version: self.data_version.clone(),
}
}
fn valued<T>(&self, value: T) -> Valued<T> {
Valued {
value,
stamp: self.stamp(),
}
}
pub fn discount_curve(
&self,
currency: &Currency,
) -> Result<&DiscountCurve, ValuationContextError> {
self.discount_curves
.get(currency)
.ok_or_else(|| ValuationContextError::MissingDiscountCurve(currency.clone()))
}
pub fn forward_curve(
&self,
currency: &Currency,
) -> Result<&ForwardCurve, ValuationContextError> {
self.forward_curves
.get(currency)
.ok_or_else(|| ValuationContextError::MissingForwardCurve(currency.clone()))
}
pub fn convert(
&self,
amount: f64,
from: &Currency,
to: &Currency,
) -> Result<f64, ValuationContextError> {
if from == to {
return Ok(amount);
}
self.fx_rates
.iter()
.find_map(|rate| rate.convert(amount, from, to).ok())
.ok_or_else(|| ValuationContextError::MissingFxRate {
from: from.clone(),
to: to.clone(),
})
}
pub fn price_bond(
&self,
bond: &FixedRateBond,
currency: &Currency,
) -> Result<Valued<BondCurveValuation>, ValuationContextError> {
self.price_bond_shifted(bond, currency, 0.0)
}
pub fn price_bond_shifted(
&self,
bond: &FixedRateBond,
currency: &Currency,
rate_shift: f64,
) -> Result<Valued<BondCurveValuation>, ValuationContextError> {
let curve = self.discount_curve(currency)?.curve().shifted(rate_shift);
let flows = bond.cashflows(self.valuation_date);
if flows.is_empty() {
return Err(ValuationContextError::Instrument(
FinanceError::InvalidInput("no cashflows remain after the valuation date"),
));
}
let mut dirty_price = 0.0;
for flow in &flows {
dirty_price += flow.amount * curve.discount_factor(flow.date)?;
}
let accrued_interest = bond.accrued_interest(self.valuation_date);
Ok(self.valued(BondCurveValuation {
dirty_price,
clean_price: dirty_price - accrued_interest,
accrued_interest,
}))
}
#[allow(clippy::too_many_arguments)]
pub fn price_european_option(
&self,
currency: &Currency,
option_type: OptionType,
spot: f64,
strike: f64,
expiry: Date,
volatility: f64,
dividend_yield: f64,
) -> Result<Valued<crate::option::OptionPricingResult>, ValuationContextError> {
self.price_european_option_shifted(
currency,
option_type,
spot,
strike,
expiry,
volatility,
dividend_yield,
0.0,
)
}
#[allow(clippy::too_many_arguments)]
pub fn price_european_option_on_surface(
&self,
symbol: &str,
currency: &Currency,
option_type: OptionType,
spot: f64,
strike: f64,
expiry: Date,
dividend_yield: f64,
) -> Result<Valued<crate::option::OptionPricingResult>, ValuationContextError> {
let volatility = self
.volatility_surface(symbol)?
.volatility(expiry, strike)?;
self.price_european_option(
currency,
option_type,
spot,
strike,
expiry,
volatility,
dividend_yield,
)
}
#[allow(clippy::too_many_arguments)]
pub fn price_european_option_shifted(
&self,
currency: &Currency,
option_type: OptionType,
spot: f64,
strike: f64,
expiry: Date,
volatility: f64,
dividend_yield: f64,
rate_shift: f64,
) -> Result<Valued<crate::option::OptionPricingResult>, ValuationContextError> {
let curve = self.discount_curve(currency)?.curve().shifted(rate_shift);
if expiry < self.valuation_date {
return Err(ValuationContextError::TimeOutsideCurve);
}
let time_to_expiry_years = year_fraction(self.valuation_date, expiry, curve.day_count());
let risk_free_rate = curve.zero_rate(time_to_expiry_years)?;
let priced = black_scholes_merton(
option_type,
&BlackScholesInputs {
spot,
strike,
time_to_expiry_years,
risk_free_rate,
dividend_yield,
volatility,
},
)?;
Ok(self.valued(priced))
}
}