convex_bonds/traits/
analytics.rs

1//! Analytics trait with blanket implementations for bonds.
2//!
3//! This module provides the `BondAnalytics` trait that offers common analytics
4//! methods for any type implementing the `Bond` trait. These are implemented
5//! as blanket implementations to avoid code duplication across bond types.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use convex_bonds::traits::{Bond, BondAnalytics};
11//! use convex_bonds::FixedRateBond;
12//!
13//! let bond = FixedRateBond::builder()
14//!     .coupon_rate(0.05)
15//!     .maturity(date!(2030-06-15))
16//!     .build()?;
17//!
18//! let ytm = bond.yield_to_maturity(settlement, clean_price)?;
19//! let duration = bond.modified_duration(settlement, ytm)?;
20//! ```
21
22use rust_decimal::prelude::*;
23use rust_decimal::Decimal;
24
25use convex_core::daycounts::DayCountConvention;
26use convex_core::types::{Date, Frequency};
27
28use crate::error::{BondError, BondResult};
29use crate::pricing::{YieldResult, YieldSolver};
30use crate::traits::Bond;
31use crate::types::YieldConvention;
32
33/// Analytics extension trait for bonds.
34///
35/// This trait provides common analytics calculations as blanket implementations
36/// for any type implementing `Bond`. It centralizes yield, duration, convexity,
37/// and DV01 calculations to avoid code duplication.
38///
39/// # Design
40///
41/// - Blanket implementation for all `Bond` implementors
42/// - Uses `YieldSolver` for yield calculations
43/// - Provides both analytical and numerical methods
44/// - All methods take settlement date and relevant market data
45pub trait BondAnalytics: Bond {
46    // ==================== Yield Calculations ====================
47
48    /// Calculates yield to maturity from clean price.
49    ///
50    /// # Arguments
51    ///
52    /// * `settlement` - Settlement date
53    /// * `clean_price` - Clean price per 100 face value
54    /// * `frequency` - Compounding frequency (defaults to semi-annual)
55    ///
56    /// # Returns
57    ///
58    /// Yield result containing the YTM and solver metadata.
59    fn yield_to_maturity(
60        &self,
61        settlement: Date,
62        clean_price: Decimal,
63        frequency: Frequency,
64    ) -> BondResult<YieldResult> {
65        let cash_flows = self.cash_flows(settlement);
66        if cash_flows.is_empty() {
67            return Err(BondError::InvalidSpec {
68                reason: "no future cash flows".to_string(),
69            });
70        }
71
72        let accrued = self.accrued_interest(settlement);
73        let day_count = self.parse_day_count()?;
74
75        let solver = YieldSolver::new().with_convention(YieldConvention::StreetConvention);
76
77        solver.solve(
78            &cash_flows,
79            clean_price,
80            accrued,
81            settlement,
82            day_count,
83            frequency,
84        )
85    }
86
87    /// Calculates yield to maturity with a specific yield convention.
88    fn yield_to_maturity_with_convention(
89        &self,
90        settlement: Date,
91        clean_price: Decimal,
92        frequency: Frequency,
93        convention: YieldConvention,
94    ) -> BondResult<YieldResult> {
95        let cash_flows = self.cash_flows(settlement);
96        if cash_flows.is_empty() {
97            return Err(BondError::InvalidSpec {
98                reason: "no future cash flows".to_string(),
99            });
100        }
101
102        let accrued = self.accrued_interest(settlement);
103        let day_count = self.parse_day_count()?;
104
105        let solver = YieldSolver::new().with_convention(convention);
106        solver.solve(
107            &cash_flows,
108            clean_price,
109            accrued,
110            settlement,
111            day_count,
112            frequency,
113        )
114    }
115
116    // ==================== Price Calculations ====================
117
118    /// Calculates dirty price from yield.
119    ///
120    /// # Arguments
121    ///
122    /// * `settlement` - Settlement date
123    /// * `ytm` - Yield to maturity as decimal (e.g., 0.05 for 5%)
124    /// * `frequency` - Compounding frequency
125    ///
126    /// # Returns
127    ///
128    /// Dirty price per 100 face value.
129    fn dirty_price_from_yield(
130        &self,
131        settlement: Date,
132        ytm: f64,
133        frequency: Frequency,
134    ) -> BondResult<f64> {
135        let cash_flows = self.cash_flows(settlement);
136        if cash_flows.is_empty() {
137            return Err(BondError::InvalidSpec {
138                reason: "no future cash flows".to_string(),
139            });
140        }
141
142        let day_count = self.parse_day_count()?;
143        let solver = YieldSolver::new();
144
145        Ok(solver.dirty_price_from_yield(&cash_flows, ytm, settlement, day_count, frequency))
146    }
147
148    /// Calculates clean price from yield.
149    fn clean_price_from_yield(
150        &self,
151        settlement: Date,
152        ytm: f64,
153        frequency: Frequency,
154    ) -> BondResult<f64> {
155        let cash_flows = self.cash_flows(settlement);
156        if cash_flows.is_empty() {
157            return Err(BondError::InvalidSpec {
158                reason: "no future cash flows".to_string(),
159            });
160        }
161
162        let accrued = self.accrued_interest(settlement);
163        let day_count = self.parse_day_count()?;
164        let solver = YieldSolver::new();
165
166        Ok(solver.clean_price_from_yield(
167            &cash_flows,
168            ytm,
169            accrued,
170            settlement,
171            day_count,
172            frequency,
173        ))
174    }
175
176    // ==================== Duration Calculations ====================
177
178    /// Calculates Macaulay duration analytically.
179    ///
180    /// Macaulay duration is the weighted average time to receive cash flows,
181    /// where weights are the present values of cash flows.
182    ///
183    /// # Arguments
184    ///
185    /// * `settlement` - Settlement date
186    /// * `ytm` - Yield to maturity as decimal
187    /// * `frequency` - Compounding frequency
188    fn macaulay_duration(
189        &self,
190        settlement: Date,
191        ytm: f64,
192        frequency: Frequency,
193    ) -> BondResult<f64> {
194        let cash_flows = self.cash_flows(settlement);
195        if cash_flows.is_empty() {
196            return Err(BondError::InvalidSpec {
197                reason: "no future cash flows".to_string(),
198            });
199        }
200
201        let day_count = self.parse_day_count()?;
202        let periods_per_year = f64::from(frequency.periods_per_year());
203        let rate_per_period = ytm / periods_per_year;
204
205        let mut weighted_time = 0.0;
206        let mut total_pv = 0.0;
207
208        for cf in &cash_flows {
209            if cf.date <= settlement {
210                continue;
211            }
212
213            let years = day_count.to_day_count().year_fraction(settlement, cf.date);
214            let years_f64 = years.to_f64().unwrap_or(0.0);
215            let periods = years_f64 * periods_per_year;
216            let amount = cf.amount.to_f64().unwrap_or(0.0);
217
218            let df = 1.0 / (1.0 + rate_per_period).powf(periods);
219            let pv = amount * df;
220
221            weighted_time += years_f64 * pv;
222            total_pv += pv;
223        }
224
225        if total_pv.abs() < 1e-10 {
226            return Err(BondError::InvalidSpec {
227                reason: "zero present value".to_string(),
228            });
229        }
230
231        Ok(weighted_time / total_pv)
232    }
233
234    /// Calculates modified duration from Macaulay duration.
235    ///
236    /// Modified Duration = Macaulay Duration / (1 + y/f)
237    ///
238    /// where y is the yield and f is the frequency.
239    fn modified_duration(
240        &self,
241        settlement: Date,
242        ytm: f64,
243        frequency: Frequency,
244    ) -> BondResult<f64> {
245        let mac_dur = self.macaulay_duration(settlement, ytm, frequency)?;
246        let periods_per_year = f64::from(frequency.periods_per_year());
247        Ok(mac_dur / (1.0 + ytm / periods_per_year))
248    }
249
250    /// Calculates effective duration using numerical bumping.
251    ///
252    /// Effective duration is computed by repricing the bond with
253    /// yield shifts and using the central difference formula:
254    ///
255    /// `D_eff` = (`P_down` - `P_up`) / (2 × `P_0` × Δy)
256    ///
257    /// # Arguments
258    ///
259    /// * `settlement` - Settlement date
260    /// * `ytm` - Current yield to maturity
261    /// * `frequency` - Compounding frequency
262    /// * `bump_bps` - Yield bump size in basis points (default: 10)
263    fn effective_duration(
264        &self,
265        settlement: Date,
266        ytm: f64,
267        frequency: Frequency,
268        bump_bps: f64,
269    ) -> BondResult<f64> {
270        let bump = bump_bps / 10_000.0;
271
272        let price_base = self.dirty_price_from_yield(settlement, ytm, frequency)?;
273        let price_up = self.dirty_price_from_yield(settlement, ytm + bump, frequency)?;
274        let price_down = self.dirty_price_from_yield(settlement, ytm - bump, frequency)?;
275
276        if price_base.abs() < 1e-10 {
277            return Err(BondError::InvalidSpec {
278                reason: "zero base price".to_string(),
279            });
280        }
281
282        Ok((price_down - price_up) / (2.0 * price_base * bump))
283    }
284
285    // ==================== Convexity Calculations ====================
286
287    /// Calculates analytical convexity.
288    ///
289    /// Convexity measures the curvature of the price-yield relationship.
290    /// It captures the second-order effect that duration misses.
291    fn convexity(&self, settlement: Date, ytm: f64, frequency: Frequency) -> BondResult<f64> {
292        let cash_flows = self.cash_flows(settlement);
293        if cash_flows.is_empty() {
294            return Err(BondError::InvalidSpec {
295                reason: "no future cash flows".to_string(),
296            });
297        }
298
299        let day_count = self.parse_day_count()?;
300        let periods_per_year = f64::from(frequency.periods_per_year());
301        let rate_per_period = ytm / periods_per_year;
302
303        let mut weighted_convexity = 0.0;
304        let mut total_pv = 0.0;
305
306        for cf in &cash_flows {
307            if cf.date <= settlement {
308                continue;
309            }
310
311            let years = day_count.to_day_count().year_fraction(settlement, cf.date);
312            let years_f64 = years.to_f64().unwrap_or(0.0);
313            let periods = years_f64 * periods_per_year;
314            let amount = cf.amount.to_f64().unwrap_or(0.0);
315
316            let df = 1.0 / (1.0 + rate_per_period).powf(periods);
317            let pv = amount * df;
318
319            // Convexity contribution: t(t+1) * PV / (1+y/f)^2
320            let convex_term = years_f64 * (years_f64 + 1.0 / periods_per_year) * pv;
321            weighted_convexity += convex_term;
322            total_pv += pv;
323        }
324
325        if total_pv.abs() < 1e-10 {
326            return Err(BondError::InvalidSpec {
327                reason: "zero present value".to_string(),
328            });
329        }
330
331        let y_factor = (1.0 + rate_per_period).powi(2);
332        Ok(weighted_convexity / (total_pv * y_factor))
333    }
334
335    /// Calculates effective convexity using numerical bumping.
336    ///
337    /// `C_eff` = (`P_up` + `P_down` - 2 × `P_0`) / (`P_0` × Δy²)
338    fn effective_convexity(
339        &self,
340        settlement: Date,
341        ytm: f64,
342        frequency: Frequency,
343        bump_bps: f64,
344    ) -> BondResult<f64> {
345        let bump = bump_bps / 10_000.0;
346
347        let price_base = self.dirty_price_from_yield(settlement, ytm, frequency)?;
348        let price_up = self.dirty_price_from_yield(settlement, ytm + bump, frequency)?;
349        let price_down = self.dirty_price_from_yield(settlement, ytm - bump, frequency)?;
350
351        if price_base.abs() < 1e-10 {
352            return Err(BondError::InvalidSpec {
353                reason: "zero base price".to_string(),
354            });
355        }
356
357        Ok((price_up + price_down - 2.0 * price_base) / (price_base * bump * bump))
358    }
359
360    // ==================== DV01 Calculations ====================
361
362    /// Calculates DV01 (dollar value of 01 - one basis point).
363    ///
364    /// DV01 = Modified Duration × Dirty Price × 0.0001
365    ///
366    /// Returns the price change per $100 face value for a 1bp yield move.
367    fn dv01(
368        &self,
369        settlement: Date,
370        ytm: f64,
371        dirty_price: f64,
372        frequency: Frequency,
373    ) -> BondResult<f64> {
374        let mod_dur = self.modified_duration(settlement, ytm, frequency)?;
375        Ok(mod_dur * dirty_price * 0.0001)
376    }
377
378    /// Calculates DV01 for a specific notional amount.
379    fn dv01_notional(
380        &self,
381        settlement: Date,
382        ytm: f64,
383        dirty_price: f64,
384        notional: f64,
385        frequency: Frequency,
386    ) -> BondResult<f64> {
387        let mod_dur = self.modified_duration(settlement, ytm, frequency)?;
388        let face = self.face_value().to_f64().unwrap_or(100.0);
389        Ok(mod_dur * dirty_price * (notional / face) * 0.0001)
390    }
391
392    // ==================== Price Change Estimation ====================
393
394    /// Estimates price change for a given yield shift.
395    ///
396    /// Uses duration + convexity approximation:
397    /// ΔP/P ≈ -`D_mod` × Δy + (1/2) × C × (Δy)²
398    fn estimate_price_change(
399        &self,
400        settlement: Date,
401        ytm: f64,
402        dirty_price: f64,
403        yield_change: f64,
404        frequency: Frequency,
405    ) -> BondResult<f64> {
406        let mod_dur = self.modified_duration(settlement, ytm, frequency)?;
407        let convex = self.convexity(settlement, ytm, frequency)?;
408
409        let duration_effect = -mod_dur * dirty_price * yield_change;
410        let convexity_effect = 0.5 * convex * dirty_price * yield_change.powi(2);
411
412        Ok(duration_effect + convexity_effect)
413    }
414
415    // ==================== Helper Methods ====================
416
417    /// Parses the day count convention string to enum.
418    ///
419    /// This method converts the string returned by `day_count_convention()`
420    /// back to the `DayCountConvention` enum.
421    fn parse_day_count(&self) -> BondResult<DayCountConvention> {
422        let dcc_str = self.day_count_convention();
423        match dcc_str {
424            "ACT/360" => Ok(DayCountConvention::Act360),
425            "ACT/365F" | "ACT/365 Fixed" => Ok(DayCountConvention::Act365Fixed),
426            "ACT/365L" | "ACT/365 Leap" => Ok(DayCountConvention::Act365Leap),
427            "ACT/ACT ISDA" | "ACT/ACT" => Ok(DayCountConvention::ActActIsda),
428            "ACT/ACT ICMA" => Ok(DayCountConvention::ActActIcma),
429            "ACT/ACT AFB" => Ok(DayCountConvention::ActActAfb),
430            "30/360 US" | "30/360" => Ok(DayCountConvention::Thirty360US),
431            "30E/360" | "30/360 E" => Ok(DayCountConvention::Thirty360E),
432            "30E/360 ISDA" => Ok(DayCountConvention::Thirty360EIsda),
433            "30/360 German" => Ok(DayCountConvention::Thirty360German),
434            _ => Err(BondError::InvalidSpec {
435                reason: format!("unknown day count convention: {dcc_str}"),
436            }),
437        }
438    }
439}
440
441// Blanket implementation for all Bond types
442impl<T: Bond + ?Sized> BondAnalytics for T {}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::instruments::FixedRateBond;
448    use rust_decimal_macros::dec;
449
450    fn date(y: i32, m: u32, d: u32) -> Date {
451        Date::from_ymd(y, m, d).unwrap()
452    }
453
454    fn create_test_bond() -> FixedRateBond {
455        FixedRateBond::builder()
456            .issue_date(date(2020, 6, 15))
457            .maturity(date(2025, 6, 15))
458            .coupon_rate(dec!(0.075))
459            .face_value(dec!(100))
460            .frequency(Frequency::SemiAnnual)
461            .day_count(DayCountConvention::Thirty360US)
462            .cusip_unchecked("097023AH7")
463            .build()
464            .unwrap()
465    }
466
467    #[test]
468    fn test_ytm_at_par() {
469        let bond = create_test_bond();
470        let settlement = date(2020, 6, 15);
471        let clean_price = dec!(100);
472
473        let result = bond.yield_to_maturity(settlement, clean_price, Frequency::SemiAnnual);
474        assert!(result.is_ok());
475
476        let ytm = result.unwrap().yield_value;
477        // At par, YTM should equal coupon rate (7.5%)
478        assert!((ytm - 0.075).abs() < 0.001);
479    }
480
481    #[test]
482    fn test_ytm_price_roundtrip() {
483        let bond = create_test_bond();
484        let settlement = date(2021, 1, 15);
485        let clean_price = dec!(105);
486
487        // Calculate YTM from price
488        let ytm_result = bond
489            .yield_to_maturity(settlement, clean_price, Frequency::SemiAnnual)
490            .unwrap();
491
492        // Calculate clean price from YTM
493        let calculated_clean = bond
494            .clean_price_from_yield(settlement, ytm_result.yield_value, Frequency::SemiAnnual)
495            .unwrap();
496
497        // Should round-trip
498        let diff = (calculated_clean - clean_price.to_f64().unwrap()).abs();
499        assert!(diff < 0.001, "Price roundtrip error: {}", diff);
500    }
501
502    #[test]
503    fn test_modified_duration() {
504        let bond = create_test_bond();
505        let settlement = date(2020, 6, 15);
506        let ytm = 0.075;
507
508        let mod_dur = bond.modified_duration(settlement, ytm, Frequency::SemiAnnual);
509        assert!(mod_dur.is_ok());
510
511        let dur = mod_dur.unwrap();
512        // 5-year bond should have duration around 4.0-4.5
513        assert!(
514            dur > 3.5 && dur < 5.0,
515            "Modified duration {} out of range",
516            dur
517        );
518    }
519
520    #[test]
521    fn test_convexity() {
522        let bond = create_test_bond();
523        let settlement = date(2020, 6, 15);
524        let ytm = 0.075;
525
526        let convex = bond.convexity(settlement, ytm, Frequency::SemiAnnual);
527        assert!(convex.is_ok());
528
529        let c = convex.unwrap();
530        // Convexity should be positive
531        assert!(c > 0.0, "Convexity should be positive");
532        // 5-year bond convexity typically in range 15-25
533        assert!(c > 10.0 && c < 30.0, "Convexity {} out of range", c);
534    }
535
536    #[test]
537    fn test_dv01() {
538        let bond = create_test_bond();
539        let settlement = date(2020, 6, 15);
540        let ytm = 0.075;
541        let dirty_price = 100.0;
542
543        let dv01 = bond.dv01(settlement, ytm, dirty_price, Frequency::SemiAnnual);
544        assert!(dv01.is_ok());
545
546        let d = dv01.unwrap();
547        // DV01 for $100 should be around 0.04-0.05 for a 4-year duration bond
548        assert!(d > 0.03 && d < 0.06, "DV01 {} out of range", d);
549    }
550
551    #[test]
552    fn test_effective_vs_analytical_duration() {
553        let bond = create_test_bond();
554        let settlement = date(2020, 6, 15);
555        let ytm = 0.075;
556
557        let mod_dur = bond
558            .modified_duration(settlement, ytm, Frequency::SemiAnnual)
559            .unwrap();
560        let eff_dur = bond
561            .effective_duration(settlement, ytm, Frequency::SemiAnnual, 10.0)
562            .unwrap();
563
564        // For vanilla bonds, effective should be close to analytical
565        let diff = (mod_dur - eff_dur).abs();
566        assert!(
567            diff < 0.1,
568            "Duration mismatch: analytical={}, effective={}",
569            mod_dur,
570            eff_dur
571        );
572    }
573
574    #[test]
575    fn test_price_change_estimation() {
576        let bond = create_test_bond();
577        let settlement = date(2020, 6, 15);
578        let ytm = 0.075;
579        let dirty_price = 100.0;
580
581        // Estimate price change for +100 bps
582        let change = bond
583            .estimate_price_change(
584                settlement,
585                ytm,
586                dirty_price,
587                0.01, // 100 bps
588                Frequency::SemiAnnual,
589            )
590            .unwrap();
591
592        // Price should drop when yield rises
593        assert!(change < 0.0);
594        // For ~4 duration, expect ~4% drop
595        assert!(
596            change > -5.0 && change < -3.0,
597            "Price change {} out of range",
598            change
599        );
600    }
601}