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