convex_bonds/instruments/
floating_rate.rs

1//! Floating Rate Notes (FRNs).
2//!
3//! This module provides floating rate note implementation with support for:
4//! - SOFR compounding (in arrears, simple average, Term SOFR)
5//! - EURIBOR, SONIA, €STR, and other reference rates
6//! - Caps and floors (collars)
7//! - Lookback and lockout conventions
8//!
9//! # Example
10//!
11//! ```rust,ignore
12//! use convex_bonds::instruments::FloatingRateNote;
13//! use convex_bonds::types::{RateIndex, SOFRConvention};
14//! use convex_core::types::Date;
15//!
16//! let frn = FloatingRateNote::builder()
17//!     .cusip_unchecked("912828ZQ7")
18//!     .index(RateIndex::Sofr)
19//!     .sofr_convention(SOFRConvention::arrc_standard())
20//!     .spread_bps(50)  // 50 basis points
21//!     .maturity(Date::from_ymd(2026, 7, 31).unwrap())
22//!     .issue_date(Date::from_ymd(2024, 7, 31).unwrap())
23//!     .us_treasury_frn()
24//!     .build()
25//!     .unwrap();
26//!
27//! // Calculate accrued interest with current rate
28//! let accrued = frn.accrued_interest_with_rate(settlement, dec!(0.053));
29//! ```
30
31use rust_decimal::Decimal;
32use serde::{Deserialize, Serialize};
33
34use convex_core::daycounts::DayCountConvention;
35use convex_core::types::{Currency, Date, Frequency};
36
37use crate::cashflows::{Schedule, ScheduleConfig};
38use crate::error::{BondError, BondResult};
39use crate::traits::{Bond, BondCashFlow, FloatingCouponBond};
40use crate::types::{BondIdentifiers, BondType, CalendarId, Cusip, Isin, RateIndex, SOFRConvention};
41
42/// A floating rate note (FRN).
43///
44/// FRNs pay coupons that reset periodically based on a reference rate
45/// (such as SOFR, EURIBOR, or SONIA) plus a fixed spread.
46///
47/// # Features
48///
49/// - Support for all major reference rates (SOFR, €STR, SONIA, EURIBOR, etc.)
50/// - SOFR compounding conventions (in arrears, simple average, Term SOFR)
51/// - Caps and floors (rate collars)
52/// - Lookback and lockout periods
53/// - Full Bond trait implementation
54#[derive(Debug, Clone)]
55pub struct FloatingRateNote {
56    /// Bond identifiers
57    identifiers: BondIdentifiers,
58
59    /// Reference rate index
60    index: RateIndex,
61
62    /// SOFR-specific compounding convention (if applicable)
63    sofr_convention: Option<SOFRConvention>,
64
65    /// Spread over reference rate in basis points
66    spread_bps: Decimal,
67
68    /// Maturity date
69    maturity: Date,
70
71    /// Issue date
72    issue_date: Date,
73
74    /// Payment frequency
75    frequency: Frequency,
76
77    /// Day count convention
78    day_count: DayCountConvention,
79
80    /// Reset lag in business days before period start
81    reset_lag: i32,
82
83    /// Payment delay in business days after period end
84    payment_delay: u32,
85
86    /// Rate cap (optional)
87    cap: Option<Decimal>,
88
89    /// Rate floor (optional)
90    floor: Option<Decimal>,
91
92    /// Settlement days
93    settlement_days: u32,
94
95    /// Calendar for business day adjustments
96    calendar: CalendarId,
97
98    /// Currency
99    currency: Currency,
100
101    /// Face value per unit
102    face_value: Decimal,
103
104    /// Current reference rate fixing (if known)
105    current_rate: Option<Decimal>,
106}
107
108impl FloatingRateNote {
109    /// Creates a new builder for `FloatingRateNote`.
110    #[must_use]
111    pub fn builder() -> FloatingRateNoteBuilder {
112        FloatingRateNoteBuilder::default()
113    }
114
115    /// Returns the reference rate index.
116    #[must_use]
117    pub fn index(&self) -> &RateIndex {
118        &self.index
119    }
120
121    /// Returns the SOFR convention if applicable.
122    #[must_use]
123    pub fn sofr_convention(&self) -> Option<&SOFRConvention> {
124        self.sofr_convention.as_ref()
125    }
126
127    /// Returns the spread in basis points.
128    #[must_use]
129    pub fn spread_bps(&self) -> Decimal {
130        self.spread_bps
131    }
132
133    /// Returns the spread as a decimal rate.
134    #[must_use]
135    pub fn spread_decimal(&self) -> Decimal {
136        self.spread_bps / Decimal::from(10000)
137    }
138
139    /// Returns the maturity date.
140    #[must_use]
141    pub fn maturity_date(&self) -> Date {
142        self.maturity
143    }
144
145    /// Returns the issue date.
146    #[must_use]
147    pub fn get_issue_date(&self) -> Date {
148        self.issue_date
149    }
150
151    /// Returns the payment frequency.
152    #[must_use]
153    pub fn frequency(&self) -> Frequency {
154        self.frequency
155    }
156
157    /// Returns the day count convention.
158    #[must_use]
159    pub fn day_count(&self) -> DayCountConvention {
160        self.day_count
161    }
162
163    /// Returns the cap rate if any.
164    #[must_use]
165    pub fn cap(&self) -> Option<Decimal> {
166        self.cap
167    }
168
169    /// Returns the floor rate if any.
170    #[must_use]
171    pub fn floor(&self) -> Option<Decimal> {
172        self.floor
173    }
174
175    /// Returns the settlement days.
176    #[must_use]
177    pub fn settlement_days(&self) -> u32 {
178        self.settlement_days
179    }
180
181    /// Returns the reset lag in business days.
182    #[must_use]
183    pub fn reset_lag(&self) -> i32 {
184        self.reset_lag
185    }
186
187    /// Sets the current reference rate.
188    pub fn set_current_rate(&mut self, rate: Decimal) {
189        self.current_rate = Some(rate);
190    }
191
192    /// Returns the current reference rate if set.
193    #[must_use]
194    pub fn current_rate(&self) -> Option<Decimal> {
195        self.current_rate
196    }
197
198    /// Calculates the effective coupon rate after applying cap/floor.
199    #[must_use]
200    pub fn effective_rate(&self, index_rate: Decimal) -> Decimal {
201        let mut rate = index_rate + self.spread_decimal();
202
203        // Apply floor
204        if let Some(floor) = self.floor {
205            if rate < floor {
206                rate = floor;
207            }
208        }
209
210        // Apply cap
211        if let Some(cap) = self.cap {
212            if rate > cap {
213                rate = cap;
214            }
215        }
216
217        rate
218    }
219
220    /// Calculates the coupon amount for a period given the index rate.
221    #[must_use]
222    pub fn period_coupon(
223        &self,
224        period_start: Date,
225        period_end: Date,
226        index_rate: Decimal,
227    ) -> Decimal {
228        let dc = self.day_count.to_day_count();
229        let year_frac = dc.year_fraction(period_start, period_end);
230        let effective_rate = self.effective_rate(index_rate);
231
232        self.face_value * effective_rate * Decimal::try_from(year_frac).unwrap_or(Decimal::ZERO)
233    }
234
235    /// Calculates accrued interest with a given reference rate.
236    #[must_use]
237    pub fn accrued_interest_with_rate(&self, settlement: Date, index_rate: Decimal) -> Decimal {
238        if settlement <= self.issue_date {
239            return Decimal::ZERO;
240        }
241
242        let Some(last_coupon) = self.previous_coupon_date(settlement) else {
243            return Decimal::ZERO;
244        };
245
246        let Some(next_coupon) = self.next_coupon_date(settlement) else {
247            return Decimal::ZERO;
248        };
249
250        if settlement >= next_coupon {
251            return Decimal::ZERO;
252        }
253
254        let dc = self.day_count.to_day_count();
255        let accrued_days = dc.day_count(last_coupon, settlement);
256        let period_days = dc.day_count(last_coupon, next_coupon);
257
258        if period_days == 0 {
259            return Decimal::ZERO;
260        }
261
262        let effective_rate = self.effective_rate(index_rate);
263        let periods_per_year = Decimal::from(self.frequency.periods_per_year());
264        let period_coupon = self.face_value * effective_rate / periods_per_year;
265
266        period_coupon * Decimal::from(accrued_days) / Decimal::from(period_days)
267    }
268
269    /// Calculates SOFR compounded in arrears for a period.
270    ///
271    /// This implements the ARRC standard methodology for calculating
272    /// compounded SOFR over an interest period.
273    ///
274    /// # Arguments
275    ///
276    /// * `daily_rates` - Vector of (date, rate) tuples for daily SOFR fixings
277    /// * `period_start` - Start of the interest period
278    /// * `period_end` - End of the interest period
279    ///
280    /// # Returns
281    ///
282    /// The compounded rate for the period (annualized).
283    #[must_use]
284    pub fn sofr_compounded_in_arrears(
285        &self,
286        daily_rates: &[(Date, Decimal)],
287        period_start: Date,
288        period_end: Date,
289    ) -> Decimal {
290        let Some(SOFRConvention::CompoundedInArrears {
291            lookback_days,
292            observation_shift,
293            lockout_days,
294        }) = &self.sofr_convention
295        else {
296            return Decimal::ZERO;
297        };
298
299        let calendar = self.calendar.to_calendar();
300        let mut compounded = 1.0_f64;
301        let mut current = period_start;
302        let mut days_count = 0_i64;
303
304        while current < period_end {
305            let next = calendar.add_business_days(current, 1);
306            let weight_days = current.days_between(&next);
307
308            // Determine observation date with lookback
309            let observation_date = if *observation_shift {
310                calendar.add_business_days(current, -(*lookback_days as i32))
311            } else {
312                current
313            };
314
315            // Apply lockout if applicable
316            let rate_date = if let Some(lock) = lockout_days {
317                let lock_start = calendar.add_business_days(period_end, -(*lock as i32));
318                if current >= lock_start {
319                    lock_start
320                } else {
321                    observation_date
322                }
323            } else {
324                observation_date
325            };
326
327            // Look up the rate
328            let rate = daily_rates
329                .iter()
330                .find(|(d, _)| *d == rate_date)
331                .map_or(0.0, |(_, r)| r.to_string().parse::<f64>().unwrap_or(0.0));
332
333            // Compound: (1 + rate * days/360)
334            compounded *= 1.0 + rate * weight_days as f64 / 360.0;
335            days_count += weight_days;
336            current = next;
337        }
338
339        if days_count == 0 {
340            return Decimal::ZERO;
341        }
342
343        // Annualize: ((compounded - 1) * 360 / days)
344        let annualized = (compounded - 1.0) * 360.0 / days_count as f64;
345        Decimal::try_from(annualized).unwrap_or(Decimal::ZERO)
346    }
347
348    /// Calculates simple average SOFR for a period.
349    #[must_use]
350    pub fn sofr_simple_average(
351        &self,
352        daily_rates: &[(Date, Decimal)],
353        period_start: Date,
354        period_end: Date,
355    ) -> Decimal {
356        let Some(SOFRConvention::SimpleAverage { lookback_days }) = &self.sofr_convention else {
357            return Decimal::ZERO;
358        };
359
360        let calendar = self.calendar.to_calendar();
361        let mut sum = 0.0_f64;
362        let mut count = 0_i32;
363        let mut current = period_start;
364
365        while current < period_end {
366            let observation_date = calendar.add_business_days(current, -(*lookback_days as i32));
367
368            let rate = daily_rates
369                .iter()
370                .find(|(d, _)| *d == observation_date)
371                .map_or(0.0, |(_, r)| r.to_string().parse::<f64>().unwrap_or(0.0));
372
373            sum += rate;
374            count += 1;
375            current = calendar.add_business_days(current, 1);
376        }
377
378        if count == 0 {
379            return Decimal::ZERO;
380        }
381
382        Decimal::try_from(sum / f64::from(count)).unwrap_or(Decimal::ZERO)
383    }
384
385    /// Returns an identifier string for display.
386    #[must_use]
387    pub fn identifier(&self) -> String {
388        if let Some(cusip) = self.identifiers.cusip() {
389            return cusip.to_string();
390        }
391        if let Some(isin) = self.identifiers.isin() {
392            return isin.to_string();
393        }
394        if let Some(ticker) = self.identifiers.ticker() {
395            return ticker.to_string();
396        }
397        "UNKNOWN".to_string()
398    }
399
400    /// Generates cash flows with projected rates from a forward curve.
401    ///
402    /// This method projects coupon amounts using forward rates from the
403    /// provided curve, applying the spread and any caps/floors.
404    ///
405    /// # Arguments
406    ///
407    /// * `from` - Settlement date
408    /// * `forward_curve` - Curve for projecting forward rates
409    ///
410    /// # Returns
411    ///
412    /// Vector of cash flows with projected coupon amounts.
413    ///
414    /// # Example
415    ///
416    /// ```rust,ignore
417    /// let flows = frn.cash_flows_projected(settlement, &forward_curve);
418    /// for flow in flows {
419    ///     println!("Date: {}, Amount: {}", flow.date(), flow.amount());
420    /// }
421    /// ```
422    pub fn cash_flows_projected<C>(&self, from: Date, forward_curve: &C) -> Vec<BondCashFlow>
423    where
424        C: convex_curves::RateCurveDyn + ?Sized,
425    {
426        if from >= self.maturity {
427            return Vec::new();
428        }
429
430        let Ok(schedule) = self.schedule() else {
431            return Vec::new();
432        };
433
434        let mut flows = Vec::new();
435        let ref_date = forward_curve.reference_date();
436
437        for (start, end) in schedule.unadjusted_periods() {
438            if end <= from {
439                continue;
440            }
441
442            // Calculate time fractions for forward rate lookup
443            let t1 = start.days_between(&ref_date) as f64 / 365.0;
444            let t2 = end.days_between(&ref_date) as f64 / 365.0;
445
446            // Get forward rate from curve
447            let fwd_rate = if t1 < 0.0 && t2 > 0.0 {
448                // Period spans reference date - use rate to end
449                forward_curve.forward_rate(0.0, t2.abs()).unwrap_or(0.0)
450            } else if t1 >= 0.0 && t2 > 0.0 {
451                forward_curve.forward_rate(t1, t2).unwrap_or(0.0)
452            } else {
453                // Historical period - use spot rate
454                forward_curve
455                    .zero_rate(t2.abs(), convex_curves::Compounding::Continuous)
456                    .unwrap_or(0.0)
457            };
458
459            // Apply spread, cap, and floor
460            let projected_rate = Decimal::try_from(fwd_rate).unwrap_or(Decimal::ZERO);
461            let effective_rate = self.effective_rate(projected_rate);
462
463            // Calculate coupon using day count
464            let dc = self.day_count.to_day_count();
465            let year_frac = dc.year_fraction(start, end);
466            let coupon_amount = self.face_value
467                * effective_rate
468                * Decimal::try_from(year_frac).unwrap_or(Decimal::ZERO);
469
470            if end == self.maturity {
471                flows.push(
472                    BondCashFlow::coupon_and_principal(end, coupon_amount, self.face_value)
473                        .with_accrual(start, end)
474                        .with_reference_rate(projected_rate),
475                );
476            } else {
477                flows.push(
478                    BondCashFlow::coupon(end, coupon_amount)
479                        .with_accrual(start, end)
480                        .with_reference_rate(projected_rate),
481                );
482            }
483        }
484
485        flows
486    }
487
488    /// Returns all fixing dates required for coupon calculations.
489    ///
490    /// For overnight compounded rates (SOFR, SONIA), this returns all business
491    /// days in each coupon period. For term rates (EURIBOR, Term SOFR), this
492    /// returns only the fixing dates based on reset lag.
493    ///
494    /// # Arguments
495    ///
496    /// * `from` - Settlement date (only returns dates for future periods)
497    ///
498    /// # Returns
499    ///
500    /// Vector of dates when index fixings are needed.
501    #[must_use]
502    pub fn required_fixing_dates(&self, from: Date) -> Vec<Date> {
503        let Ok(schedule) = self.schedule() else {
504            return Vec::new();
505        };
506
507        let calendar = self.calendar.to_calendar();
508        let mut dates = Vec::new();
509
510        // Determine if this is an overnight compounding index
511        let is_overnight = matches!(
512            self.index,
513            RateIndex::Sofr
514                | RateIndex::Sonia
515                | RateIndex::Estr
516                | RateIndex::Tonar
517                | RateIndex::Saron
518                | RateIndex::Corra
519                | RateIndex::Aonia
520                | RateIndex::Honia
521        );
522
523        for (start, end) in schedule.unadjusted_periods() {
524            if end <= from {
525                continue;
526            }
527
528            if is_overnight {
529                // For overnight rates - need all business days in period
530                if let Some(conv) = &self.sofr_convention {
531                    let lookback = conv.lookback_days().unwrap_or(0);
532                    let obs_shift = conv.is_in_arrears();
533
534                    let mut current = start;
535                    while current < end {
536                        let obs_date = if obs_shift {
537                            calendar.add_business_days(current, -(lookback as i32))
538                        } else {
539                            current
540                        };
541                        dates.push(obs_date);
542                        current = calendar.add_business_days(current, 1);
543                    }
544                }
545            } else {
546                // For term rates - just the fixing date
547                let fixing_date = calendar.add_business_days(start, self.reset_lag);
548                dates.push(fixing_date);
549            }
550        }
551
552        // Remove duplicates and sort
553        dates.sort();
554        dates.dedup();
555        dates
556    }
557
558    /// Calculates accrued interest using rates from a fixing store.
559    ///
560    /// For overnight compounded rates, this compounds the daily rates
561    /// from period start to settlement. For term rates, uses the fixed
562    /// rate for the period.
563    ///
564    /// # Arguments
565    ///
566    /// * `settlement` - Settlement date
567    /// * `store` - Index fixing store with historical rates
568    ///
569    /// # Returns
570    ///
571    /// Accrued interest amount, or zero if fixings are unavailable.
572    #[must_use]
573    pub fn accrued_interest_from_store(
574        &self,
575        settlement: Date,
576        store: &crate::indices::IndexFixingStore,
577    ) -> Decimal {
578        if settlement <= self.issue_date {
579            return Decimal::ZERO;
580        }
581
582        let Some(last_coupon) = self.previous_coupon_date(settlement) else {
583            return Decimal::ZERO;
584        };
585
586        // For overnight compounded rates, we need to compound from period start to settlement
587        if let Some(conv) = &self.sofr_convention {
588            if conv.is_in_arrears() {
589                let calendar = self.calendar.to_calendar();
590                let rate = crate::indices::OvernightCompounding::compounded_rate(
591                    store,
592                    &self.index,
593                    last_coupon,
594                    settlement,
595                    conv,
596                    calendar.as_ref(),
597                );
598
599                if let Some(r) = rate {
600                    let dc = self.day_count.to_day_count();
601                    let year_frac = dc.year_fraction(last_coupon, settlement);
602                    let effective = self.effective_rate(r);
603                    return self.face_value
604                        * effective
605                        * Decimal::try_from(year_frac).unwrap_or(Decimal::ZERO);
606                }
607            }
608        }
609
610        // Fall back to term rate lookup
611        let calendar = self.calendar.to_calendar();
612        let fixing_date = calendar.add_business_days(last_coupon, self.reset_lag);
613
614        if let Some(rate) = store.get_fixing(&self.index, fixing_date) {
615            self.accrued_interest_with_rate(settlement, rate)
616        } else {
617            Decimal::ZERO
618        }
619    }
620
621    /// Generates the payment schedule.
622    fn schedule(&self) -> BondResult<Schedule> {
623        let config = ScheduleConfig::new(self.issue_date, self.maturity, self.frequency)
624            .with_calendar(self.calendar.clone());
625        Schedule::generate(config)
626    }
627}
628
629// ==================== Bond Trait Implementation ====================
630
631impl Bond for FloatingRateNote {
632    fn identifiers(&self) -> &BondIdentifiers {
633        &self.identifiers
634    }
635
636    fn bond_type(&self) -> BondType {
637        match (&self.cap, &self.floor) {
638            (Some(_), Some(_)) => BondType::CollaredFRN,
639            (Some(_), None) => BondType::CappedFRN,
640            (None, Some(_)) => BondType::FlooredFRN,
641            (None, None) => BondType::FloatingRateNote,
642        }
643    }
644
645    fn currency(&self) -> Currency {
646        self.currency
647    }
648
649    fn maturity(&self) -> Option<Date> {
650        Some(self.maturity)
651    }
652
653    fn issue_date(&self) -> Date {
654        self.issue_date
655    }
656
657    fn first_settlement_date(&self) -> Date {
658        let calendar = self.calendar.to_calendar();
659        calendar.add_business_days(self.issue_date, self.settlement_days as i32)
660    }
661
662    fn dated_date(&self) -> Date {
663        self.issue_date
664    }
665
666    fn face_value(&self) -> Decimal {
667        self.face_value
668    }
669
670    fn frequency(&self) -> Frequency {
671        self.frequency
672    }
673
674    fn cash_flows(&self, from: Date) -> Vec<BondCashFlow> {
675        if from >= self.maturity {
676            return Vec::new();
677        }
678
679        let Ok(schedule) = self.schedule() else {
680            return Vec::new();
681        };
682
683        let mut flows = Vec::new();
684
685        for (start, end) in schedule.unadjusted_periods() {
686            if end <= from {
687                continue;
688            }
689
690            // For FRNs, we generate cash flows with estimated rate
691            // The actual amount depends on the reference rate at fixing
692            let rate = self.current_rate.unwrap_or(Decimal::ZERO);
693            let coupon_amount = self.period_coupon(start, end, rate);
694
695            if end == self.maturity {
696                // Final payment includes principal
697                flows.push(
698                    BondCashFlow::coupon_and_principal(end, coupon_amount, self.face_value)
699                        .with_accrual(start, end),
700                );
701            } else {
702                flows.push(BondCashFlow::coupon(end, coupon_amount).with_accrual(start, end));
703            }
704        }
705
706        flows
707    }
708
709    fn next_coupon_date(&self, after: Date) -> Option<Date> {
710        let schedule = self.schedule().ok()?;
711        schedule.dates().iter().find(|&&d| d > after).copied()
712    }
713
714    fn previous_coupon_date(&self, before: Date) -> Option<Date> {
715        let schedule = self.schedule().ok()?;
716        schedule.dates().iter().rfind(|&&d| d < before).copied()
717    }
718
719    fn accrued_interest(&self, settlement: Date) -> Decimal {
720        // Use current rate if set, otherwise assume zero
721        let rate = self.current_rate.unwrap_or(Decimal::ZERO);
722        self.accrued_interest_with_rate(settlement, rate)
723    }
724
725    fn day_count_convention(&self) -> &str {
726        match self.day_count {
727            DayCountConvention::Act360 => "ACT/360",
728            DayCountConvention::Act365Fixed => "ACT/365F",
729            DayCountConvention::Act365Leap => "ACT/365L",
730            DayCountConvention::ActActIsda => "ACT/ACT ISDA",
731            DayCountConvention::ActActIcma => "ACT/ACT ICMA",
732            DayCountConvention::ActActAfb => "ACT/ACT AFB",
733            DayCountConvention::Thirty360US => "30/360 US",
734            DayCountConvention::Thirty360E => "30E/360",
735            DayCountConvention::Thirty360EIsda => "30E/360 ISDA",
736            DayCountConvention::Thirty360German => "30/360 German",
737        }
738    }
739
740    fn calendar(&self) -> &CalendarId {
741        &self.calendar
742    }
743
744    fn redemption_value(&self) -> Decimal {
745        self.face_value
746    }
747}
748
749// ==================== FloatingCouponBond Trait Implementation ====================
750
751impl FloatingCouponBond for FloatingRateNote {
752    fn rate_index(&self) -> &RateIndex {
753        &self.index
754    }
755
756    fn spread_bps(&self) -> Decimal {
757        self.spread_bps
758    }
759
760    fn reset_frequency(&self) -> u32 {
761        self.frequency.periods_per_year()
762    }
763
764    fn lookback_days(&self) -> u32 {
765        self.sofr_convention
766            .as_ref()
767            .and_then(crate::types::SOFRConvention::lookback_days)
768            .unwrap_or(0)
769    }
770
771    fn floor(&self) -> Option<Decimal> {
772        self.floor
773    }
774
775    fn cap(&self) -> Option<Decimal> {
776        self.cap
777    }
778
779    fn next_reset_date(&self, after: Date) -> Option<Date> {
780        self.next_coupon_date(after)
781    }
782
783    fn fixing_date(&self, reset_date: Date) -> Date {
784        let calendar = self.calendar.to_calendar();
785        calendar.add_business_days(reset_date, self.reset_lag)
786    }
787}
788
789// ==================== Builder ====================
790
791/// Builder for `FloatingRateNote`.
792#[derive(Debug, Clone, Default)]
793pub struct FloatingRateNoteBuilder {
794    identifiers: Option<BondIdentifiers>,
795    index: Option<RateIndex>,
796    sofr_convention: Option<SOFRConvention>,
797    spread_bps: Option<Decimal>,
798    maturity: Option<Date>,
799    issue_date: Option<Date>,
800    frequency: Option<Frequency>,
801    day_count: Option<DayCountConvention>,
802    reset_lag: Option<i32>,
803    payment_delay: Option<u32>,
804    cap: Option<Decimal>,
805    floor: Option<Decimal>,
806    settlement_days: Option<u32>,
807    calendar: Option<CalendarId>,
808    currency: Option<Currency>,
809    face_value: Option<Decimal>,
810}
811
812impl FloatingRateNoteBuilder {
813    /// Creates a new builder.
814    #[must_use]
815    pub fn new() -> Self {
816        Self::default()
817    }
818
819    /// Sets the bond identifiers.
820    #[must_use]
821    pub fn identifiers(mut self, ids: BondIdentifiers) -> Self {
822        self.identifiers = Some(ids);
823        self
824    }
825
826    /// Sets the CUSIP identifier with validation.
827    pub fn cusip(mut self, cusip: &str) -> Result<Self, crate::error::IdentifierError> {
828        let cusip = Cusip::new(cusip)?;
829        self.identifiers = Some(BondIdentifiers::new().with_cusip(cusip));
830        Ok(self)
831    }
832
833    /// Sets the CUSIP identifier without validation.
834    #[must_use]
835    pub fn cusip_unchecked(mut self, cusip: &str) -> Self {
836        self.identifiers = Some(BondIdentifiers::new().with_cusip(Cusip::new_unchecked(cusip)));
837        self
838    }
839
840    /// Sets the ISIN identifier.
841    #[must_use]
842    pub fn isin_unchecked(mut self, isin: &str) -> Self {
843        self.identifiers = Some(BondIdentifiers::new().with_isin(Isin::new_unchecked(isin)));
844        self
845    }
846
847    /// Sets the reference rate index.
848    #[must_use]
849    pub fn index(mut self, index: RateIndex) -> Self {
850        self.index = Some(index);
851        self
852    }
853
854    /// Sets the SOFR compounding convention.
855    #[must_use]
856    pub fn sofr_convention(mut self, convention: SOFRConvention) -> Self {
857        self.sofr_convention = Some(convention);
858        self
859    }
860
861    /// Sets the spread in basis points.
862    #[must_use]
863    pub fn spread_bps(mut self, bps: i32) -> Self {
864        self.spread_bps = Some(Decimal::from(bps));
865        self
866    }
867
868    /// Sets the spread as a decimal.
869    #[must_use]
870    pub fn spread_decimal(mut self, spread: Decimal) -> Self {
871        self.spread_bps = Some(spread * Decimal::from(10000));
872        self
873    }
874
875    /// Sets the maturity date.
876    #[must_use]
877    pub fn maturity(mut self, date: Date) -> Self {
878        self.maturity = Some(date);
879        self
880    }
881
882    /// Sets the issue date.
883    #[must_use]
884    pub fn issue_date(mut self, date: Date) -> Self {
885        self.issue_date = Some(date);
886        self
887    }
888
889    /// Sets the payment frequency.
890    #[must_use]
891    pub fn frequency(mut self, freq: Frequency) -> Self {
892        self.frequency = Some(freq);
893        self
894    }
895
896    /// Sets the day count convention.
897    #[must_use]
898    pub fn day_count(mut self, dc: DayCountConvention) -> Self {
899        self.day_count = Some(dc);
900        self
901    }
902
903    /// Sets the reset lag in business days.
904    #[must_use]
905    pub fn reset_lag(mut self, days: i32) -> Self {
906        self.reset_lag = Some(days);
907        self
908    }
909
910    /// Sets the payment delay in business days.
911    #[must_use]
912    pub fn payment_delay(mut self, days: u32) -> Self {
913        self.payment_delay = Some(days);
914        self
915    }
916
917    /// Sets the rate cap.
918    #[must_use]
919    pub fn cap(mut self, rate: Decimal) -> Self {
920        self.cap = Some(rate);
921        self
922    }
923
924    /// Sets the rate floor.
925    #[must_use]
926    pub fn floor(mut self, rate: Decimal) -> Self {
927        self.floor = Some(rate);
928        self
929    }
930
931    /// Sets the settlement days.
932    #[must_use]
933    pub fn settlement_days(mut self, days: u32) -> Self {
934        self.settlement_days = Some(days);
935        self
936    }
937
938    /// Sets the calendar.
939    #[must_use]
940    pub fn calendar(mut self, calendar: CalendarId) -> Self {
941        self.calendar = Some(calendar);
942        self
943    }
944
945    /// Sets the currency.
946    #[must_use]
947    pub fn currency(mut self, currency: Currency) -> Self {
948        self.currency = Some(currency);
949        self
950    }
951
952    /// Sets the face value.
953    #[must_use]
954    pub fn face_value(mut self, value: Decimal) -> Self {
955        self.face_value = Some(value);
956        self
957    }
958
959    // ==================== Market Convention Presets ====================
960
961    /// Applies US Treasury FRN conventions.
962    ///
963    /// - Index: SOFR
964    /// - Convention: Simple average with 2-day lookback
965    /// - Day count: ACT/360
966    /// - Frequency: Quarterly
967    /// - Settlement: T+1
968    #[must_use]
969    pub fn us_treasury_frn(mut self) -> Self {
970        self.index = Some(RateIndex::Sofr);
971        self.sofr_convention = Some(SOFRConvention::SimpleAverage { lookback_days: 2 });
972        self.day_count = Some(DayCountConvention::Act360);
973        self.frequency = Some(Frequency::Quarterly);
974        self.settlement_days = Some(1);
975        self.calendar = Some(CalendarId::us_government());
976        self.currency = Some(Currency::USD);
977        self.reset_lag = Some(-2);
978        self
979    }
980
981    /// Applies corporate SOFR FRN conventions.
982    ///
983    /// - Index: SOFR
984    /// - Convention: Compounded in arrears with 5-day lookback
985    /// - Day count: ACT/360
986    /// - Frequency: Quarterly
987    /// - Settlement: T+2
988    #[must_use]
989    pub fn corporate_sofr(mut self) -> Self {
990        self.index = Some(RateIndex::Sofr);
991        self.sofr_convention = Some(SOFRConvention::arrc_standard());
992        self.day_count = Some(DayCountConvention::Act360);
993        self.frequency = Some(Frequency::Quarterly);
994        self.settlement_days = Some(2);
995        self.calendar = Some(CalendarId::us_government());
996        self.currency = Some(Currency::USD);
997        self.reset_lag = Some(-2);
998        self
999    }
1000
1001    /// Applies UK SONIA FRN conventions.
1002    ///
1003    /// - Index: SONIA
1004    /// - Day count: ACT/365F
1005    /// - Frequency: Quarterly
1006    /// - Settlement: T+1
1007    #[must_use]
1008    pub fn uk_sonia_frn(mut self) -> Self {
1009        self.index = Some(RateIndex::Sonia);
1010        self.day_count = Some(DayCountConvention::Act365Fixed);
1011        self.frequency = Some(Frequency::Quarterly);
1012        self.settlement_days = Some(1);
1013        self.calendar = Some(CalendarId::uk());
1014        self.currency = Some(Currency::GBP);
1015        self.reset_lag = Some(-5);
1016        self
1017    }
1018
1019    /// Applies €STR FRN conventions.
1020    ///
1021    /// - Index: €STR
1022    /// - Day count: ACT/360
1023    /// - Frequency: Quarterly
1024    /// - Settlement: T+2
1025    #[must_use]
1026    pub fn estr_frn(mut self) -> Self {
1027        self.index = Some(RateIndex::Estr);
1028        self.day_count = Some(DayCountConvention::Act360);
1029        self.frequency = Some(Frequency::Quarterly);
1030        self.settlement_days = Some(2);
1031        self.calendar = Some(CalendarId::target2());
1032        self.currency = Some(Currency::EUR);
1033        self.reset_lag = Some(-2);
1034        self
1035    }
1036
1037    /// Applies EURIBOR FRN conventions.
1038    ///
1039    /// - Day count: ACT/360
1040    /// - Frequency: Based on tenor (monthly for 1M, quarterly for 3M, semi-annual for 6M, annual for 12M)
1041    /// - Settlement: T+2
1042    #[must_use]
1043    pub fn euribor_frn(mut self, tenor: crate::types::Tenor) -> Self {
1044        use crate::types::Tenor;
1045        let index = match tenor {
1046            Tenor::M1 => RateIndex::Euribor1M,
1047            Tenor::M3 => RateIndex::Euribor3M,
1048            Tenor::M6 => RateIndex::Euribor6M,
1049            Tenor::M12 | Tenor::Y1 => RateIndex::Euribor12M,
1050            _ => RateIndex::Euribor3M, // Default to 3M
1051        };
1052        let frequency = match tenor {
1053            Tenor::M1 => Frequency::Monthly,
1054            Tenor::M3 => Frequency::Quarterly,
1055            Tenor::M6 => Frequency::SemiAnnual,
1056            Tenor::M12 | Tenor::Y1 => Frequency::Annual,
1057            _ => Frequency::Quarterly,
1058        };
1059        self.index = Some(index);
1060        self.day_count = Some(DayCountConvention::Act360);
1061        self.frequency = Some(frequency);
1062        self.settlement_days = Some(2);
1063        self.calendar = Some(CalendarId::target2());
1064        self.currency = Some(Currency::EUR);
1065        self.reset_lag = Some(-2);
1066        self
1067    }
1068
1069    /// Builds the `FloatingRateNote`.
1070    pub fn build(self) -> BondResult<FloatingRateNote> {
1071        let identifiers = self.identifiers.unwrap_or_default();
1072        let index = self.index.ok_or(BondError::MissingField {
1073            field: "index".to_string(),
1074        })?;
1075        let maturity = self.maturity.ok_or(BondError::MissingField {
1076            field: "maturity".to_string(),
1077        })?;
1078        let issue_date = self.issue_date.ok_or(BondError::MissingField {
1079            field: "issue_date".to_string(),
1080        })?;
1081
1082        if maturity <= issue_date {
1083            return Err(BondError::InvalidSpec {
1084                reason: "Maturity must be after issue date".to_string(),
1085            });
1086        }
1087
1088        Ok(FloatingRateNote {
1089            identifiers,
1090            index,
1091            sofr_convention: self.sofr_convention,
1092            spread_bps: self.spread_bps.unwrap_or(Decimal::ZERO),
1093            maturity,
1094            issue_date,
1095            frequency: self.frequency.unwrap_or(Frequency::Quarterly),
1096            day_count: self.day_count.unwrap_or(DayCountConvention::Act360),
1097            reset_lag: self.reset_lag.unwrap_or(-2),
1098            payment_delay: self.payment_delay.unwrap_or(0),
1099            cap: self.cap,
1100            floor: self.floor,
1101            settlement_days: self.settlement_days.unwrap_or(2),
1102            calendar: self.calendar.unwrap_or_else(CalendarId::weekend_only),
1103            currency: self.currency.unwrap_or(Currency::USD),
1104            face_value: self.face_value.unwrap_or(Decimal::ONE_HUNDRED),
1105            current_rate: None,
1106        })
1107    }
1108}
1109
1110// ==================== Serde Support ====================
1111
1112// Helper functions for DayCountConvention serialization
1113fn day_count_to_string(dc: &DayCountConvention) -> &'static str {
1114    match dc {
1115        DayCountConvention::Act360 => "Act360",
1116        DayCountConvention::Act365Fixed => "Act365Fixed",
1117        DayCountConvention::Act365Leap => "Act365Leap",
1118        DayCountConvention::ActActIsda => "ActActIsda",
1119        DayCountConvention::ActActIcma => "ActActIcma",
1120        DayCountConvention::ActActAfb => "ActActAfb",
1121        DayCountConvention::Thirty360US => "Thirty360US",
1122        DayCountConvention::Thirty360E => "Thirty360E",
1123        DayCountConvention::Thirty360EIsda => "Thirty360EIsda",
1124        DayCountConvention::Thirty360German => "Thirty360German",
1125    }
1126}
1127
1128fn string_to_day_count(s: &str) -> DayCountConvention {
1129    match s {
1130        "Act360" => DayCountConvention::Act360,
1131        "Act365Fixed" => DayCountConvention::Act365Fixed,
1132        "Act365Leap" => DayCountConvention::Act365Leap,
1133        "ActActIsda" => DayCountConvention::ActActIsda,
1134        "ActActIcma" => DayCountConvention::ActActIcma,
1135        "ActActAfb" => DayCountConvention::ActActAfb,
1136        "Thirty360US" => DayCountConvention::Thirty360US,
1137        "Thirty360E" => DayCountConvention::Thirty360E,
1138        "Thirty360EIsda" => DayCountConvention::Thirty360EIsda,
1139        "Thirty360German" => DayCountConvention::Thirty360German,
1140        _ => DayCountConvention::Act360, // Default
1141    }
1142}
1143
1144impl Serialize for FloatingRateNote {
1145    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1146    where
1147        S: serde::Serializer,
1148    {
1149        use serde::ser::SerializeStruct;
1150        let mut state = serializer.serialize_struct("FloatingRateNote", 16)?;
1151        state.serialize_field("identifiers", &self.identifiers)?;
1152        state.serialize_field("index", &self.index)?;
1153        state.serialize_field("sofr_convention", &self.sofr_convention)?;
1154        state.serialize_field("spread_bps", &self.spread_bps)?;
1155        state.serialize_field("maturity", &self.maturity)?;
1156        state.serialize_field("issue_date", &self.issue_date)?;
1157        state.serialize_field("frequency", &self.frequency)?;
1158        state.serialize_field("day_count", &day_count_to_string(&self.day_count))?;
1159        state.serialize_field("reset_lag", &self.reset_lag)?;
1160        state.serialize_field("payment_delay", &self.payment_delay)?;
1161        state.serialize_field("cap", &self.cap)?;
1162        state.serialize_field("floor", &self.floor)?;
1163        state.serialize_field("settlement_days", &self.settlement_days)?;
1164        state.serialize_field("calendar", &self.calendar)?;
1165        state.serialize_field("currency", &self.currency)?;
1166        state.serialize_field("face_value", &self.face_value)?;
1167        state.end()
1168    }
1169}
1170
1171impl<'de> Deserialize<'de> for FloatingRateNote {
1172    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1173    where
1174        D: serde::Deserializer<'de>,
1175    {
1176        #[derive(Deserialize)]
1177        struct FloatingRateNoteData {
1178            identifiers: BondIdentifiers,
1179            index: RateIndex,
1180            sofr_convention: Option<SOFRConvention>,
1181            spread_bps: Decimal,
1182            maturity: Date,
1183            issue_date: Date,
1184            frequency: Frequency,
1185            day_count: String,
1186            reset_lag: i32,
1187            payment_delay: u32,
1188            cap: Option<Decimal>,
1189            floor: Option<Decimal>,
1190            settlement_days: u32,
1191            calendar: CalendarId,
1192            currency: Currency,
1193            face_value: Decimal,
1194        }
1195
1196        let data = FloatingRateNoteData::deserialize(deserializer)?;
1197        Ok(FloatingRateNote {
1198            identifiers: data.identifiers,
1199            index: data.index,
1200            sofr_convention: data.sofr_convention,
1201            spread_bps: data.spread_bps,
1202            maturity: data.maturity,
1203            issue_date: data.issue_date,
1204            frequency: data.frequency,
1205            day_count: string_to_day_count(&data.day_count),
1206            reset_lag: data.reset_lag,
1207            payment_delay: data.payment_delay,
1208            cap: data.cap,
1209            floor: data.floor,
1210            settlement_days: data.settlement_days,
1211            calendar: data.calendar,
1212            currency: data.currency,
1213            face_value: data.face_value,
1214            current_rate: None,
1215        })
1216    }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222    use rust_decimal_macros::dec;
1223
1224    fn date(y: i32, m: u32, d: u32) -> Date {
1225        Date::from_ymd(y, m, d).unwrap()
1226    }
1227
1228    #[test]
1229    fn test_frn_builder() {
1230        let frn = FloatingRateNote::builder()
1231            .cusip_unchecked("912828ZQ7")
1232            .index(RateIndex::Sofr)
1233            .sofr_convention(SOFRConvention::arrc_standard())
1234            .spread_bps(50)
1235            .maturity(date(2026, 7, 31))
1236            .issue_date(date(2024, 7, 31))
1237            .build()
1238            .unwrap();
1239
1240        assert_eq!(frn.spread_bps(), dec!(50));
1241        assert_eq!(frn.spread_decimal(), dec!(0.0050));
1242        assert!(frn.sofr_convention().is_some());
1243    }
1244
1245    #[test]
1246    fn test_us_treasury_frn() {
1247        let frn = FloatingRateNote::builder()
1248            .cusip_unchecked("912828ZQ7")
1249            .spread_bps(15)
1250            .maturity(date(2026, 7, 31))
1251            .issue_date(date(2024, 7, 31))
1252            .us_treasury_frn()
1253            .build()
1254            .unwrap();
1255
1256        assert_eq!(*frn.index(), RateIndex::Sofr);
1257        assert_eq!(frn.day_count(), DayCountConvention::Act360);
1258        assert_eq!(frn.frequency(), Frequency::Quarterly);
1259        assert_eq!(frn.settlement_days(), 1);
1260    }
1261
1262    #[test]
1263    fn test_corporate_sofr_frn() {
1264        let frn = FloatingRateNote::builder()
1265            .cusip_unchecked("TEST12345")
1266            .spread_bps(150)
1267            .maturity(date(2027, 6, 15))
1268            .issue_date(date(2024, 6, 15))
1269            .corporate_sofr()
1270            .build()
1271            .unwrap();
1272
1273        assert_eq!(*frn.index(), RateIndex::Sofr);
1274        assert!(frn.sofr_convention().unwrap().is_in_arrears());
1275        assert_eq!(frn.settlement_days(), 2);
1276    }
1277
1278    #[test]
1279    fn test_effective_rate_with_floor() {
1280        let frn = FloatingRateNote::builder()
1281            .cusip_unchecked("TEST12345")
1282            .index(RateIndex::Sofr)
1283            .spread_bps(50) // 50 bps spread
1284            .floor(dec!(0.01)) // 1% floor
1285            .maturity(date(2026, 6, 15))
1286            .issue_date(date(2024, 6, 15))
1287            .build()
1288            .unwrap();
1289
1290        // Index at 0.3%, spread 0.5% = 0.8% < floor 1%
1291        let effective = frn.effective_rate(dec!(0.003));
1292        assert_eq!(effective, dec!(0.01)); // Floor applied
1293
1294        // Index at 0.6%, spread 0.5% = 1.1% > floor 1%
1295        let effective = frn.effective_rate(dec!(0.006));
1296        assert_eq!(effective, dec!(0.011)); // No floor
1297    }
1298
1299    #[test]
1300    fn test_effective_rate_with_cap() {
1301        let frn = FloatingRateNote::builder()
1302            .cusip_unchecked("TEST12345")
1303            .index(RateIndex::Sofr)
1304            .spread_bps(50)
1305            .cap(dec!(0.08)) // 8% cap
1306            .maturity(date(2026, 6, 15))
1307            .issue_date(date(2024, 6, 15))
1308            .build()
1309            .unwrap();
1310
1311        // Index at 8%, spread 0.5% = 8.5% > cap 8%
1312        let effective = frn.effective_rate(dec!(0.08));
1313        assert_eq!(effective, dec!(0.08)); // Cap applied
1314
1315        // Index at 5%, spread 0.5% = 5.5% < cap 8%
1316        let effective = frn.effective_rate(dec!(0.05));
1317        assert_eq!(effective, dec!(0.055)); // No cap
1318    }
1319
1320    #[test]
1321    fn test_effective_rate_collar() {
1322        let frn = FloatingRateNote::builder()
1323            .cusip_unchecked("TEST12345")
1324            .index(RateIndex::Sofr)
1325            .spread_bps(50)
1326            .floor(dec!(0.02)) // 2% floor
1327            .cap(dec!(0.06)) // 6% cap
1328            .maturity(date(2026, 6, 15))
1329            .issue_date(date(2024, 6, 15))
1330            .build()
1331            .unwrap();
1332
1333        // Below floor
1334        assert_eq!(frn.effective_rate(dec!(0.01)), dec!(0.02));
1335
1336        // In range
1337        assert_eq!(frn.effective_rate(dec!(0.04)), dec!(0.045));
1338
1339        // Above cap
1340        assert_eq!(frn.effective_rate(dec!(0.06)), dec!(0.06));
1341    }
1342
1343    #[test]
1344    fn test_period_coupon() {
1345        let frn = FloatingRateNote::builder()
1346            .cusip_unchecked("TEST12345")
1347            .index(RateIndex::Sofr)
1348            .spread_bps(50)
1349            .face_value(dec!(100))
1350            .maturity(date(2026, 6, 15))
1351            .issue_date(date(2024, 6, 15))
1352            .day_count(DayCountConvention::Act360)
1353            .build()
1354            .unwrap();
1355
1356        // 90-day period at 5% (with 0.5% spread = 5.5%)
1357        let coupon = frn.period_coupon(date(2025, 1, 15), date(2025, 4, 15), dec!(0.05));
1358
1359        // 100 * 0.055 * (90/360) = 1.375
1360        assert!(coupon > dec!(1.37) && coupon < dec!(1.38));
1361    }
1362
1363    #[test]
1364    fn test_bond_type_classification() {
1365        // Plain FRN
1366        let frn = FloatingRateNote::builder()
1367            .cusip_unchecked("TEST12345")
1368            .index(RateIndex::Sofr)
1369            .maturity(date(2026, 6, 15))
1370            .issue_date(date(2024, 6, 15))
1371            .build()
1372            .unwrap();
1373        assert_eq!(frn.bond_type(), BondType::FloatingRateNote);
1374
1375        // Capped FRN
1376        let frn = FloatingRateNote::builder()
1377            .cusip_unchecked("TEST12345")
1378            .index(RateIndex::Sofr)
1379            .cap(dec!(0.08))
1380            .maturity(date(2026, 6, 15))
1381            .issue_date(date(2024, 6, 15))
1382            .build()
1383            .unwrap();
1384        assert_eq!(frn.bond_type(), BondType::CappedFRN);
1385
1386        // Floored FRN
1387        let frn = FloatingRateNote::builder()
1388            .cusip_unchecked("TEST12345")
1389            .index(RateIndex::Sofr)
1390            .floor(dec!(0.02))
1391            .maturity(date(2026, 6, 15))
1392            .issue_date(date(2024, 6, 15))
1393            .build()
1394            .unwrap();
1395        assert_eq!(frn.bond_type(), BondType::FlooredFRN);
1396
1397        // Collared FRN
1398        let frn = FloatingRateNote::builder()
1399            .cusip_unchecked("TEST12345")
1400            .index(RateIndex::Sofr)
1401            .cap(dec!(0.08))
1402            .floor(dec!(0.02))
1403            .maturity(date(2026, 6, 15))
1404            .issue_date(date(2024, 6, 15))
1405            .build()
1406            .unwrap();
1407        assert_eq!(frn.bond_type(), BondType::CollaredFRN);
1408    }
1409
1410    #[test]
1411    fn test_accrued_interest() {
1412        let mut frn = FloatingRateNote::builder()
1413            .cusip_unchecked("TEST12345")
1414            .index(RateIndex::Sofr)
1415            .spread_bps(50)
1416            .face_value(dec!(100))
1417            .frequency(Frequency::Quarterly)
1418            .day_count(DayCountConvention::Act360)
1419            .maturity(date(2026, 6, 15))
1420            .issue_date(date(2024, 6, 15))
1421            .build()
1422            .unwrap();
1423
1424        // Set current rate
1425        frn.set_current_rate(dec!(0.05)); // 5% SOFR
1426
1427        // Settlement mid-period
1428        let settlement = date(2025, 2, 15);
1429        let accrued = frn.accrued_interest(settlement);
1430
1431        // Should have some accrued interest
1432        assert!(accrued > Decimal::ZERO);
1433    }
1434
1435    #[test]
1436    fn test_cash_flows() {
1437        let mut frn = FloatingRateNote::builder()
1438            .cusip_unchecked("TEST12345")
1439            .index(RateIndex::Sofr)
1440            .spread_bps(50)
1441            .frequency(Frequency::Quarterly)
1442            .maturity(date(2025, 6, 15))
1443            .issue_date(date(2024, 6, 15))
1444            .build()
1445            .unwrap();
1446
1447        frn.set_current_rate(dec!(0.05));
1448
1449        let flows = frn.cash_flows(date(2024, 6, 15));
1450
1451        // 1 year quarterly = 4 payments
1452        assert_eq!(flows.len(), 4);
1453
1454        // Last payment includes principal
1455        assert!(flows.last().unwrap().is_principal());
1456    }
1457
1458    #[test]
1459    fn test_sonia_frn() {
1460        let frn = FloatingRateNote::builder()
1461            .cusip_unchecked("GBTEST001")
1462            .spread_bps(25)
1463            .maturity(date(2026, 9, 30))
1464            .issue_date(date(2024, 9, 30))
1465            .uk_sonia_frn()
1466            .build()
1467            .unwrap();
1468
1469        assert_eq!(*frn.index(), RateIndex::Sonia);
1470        assert_eq!(frn.day_count(), DayCountConvention::Act365Fixed);
1471        assert_eq!(frn.currency(), Currency::GBP);
1472    }
1473
1474    #[test]
1475    fn test_estr_frn() {
1476        let frn = FloatingRateNote::builder()
1477            .cusip_unchecked("EUTEST001")
1478            .spread_bps(30)
1479            .maturity(date(2026, 12, 15))
1480            .issue_date(date(2024, 12, 15))
1481            .estr_frn()
1482            .build()
1483            .unwrap();
1484
1485        assert_eq!(*frn.index(), RateIndex::Estr);
1486        assert_eq!(frn.day_count(), DayCountConvention::Act360);
1487        assert_eq!(frn.currency(), Currency::EUR);
1488    }
1489
1490    #[test]
1491    fn test_sofr_convention_display() {
1492        let conv = SOFRConvention::arrc_standard();
1493        let display = format!("{}", conv);
1494        assert!(display.contains("5D lookback"));
1495        assert!(display.contains("observation shift"));
1496    }
1497
1498    #[test]
1499    fn test_missing_required_fields() {
1500        // Missing index
1501        let result = FloatingRateNote::builder()
1502            .cusip_unchecked("TEST12345")
1503            .maturity(date(2026, 6, 15))
1504            .issue_date(date(2024, 6, 15))
1505            .build();
1506        assert!(result.is_err());
1507
1508        // Missing maturity
1509        let result = FloatingRateNote::builder()
1510            .cusip_unchecked("TEST12345")
1511            .index(RateIndex::Sofr)
1512            .issue_date(date(2024, 6, 15))
1513            .build();
1514        assert!(result.is_err());
1515    }
1516
1517    #[test]
1518    fn test_invalid_dates() {
1519        let result = FloatingRateNote::builder()
1520            .cusip_unchecked("TEST12345")
1521            .index(RateIndex::Sofr)
1522            .maturity(date(2024, 6, 15))
1523            .issue_date(date(2026, 6, 15)) // Issue after maturity
1524            .build();
1525        assert!(result.is_err());
1526    }
1527}