← Program logic

crates/bond_math/src/amount.rs

Source at revision e5ff2b3cb157, the source of the program deployed on devnet. SHA-256 of this file 7523ed837e96e00dbe47f330b91c0bf0227a2552f79e48692725cf41ca7b5f1c

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}