1use 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
33pub trait BondAnalytics: Bond {
46 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 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 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 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 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 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 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 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 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 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 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 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 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 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
441impl<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 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 let ytm_result = bond
489 .yield_to_maturity(settlement, clean_price, Frequency::SemiAnnual)
490 .unwrap();
491
492 let calculated_clean = bond
494 .clean_price_from_yield(settlement, ytm_result.yield_value, Frequency::SemiAnnual)
495 .unwrap();
496
497 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 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 assert!(c > 0.0, "Convexity should be positive");
532 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 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 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 let change = bond
583 .estimate_price_change(
584 settlement,
585 ytm,
586 dirty_price,
587 0.01, Frequency::SemiAnnual,
589 )
590 .unwrap();
591
592 assert!(change < 0.0);
594 assert!(
596 change > -5.0 && change < -3.0,
597 "Price change {} out of range",
598 change
599 );
600 }
601}