Skip to main content

regit_curves/instruments/
swap_fixed_float.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Vanilla fixed-floating interest-rate swap.
5//!
6//! A vanilla IRS exchanges a stream of fixed coupons against a stream of
7//! floating coupons indexed off a single LIBOR/IBOR-style tenor. In the
8//! **single-curve** world — where the same curve both discounts cash flows
9//! and projects forward rates — the floating leg's PV telescopes to the two
10//! end discount factors, and the par-rate equation reduces to
11//!
12//! ```text
13//! rate * SUM_i tau_i^fixed * D(t_i^fixed)  =  D(t_start) - D(t_maturity),
14//! ```
15//!
16//! where `D` is the discount curve evaluated at the curve's year-fraction
17//! axis, `tau_i^fixed` is the accrual of the `i`-th fixed-leg period under
18//! the leg's `fixed_daycount`, and `t_i^fixed` is the year fraction (on the
19//! curve's day-count axis) from the curve's reference date to the period's
20//! payment date.
21//!
22//! The residual returned by the instrument's `residual` method is
23//!
24//! ```text
25//! residual = PV_fixed - PV_float
26//!          = rate * SUM_i tau_i^fixed * D(t_i^fixed) - (D(t_start) - D(t_maturity)).
27//! ```
28//!
29//! Zero at the bootstrap solution; positive when the quoted rate is above
30//! the curve-implied par, negative when below.
31//!
32//! # Single-curve vs multi-curve
33//!
34//! The single-curve identity above is exact when one curve handles both
35//! discounting and forward projection. Post-2008 markets price vanilla
36//! swaps in a **multi-curve** framework — an OIS curve discounts cash flows
37//! while a separate tenor-projection curve produces the floating-leg
38//! forwards. Multi-curve pricing lives in `multi_curve.rs`; here we expose
39//! the single-curve form, which is the right model when the OIS and
40//! projection curves coincide (e.g. for OIS swaps, or for a synthetic
41//! single-curve calibration).
42//!
43//! # References
44//!
45//! - Hagan, P. S. & West, G., "Interpolation methods for curve construction",
46//!   *Applied Mathematical Finance* 13(2):89-129 (2006), §2.2-2.3. Par-swap
47//!   rate identity in the single-curve bootstrap.
48//! - Mercurio, F., "Interest rates and the credit crunch: new formulas and
49//!   market models", *SSRN* 1332205 (2009), §3. Single- and multi-curve
50//!   swap-pricing forms; the float-leg telescoping is equation (3.3).
51//! - ISDA, *2006 ISDA Definitions*, §6 ("Fixed Amounts and Floating
52//!   Amounts") and §4.6 ("Calculation Period"). Coupon accrual conventions.
53
54use crate::errors::BootstrapError;
55use crate::types::{Date, Daycount, Frequency};
56
57use super::{CurveSnapshot, InstrumentLike, SwapSchedule};
58
59/// A vanilla fixed-floating interest-rate swap with separate fixed- and
60/// floating-leg schedules.
61///
62/// Both legs span the same `[start, maturity]` interval but may use
63/// different payment frequencies and day-count conventions (e.g. semi-
64/// annual 30/360 fixed against quarterly Act/360 float — the standard USD
65/// LIBOR vanilla convention).
66///
67/// Constructed via [`SwapFixedFloat::new`] (which builds regular schedules
68/// internally) or [`SwapFixedFloat::with_schedules`] (which accepts
69/// pre-built schedules — useful for stub first/last periods).
70///
71/// # Examples
72///
73/// ```
74/// use regit_curves::instruments::SwapFixedFloat;
75/// use regit_curves::types::{Date, Daycount, Frequency};
76///
77/// let start    = Date::from_ymd(2024, 1, 2).unwrap();
78/// let maturity = Date::from_ymd(2026, 1, 2).unwrap();
79/// let swap = SwapFixedFloat::new(
80///     start,
81///     maturity,
82///     0.04,
83///     Frequency::SemiAnnual,
84///     Daycount::Act360,
85///     Frequency::Quarterly,
86///     Daycount::Act360,
87/// )
88/// .unwrap();
89/// assert_eq!(swap.fixed_schedule.len(), 4);
90/// assert_eq!(swap.float_schedule.len(), 8);
91/// ```
92#[derive(Debug, Clone, PartialEq)]
93pub struct SwapFixedFloat {
94    /// Effective (start) date of both legs.
95    pub start: Date,
96    /// Maturity date of both legs.
97    pub maturity: Date,
98    /// Quoted (par) fixed rate, decimal (e.g. `0.04` for 4%).
99    pub rate: f64,
100    /// Fixed-leg payment frequency.
101    pub fixed_freq: Frequency,
102    /// Fixed-leg day-count convention (drives the `tau_i^fixed` accruals).
103    pub fixed_daycount: Daycount,
104    /// Float-leg payment frequency.
105    pub float_freq: Frequency,
106    /// Float-leg day-count convention (carried for symmetry / multi-curve
107    /// pricing; not used in the single-curve identity).
108    pub float_daycount: Daycount,
109    /// Fixed-leg payment schedule.
110    pub fixed_schedule: SwapSchedule,
111    /// Float-leg payment schedule.
112    pub float_schedule: SwapSchedule,
113}
114
115impl SwapFixedFloat {
116    /// Constructs a swap with regularly generated fixed- and float-leg
117    /// schedules.
118    ///
119    /// Validation:
120    ///
121    /// - `rate` must be finite.
122    /// - `start < maturity`.
123    /// - Both `(start, maturity, freq)` triples must yield a regular schedule
124    ///   (i.e. the term must be an integer multiple of each leg's period).
125    ///
126    /// # Errors
127    ///
128    /// - [`BootstrapError::InvalidInstrument`] if `rate` is not finite, if
129    ///   `start >= maturity`, or if either schedule cannot be built regularly
130    ///   at the requested frequency.
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use regit_curves::instruments::SwapFixedFloat;
136    /// use regit_curves::types::{Date, Daycount, Frequency};
137    /// use regit_curves::BootstrapError;
138    ///
139    /// let s = Date::from_ymd(2024, 1, 2).unwrap();
140    /// let m = Date::from_ymd(2026, 1, 2).unwrap();
141    /// assert!(
142    ///     SwapFixedFloat::new(
143    ///         s,
144    ///         m,
145    ///         0.04,
146    ///         Frequency::SemiAnnual,
147    ///         Daycount::Act360,
148    ///         Frequency::Quarterly,
149    ///         Daycount::Act360,
150    ///     )
151    ///     .is_ok()
152    /// );
153    /// // Inverted dates rejected:
154    /// assert!(matches!(
155    ///     SwapFixedFloat::new(
156    ///         m,
157    ///         s,
158    ///         0.04,
159    ///         Frequency::SemiAnnual,
160    ///         Daycount::Act360,
161    ///         Frequency::Quarterly,
162    ///         Daycount::Act360,
163    ///     )
164    ///     .unwrap_err(),
165    ///     BootstrapError::InvalidInstrument { .. },
166    /// ));
167    /// ```
168    pub fn new(
169        start: Date,
170        maturity: Date,
171        rate: f64,
172        fixed_freq: Frequency,
173        fixed_daycount: Daycount,
174        float_freq: Frequency,
175        float_daycount: Daycount,
176    ) -> Result<Self, BootstrapError> {
177        if !rate.is_finite() {
178            return Err(BootstrapError::InvalidInstrument {
179                at_index: 0,
180                reason: "swap rate must be finite",
181            });
182        }
183        if start.serial() >= maturity.serial() {
184            return Err(BootstrapError::InvalidInstrument {
185                at_index: 0,
186                reason: "swap start must precede maturity",
187            });
188        }
189        let fixed_schedule = SwapSchedule::from_regular(start, maturity, fixed_freq)?;
190        let float_schedule = SwapSchedule::from_regular(start, maturity, float_freq)?;
191        Ok(Self {
192            start,
193            maturity,
194            rate,
195            fixed_freq,
196            fixed_daycount,
197            float_freq,
198            float_daycount,
199            fixed_schedule,
200            float_schedule,
201        })
202    }
203
204    /// Constructs a swap from pre-built schedules — the irregular-stub
205    /// counterpart to [`SwapFixedFloat::new`].
206    ///
207    /// Validation:
208    ///
209    /// - `rate` must be finite.
210    /// - `start < maturity`.
211    /// - Both schedules must align: `fixed_schedule.start() == start`,
212    ///   `fixed_schedule.maturity() == maturity`, and likewise for
213    ///   `float_schedule`.
214    ///
215    /// # Errors
216    ///
217    /// - [`BootstrapError::InvalidInstrument`] if any validation step fails.
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use regit_curves::instruments::{SwapFixedFloat, SwapSchedule};
223    /// use regit_curves::types::{Date, Daycount, Frequency};
224    ///
225    /// let s = Date::from_ymd(2024, 1, 2).unwrap();
226    /// let m = Date::from_ymd(2026, 1, 2).unwrap();
227    /// let fixed = SwapSchedule::from_regular(s, m, Frequency::SemiAnnual).unwrap();
228    /// let float = SwapSchedule::from_regular(s, m, Frequency::Quarterly).unwrap();
229    /// let swap = SwapFixedFloat::with_schedules(
230    ///     s,
231    ///     m,
232    ///     0.04,
233    ///     Frequency::SemiAnnual,
234    ///     Daycount::Act360,
235    ///     Frequency::Quarterly,
236    ///     Daycount::Act360,
237    ///     fixed,
238    ///     float,
239    /// )
240    /// .unwrap();
241    /// assert_eq!(swap.fixed_schedule.len(), 4);
242    /// ```
243    #[allow(clippy::too_many_arguments)]
244    pub fn with_schedules(
245        start: Date,
246        maturity: Date,
247        rate: f64,
248        fixed_freq: Frequency,
249        fixed_daycount: Daycount,
250        float_freq: Frequency,
251        float_daycount: Daycount,
252        fixed_schedule: SwapSchedule,
253        float_schedule: SwapSchedule,
254    ) -> Result<Self, BootstrapError> {
255        if !rate.is_finite() {
256            return Err(BootstrapError::InvalidInstrument {
257                at_index: 0,
258                reason: "swap rate must be finite",
259            });
260        }
261        if start.serial() >= maturity.serial() {
262            return Err(BootstrapError::InvalidInstrument {
263                at_index: 0,
264                reason: "swap start must precede maturity",
265            });
266        }
267        if fixed_schedule.start() != start || fixed_schedule.maturity() != maturity {
268            return Err(BootstrapError::InvalidInstrument {
269                at_index: 0,
270                reason: "fixed schedule does not span [start, maturity]",
271            });
272        }
273        if float_schedule.start() != start || float_schedule.maturity() != maturity {
274            return Err(BootstrapError::InvalidInstrument {
275                at_index: 0,
276                reason: "float schedule does not span [start, maturity]",
277            });
278        }
279        Ok(Self {
280            start,
281            maturity,
282            rate,
283            fixed_freq,
284            fixed_daycount,
285            float_freq,
286            float_daycount,
287            fixed_schedule,
288            float_schedule,
289        })
290    }
291
292    /// Returns the fixed-leg PV:
293    ///
294    /// ```text
295    /// PV_fixed = rate * SUM_i tau_i^fixed * D(t_i^fixed).
296    /// ```
297    ///
298    /// `tau_i^fixed` is the year fraction of the `i`-th fixed-leg period
299    /// under `fixed_daycount`; `t_i^fixed` is the year fraction from
300    /// `curve.reference_date` to the period's payment date under the
301    /// curve's own day-count.
302    ///
303    /// # Errors
304    ///
305    /// - [`BootstrapError::Type`] if any day-count query fails (e.g. an
306    ///   uninitialised `Business252` calendar).
307    /// - [`BootstrapError::InvalidInstrument`] if the curve snapshot is
308    ///   empty / inconsistent.
309    ///
310    /// # Examples
311    ///
312    /// ```
313    /// use regit_curves::instruments::SwapFixedFloat;
314    /// use regit_curves::types::{Date, Daycount, Frequency};
315    ///
316    /// let s = Date::from_ymd(2024, 1, 2).unwrap();
317    /// let m = Date::from_ymd(2026, 1, 2).unwrap();
318    /// let swap = SwapFixedFloat::new(
319    ///     s,
320    ///     m,
321    ///     0.04,
322    ///     Frequency::SemiAnnual,
323    ///     Daycount::Act360,
324    ///     Frequency::Quarterly,
325    ///     Daycount::Act360,
326    /// )
327    /// .unwrap();
328    /// // Fixed-leg PV is strictly positive against any sensible curve.
329    /// assert!(swap.rate > 0.0);
330    /// # let _ = swap;
331    /// ```
332    pub(crate) fn fixed_leg_pv(
333        &self,
334        _reference_date: Date,
335        curve: &CurveSnapshot<'_>,
336    ) -> Result<f64, BootstrapError> {
337        let mut annuity = 0.0_f64;
338        for i in 0..self.fixed_schedule.len() {
339            let period_start = self.fixed_schedule.period_start(i);
340            let payment = self.fixed_schedule.period_end(i);
341            let tau_i = self.fixed_daycount.year_fraction(period_start, payment)?;
342            let t_payment = curve
343                .daycount
344                .year_fraction(curve.reference_date, payment)?;
345            let d_payment =
346                curve
347                    .discount_at(t_payment)
348                    .ok_or(BootstrapError::InvalidInstrument {
349                        at_index: 0,
350                        reason: "curve snapshot is empty",
351                    })?;
352            annuity += tau_i * d_payment;
353        }
354        Ok(self.rate * annuity)
355    }
356
357    /// Returns the floating-leg PV under the single-curve telescoping
358    /// identity:
359    ///
360    /// ```text
361    /// PV_float = D(t_start) - D(t_maturity).
362    /// ```
363    ///
364    /// This holds exactly when the curve handles both discounting and
365    /// forward projection (single-curve regime); the multi-curve form lives
366    /// in `multi_curve.rs`.
367    ///
368    /// # Errors
369    ///
370    /// - [`BootstrapError::Type`] if a day-count query fails.
371    /// - [`BootstrapError::InvalidInstrument`] if the curve snapshot is
372    ///   empty / inconsistent.
373    ///
374    /// # Examples
375    ///
376    /// ```
377    /// use regit_curves::instruments::SwapFixedFloat;
378    /// use regit_curves::types::{Date, Daycount, Frequency};
379    ///
380    /// let s = Date::from_ymd(2024, 1, 2).unwrap();
381    /// let m = Date::from_ymd(2026, 1, 2).unwrap();
382    /// let swap = SwapFixedFloat::new(
383    ///     s,
384    ///     m,
385    ///     0.04,
386    ///     Frequency::SemiAnnual,
387    ///     Daycount::Act360,
388    ///     Frequency::Quarterly,
389    ///     Daycount::Act360,
390    /// )
391    /// .unwrap();
392    /// // Float-leg PV is nominally positive for an upward-sloping curve.
393    /// assert_eq!(swap.start, s);
394    /// # let _ = swap;
395    /// ```
396    pub(crate) fn float_leg_pv_single_curve(
397        &self,
398        _reference_date: Date,
399        curve: &CurveSnapshot<'_>,
400    ) -> Result<f64, BootstrapError> {
401        let t_start = curve
402            .daycount
403            .year_fraction(curve.reference_date, self.start)?;
404        let t_maturity = curve
405            .daycount
406            .year_fraction(curve.reference_date, self.maturity)?;
407        let d_start = curve
408            .discount_at(t_start)
409            .ok_or(BootstrapError::InvalidInstrument {
410                at_index: 0,
411                reason: "curve snapshot is empty",
412            })?;
413        let d_maturity =
414            curve
415                .discount_at(t_maturity)
416                .ok_or(BootstrapError::InvalidInstrument {
417                    at_index: 0,
418                    reason: "curve snapshot is empty",
419                })?;
420        Ok(d_start - d_maturity)
421    }
422}
423
424impl InstrumentLike for SwapFixedFloat {
425    #[inline]
426    fn pillar(&self) -> Date {
427        self.maturity
428    }
429
430    fn residual(
431        &self,
432        reference_date: Date,
433        curve: &CurveSnapshot<'_>,
434    ) -> Result<f64, BootstrapError> {
435        let pv_fixed = self.fixed_leg_pv(reference_date, curve)?;
436        let pv_float = self.float_leg_pv_single_curve(reference_date, curve)?;
437        Ok(pv_fixed - pv_float)
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::instruments::CurveSnapshot;
445
446    fn d(y: i32, m: u32, day: u32) -> Date {
447        Date::from_ymd(y, m, day).unwrap()
448    }
449
450    // ─── Construction & validation ───────────────────────────────────────
451
452    #[test]
453    fn new_accepts_valid_2y_sa_q_swap() {
454        let s = d(2024, 1, 2);
455        let m = d(2026, 1, 2);
456        let swap = SwapFixedFloat::new(
457            s,
458            m,
459            0.04,
460            Frequency::SemiAnnual,
461            Daycount::Act360,
462            Frequency::Quarterly,
463            Daycount::Act360,
464        )
465        .unwrap();
466        assert_eq!(swap.start, s);
467        assert_eq!(swap.maturity, m);
468        assert_eq!(swap.fixed_schedule.len(), 4);
469        assert_eq!(swap.float_schedule.len(), 8);
470        assert_eq!(swap.pillar(), m);
471    }
472
473    #[test]
474    fn new_accepts_negative_rate() {
475        let s = d(2024, 1, 2);
476        let m = d(2026, 1, 2);
477        let swap = SwapFixedFloat::new(
478            s,
479            m,
480            -0.005,
481            Frequency::SemiAnnual,
482            Daycount::Act360,
483            Frequency::Quarterly,
484            Daycount::Act360,
485        )
486        .unwrap();
487        assert!(swap.rate < 0.0);
488    }
489
490    #[test]
491    fn new_rejects_nan_rate() {
492        let s = d(2024, 1, 2);
493        let m = d(2026, 1, 2);
494        let err = SwapFixedFloat::new(
495            s,
496            m,
497            f64::NAN,
498            Frequency::SemiAnnual,
499            Daycount::Act360,
500            Frequency::Quarterly,
501            Daycount::Act360,
502        )
503        .unwrap_err();
504        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
505    }
506
507    #[test]
508    fn new_rejects_inf_rate() {
509        let s = d(2024, 1, 2);
510        let m = d(2026, 1, 2);
511        let err = SwapFixedFloat::new(
512            s,
513            m,
514            f64::INFINITY,
515            Frequency::SemiAnnual,
516            Daycount::Act360,
517            Frequency::Quarterly,
518            Daycount::Act360,
519        )
520        .unwrap_err();
521        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
522    }
523
524    #[test]
525    fn new_rejects_inverted_dates() {
526        let s = d(2024, 1, 2);
527        let m = d(2026, 1, 2);
528        let err = SwapFixedFloat::new(
529            m,
530            s,
531            0.04,
532            Frequency::SemiAnnual,
533            Daycount::Act360,
534            Frequency::Quarterly,
535            Daycount::Act360,
536        )
537        .unwrap_err();
538        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
539    }
540
541    #[test]
542    fn new_rejects_equal_dates() {
543        let s = d(2024, 1, 2);
544        let err = SwapFixedFloat::new(
545            s,
546            s,
547            0.04,
548            Frequency::SemiAnnual,
549            Daycount::Act360,
550            Frequency::Quarterly,
551            Daycount::Act360,
552        )
553        .unwrap_err();
554        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
555    }
556
557    #[test]
558    fn new_rejects_irregular_term() {
559        // 13 months — not divisible by SA cadence.
560        let s = d(2024, 1, 2);
561        let m = d(2025, 2, 2);
562        let err = SwapFixedFloat::new(
563            s,
564            m,
565            0.04,
566            Frequency::SemiAnnual,
567            Daycount::Act360,
568            Frequency::Quarterly,
569            Daycount::Act360,
570        )
571        .unwrap_err();
572        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
573    }
574
575    #[test]
576    fn with_schedules_validates_alignment() {
577        let s = d(2024, 1, 2);
578        let m = d(2026, 1, 2);
579        let fixed = SwapSchedule::from_regular(s, m, Frequency::SemiAnnual).unwrap();
580        let float = SwapSchedule::from_regular(s, m, Frequency::Quarterly).unwrap();
581        let ok = SwapFixedFloat::with_schedules(
582            s,
583            m,
584            0.04,
585            Frequency::SemiAnnual,
586            Daycount::Act360,
587            Frequency::Quarterly,
588            Daycount::Act360,
589            fixed.clone(),
590            float.clone(),
591        );
592        assert!(ok.is_ok());
593
594        // Misaligned start: schedule starts at s+1y but swap claims s.
595        let mid = d(2025, 1, 2);
596        let mismatched = SwapSchedule::from_regular(mid, m, Frequency::SemiAnnual).unwrap();
597        let err = SwapFixedFloat::with_schedules(
598            s,
599            m,
600            0.04,
601            Frequency::SemiAnnual,
602            Daycount::Act360,
603            Frequency::Quarterly,
604            Daycount::Act360,
605            mismatched,
606            float.clone(),
607        )
608        .unwrap_err();
609        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
610
611        // Misaligned float schedule.
612        let mismatched_float = SwapSchedule::from_regular(mid, m, Frequency::Quarterly).unwrap();
613        let err = SwapFixedFloat::with_schedules(
614            s,
615            m,
616            0.04,
617            Frequency::SemiAnnual,
618            Daycount::Act360,
619            Frequency::Quarterly,
620            Daycount::Act360,
621            fixed,
622            mismatched_float,
623        )
624        .unwrap_err();
625        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
626    }
627
628    #[test]
629    fn with_schedules_rejects_nan_rate() {
630        let s = d(2024, 1, 2);
631        let m = d(2026, 1, 2);
632        let fixed = SwapSchedule::from_regular(s, m, Frequency::SemiAnnual).unwrap();
633        let float = SwapSchedule::from_regular(s, m, Frequency::Quarterly).unwrap();
634        let err = SwapFixedFloat::with_schedules(
635            s,
636            m,
637            f64::NAN,
638            Frequency::SemiAnnual,
639            Daycount::Act360,
640            Frequency::Quarterly,
641            Daycount::Act360,
642            fixed,
643            float,
644        )
645        .unwrap_err();
646        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
647    }
648
649    #[test]
650    fn with_schedules_rejects_inverted_dates() {
651        let s = d(2024, 1, 2);
652        let m = d(2026, 1, 2);
653        let fixed = SwapSchedule::from_regular(s, m, Frequency::SemiAnnual).unwrap();
654        let float = SwapSchedule::from_regular(s, m, Frequency::Quarterly).unwrap();
655        let err = SwapFixedFloat::with_schedules(
656            m,
657            s,
658            0.04,
659            Frequency::SemiAnnual,
660            Daycount::Act360,
661            Frequency::Quarterly,
662            Daycount::Act360,
663            fixed,
664            float,
665        )
666        .unwrap_err();
667        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
668    }
669
670    // ─── Pricing identity on a flat continuously-compounded curve ────────
671
672    /// Builds a hand-rolled flat continuously-compounded discount curve
673    /// `D(t) = exp(-r * t)` evaluated on a quarterly grid out to ~30 years.
674    fn flat_curve(reference_date: Date, daycount: Daycount, r: f64) -> (Vec<f64>, Vec<f64>) {
675        let mut times = Vec::new();
676        let mut discounts = Vec::new();
677        for i in 0..=120 {
678            let date = Date::from_serial(reference_date.serial() + i * 91);
679            let t = daycount.year_fraction(reference_date, date).unwrap();
680            times.push(t);
681            discounts.push((-r * t).exp());
682        }
683        (times, discounts)
684    }
685
686    /// Closed-form par rate of a fixed/float swap against a flat
687    /// continuously-compounded curve at `r_c`. Uses the float telescoping:
688    /// `r_par = (D(t_start) - D(t_maturity)) / SUM tau_i^fixed * D(t_i^fixed)`.
689    fn par_rate_against_flat(
690        swap_start: Date,
691        swap_maturity: Date,
692        fixed_freq: Frequency,
693        fixed_daycount: Daycount,
694        reference_date: Date,
695        curve_daycount: Daycount,
696        r_c: f64,
697    ) -> f64 {
698        let schedule = SwapSchedule::from_regular(swap_start, swap_maturity, fixed_freq).unwrap();
699        let mut annuity = 0.0_f64;
700        for i in 0..schedule.len() {
701            let p_start = schedule.period_start(i);
702            let p_end = schedule.period_end(i);
703            let tau_i = fixed_daycount.year_fraction(p_start, p_end).unwrap();
704            let t = curve_daycount.year_fraction(reference_date, p_end).unwrap();
705            annuity += tau_i * (-r_c * t).exp();
706        }
707        let t_start = curve_daycount
708            .year_fraction(reference_date, swap_start)
709            .unwrap();
710        let t_mat = curve_daycount
711            .year_fraction(reference_date, swap_maturity)
712            .unwrap();
713        ((-r_c * t_start).exp() - (-r_c * t_mat).exp()) / annuity
714    }
715
716    #[test]
717    fn residual_is_zero_on_flat_curve_with_closed_form_par_rate() {
718        // 2y semi-annual fixed (Act/360) against quarterly float (Act/360);
719        // flat continuously-compounded curve at r_c = 0.04.
720        let reference = d(2024, 1, 2);
721        let start = reference;
722        let maturity = d(2026, 1, 2);
723        let dc_curve = Daycount::Act360;
724        let r_c = 0.04_f64;
725        let (times, discounts) = flat_curve(reference, dc_curve, r_c);
726
727        let r_par = par_rate_against_flat(
728            start,
729            maturity,
730            Frequency::SemiAnnual,
731            Daycount::Act360,
732            reference,
733            dc_curve,
734            r_c,
735        );
736        // Sanity bound: par rate is within a handful of bp of the simple
737        // continuous-equivalent for 2y at 4%.
738        assert!(r_par > 0.03 && r_par < 0.05, "unexpected r_par = {r_par}");
739
740        let swap = SwapFixedFloat::new(
741            start,
742            maturity,
743            r_par,
744            Frequency::SemiAnnual,
745            Daycount::Act360,
746            Frequency::Quarterly,
747            Daycount::Act360,
748        )
749        .unwrap();
750        let snapshot = CurveSnapshot {
751            reference_date: reference,
752            daycount: dc_curve,
753            times: &times,
754            discounts: &discounts,
755        };
756        let residual = swap.residual(reference, &snapshot).unwrap();
757        assert!(
758            residual.abs() < 1e-10,
759            "residual at par should be < 1e-10, got {residual}",
760        );
761    }
762
763    #[test]
764    fn residual_sign_responds_to_rate_perturbation() {
765        // Quoting fixed above par makes PV_fixed > PV_float -> residual > 0.
766        let reference = d(2024, 1, 2);
767        let start = reference;
768        let maturity = d(2026, 1, 2);
769        let dc_curve = Daycount::Act360;
770        let r_c = 0.04_f64;
771        let (times, discounts) = flat_curve(reference, dc_curve, r_c);
772
773        let r_par = par_rate_against_flat(
774            start,
775            maturity,
776            Frequency::SemiAnnual,
777            Daycount::Act360,
778            reference,
779            dc_curve,
780            r_c,
781        );
782        let snapshot = CurveSnapshot {
783            reference_date: reference,
784            daycount: dc_curve,
785            times: &times,
786            discounts: &discounts,
787        };
788
789        let high = SwapFixedFloat::new(
790            start,
791            maturity,
792            r_par + 0.005,
793            Frequency::SemiAnnual,
794            Daycount::Act360,
795            Frequency::Quarterly,
796            Daycount::Act360,
797        )
798        .unwrap();
799        let low = SwapFixedFloat::new(
800            start,
801            maturity,
802            r_par - 0.005,
803            Frequency::SemiAnnual,
804            Daycount::Act360,
805            Frequency::Quarterly,
806            Daycount::Act360,
807        )
808        .unwrap();
809        let res_high = high.residual(reference, &snapshot).unwrap();
810        let res_low = low.residual(reference, &snapshot).unwrap();
811        assert!(
812            res_high > 1e-6,
813            "expected positive residual, got {res_high}"
814        );
815        assert!(res_low < -1e-6, "expected negative residual, got {res_low}");
816    }
817
818    #[test]
819    fn fixed_leg_pv_matches_manual_sum() {
820        // Walk the same sum the implementation walks and compare.
821        let reference = d(2024, 1, 2);
822        let start = reference;
823        let maturity = d(2026, 1, 2);
824        let dc_curve = Daycount::Act360;
825        let r_c = 0.04_f64;
826        let (times, discounts) = flat_curve(reference, dc_curve, r_c);
827
828        let swap = SwapFixedFloat::new(
829            start,
830            maturity,
831            0.04,
832            Frequency::SemiAnnual,
833            Daycount::Act360,
834            Frequency::Quarterly,
835            Daycount::Act360,
836        )
837        .unwrap();
838        let snapshot = CurveSnapshot {
839            reference_date: reference,
840            daycount: dc_curve,
841            times: &times,
842            discounts: &discounts,
843        };
844
845        // Manual computation: 4 SA periods, each ~ tau_i * exp(-r_c * t_i).
846        let mut expected = 0.0_f64;
847        for i in 0..swap.fixed_schedule.len() {
848            let p_start = swap.fixed_schedule.period_start(i);
849            let p_end = swap.fixed_schedule.period_end(i);
850            let tau = Daycount::Act360.year_fraction(p_start, p_end).unwrap();
851            let t = dc_curve.year_fraction(reference, p_end).unwrap();
852            expected += tau * (-r_c * t).exp();
853        }
854        expected *= 0.04;
855        let got = swap.fixed_leg_pv(reference, &snapshot).unwrap();
856        assert!(
857            (got - expected).abs() < 1e-12,
858            "fixed_leg_pv mismatch: got {got}, expected {expected}",
859        );
860    }
861
862    #[test]
863    fn float_leg_pv_telescopes_to_two_discounts() {
864        let reference = d(2024, 1, 2);
865        let start = reference;
866        let maturity = d(2026, 1, 2);
867        let dc_curve = Daycount::Act360;
868        let r_c = 0.04_f64;
869        let (times, discounts) = flat_curve(reference, dc_curve, r_c);
870
871        let swap = SwapFixedFloat::new(
872            start,
873            maturity,
874            0.04,
875            Frequency::SemiAnnual,
876            Daycount::Act360,
877            Frequency::Quarterly,
878            Daycount::Act360,
879        )
880        .unwrap();
881        let snapshot = CurveSnapshot {
882            reference_date: reference,
883            daycount: dc_curve,
884            times: &times,
885            discounts: &discounts,
886        };
887
888        let t_start = dc_curve.year_fraction(reference, start).unwrap();
889        let t_mat = dc_curve.year_fraction(reference, maturity).unwrap();
890        let expected = (-r_c * t_start).exp() - (-r_c * t_mat).exp();
891        let got = swap
892            .float_leg_pv_single_curve(reference, &snapshot)
893            .unwrap();
894        assert!((got - expected).abs() < 1e-14);
895    }
896
897    #[test]
898    fn fixed_leg_pv_errors_on_empty_snapshot() {
899        let reference = d(2024, 1, 2);
900        let swap = SwapFixedFloat::new(
901            reference,
902            d(2026, 1, 2),
903            0.04,
904            Frequency::SemiAnnual,
905            Daycount::Act360,
906            Frequency::Quarterly,
907            Daycount::Act360,
908        )
909        .unwrap();
910        let snapshot = CurveSnapshot {
911            reference_date: reference,
912            daycount: Daycount::Act360,
913            times: &[],
914            discounts: &[],
915        };
916        let err = swap.fixed_leg_pv(reference, &snapshot).unwrap_err();
917        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
918    }
919
920    #[test]
921    fn float_leg_pv_errors_on_empty_snapshot() {
922        let reference = d(2024, 1, 2);
923        let swap = SwapFixedFloat::new(
924            reference,
925            d(2026, 1, 2),
926            0.04,
927            Frequency::SemiAnnual,
928            Daycount::Act360,
929            Frequency::Quarterly,
930            Daycount::Act360,
931        )
932        .unwrap();
933        let snapshot = CurveSnapshot {
934            reference_date: reference,
935            daycount: Daycount::Act360,
936            times: &[],
937            discounts: &[],
938        };
939        let err = swap
940            .float_leg_pv_single_curve(reference, &snapshot)
941            .unwrap_err();
942        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
943    }
944
945    #[test]
946    fn pillar_is_maturity() {
947        let s = d(2024, 1, 2);
948        let m = d(2029, 1, 2);
949        let swap = SwapFixedFloat::new(
950            s,
951            m,
952            0.04,
953            Frequency::SemiAnnual,
954            Daycount::Act360,
955            Frequency::Quarterly,
956            Daycount::Act360,
957        )
958        .unwrap();
959        assert_eq!(swap.pillar(), m);
960    }
961
962    #[test]
963    fn par_rate_invariant_under_mixed_daycounts() {
964        // Fixed Thirty360 against float Act360 — the par-rate identity still
965        // drives residual to zero with the right fixed-day-count accrual.
966        let reference = d(2024, 1, 2);
967        let start = reference;
968        let maturity = d(2026, 1, 2);
969        let dc_curve = Daycount::Act360;
970        let r_c = 0.04_f64;
971        let (times, discounts) = flat_curve(reference, dc_curve, r_c);
972
973        let r_par = par_rate_against_flat(
974            start,
975            maturity,
976            Frequency::SemiAnnual,
977            Daycount::Thirty360BondBasis,
978            reference,
979            dc_curve,
980            r_c,
981        );
982        let swap = SwapFixedFloat::new(
983            start,
984            maturity,
985            r_par,
986            Frequency::SemiAnnual,
987            Daycount::Thirty360BondBasis,
988            Frequency::Quarterly,
989            Daycount::Act360,
990        )
991        .unwrap();
992        let snapshot = CurveSnapshot {
993            reference_date: reference,
994            daycount: dc_curve,
995            times: &times,
996            discounts: &discounts,
997        };
998        let residual = swap.residual(reference, &snapshot).unwrap();
999        assert!(residual.abs() < 1e-10);
1000    }
1001
1002    #[test]
1003    fn five_year_swap_residual_zero_at_par() {
1004        let reference = d(2024, 1, 2);
1005        let start = reference;
1006        let maturity = d(2029, 1, 2);
1007        let dc_curve = Daycount::Act360;
1008        let r_c = 0.035_f64;
1009        let (times, discounts) = flat_curve(reference, dc_curve, r_c);
1010
1011        let r_par = par_rate_against_flat(
1012            start,
1013            maturity,
1014            Frequency::SemiAnnual,
1015            Daycount::Act360,
1016            reference,
1017            dc_curve,
1018            r_c,
1019        );
1020        let swap = SwapFixedFloat::new(
1021            start,
1022            maturity,
1023            r_par,
1024            Frequency::SemiAnnual,
1025            Daycount::Act360,
1026            Frequency::Quarterly,
1027            Daycount::Act360,
1028        )
1029        .unwrap();
1030        let snapshot = CurveSnapshot {
1031            reference_date: reference,
1032            daycount: dc_curve,
1033            times: &times,
1034            discounts: &discounts,
1035        };
1036        let residual = swap.residual(reference, &snapshot).unwrap();
1037        assert!(
1038            residual.abs() < 1e-10,
1039            "5y residual at par should be < 1e-10, got {residual}",
1040        );
1041    }
1042
1043    #[test]
1044    fn debug_format_contains_struct_name() {
1045        let swap = SwapFixedFloat::new(
1046            d(2024, 1, 2),
1047            d(2026, 1, 2),
1048            0.04,
1049            Frequency::SemiAnnual,
1050            Daycount::Act360,
1051            Frequency::Quarterly,
1052            Daycount::Act360,
1053        )
1054        .unwrap();
1055        let s = format!("{swap:?}");
1056        assert!(s.contains("SwapFixedFloat"));
1057    }
1058
1059    #[test]
1060    fn clone_and_eq_round_trip() {
1061        let swap = SwapFixedFloat::new(
1062            d(2024, 1, 2),
1063            d(2026, 1, 2),
1064            0.04,
1065            Frequency::SemiAnnual,
1066            Daycount::Act360,
1067            Frequency::Quarterly,
1068            Daycount::Act360,
1069        )
1070        .unwrap();
1071        let cloned = swap.clone();
1072        assert_eq!(swap, cloned);
1073    }
1074}