Skip to main content

basis_points/
lib.rs

1//! `basis-points` provides a validated [`BasisPoints`] newtype for percentage-like
2//! values represented in basis points (`0..=10_000`).
3//!
4//! The crate includes integer conversions and optional
5//! [`rust_decimal::Decimal`](https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html)
6//! conversions behind the `decimal` feature. With `decimal` enabled,
7//! [`BasisPoints`] converts to [`Decimal`] as a unit fraction in `0..=1`
8//! (for example, `2500` bps becomes `0.25`).
9
10use bytemuck::{Pod, Zeroable};
11#[cfg(feature = "codama")]
12use codama::CodamaType;
13#[cfg(feature = "decimal")]
14use rust_decimal::Decimal;
15
16use crate::error::BasisPointsError;
17pub mod error;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Zeroable, Pod)]
20#[cfg_attr(
21    feature = "anchor",
22    derive(
23        anchor_lang::InitSpace,
24        anchor_lang::prelude::AnchorSerialize,
25        anchor_lang::prelude::AnchorDeserialize
26    )
27)]
28#[cfg_attr(feature = "codama", derive(CodamaType))]
29#[repr(C)]
30/// A validated basis-points value in the inclusive range `0..=10_000`.
31pub struct BasisPoints(u16);
32
33impl BasisPoints {
34    /// The maximum valid basis-points value (`10000`, equal to 100%).
35    pub const MAX: u16 = 10000;
36
37    /// Creates a new [`BasisPoints`] after validating the input range.
38    ///
39    /// Returns [`BasisPointsError::InvalidBasisPoints`] if `bps > 10_000`.
40    pub fn new(bps: u16) -> Result<Self, BasisPointsError> {
41        if bps > Self::MAX {
42            return Err(BasisPointsError::InvalidBasisPoints);
43        }
44
45        Ok(Self(bps))
46    }
47
48    /// Checked addition. Returns `None` if the result exceeds `10_000`.
49    pub fn checked_add(self, rhs: Self) -> Option<Self> {
50        let lhs = u16::from(self);
51        let rhs = u16::from(rhs);
52        let result = lhs.checked_add(rhs)?;
53        Self::new(result).ok()
54    }
55
56    /// Checked subtraction. Returns `None` if underflow occurs.
57    pub fn checked_sub(self, rhs: Self) -> Option<Self> {
58        let lhs = u16::from(self);
59        let rhs = u16::from(rhs);
60        let result = lhs.checked_sub(rhs)?;
61        Self::new(result).ok()
62    }
63
64    /// Checked multiplication. Returns `None` if the result exceeds `10_000`.
65    pub fn checked_mul(self, rhs: Self) -> Option<Self> {
66        let lhs = u16::from(self);
67        let rhs = u16::from(rhs);
68        let result = lhs.checked_mul(rhs)?;
69        Self::new(result).ok()
70    }
71
72    /// Checked division. Returns `None` on division-by-zero.
73    pub fn checked_div(self, rhs: Self) -> Option<Self> {
74        let lhs = u16::from(self);
75        let rhs = u16::from(rhs);
76        let result = lhs.checked_div(rhs)?;
77        Self::new(result).ok()
78    }
79}
80
81impl From<BasisPoints> for u16 {
82    fn from(bps: BasisPoints) -> Self {
83        bps.0
84    }
85}
86
87impl From<BasisPoints> for u32 {
88    fn from(bps: BasisPoints) -> Self {
89        u32::from(bps.0)
90    }
91}
92
93impl From<BasisPoints> for u64 {
94    fn from(bps: BasisPoints) -> Self {
95        u64::from(bps.0)
96    }
97}
98
99impl From<BasisPoints> for u128 {
100    fn from(bps: BasisPoints) -> Self {
101        u128::from(bps.0)
102    }
103}
104
105#[cfg(feature = "decimal")]
106impl From<BasisPoints> for Decimal {
107    /// Converts basis points to a proportional rate in `0..=1` (divide by `10_000`).
108    fn from(bps: BasisPoints) -> Self {
109        Decimal::from(bps.0) / Decimal::from(BasisPoints::MAX)
110    }
111}
112
113#[cfg(feature = "decimal")]
114impl TryFrom<Decimal> for BasisPoints {
115    type Error = BasisPointsError;
116
117    /// Converts a proportional rate in `0..=1` to basis points (multiply by `10_000`).
118    ///
119    /// Fractional results truncate toward zero when casting to `u16`.
120    fn try_from(decimal: Decimal) -> Result<Self, Self::Error> {
121        let bps = (decimal * Decimal::from(BasisPoints::MAX))
122            .try_into()
123            .map_err(|_| BasisPointsError::ConversionFailed)?;
124        BasisPoints::new(bps)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_new() {
134        assert_eq!(BasisPoints::new(10000), Ok(BasisPoints(10000)));
135    }
136
137    #[test]
138    fn test_new_error() {
139        assert_eq!(
140            BasisPoints::new(10001),
141            Err(BasisPointsError::InvalidBasisPoints)
142        );
143    }
144
145    #[test]
146    fn test_checked_add() {
147        let lhs = BasisPoints::new(2500).unwrap();
148        let rhs = BasisPoints::new(2500).unwrap();
149
150        assert_eq!(lhs.checked_add(rhs), BasisPoints::new(5000).ok());
151    }
152
153    #[test]
154    fn test_checked_add_overflow() {
155        let lhs = BasisPoints::new(9000).unwrap();
156        let rhs = BasisPoints::new(2000).unwrap();
157
158        assert_eq!(lhs.checked_add(rhs), None);
159    }
160
161    #[test]
162    fn test_checked_sub() {
163        let lhs = BasisPoints::new(2500).unwrap();
164        let rhs = BasisPoints::new(500).unwrap();
165
166        assert_eq!(lhs.checked_sub(rhs), BasisPoints::new(2000).ok());
167    }
168
169    #[test]
170    fn test_checked_sub_underflow() {
171        let lhs = BasisPoints::new(500).unwrap();
172        let rhs = BasisPoints::new(2500).unwrap();
173
174        assert_eq!(lhs.checked_sub(rhs), None);
175    }
176
177    #[test]
178    fn test_checked_mul() {
179        let lhs = BasisPoints::new(100).unwrap();
180        let rhs = BasisPoints::new(50).unwrap();
181
182        assert_eq!(lhs.checked_mul(rhs), BasisPoints::new(5000).ok());
183    }
184
185    #[test]
186    fn test_checked_mul_overflow() {
187        let lhs = BasisPoints::new(200).unwrap();
188        let rhs = BasisPoints::new(100).unwrap();
189
190        assert_eq!(lhs.checked_mul(rhs), None);
191    }
192
193    #[test]
194    fn test_checked_div() {
195        let lhs = BasisPoints::new(5000).unwrap();
196        let rhs = BasisPoints::new(100).unwrap();
197
198        assert_eq!(lhs.checked_div(rhs), BasisPoints::new(50).ok());
199    }
200
201    #[test]
202    fn test_checked_div_by_zero() {
203        let lhs = BasisPoints::new(5000).unwrap();
204        let rhs = BasisPoints::new(0).unwrap();
205
206        assert_eq!(lhs.checked_div(rhs), None);
207    }
208
209    #[cfg(feature = "decimal")]
210    #[test]
211    fn test_from_basis_points_to_decimal() {
212        let bps = BasisPoints::new(1234).unwrap();
213        let decimal: Decimal = bps.into();
214
215        assert_eq!(decimal, Decimal::new(1234, 4));
216    }
217
218    #[cfg(feature = "decimal")]
219    #[test]
220    fn test_from_basis_points_max_to_decimal_one() {
221        let bps = BasisPoints::new(BasisPoints::MAX).unwrap();
222        let decimal: Decimal = bps.into();
223
224        assert_eq!(decimal, Decimal::ONE);
225    }
226
227    #[cfg(feature = "decimal")]
228    #[test]
229    fn test_try_from_decimal_to_basis_points() {
230        let decimal = Decimal::new(4321, 4);
231        let bps = BasisPoints::try_from(decimal);
232
233        assert_eq!(bps, Ok(BasisPoints(4321)));
234    }
235
236    #[cfg(feature = "decimal")]
237    #[test]
238    fn test_round_trip_basis_points_decimal_basis_points() {
239        let original = BasisPoints::new(9999).unwrap();
240        let decimal: Decimal = original.into();
241        let round_trip = BasisPoints::try_from(decimal);
242
243        assert_eq!(round_trip, Ok(original));
244    }
245
246    #[cfg(feature = "decimal")]
247    #[test]
248    fn test_try_from_decimal_fractional_truncates() {
249        let decimal = Decimal::new(12345, 5); // 0.12345
250        let bps = BasisPoints::try_from(decimal);
251
252        assert_eq!(bps, Ok(BasisPoints(1234)));
253    }
254
255    #[cfg(feature = "decimal")]
256    #[test]
257    fn test_try_from_decimal_above_basis_points_max_error() {
258        let decimal = Decimal::new(10001, 4); // 1.0001
259        let bps = BasisPoints::try_from(decimal);
260
261        assert_eq!(bps, Err(BasisPointsError::InvalidBasisPoints));
262    }
263
264    #[cfg(feature = "decimal")]
265    #[test]
266    fn test_try_from_decimal_u16_overflow_error() {
267        let decimal = Decimal::new(7, 0); // 700% when scaled
268        let bps = BasisPoints::try_from(decimal);
269
270        assert_eq!(bps, Err(BasisPointsError::ConversionFailed));
271    }
272}