kestrel_chartkit/valuation/
bootstrap.rs1#[cfg(feature = "serde")]
16use serde::{Deserialize, Serialize};
17
18use crate::finance::{year_fraction, CouponSchedule, Date, DayCountConvention};
19
20use super::{ValuationContextError, YieldCurve};
21
22#[derive(Debug, Clone, Copy, PartialEq)]
24#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
25pub enum CalibrationInstrument {
26 ZeroRate { maturity: Date, rate: f64 },
28 ParSwap {
36 maturity: Date,
37 rate: f64,
38 frequency: u32,
39 },
40}
41
42impl CalibrationInstrument {
43 pub fn maturity(&self) -> Date {
44 match self {
45 Self::ZeroRate { maturity, .. } | Self::ParSwap { maturity, .. } => *maturity,
46 }
47 }
48}
49
50impl YieldCurve {
51 pub fn bootstrap(
66 reference: Date,
67 instruments: &[CalibrationInstrument],
68 day_count: DayCountConvention,
69 ) -> Result<Self, ValuationContextError> {
70 if instruments.is_empty() {
71 return Err(ValuationContextError::InvalidCurve(
72 "bootstrapping needs at least one instrument",
73 ));
74 }
75
76 let mut sorted = instruments.to_vec();
77 sorted.sort_by_key(|instrument| instrument.maturity());
78 if sorted
79 .windows(2)
80 .any(|w| w[0].maturity() == w[1].maturity())
81 {
82 return Err(ValuationContextError::InvalidCurve(
83 "two instruments share a maturity",
84 ));
85 }
86 if sorted[0].maturity() <= reference {
87 return Err(ValuationContextError::InvalidCurve(
88 "instruments must mature after the reference date",
89 ));
90 }
91
92 let mut nodes: Vec<(f64, f64)> = Vec::with_capacity(sorted.len());
93 for instrument in &sorted {
94 let time = year_fraction(reference, instrument.maturity(), day_count);
95 match instrument {
96 CalibrationInstrument::ZeroRate { rate, .. } => {
97 if !rate.is_finite() {
98 return Err(ValuationContextError::InvalidCurve(
99 "a quoted zero rate must be finite",
100 ));
101 }
102 nodes.push((time, *rate));
103 }
104 CalibrationInstrument::ParSwap {
105 maturity,
106 rate,
107 frequency,
108 } => {
109 let rate = solve_par_swap_node(
110 reference, *maturity, *rate, *frequency, day_count, &nodes,
111 )?;
112 nodes.push((time, rate));
113 }
114 }
115 }
116
117 Self::from_zero_rates(reference, nodes, day_count)
118 }
119
120 pub fn par_swap_rate(
126 &self,
127 maturity: Date,
128 frequency: u32,
129 ) -> Result<f64, ValuationContextError> {
130 let (annuity, final_discount) = swap_legs(self, maturity, frequency)?;
131 if annuity <= 0.0 {
132 return Err(ValuationContextError::InvalidCurve(
133 "swap annuity is not positive",
134 ));
135 }
136 Ok((1.0 - final_discount) / annuity)
137 }
138}
139
140fn swap_legs(
142 curve: &YieldCurve,
143 maturity: Date,
144 frequency: u32,
145) -> Result<(f64, f64), ValuationContextError> {
146 let schedule = CouponSchedule::covering(curve.reference_date(), maturity, frequency)
147 .map_err(ValuationContextError::Instrument)?;
148
149 let mut annuity = 0.0;
150 for index in 0..schedule.period_count() {
151 let (start, end) = schedule
152 .period(index)
153 .expect("index below period_count is valid");
154 let accrual = year_fraction(start, end, curve.day_count());
155 annuity += accrual * curve.discount_factor(end)?;
156 }
157 Ok((annuity, curve.discount_factor(maturity)?))
158}
159
160fn solve_par_swap_node(
166 reference: Date,
167 maturity: Date,
168 quoted_rate: f64,
169 frequency: u32,
170 day_count: DayCountConvention,
171 fixed_nodes: &[(f64, f64)],
172) -> Result<f64, ValuationContextError> {
173 if !quoted_rate.is_finite() {
174 return Err(ValuationContextError::InvalidCurve(
175 "a quoted swap rate must be finite",
176 ));
177 }
178
179 let time = year_fraction(reference, maturity, day_count);
180 let mismatch = |candidate: f64| -> Result<f64, ValuationContextError> {
181 let mut nodes = fixed_nodes.to_vec();
182 nodes.push((time, candidate));
183 let curve = YieldCurve::from_zero_rates(reference, nodes, day_count)?;
184 let (annuity, final_discount) = swap_legs(&curve, maturity, frequency)?;
185 Ok(quoted_rate * annuity - (1.0 - final_discount))
187 };
188
189 let (mut low, mut high) = (-0.5, 1.0);
190 let (low_value, high_value) = (mismatch(low)?, mismatch(high)?);
191 if low_value.signum() == high_value.signum() {
192 return Err(ValuationContextError::InvalidCurve(
193 "no zero rate between -50% and +100% prices this swap at par",
194 ));
195 }
196
197 for _ in 0..100 {
200 let middle = 0.5 * (low + high);
201 let value = mismatch(middle)?;
202 if value == 0.0 {
203 return Ok(middle);
204 }
205 if value.signum() == low_value.signum() {
206 low = middle;
207 } else {
208 high = middle;
209 }
210 }
211 Ok(0.5 * (low + high))
212}