Skip to main content

kestrel_chartkit/
finance.rs

1//! Financial day-count conventions, coupon schedules, cashflow discounting, and bond valuation.
2//!
3//! Day-count year fractions (Actual/360, Actual/365Fixed, 30/360 Bond Basis, Actual/Actual ISDA),
4//! discounting, and fixed-rate bond valuation — clean and dirty price, accrued interest,
5//! Macaulay/Modified duration, DV01 and yield inversion.
6//!
7//! Valuation runs over an explicit [`CouponSchedule`] rather than a derived time grid: coupon
8//! dates are generated from an anchor in whole months, with the month-end rule and stub periods
9//! ([`ScheduleStub`]) handled as their own cases, and payment dates optionally moved by a
10//! [`BusinessDayConvention`] over a [`BusinessCalendar`]. Accrual stays on the unadjusted period
11//! boundaries; only the payment dates move. That separation is what makes accrued interest exact
12//! on a coupon date and correct between two of them.
13//!
14//! Deliberately not modelled: market holiday calendars (the caller supplies holidays — a bundled
15//! list is a maintenance promise this crate cannot keep), non-Saturday/Sunday weekends, and
16//! schedules whose periods are not whole months apart.
17
18use std::fmt;
19
20#[cfg(feature = "serde")]
21use serde::{Deserialize, Serialize};
22
23/// A simple calendar date (year, month, day) in the Gregorian calendar.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
26pub struct Date {
27    pub year: i32,
28    pub month: u32,
29    pub day: u32,
30}
31
32impl Date {
33    /// Creates a new date, validating month (1..=12) and day (1..=days_in_month).
34    pub fn new(year: i32, month: u32, day: u32) -> Option<Self> {
35        if !(1..=12).contains(&month) || day < 1 {
36            return None;
37        }
38        let days = Self::days_in_month(year, month);
39        if day > days {
40            return None;
41        }
42        Some(Self { year, month, day })
43    }
44
45    pub fn is_leap_year(year: i32) -> bool {
46        (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
47    }
48
49    pub fn days_in_month(year: i32, month: u32) -> u32 {
50        match month {
51            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
52            4 | 6 | 9 | 11 => 30,
53            2 => {
54                if Self::is_leap_year(year) {
55                    29
56                } else {
57                    28
58                }
59            }
60            _ => 0,
61        }
62    }
63
64    /// Converts the date into an ordinal day count (days since 0001-01-01).
65    pub fn to_day_number(&self) -> i64 {
66        let mut y = self.year as i64;
67        let mut m = self.month as i64;
68        if m <= 2 {
69            y -= 1;
70            m += 12;
71        }
72        // Gregorian calendar formula
73        (365 * y) + (y / 4) - (y / 100) + (y / 400) + ((153 * (m + 1)) / 5) + self.day as i64 - 428
74    }
75
76    /// Returns the number of actual elapsed calendar days from `self` to `other` (`other - self`).
77    pub fn days_until(&self, other: &Date) -> i64 {
78        other.to_day_number() - self.to_day_number()
79    }
80
81    /// The weekday this date falls on.
82    pub fn weekday(&self) -> Weekday {
83        match self.to_day_number().rem_euclid(7) {
84            0 => Weekday::Sunday,
85            1 => Weekday::Monday,
86            2 => Weekday::Tuesday,
87            3 => Weekday::Wednesday,
88            4 => Weekday::Thursday,
89            5 => Weekday::Friday,
90            _ => Weekday::Saturday,
91        }
92    }
93
94    /// Whether this is the last day of its month.
95    pub fn is_month_end(&self) -> bool {
96        self.day == Self::days_in_month(self.year, self.month)
97    }
98
99    /// Shifts by whole months, clamping the day to the length of the target month: 31 August
100    /// minus six months is 28 (or 29) February, not an invalid 31 February.
101    ///
102    /// Clamping is not the same as the month-end rule — see [`CouponSchedule`], which applies that
103    /// rule on top when the anchor date is itself a month end.
104    pub fn add_months(&self, months: i32) -> Date {
105        let total = self.year as i64 * 12 + (self.month as i64 - 1) + months as i64;
106        let year = total.div_euclid(12) as i32;
107        let month = total.rem_euclid(12) as u32 + 1;
108        let day = self.day.min(Self::days_in_month(year, month));
109        Date { year, month, day }
110    }
111
112    /// Shifts by whole calendar days.
113    pub fn add_days(&self, days: i64) -> Date {
114        // Round-trip through the ordinal day number rather than carrying month lengths by hand.
115        let target = self.to_day_number() + days;
116        let mut year = self.year + (days / 366) as i32 - 1;
117        loop {
118            let start = Date {
119                year,
120                month: 1,
121                day: 1,
122            }
123            .to_day_number();
124            let next = Date {
125                year: year + 1,
126                month: 1,
127                day: 1,
128            }
129            .to_day_number();
130            if target < start {
131                year -= 1;
132                continue;
133            }
134            if target >= next {
135                year += 1;
136                continue;
137            }
138            let mut remaining = target - start;
139            for month in 1..=12u32 {
140                let length = Self::days_in_month(year, month) as i64;
141                if remaining < length {
142                    return Date {
143                        year,
144                        month,
145                        day: remaining as u32 + 1,
146                    };
147                }
148                remaining -= length;
149            }
150            unreachable!("a year holds all its days");
151        }
152    }
153
154    /// Moves to the last day of its month.
155    pub fn to_month_end(&self) -> Date {
156        Date {
157            year: self.year,
158            month: self.month,
159            day: Self::days_in_month(self.year, self.month),
160        }
161    }
162}
163
164/// Day of the week.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
166#[cfg_attr(
167    feature = "serde",
168    derive(Serialize, Deserialize),
169    serde(rename_all = "snake_case")
170)]
171pub enum Weekday {
172    Sunday,
173    Monday,
174    Tuesday,
175    Wednesday,
176    Thursday,
177    Friday,
178    Saturday,
179}
180
181impl Weekday {
182    /// Saturday and Sunday. Markets with a different weekend are not modelled here; pass those
183    /// days as holidays to [`BusinessCalendar`] instead.
184    pub fn is_weekend(self) -> bool {
185        matches!(self, Weekday::Saturday | Weekday::Sunday)
186    }
187}
188
189/// Financial day-count convention determining the fraction of a year between two dates.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
191#[cfg_attr(
192    feature = "serde",
193    derive(Serialize, Deserialize),
194    serde(rename_all = "snake_case")
195)]
196pub enum DayCountConvention {
197    /// Money market convention: Actual calendar days divided by 360.
198    #[default]
199    Actual360,
200    /// Fixed year convention: Actual calendar days divided by 365.
201    Actual365Fixed,
202    /// 30/360 Bond Basis: `360 · (Y2 - Y1) + 30 · (M2 - M1) + (D2 - D1)` days over 360, where a
203    /// start day of 31 counts as 30 and an end day of 31 counts as 30 once the start day is 30
204    /// or 31. There is no separate end-of-February rule.
205    Thirty360,
206    /// Actual/Actual ISDA: Splits leap years and normal years proportionally.
207    ActualActualISDA,
208}
209
210/// Computes the year fraction between two dates according to the chosen day-count convention.
211pub fn year_fraction(d1: Date, d2: Date, convention: DayCountConvention) -> f64 {
212    if d1 == d2 {
213        return 0.0;
214    }
215    let (start, end, sign) = if d1 <= d2 {
216        (d1, d2, 1.0)
217    } else {
218        (d2, d1, -1.0)
219    };
220
221    let fraction = match convention {
222        DayCountConvention::Actual360 => start.days_until(&end) as f64 / 360.0,
223        DayCountConvention::Actual365Fixed => start.days_until(&end) as f64 / 365.0,
224        DayCountConvention::Thirty360 => {
225            let mut d1_day = start.day;
226            let mut d2_day = end.day;
227            if d1_day == 31 {
228                d1_day = 30;
229            }
230            if d2_day == 31 && d1_day >= 30 {
231                d2_day = 30;
232            }
233            let days_360 = (end.year as i64 - start.year as i64) * 360
234                + (end.month as i64 - start.month as i64) * 30
235                + (d2_day as i64 - d1_day as i64);
236            days_360 as f64 / 360.0
237        }
238        DayCountConvention::ActualActualISDA => {
239            if start.year == end.year {
240                let year_days = if Date::is_leap_year(start.year) {
241                    366.0
242                } else {
243                    365.0
244                };
245                start.days_until(&end) as f64 / year_days
246            } else {
247                let end_of_first_year = Date::new(start.year, 12, 31).unwrap();
248                let start_of_last_year = Date::new(end.year, 1, 1).unwrap();
249
250                let first_year_days = if Date::is_leap_year(start.year) {
251                    366.0
252                } else {
253                    365.0
254                };
255                let last_year_days = if Date::is_leap_year(end.year) {
256                    366.0
257                } else {
258                    365.0
259                };
260
261                let days1 = start.days_until(&end_of_first_year) + 1;
262                let days2 = start_of_last_year.days_until(&end);
263
264                let middle_years = (end.year - start.year - 1).max(0) as f64;
265
266                (days1 as f64 / first_year_days) + middle_years + (days2 as f64 / last_year_days)
267            }
268        }
269    };
270
271    sign * fraction
272}
273
274/// Compounding frequency convention for interest rates and discounting.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
276#[cfg_attr(
277    feature = "serde",
278    derive(Serialize, Deserialize),
279    serde(rename_all = "snake_case")
280)]
281pub enum Compounding {
282    /// Continuous compounding: $D(\tau) = e^{-r \tau}$.
283    #[default]
284    Continuous,
285    /// Annual compounding: $D(\tau) = (1 + r)^{-\tau}$.
286    Annual,
287    /// Periodic compounding $m$ times per year: $D(\tau) = (1 + r/m)^{-m \tau}$.
288    Periodic(u32),
289}
290
291/// Computes the discount factor $D(\tau)$ for a given interest rate, time fraction $\tau$, and compounding method.
292///
293/// Supports negative interest rates; the discount factor remains strictly positive.
294pub fn discount_factor(rate: f64, tau: f64, compounding: Compounding) -> f64 {
295    if !rate.is_finite() || !tau.is_finite() || tau < 0.0 {
296        return 0.0;
297    }
298    match compounding {
299        Compounding::Continuous => (-rate * tau).exp(),
300        Compounding::Annual => {
301            if rate <= -1.0 {
302                0.0
303            } else {
304                (1.0 + rate).powf(-tau)
305            }
306        }
307        Compounding::Periodic(m) => {
308            let m_f = m.max(1) as f64;
309            let base = 1.0 + rate / m_f;
310            if base <= 0.0 {
311                0.0
312            } else {
313                base.powf(-m_f * tau)
314            }
315        }
316    }
317}
318
319/// Which way a payment date moves when it falls on a non-business day.
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
321#[cfg_attr(
322    feature = "serde",
323    derive(Serialize, Deserialize),
324    serde(rename_all = "snake_case")
325)]
326pub enum BusinessDayConvention {
327    /// The date stays where the schedule put it. The default, and the right choice when the
328    /// terms of the instrument do not name a rule.
329    #[default]
330    Unadjusted,
331    /// Move forward to the next business day.
332    Following,
333    /// Move forward to the next business day, unless that leaves the month — then move backward.
334    ModifiedFollowing,
335    /// Move backward to the previous business day.
336    Preceding,
337}
338
339/// Which days are not business days: weekends, plus whatever holidays the caller supplies.
340///
341/// No market calendars ship with this crate. A bundled holiday list is a maintenance promise —
342/// holidays are announced, moved and added per market and year — and a stale list is worse than
343/// no list, because it looks authoritative. Callers that need real holidays pass them in.
344#[derive(Debug, Clone, Default, PartialEq, Eq)]
345#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
346pub struct BusinessCalendar {
347    holidays: Vec<Date>,
348}
349
350impl BusinessCalendar {
351    /// Saturdays and Sundays only.
352    pub fn weekends_only() -> Self {
353        Self::default()
354    }
355
356    /// Saturdays, Sundays and the given dates.
357    pub fn with_holidays(holidays: impl IntoIterator<Item = Date>) -> Self {
358        let mut holidays: Vec<Date> = holidays.into_iter().collect();
359        holidays.sort_unstable();
360        holidays.dedup();
361        Self { holidays }
362    }
363
364    pub fn is_business_day(&self, date: Date) -> bool {
365        !date.weekday().is_weekend() && self.holidays.binary_search(&date).is_err()
366    }
367
368    /// Applies `convention` to `date`. An [`BusinessDayConvention::Unadjusted`] date is returned
369    /// unchanged even if it is a holiday.
370    pub fn adjust(&self, date: Date, convention: BusinessDayConvention) -> Date {
371        match convention {
372            BusinessDayConvention::Unadjusted => date,
373            BusinessDayConvention::Following => self.roll(date, 1),
374            BusinessDayConvention::Preceding => self.roll(date, -1),
375            BusinessDayConvention::ModifiedFollowing => {
376                let forward = self.roll(date, 1);
377                if forward.month == date.month && forward.year == date.year {
378                    forward
379                } else {
380                    self.roll(date, -1)
381                }
382            }
383        }
384    }
385
386    fn roll(&self, date: Date, step: i32) -> Date {
387        let mut current = date;
388        // A run of non-business days longer than a fortnight would mean the caller declared a
389        // shutdown, not a holiday; bounding the walk keeps a bad input from spinning forever.
390        for _ in 0..14 {
391            if self.is_business_day(current) {
392                return current;
393            }
394            current = current.add_days(step as i64);
395        }
396        current
397    }
398}
399
400/// Where the period that does not fit the regular frequency is placed, and whether it is shorter
401/// or longer than a regular one.
402#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
403#[cfg_attr(
404    feature = "serde",
405    derive(Serialize, Deserialize),
406    serde(rename_all = "snake_case")
407)]
408pub enum ScheduleStub {
409    /// Generate backward from maturity; a leftover front period stays as a short first period.
410    /// The default, and by far the most common arrangement for a fixed-rate bond.
411    #[default]
412    ShortFirst,
413    /// Generate backward from maturity; a leftover front period is absorbed into the following
414    /// one, making a long first period.
415    LongFirst,
416    /// Generate forward from issue; a leftover final period stays as a short last period.
417    ShortLast,
418    /// Generate forward from issue; a leftover final period is absorbed into the preceding one,
419    /// making a long last period.
420    LongLast,
421}
422
423/// The coupon periods of a fixed-rate instrument: when interest accrues, and when it is paid.
424///
425/// Two date series, deliberately separate:
426///
427/// * **Accrual dates** are the period boundaries. They are never business-day adjusted, which is
428///   the market convention for fixed-rate bonds: a coupon covers a calendar period regardless of
429///   which days the payment system was open. Coupon amounts and accrued interest come from these.
430/// * **Payment dates** are the accrual end dates after applying a [`BusinessDayConvention`] over
431///   a [`BusinessCalendar`]. Money moves on these, so discounting uses them.
432///
433/// With [`BusinessDayConvention::Unadjusted`] the two coincide.
434///
435/// Generation anchors on maturity (backward) or issue (forward) and steps in whole months of
436/// `12 / frequency`. The **month-end rule** applies when the anchor is the last day of its month:
437/// every generated date is then moved to its own month end, so a 31 August anchor yields 28/29
438/// February rather than the 28th of every February and the 31st of every August. Without that
439/// rule, day clamping alone would silently shorten every second period.
440#[derive(Debug, Clone, PartialEq, Eq)]
441#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
442pub struct CouponSchedule {
443    accrual: Vec<Date>,
444    payment: Vec<Date>,
445}
446
447impl CouponSchedule {
448    /// Builds a schedule between `issue` and `maturity`.
449    ///
450    /// Fails when the dates are out of order, when `frequency` is not a whole number of months
451    /// (1, 2, 3, 4, 6 or 12 per year), or when the resulting schedule would have no period.
452    pub fn generate(
453        issue: Date,
454        maturity: Date,
455        frequency: u32,
456        stub: ScheduleStub,
457        convention: BusinessDayConvention,
458        calendar: &BusinessCalendar,
459    ) -> Result<Self, FinanceError> {
460        let step = months_per_period(frequency)?;
461        if issue >= maturity {
462            return Err(FinanceError::InvalidInput("issue must precede maturity"));
463        }
464
465        let mut accrual = match stub {
466            ScheduleStub::ShortFirst | ScheduleStub::LongFirst => {
467                let mut dates = Vec::new();
468                let month_end = maturity.is_month_end();
469                let mut k = 0i32;
470                loop {
471                    let date = anchored(maturity, -(k * step), month_end);
472                    dates.push(date);
473                    if date <= issue {
474                        break;
475                    }
476                    k += 1;
477                }
478                dates.reverse();
479                // `dates[0]` is the first generated date at or before issue. A date strictly
480                // before issue means the instrument starts inside a period: that front piece is
481                // the stub.
482                if dates[0] < issue {
483                    dates[0] = issue;
484                    if stub == ScheduleStub::LongFirst && dates.len() > 2 {
485                        dates.remove(1);
486                    }
487                }
488                dates
489            }
490            ScheduleStub::ShortLast | ScheduleStub::LongLast => {
491                let mut dates = Vec::new();
492                let month_end = issue.is_month_end();
493                let mut k = 0i32;
494                loop {
495                    let date = anchored(issue, k * step, month_end);
496                    dates.push(date);
497                    if date >= maturity {
498                        break;
499                    }
500                    k += 1;
501                }
502                if *dates.last().expect("loop pushes at least once") > maturity {
503                    let last = dates.len() - 1;
504                    dates[last] = maturity;
505                    if stub == ScheduleStub::LongLast && dates.len() > 2 {
506                        dates.remove(last - 1);
507                    }
508                }
509                dates
510            }
511        };
512        accrual.dedup();
513        Self::from_accrual_dates(accrual, convention, calendar)
514    }
515
516    /// A regular, unadjusted schedule from `issue` to `maturity` with a short first period if the
517    /// dates do not divide evenly.
518    pub fn regular(issue: Date, maturity: Date, frequency: u32) -> Result<Self, FinanceError> {
519        Self::generate(
520            issue,
521            maturity,
522            frequency,
523            ScheduleStub::ShortFirst,
524            BusinessDayConvention::Unadjusted,
525            &BusinessCalendar::weekends_only(),
526        )
527    }
528
529    /// The regular, unadjusted schedule ending at `maturity` that reaches back far enough to
530    /// contain `settlement`.
531    ///
532    /// For a bond whose issue date is not known — the common case when only settlement and
533    /// maturity are given — this reconstructs the period `settlement` falls in by stepping
534    /// backward from maturity. Every period is regular; there is no stub.
535    pub fn covering(
536        settlement: Date,
537        maturity: Date,
538        frequency: u32,
539    ) -> Result<Self, FinanceError> {
540        let step = months_per_period(frequency)?;
541        if settlement >= maturity {
542            return Err(FinanceError::InvalidInput(
543                "settlement must precede maturity",
544            ));
545        }
546
547        let month_end = maturity.is_month_end();
548        let mut dates = Vec::new();
549        let mut k = 0i32;
550        loop {
551            let date = anchored(maturity, -(k * step), month_end);
552            dates.push(date);
553            if date <= settlement {
554                break;
555            }
556            k += 1;
557        }
558        dates.reverse();
559        Self::from_accrual_dates(
560            dates,
561            BusinessDayConvention::Unadjusted,
562            &BusinessCalendar::weekends_only(),
563        )
564    }
565
566    /// Builds a schedule from explicit accrual boundaries, ascending, at least two of them.
567    /// Payment dates follow from `convention` over `calendar`.
568    pub fn from_accrual_dates(
569        accrual: Vec<Date>,
570        convention: BusinessDayConvention,
571        calendar: &BusinessCalendar,
572    ) -> Result<Self, FinanceError> {
573        if accrual.len() < 2 {
574            return Err(FinanceError::InvalidInput(
575                "a schedule needs at least two accrual dates",
576            ));
577        }
578        if accrual.windows(2).any(|w| w[0] >= w[1]) {
579            return Err(FinanceError::InvalidInput(
580                "accrual dates must be strictly ascending",
581            ));
582        }
583
584        let payment = accrual[1..]
585            .iter()
586            .map(|date| calendar.adjust(*date, convention))
587            .collect();
588        Ok(Self { accrual, payment })
589    }
590
591    /// Period boundaries, ascending. One more entry than there are periods.
592    pub fn accrual_dates(&self) -> &[Date] {
593        &self.accrual
594    }
595
596    /// Payment date of each period, in order.
597    pub fn payment_dates(&self) -> &[Date] {
598        &self.payment
599    }
600
601    pub fn period_count(&self) -> usize {
602        self.payment.len()
603    }
604
605    /// Start and end of period `index`, in accrual terms.
606    pub fn period(&self, index: usize) -> Option<(Date, Date)> {
607        Some((*self.accrual.get(index)?, *self.accrual.get(index + 1)?))
608    }
609
610    /// The period `date` accrues in: the one whose start is at or before `date` and whose end is
611    /// strictly after it. A date on a period boundary belongs to the period starting there, so a
612    /// settlement on a coupon date accrues nothing.
613    pub fn period_containing(&self, date: Date) -> Option<usize> {
614        (0..self.period_count()).find(|&i| self.accrual[i] <= date && date < self.accrual[i + 1])
615    }
616}
617
618/// Whole months between two coupon dates for a given yearly frequency.
619fn months_per_period(frequency: u32) -> Result<i32, FinanceError> {
620    match frequency {
621        1 | 2 | 3 | 4 | 6 | 12 => Ok((12 / frequency) as i32),
622        _ => Err(FinanceError::InvalidInput(
623            "frequency must divide 12 evenly (1, 2, 3, 4, 6 or 12)",
624        )),
625    }
626}
627
628/// `anchor` shifted by `months`, with the month-end rule applied when the anchor is a month end.
629fn anchored(anchor: Date, months: i32, month_end: bool) -> Date {
630    let shifted = anchor.add_months(months);
631    if month_end {
632        shifted.to_month_end()
633    } else {
634        shifted
635    }
636}
637
638/// One dated payment of an instrument.
639#[derive(Debug, Clone, Copy, PartialEq)]
640#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
641pub struct Cashflow {
642    /// When the money moves — the business-day adjusted date.
643    pub date: Date,
644    pub amount: f64,
645}
646
647/// Result of evaluating a fixed-coupon bond.
648#[derive(Debug, Clone, PartialEq)]
649#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
650pub struct BondPricingResult {
651    /// Dirty (full) price including accrued interest.
652    pub dirty_price: f64,
653    /// Clean price excluding accrued interest (`dirty_price - accrued_interest`).
654    pub clean_price: f64,
655    /// Accrued interest earned since the last coupon date.
656    pub accrued_interest: f64,
657    /// Macaulay duration in years: weighted average maturity of cashflows.
658    pub macaulay_duration: f64,
659    /// Modified duration: percentage price change per 100 bp change in yield.
660    pub modified_duration: f64,
661    /// Dollar value of a 1 basis point (0.01% = 0.0001) yield decrease: `dirty_price * modified_duration * 0.0001`.
662    pub dv01: f64,
663}
664
665/// Errors originating from finance or bond pricing calculations.
666#[derive(Debug, Clone, PartialEq)]
667pub enum FinanceError {
668    InvalidInput(&'static str),
669    SolverFailedToConverge,
670}
671
672impl fmt::Display for FinanceError {
673    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
674        match self {
675            Self::InvalidInput(msg) => write!(f, "invalid finance input: {msg}"),
676            Self::SolverFailedToConverge => {
677                write!(f, "yield to maturity solver failed to converge")
678            }
679        }
680    }
681}
682
683impl std::error::Error for FinanceError {}
684
685/// A fixed-rate bond: a nominal, a coupon rate, and the schedule that says when interest accrues
686/// and when it is paid.
687///
688/// The coupon of a period is `face_value * coupon_rate * yearFraction(period)` under the bond's
689/// own day count. That is the convention itself doing the work rather than a fixed
690/// `rate / frequency` amount: under 30/360 both give the same number, while under an actual day
691/// count a 184-day half-year pays more than a 181-day one, as it should. Accrued interest uses
692/// the same expression over the part of the period already elapsed, so accrual and coupon can
693/// never disagree.
694///
695/// Discounting uses the *payment* dates — money moves then — while accrual uses the unadjusted
696/// period boundaries; see [`CouponSchedule`].
697#[derive(Debug, Clone, PartialEq)]
698#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
699pub struct FixedRateBond {
700    face_value: f64,
701    coupon_rate: f64,
702    frequency: u32,
703    schedule: CouponSchedule,
704    day_count: DayCountConvention,
705}
706
707impl FixedRateBond {
708    pub fn new(
709        face_value: f64,
710        coupon_rate: f64,
711        frequency: u32,
712        schedule: CouponSchedule,
713        day_count: DayCountConvention,
714    ) -> Result<Self, FinanceError> {
715        if !face_value.is_finite() || face_value <= 0.0 {
716            return Err(FinanceError::InvalidInput("face_value must be positive"));
717        }
718        if !coupon_rate.is_finite() || coupon_rate < 0.0 {
719            return Err(FinanceError::InvalidInput(
720                "coupon_rate must be non-negative",
721            ));
722        }
723        months_per_period(frequency)?;
724        Ok(Self {
725            face_value,
726            coupon_rate,
727            frequency,
728            schedule,
729            day_count,
730        })
731    }
732
733    pub fn schedule(&self) -> &CouponSchedule {
734        &self.schedule
735    }
736
737    pub fn face_value(&self) -> f64 {
738        self.face_value
739    }
740
741    /// Coupon paid for period `index`, from the day count over that period.
742    pub fn coupon_amount(&self, index: usize) -> Option<f64> {
743        let (start, end) = self.schedule.period(index)?;
744        Some(self.face_value * self.coupon_rate * year_fraction(start, end, self.day_count))
745    }
746
747    /// Interest earned but not yet paid at `settlement`.
748    ///
749    /// Zero when `settlement` falls on a period boundary — the coupon for the period that just
750    /// ended has been paid, and the new one has not started accruing. Zero as well outside the
751    /// schedule entirely.
752    pub fn accrued_interest(&self, settlement: Date) -> f64 {
753        let Some(index) = self.schedule.period_containing(settlement) else {
754            return 0.0;
755        };
756        let (start, _) = self
757            .schedule
758            .period(index)
759            .expect("period_containing returned a valid index");
760        self.face_value * self.coupon_rate * year_fraction(start, settlement, self.day_count)
761    }
762
763    /// Every payment still outstanding after `settlement`, in order: the remaining coupons, with
764    /// the nominal added to the last one.
765    ///
766    /// A coupon whose payment date equals `settlement` is *not* outstanding — it is paid that
767    /// day, which is also why accrued interest is zero there.
768    pub fn cashflows(&self, settlement: Date) -> Vec<Cashflow> {
769        let mut flows = Vec::new();
770        let last = self.schedule.period_count().saturating_sub(1);
771        for index in 0..self.schedule.period_count() {
772            let payment = self.schedule.payment_dates()[index];
773            if payment <= settlement {
774                continue;
775            }
776            let mut amount = self.coupon_amount(index).unwrap_or(0.0);
777            if index == last {
778                amount += self.face_value;
779            }
780            flows.push(Cashflow {
781                date: payment,
782                amount,
783            });
784        }
785        flows
786    }
787
788    /// Prices the bond at `settlement` for a given yield, compounded at the coupon frequency.
789    ///
790    /// Every figure comes from the same cashflows: dirty price is their present value, clean
791    /// price is that minus accrued interest, Macaulay duration their present-value-weighted time,
792    /// and DV01 follows from dirty price and modified duration.
793    pub fn price(&self, settlement: Date, ytm: f64) -> Result<BondPricingResult, FinanceError> {
794        if !ytm.is_finite() {
795            return Err(FinanceError::InvalidInput("ytm must be finite"));
796        }
797        let flows = self.cashflows(settlement);
798        if flows.is_empty() {
799            return Err(FinanceError::InvalidInput(
800                "no cashflows remain after settlement",
801            ));
802        }
803
804        let compounding = Compounding::Periodic(self.frequency);
805        let mut dirty_price = 0.0f64;
806        let mut weighted_pv_sum = 0.0f64;
807        for flow in &flows {
808            let tau = year_fraction(settlement, flow.date, self.day_count);
809            let pv = flow.amount * discount_factor(ytm, tau, compounding);
810            dirty_price += pv;
811            weighted_pv_sum += tau * pv;
812        }
813
814        let macaulay_duration = if dirty_price > 0.0 {
815            weighted_pv_sum / dirty_price
816        } else {
817            0.0
818        };
819        let modified_duration = macaulay_duration / (1.0 + ytm / self.frequency as f64);
820        let accrued_interest = self.accrued_interest(settlement);
821
822        Ok(BondPricingResult {
823            dirty_price,
824            clean_price: dirty_price - accrued_interest,
825            accrued_interest,
826            macaulay_duration,
827            modified_duration,
828            dv01: dirty_price * modified_duration * 0.0001,
829        })
830    }
831
832    /// Inverts [`FixedRateBond::price`]: the yield at which the bond's clean price equals
833    /// `clean_price`.
834    pub fn yield_to_maturity(
835        &self,
836        settlement: Date,
837        clean_price: f64,
838    ) -> Result<f64, FinanceError> {
839        if !clean_price.is_finite() || clean_price <= 0.0 {
840            return Err(FinanceError::InvalidInput("clean_price must be positive"));
841        }
842
843        let mut ytm = self.coupon_rate.max(0.01);
844        for _ in 0..100 {
845            let priced = self.price(settlement, ytm)?;
846            let diff = priced.clean_price - clean_price;
847            if diff.abs() < 1e-8 {
848                return Ok(ytm);
849            }
850            // dPrice/dYield = -dirty_price * modified_duration.
851            let derivative = -priced.dirty_price * priced.modified_duration;
852            if derivative.abs() < 1e-12 {
853                return Err(FinanceError::SolverFailedToConverge);
854            }
855            ytm = (ytm - diff / derivative).max(-0.5);
856        }
857        Ok(ytm)
858    }
859}
860
861/// The terms a consumer supplies so this crate can build a bond's schedule and value it.
862///
863/// This is the data contract between a consuming application and `kestrel-chartkit`, and it is
864/// drawn along one line: **the consumer owns what the instrument *is*, this crate owns what
865/// follows from it.** A consumer reads these fields from wherever its product master lives and
866/// hands them over; it does not compute coupon dates, accrual or prices itself, and this crate
867/// does not go looking for instrument data.
868///
869/// What is deliberately *not* in here:
870///
871/// * **Holidays.** They are market data with their own validity — announced, moved and revised
872///   per market and year — so they are passed to [`BondSpec::build`] as a [`BusinessCalendar`]
873///   rather than frozen into the instrument's terms.
874/// * **Currency, multiplier and quantity steps.** Those belong to
875///   [`ContractSpec`](crate::contract::ContractSpec). Repeating them here would create a second
876///   truth about the same instrument.
877/// * **Market prices and yields.** They are observations, not terms, and are passed per
878///   valuation.
879///
880/// The day count has no default: it decides every coupon amount and every accrual, and a silently
881/// assumed one would be wrong more often than right.
882#[derive(Debug, Clone, PartialEq)]
883#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
884pub struct BondSpec {
885    /// Redeemed at maturity, and the base of every coupon.
886    pub face_value: f64,
887    /// Annual coupon rate as a fraction, e.g. `0.05` for 5%.
888    pub coupon_rate: f64,
889    /// Coupon payments per year; must divide 12 evenly.
890    pub frequency: u32,
891    /// Start of the first accrual period — usually the issue or dated date, not the settlement of
892    /// a later trade.
893    pub issue: Date,
894    pub maturity: Date,
895    /// Governs coupon amounts, accrued interest and discounting alike.
896    pub day_count: DayCountConvention,
897    /// Where an irregular period sits, if the dates do not divide evenly.
898    pub stub: ScheduleStub,
899    /// How payment dates move off non-business days. The accrual dates never move.
900    pub business_day_convention: BusinessDayConvention,
901}
902
903impl BondSpec {
904    /// The terms every bond needs. Stub placement and business-day handling take their documented
905    /// defaults ([`ScheduleStub::ShortFirst`], [`BusinessDayConvention::Unadjusted`]) and are set
906    /// with [`BondSpec::with_stub`] and [`BondSpec::with_business_day_convention`].
907    pub fn new(
908        face_value: f64,
909        coupon_rate: f64,
910        frequency: u32,
911        issue: Date,
912        maturity: Date,
913        day_count: DayCountConvention,
914    ) -> Self {
915        Self {
916            face_value,
917            coupon_rate,
918            frequency,
919            issue,
920            maturity,
921            day_count,
922            stub: ScheduleStub::default(),
923            business_day_convention: BusinessDayConvention::default(),
924        }
925    }
926
927    pub fn with_stub(mut self, stub: ScheduleStub) -> Self {
928        self.stub = stub;
929        self
930    }
931
932    pub fn with_business_day_convention(mut self, convention: BusinessDayConvention) -> Self {
933        self.business_day_convention = convention;
934        self
935    }
936
937    /// The coupon schedule these terms describe, against the holidays of the market it trades in.
938    pub fn schedule(&self, calendar: &BusinessCalendar) -> Result<CouponSchedule, FinanceError> {
939        CouponSchedule::generate(
940            self.issue,
941            self.maturity,
942            self.frequency,
943            self.stub,
944            self.business_day_convention,
945            calendar,
946        )
947    }
948
949    /// The valuable instrument these terms describe. Rejects the same inputs
950    /// [`CouponSchedule::generate`] and [`FixedRateBond::new`] reject, so a consumer finds a bad
951    /// product record here rather than in a price.
952    pub fn build(&self, calendar: &BusinessCalendar) -> Result<FixedRateBond, FinanceError> {
953        FixedRateBond::new(
954            self.face_value,
955            self.coupon_rate,
956            self.frequency,
957            self.schedule(calendar)?,
958            self.day_count,
959        )
960    }
961}
962
963/// Prices a standard fixed-rate bond with regular coupon payments.
964///
965/// A convenience over [`FixedRateBond`] for the common case where only settlement and maturity
966/// are known: the coupon dates are reconstructed backward from maturity via
967/// [`CouponSchedule::covering`], so every period is regular and unadjusted. Bonds with a stub
968/// period, a business-day rule or an explicitly known issue date need [`CouponSchedule::generate`]
969/// and [`FixedRateBond`] instead — this entry point cannot infer any of those from its arguments.
970///
971/// Returns clean price, dirty price, accrued interest, Macaulay/Modified duration and DV01, all
972/// from the same schedule and the same cashflows.
973pub fn price_bond(
974    face_value: f64,
975    coupon_rate: f64,
976    frequency: u32,
977    settlement: Date,
978    maturity: Date,
979    ytm: f64,
980    convention: DayCountConvention,
981) -> Result<BondPricingResult, FinanceError> {
982    let schedule = CouponSchedule::covering(settlement, maturity, frequency)?;
983    FixedRateBond::new(face_value, coupon_rate, frequency, schedule, convention)?
984        .price(settlement, ytm)
985}
986
987/// Solves for the Yield to Maturity (YTM) given a clean bond market price.
988///
989/// Same schedule reconstruction as [`price_bond`], and the same limits.
990pub fn yield_to_maturity(
991    clean_price: f64,
992    face_value: f64,
993    coupon_rate: f64,
994    frequency: u32,
995    settlement: Date,
996    maturity: Date,
997    convention: DayCountConvention,
998) -> Result<f64, FinanceError> {
999    let schedule = CouponSchedule::covering(settlement, maturity, frequency)?;
1000    FixedRateBond::new(face_value, coupon_rate, frequency, schedule, convention)?
1001        .yield_to_maturity(settlement, clean_price)
1002}