1//! Entitlement amounts in token base units.
2//!
3//! Rounding follows SCoRE Corporate Actions Standard 4 (ECB AMI-SeCo, 2021): the issue-wide rate is
4//! applied to the holder's total position, with no per-bond intermediate rounding, and the cash amount
5//! is rounded down.
6
7use crate::date::Date;
8use crate::daycount::Thirty360;
9
10pub const BPS_PER_UNIT: u128 = 10_000;
11
12/// How the coupon for one period is computed.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum AccrualBasis {
15 /// Rate / periods per year, the same for every regular period. Kazakh treasury formulas:
16 /// MEOKAM `S = N*C*180/360` (2 per year), MEUKAM `S = N*C` (1 per year). FinancePy `CouponType.FIXED`.
17 FixedPerPeriod { periods_per_year: u32 },
18 /// Rate x 30/360 day count / 360. QuantLib `FixedRateCoupon` with `Thirty360`; FinancePy `CouponType.ACCRUED`.
19 DayCount(Thirty360),
20}
21
22/// Coupon owed on `position` bonds for the period `[start, end)`. `None` on arithmetic overflow.
23pub fn coupon_amount(
24 position: u64,
25 face_units: u64,
26 rate_bps: u32,
27 basis: AccrualBasis,
28 start: Date,
29 end: Date,
30) -> Option<u64> {
31 let gross = u128::from(position)
32 .checked_mul(u128::from(face_units))?
33 .checked_mul(u128::from(rate_bps))?;
34 let amount = match basis {
35 AccrualBasis::FixedPerPeriod { periods_per_year } => {
36 if periods_per_year == 0 {
37 return None;
38 }
39 gross / (BPS_PER_UNIT * u128::from(periods_per_year))
40 }
41 AccrualBasis::DayCount(convention) => {
42 let days = u128::try_from(convention.day_count(start, end)).ok()?;
43 gross.checked_mul(days)? / (BPS_PER_UNIT * 360)
44 }
45 };
46 u64::try_from(amount).ok()
47}
48
49/// Principal owed on `position` bonds at `redemption_price_bps` of face (10_000 = 100%), rounded down.
50pub fn principal_amount(position: u64, face_units: u64, redemption_price_bps: u32) -> Option<u64> {
51 let amount = u128::from(position)
52 .checked_mul(u128::from(face_units))?
53 .checked_mul(u128::from(redemption_price_bps))?
54 / BPS_PER_UNIT;
55 u64::try_from(amount).ok()
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 const USD: u64 = 1_000_000; // 6 decimals, as the test cash token
63
64 fn d(y: i32, m: u8, day: u8) -> Date {
65 Date::new(y, m, day).unwrap()
66 }
67
68 #[test]
69 fn listing_example() {
70 // 10 bonds x 1,000 x 10% / 2 = 500 per coupon; 10,000 principal at maturity.
71 let basis = AccrualBasis::FixedPerPeriod { periods_per_year: 2 };
72 let coupon = coupon_amount(10, 1_000 * USD, 1_000, basis, d(2026, 3, 6), d(2026, 9, 6));
73 assert_eq!(coupon, Some(500 * USD));
74 assert_eq!(principal_amount(10, 1_000 * USD, 10_000), Some(10_000 * USD));
75 }
76
77 #[test]
78 fn rounds_down_on_total_holding() {
79 // 1 bond, face 1.000003, 10% semi-annual: 0.05000015 -> 0.050000 (not rounded per bond first).
80 let basis = AccrualBasis::FixedPerPeriod { periods_per_year: 2 };
81 assert_eq!(coupon_amount(1, 1_000_003, 1_000, basis, d(2026, 3, 6), d(2026, 9, 6)), Some(50_000));
82 assert_eq!(coupon_amount(7, 1_000_003, 1_000, basis, d(2026, 3, 6), d(2026, 9, 6)), Some(350_001));
83 }
84
85 #[test]
86 fn overflow_is_reported() {
87 let basis = AccrualBasis::FixedPerPeriod { periods_per_year: 1 };
88 assert_eq!(coupon_amount(u64::MAX, u64::MAX, u32::MAX, basis, d(2026, 3, 6), d(2027, 3, 6)), None);
89 }
90}
91
92/// Fixed-period coupon with no date inputs; exact integer arithmetic on the total holding.
93pub fn fixed_coupon_amount(position: u64, face_units: u64, rate_bps: u32, periods_per_year: u32) -> Option<u64> {
94 if periods_per_year == 0 { return None; }
95 let gross = u128::from(position).checked_mul(u128::from(face_units))?.checked_mul(u128::from(rate_bps))?;
96 u64::try_from(gross / (BPS_PER_UNIT * u128::from(periods_per_year))).ok()
97}