1pub mod bootstrap;
32pub mod portfolio;
33pub mod volatility;
34
35use std::collections::HashMap;
36use std::fmt;
37
38#[cfg(feature = "serde")]
39use serde::{Deserialize, Serialize};
40
41use crate::contract::{Currency, FxRate};
42use crate::finance::{year_fraction, Date, DayCountConvention, FinanceError, FixedRateBond};
43use crate::option::{black_scholes_merton, BlackScholesInputs, OptionError, OptionType};
44
45#[derive(Debug, Clone, PartialEq)]
50pub enum ValuationContextError {
51 MissingDiscountCurve(Currency),
53 MissingForwardCurve(Currency),
55 MissingVolatilitySurface(String),
57 MissingFxRate { from: Currency, to: Currency },
59 TimeOutsideCurve,
61 InvalidCurve(&'static str),
63 Instrument(FinanceError),
65 Option(OptionError),
67 InvalidScenario(&'static str),
69 UnsupportedExercise(crate::option::OptionStyle),
71 OutsideSurfaceValidity,
74}
75
76impl fmt::Display for ValuationContextError {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 Self::MissingDiscountCurve(currency) => {
80 write!(f, "no discount curve for {currency}")
81 }
82 Self::MissingForwardCurve(currency) => {
83 write!(f, "no forward curve for {currency}")
84 }
85 Self::MissingVolatilitySurface(symbol) => {
86 write!(f, "no volatility surface for {symbol}")
87 }
88 Self::MissingFxRate { from, to } => {
89 write!(f, "no fx rate from {from} to {to}")
90 }
91 Self::TimeOutsideCurve => f.write_str("date lies before the curve's reference date"),
92 Self::InvalidCurve(reason) => write!(f, "invalid curve: {reason}"),
93 Self::Instrument(err) => write!(f, "invalid instrument: {err}"),
94 Self::Option(err) => write!(f, "invalid option inputs: {err:?}"),
95 Self::InvalidScenario(reason) => write!(f, "invalid scenario: {reason}"),
96 Self::OutsideSurfaceValidity => {
97 f.write_str("strike or maturity lies outside the volatility surface's validity")
98 }
99 Self::UnsupportedExercise(style) => write!(
100 f,
101 "no pricing engine for {style:?} exercise; only European options are valued here"
102 ),
103 }
104 }
105}
106
107impl std::error::Error for ValuationContextError {}
108
109impl From<FinanceError> for ValuationContextError {
110 fn from(err: FinanceError) -> Self {
111 Self::Instrument(err)
112 }
113}
114
115impl From<OptionError> for ValuationContextError {
116 fn from(err: OptionError) -> Self {
117 Self::Option(err)
118 }
119}
120
121#[derive(Debug, Clone, PartialEq)]
133#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
134pub struct YieldCurve {
135 reference: Date,
136 day_count: DayCountConvention,
137 nodes: Vec<(f64, f64)>,
139}
140
141impl YieldCurve {
142 pub fn flat(reference: Date, rate: f64, day_count: DayCountConvention) -> Self {
144 Self {
145 reference,
146 day_count,
147 nodes: vec![(0.0, rate)],
148 }
149 }
150
151 pub fn from_zero_rates(
156 reference: Date,
157 nodes: Vec<(f64, f64)>,
158 day_count: DayCountConvention,
159 ) -> Result<Self, ValuationContextError> {
160 if nodes.is_empty() {
161 return Err(ValuationContextError::InvalidCurve(
162 "a curve needs at least one node",
163 ));
164 }
165 if nodes
166 .iter()
167 .any(|(t, r)| !t.is_finite() || *t < 0.0 || !r.is_finite())
168 {
169 return Err(ValuationContextError::InvalidCurve(
170 "node times must be finite and non-negative, rates finite",
171 ));
172 }
173 if nodes.windows(2).any(|w| w[0].0 >= w[1].0) {
174 return Err(ValuationContextError::InvalidCurve(
175 "node times must be strictly ascending",
176 ));
177 }
178 Ok(Self {
179 reference,
180 day_count,
181 nodes,
182 })
183 }
184
185 pub fn reference_date(&self) -> Date {
186 self.reference
187 }
188
189 pub fn day_count(&self) -> DayCountConvention {
190 self.day_count
191 }
192
193 pub fn nodes(&self) -> &[(f64, f64)] {
194 &self.nodes
195 }
196
197 pub fn time_to(&self, date: Date) -> Result<f64, ValuationContextError> {
199 if date < self.reference {
200 return Err(ValuationContextError::TimeOutsideCurve);
201 }
202 Ok(year_fraction(self.reference, date, self.day_count))
203 }
204
205 pub fn zero_rate(&self, t: f64) -> Result<f64, ValuationContextError> {
207 if !t.is_finite() || t < 0.0 {
208 return Err(ValuationContextError::TimeOutsideCurve);
209 }
210 let first = self.nodes[0];
211 if t <= first.0 {
212 return Ok(first.1);
213 }
214 let last = self.nodes[self.nodes.len() - 1];
215 if t >= last.0 {
216 return Ok(last.1);
217 }
218 let index = self
219 .nodes
220 .partition_point(|(node_t, _)| *node_t <= t)
221 .max(1);
222 let (t0, r0) = self.nodes[index - 1];
223 let (t1, r1) = self.nodes[index];
224 let weight = (t - t0) / (t1 - t0);
225 Ok(r0 + weight * (r1 - r0))
226 }
227
228 pub fn discount_factor_at(&self, t: f64) -> Result<f64, ValuationContextError> {
230 Ok((-self.zero_rate(t)? * t).exp())
231 }
232
233 pub fn discount_factor(&self, date: Date) -> Result<f64, ValuationContextError> {
235 self.discount_factor_at(self.time_to(date)?)
236 }
237
238 pub fn shifted(&self, delta: f64) -> Self {
244 Self {
245 reference: self.reference,
246 day_count: self.day_count,
247 nodes: self
248 .nodes
249 .iter()
250 .map(|(t, rate)| (*t, rate + delta))
251 .collect(),
252 }
253 }
254
255 pub fn forward_rate(&self, t1: f64, t2: f64) -> Result<f64, ValuationContextError> {
258 if !t2.is_finite() || t2 <= t1 {
259 return Err(ValuationContextError::TimeOutsideCurve);
260 }
261 let (z1, z2) = (self.zero_rate(t1)?, self.zero_rate(t2)?);
262 Ok((z2 * t2 - z1 * t1) / (t2 - t1))
263 }
264}
265
266#[derive(Debug, Clone, PartialEq)]
271#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
272pub struct DiscountCurve(YieldCurve);
273
274impl DiscountCurve {
275 pub fn new(curve: YieldCurve) -> Self {
276 Self(curve)
277 }
278
279 pub fn curve(&self) -> &YieldCurve {
280 &self.0
281 }
282
283 pub fn discount_factor(&self, date: Date) -> Result<f64, ValuationContextError> {
284 self.0.discount_factor(date)
285 }
286}
287
288#[derive(Debug, Clone, PartialEq)]
290#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
291pub struct ForwardCurve(YieldCurve);
292
293impl ForwardCurve {
294 pub fn new(curve: YieldCurve) -> Self {
295 Self(curve)
296 }
297
298 pub fn curve(&self) -> &YieldCurve {
299 &self.0
300 }
301
302 pub fn forward_rate(&self, t1: f64, t2: f64) -> Result<f64, ValuationContextError> {
303 self.0.forward_rate(t1, t2)
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
314#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
315pub struct ValuationStamp {
316 pub valuation_date: Date,
318 pub as_of: i64,
320 pub data_version: String,
322}
323
324#[derive(Debug, Clone, PartialEq)]
326#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
327pub struct Valued<T> {
328 pub value: T,
329 pub stamp: ValuationStamp,
330}
331
332impl<T> Valued<T> {
333 pub fn into_inner(self) -> T {
334 self.value
335 }
336}
337
338#[derive(Debug, Clone, Copy, PartialEq)]
340#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
341pub struct BondCurveValuation {
342 pub dirty_price: f64,
344 pub clean_price: f64,
346 pub accrued_interest: f64,
347}
348
349#[derive(Debug, Clone, PartialEq)]
351#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
352pub struct ValuationContext {
353 valuation_date: Date,
354 as_of: i64,
355 data_version: String,
356 discount_curves: HashMap<Currency, DiscountCurve>,
357 forward_curves: HashMap<Currency, ForwardCurve>,
358 volatility_surfaces: HashMap<String, volatility::VolatilitySurface>,
359 fx_rates: Vec<FxRate>,
360}
361
362impl ValuationContext {
363 pub fn new(valuation_date: Date, as_of: i64, data_version: impl Into<String>) -> Self {
365 Self {
366 valuation_date,
367 as_of,
368 data_version: data_version.into(),
369 discount_curves: HashMap::new(),
370 forward_curves: HashMap::new(),
371 volatility_surfaces: HashMap::new(),
372 fx_rates: Vec::new(),
373 }
374 }
375
376 pub fn with_discount_curve(mut self, currency: Currency, curve: DiscountCurve) -> Self {
377 self.discount_curves.insert(currency, curve);
378 self
379 }
380
381 pub fn with_forward_curve(mut self, currency: Currency, curve: ForwardCurve) -> Self {
382 self.forward_curves.insert(currency, curve);
383 self
384 }
385
386 pub fn with_volatility_surface(
392 mut self,
393 symbol: impl Into<String>,
394 surface: volatility::VolatilitySurface,
395 ) -> Self {
396 self.volatility_surfaces.insert(symbol.into(), surface);
397 self
398 }
399
400 pub fn volatility_surface(
401 &self,
402 symbol: &str,
403 ) -> Result<&volatility::VolatilitySurface, ValuationContextError> {
404 self.volatility_surfaces
405 .get(symbol)
406 .ok_or_else(|| ValuationContextError::MissingVolatilitySurface(symbol.to_string()))
407 }
408
409 pub fn with_fx_rate(mut self, rate: FxRate) -> Self {
410 self.fx_rates.push(rate);
411 self
412 }
413
414 pub fn valuation_date(&self) -> Date {
415 self.valuation_date
416 }
417
418 pub fn stamp(&self) -> ValuationStamp {
420 ValuationStamp {
421 valuation_date: self.valuation_date,
422 as_of: self.as_of,
423 data_version: self.data_version.clone(),
424 }
425 }
426
427 fn valued<T>(&self, value: T) -> Valued<T> {
428 Valued {
429 value,
430 stamp: self.stamp(),
431 }
432 }
433
434 pub fn discount_curve(
435 &self,
436 currency: &Currency,
437 ) -> Result<&DiscountCurve, ValuationContextError> {
438 self.discount_curves
439 .get(currency)
440 .ok_or_else(|| ValuationContextError::MissingDiscountCurve(currency.clone()))
441 }
442
443 pub fn forward_curve(
444 &self,
445 currency: &Currency,
446 ) -> Result<&ForwardCurve, ValuationContextError> {
447 self.forward_curves
448 .get(currency)
449 .ok_or_else(|| ValuationContextError::MissingForwardCurve(currency.clone()))
450 }
451
452 pub fn convert(
458 &self,
459 amount: f64,
460 from: &Currency,
461 to: &Currency,
462 ) -> Result<f64, ValuationContextError> {
463 if from == to {
464 return Ok(amount);
465 }
466 self.fx_rates
467 .iter()
468 .find_map(|rate| rate.convert(amount, from, to).ok())
469 .ok_or_else(|| ValuationContextError::MissingFxRate {
470 from: from.clone(),
471 to: to.clone(),
472 })
473 }
474
475 pub fn price_bond(
481 &self,
482 bond: &FixedRateBond,
483 currency: &Currency,
484 ) -> Result<Valued<BondCurveValuation>, ValuationContextError> {
485 self.price_bond_shifted(bond, currency, 0.0)
486 }
487
488 pub fn price_bond_shifted(
492 &self,
493 bond: &FixedRateBond,
494 currency: &Currency,
495 rate_shift: f64,
496 ) -> Result<Valued<BondCurveValuation>, ValuationContextError> {
497 let curve = self.discount_curve(currency)?.curve().shifted(rate_shift);
498 let flows = bond.cashflows(self.valuation_date);
499 if flows.is_empty() {
500 return Err(ValuationContextError::Instrument(
501 FinanceError::InvalidInput("no cashflows remain after the valuation date"),
502 ));
503 }
504
505 let mut dirty_price = 0.0;
506 for flow in &flows {
507 dirty_price += flow.amount * curve.discount_factor(flow.date)?;
508 }
509 let accrued_interest = bond.accrued_interest(self.valuation_date);
510
511 Ok(self.valued(BondCurveValuation {
512 dirty_price,
513 clean_price: dirty_price - accrued_interest,
514 accrued_interest,
515 }))
516 }
517
518 #[allow(clippy::too_many_arguments)]
528 pub fn price_european_option(
529 &self,
530 currency: &Currency,
531 option_type: OptionType,
532 spot: f64,
533 strike: f64,
534 expiry: Date,
535 volatility: f64,
536 dividend_yield: f64,
537 ) -> Result<Valued<crate::option::OptionPricingResult>, ValuationContextError> {
538 self.price_european_option_shifted(
539 currency,
540 option_type,
541 spot,
542 strike,
543 expiry,
544 volatility,
545 dividend_yield,
546 0.0,
547 )
548 }
549
550 #[allow(clippy::too_many_arguments)]
559 pub fn price_european_option_on_surface(
560 &self,
561 symbol: &str,
562 currency: &Currency,
563 option_type: OptionType,
564 spot: f64,
565 strike: f64,
566 expiry: Date,
567 dividend_yield: f64,
568 ) -> Result<Valued<crate::option::OptionPricingResult>, ValuationContextError> {
569 let volatility = self
570 .volatility_surface(symbol)?
571 .volatility(expiry, strike)?;
572 self.price_european_option(
573 currency,
574 option_type,
575 spot,
576 strike,
577 expiry,
578 volatility,
579 dividend_yield,
580 )
581 }
582
583 #[allow(clippy::too_many_arguments)]
586 pub fn price_european_option_shifted(
587 &self,
588 currency: &Currency,
589 option_type: OptionType,
590 spot: f64,
591 strike: f64,
592 expiry: Date,
593 volatility: f64,
594 dividend_yield: f64,
595 rate_shift: f64,
596 ) -> Result<Valued<crate::option::OptionPricingResult>, ValuationContextError> {
597 let curve = self.discount_curve(currency)?.curve().shifted(rate_shift);
598 if expiry < self.valuation_date {
599 return Err(ValuationContextError::TimeOutsideCurve);
600 }
601 let time_to_expiry_years = year_fraction(self.valuation_date, expiry, curve.day_count());
602 let risk_free_rate = curve.zero_rate(time_to_expiry_years)?;
603
604 let priced = black_scholes_merton(
605 option_type,
606 &BlackScholesInputs {
607 spot,
608 strike,
609 time_to_expiry_years,
610 risk_free_rate,
611 dividend_yield,
612 volatility,
613 },
614 )?;
615 Ok(self.valued(priced))
616 }
617}