Skip to main content

kestrel_chartkit/valuation/
mod.rs

1//! A shared valuation context: when we are valuing, on which market data, and with which curves.
2//!
3//! A single constant interest rate is enough to discount one cashflow, and that is what
4//! [`crate::finance::discount_factor`] does. It is not a market: real term structures differ by
5//! maturity, differ again between discounting and projecting forward rates, and differ per
6//! currency. This module holds those pieces together so a valuation is reproducible rather than
7//! assembled from whatever constants a caller had at hand.
8//!
9//! (Dieser Modul-Doc-Kommentar verwendet voll qualifizierte `crate::`-Pfade: rustdoc löst
10//! Intra-Doc-Links hier gegen den Crate-Wurzel-Scope auf, weil `pub mod valuation;` in `lib.rs`
11//! einen eigenen `///`-Kommentar trägt, der mit diesem zu einem Block verschmilzt — dieselbe
12//! Lage wie in `applicability.rs`.)
13//!
14//! Three rules run through it:
15//!
16//! * **Missing data is an error, never a default.** A currency without a curve does not silently
17//!   become a zero rate; asking for it fails.
18//! * **Discounting and projecting are different roles**, so [`DiscountCurve`] and [`ForwardCurve`]
19//!   are different types even though they share the same term structure representation. Passing
20//!   one where the other belongs will not compile.
21//! * **A number carries its inputs.** Every valuation returns a [`Valued`] with the
22//!   [`ValuationStamp`] it was produced under, so a result found later can be traced back to the
23//!   data snapshot that produced it.
24//!
25//! [`crate::valuation::portfolio`] builds on this: positions valued by model, revalued under market scenarios,
26//! and aggregated in account currency.
27//!
28//! [`crate::valuation::bootstrap`] builds a curve from quoted instruments instead of from given zero rates,
29//! and [`crate::valuation::volatility`] holds volatility over strike and maturity.
30
31pub 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/// Why a valuation in a [`ValuationContext`] could not be produced.
46///
47/// Distinct from [`crate::contract::ValuationError`], which is about a single contract's own
48/// arithmetic; this one is about the market data a valuation runs on.
49#[derive(Debug, Clone, PartialEq)]
50pub enum ValuationContextError {
51    /// No discount curve for this currency. The context does not invent one.
52    MissingDiscountCurve(Currency),
53    /// No forward curve for this currency.
54    MissingForwardCurve(Currency),
55    /// No volatility surface for this underlying.
56    MissingVolatilitySurface(String),
57    /// No exchange rate connecting these two currencies.
58    MissingFxRate { from: Currency, to: Currency },
59    /// A curve was asked for a date before its reference date, or for a non-finite time.
60    TimeOutsideCurve,
61    /// The curve's nodes do not describe a term structure.
62    InvalidCurve(&'static str),
63    /// The underlying instrument or its schedule is invalid.
64    Instrument(FinanceError),
65    /// The option inputs are invalid.
66    Option(OptionError),
67    /// A scenario's shocks are not usable.
68    InvalidScenario(&'static str),
69    /// The instrument's exercise style has no pricing engine in this crate.
70    UnsupportedExercise(crate::option::OptionStyle),
71    /// The requested strike or maturity lies further outside the quoted volatility grid than its
72    /// declared validity allows.
73    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/// A term structure of continuously compounded zero rates.
122///
123/// Between nodes the zero rate is linear in time; outside them it is held flat at the first
124/// respectively last node's rate. Flat extrapolation is a convention, not a derivation — it is
125/// stated here rather than left implicit, because the alternative (continuing the slope) produces
126/// nonsensical discount factors a few years past the last node.
127///
128/// Continuously compounded zero rates are the representation because they stay well behaved when
129/// rates are negative: the discount factor `exp(-z * t)` is positive for every real `z`, and a
130/// flat curve is exactly the single-node case, so the constant-rate results this crate produced
131/// before remain reproducible.
132#[derive(Debug, Clone, PartialEq)]
133#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
134pub struct YieldCurve {
135    reference: Date,
136    day_count: DayCountConvention,
137    /// `(time in years from the reference date, continuously compounded zero rate)`, ascending.
138    nodes: Vec<(f64, f64)>,
139}
140
141impl YieldCurve {
142    /// A curve with one rate for every maturity — the compatible special case.
143    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    /// A curve through the given `(time, zero rate)` nodes.
152    ///
153    /// Times are years from `reference` and must be finite, non-negative and strictly ascending;
154    /// rates must be finite. At least one node is required.
155    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    /// Years from the reference date to `date`, under the curve's own day count.
198    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    /// Continuously compounded zero rate for maturity `t`, in years.
206    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    /// Discount factor for maturity `t`, in years: `exp(-z(t) * t)`.
229    pub fn discount_factor_at(&self, t: f64) -> Result<f64, ValuationContextError> {
230        Ok((-self.zero_rate(t)? * t).exp())
231    }
232
233    /// Discount factor for a calendar date.
234    pub fn discount_factor(&self, date: Date) -> Result<f64, ValuationContextError> {
235        self.discount_factor_at(self.time_to(date)?)
236    }
237
238    /// The same curve with every zero rate shifted by `delta`, in absolute rate units — a
239    /// parallel shift. `0.0001` is one basis point.
240    ///
241    /// Parallel is the only shape offered here: a twist or a steepening needs a statement about
242    /// which part of the curve moves how, and that belongs to whoever has that view.
243    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    /// Continuously compounded forward rate covering `t1..t2`, from the same term structure:
256    /// `(z(t2) * t2 - z(t1) * t1) / (t2 - t1)`.
257    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/// The curve money is discounted on.
267///
268/// A separate type from [`ForwardCurve`] on purpose: the two answer different questions and are
269/// not interchangeable, even when a market happens to use the same numbers for both.
270#[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/// The curve future rates are projected from.
289#[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/// Which inputs a valuation was produced from.
308///
309/// Carried out with every result so a number found in a report months later can be traced to the
310/// data it came from. `as_of` is the market data snapshot, which is not the same as the valuation
311/// date: revaluing an old date on today's data and on that day's data are different runs, and the
312/// stamp is what tells them apart.
313#[derive(Debug, Clone, PartialEq, Eq)]
314#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
315pub struct ValuationStamp {
316    /// The date being valued.
317    pub valuation_date: Date,
318    /// Unix timestamp of the market data snapshot the context was built from.
319    pub as_of: i64,
320    /// Caller-defined identifier of that input set — a snapshot id, a dataset version, a commit.
321    pub data_version: String,
322}
323
324/// A value together with the inputs it was produced from.
325#[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/// A bond valued against a curve rather than against a single yield.
339#[derive(Debug, Clone, Copy, PartialEq)]
340#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
341pub struct BondCurveValuation {
342    /// Present value of every outstanding cashflow.
343    pub dirty_price: f64,
344    /// `dirty_price` minus accrued interest.
345    pub clean_price: f64,
346    pub accrued_interest: f64,
347}
348
349/// When we are valuing, on which market data, and with which curves.
350#[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    /// An empty context. Curves and rates are added explicitly; nothing is assumed.
364    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    /// Adds a volatility surface for one underlying.
387    ///
388    /// Keyed by the underlying's symbol rather than by currency: two instruments quoted in the
389    /// same currency have their own smiles, and sharing one between them would be a statement
390    /// about the market that nobody made.
391    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    /// The inputs this context stands for.
419    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    /// Converts an amount between currencies using the rates this context holds.
453    ///
454    /// Direct and inverse quotations both count; no cross rates are constructed through a third
455    /// currency, because which currency to route through is a decision this crate has no basis
456    /// for making.
457    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    /// Present value of a bond's outstanding cashflows on the currency's discount curve.
476    ///
477    /// This is the curve-based counterpart to [`FixedRateBond::price`], which discounts at a
478    /// single yield. Accrued interest is the same figure in both — it comes from the schedule,
479    /// not from the discounting.
480    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    /// [`ValuationContext::price_bond`] with every zero rate shifted in parallel by `rate_shift`
489    /// (absolute rate units). The scenario machinery in [`portfolio`] uses this; a shift of zero
490    /// is the unshocked case.
491    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    /// Prices a European option, taking the interest rate from the currency's discount curve.
519    ///
520    /// The rate used is the zero rate to expiry. For a European option that is not an
521    /// approximation: only the discount factor to expiry and the forward enter the formula, and
522    /// both follow from that single rate.
523    ///
524    /// Volatility and dividend yield are passed in rather than read from the context: a
525    /// volatility surface over strike and maturity is not modelled yet, and inventing a flat one
526    /// here would hide that.
527    #[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    /// Prices a European option taking *both* the rate and the volatility from the context: the
551    /// rate from the currency's discount curve, the volatility from the underlying's surface at
552    /// this option's own strike and expiry.
553    ///
554    /// The surface is what makes this different from
555    /// [`ValuationContext::price_european_option`], which takes a volatility from the caller. A
556    /// strike or expiry outside the surface's declared validity fails here rather than being
557    /// answered with the nearest quoted value.
558    #[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    /// [`ValuationContext::price_european_option`] with the curve shifted in parallel by
584    /// `rate_shift` (absolute rate units).
585    #[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}