Skip to main content

cinder/tui/
math.rs

1//! Tick / lot price conversions.
2
3use std::fmt;
4
5use super::constants::QUOTE_LOT_DECIMALS;
6
7/// Release-safety ceiling for user-entered base-asset size. This is far above
8/// normal UI presets but prevents pathological input from ever reaching an
9/// on-chain lot conversion.
10pub const MAX_UI_ORDER_SIZE_UNITS: f64 = 1_000_000_000.0;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum LotConversionError {
14    NotFinite,
15    NonPositive,
16    AboveUiLimit,
17    BelowMinimumLot,
18    TooLarge,
19}
20
21impl fmt::Display for LotConversionError {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        match self {
24            Self::NotFinite => f.write_str("size must be a finite number"),
25            Self::NonPositive => f.write_str("size must be greater than zero"),
26            Self::AboveUiLimit => f.write_str("size is above the release safety limit"),
27            Self::BelowMinimumLot => f.write_str("size is below one base lot for this market"),
28            Self::TooLarge => f.write_str("size is too large to encode as base lots"),
29        }
30    }
31}
32
33/// Converts on-chain price ticks into an absolute USD float representation.
34#[inline]
35pub fn ticks_to_price(ticks: u64, tick_size: u64, base_lot_decimals: i8) -> f64 {
36    ticks as f64 * tick_size as f64 * 10_f64.powi(base_lot_decimals as i32)
37        / 10_f64.powi(QUOTE_LOT_DECIMALS)
38}
39
40/// Converts on-chain base lots into the absolute token unit size float.
41#[inline]
42pub fn base_lots_to_units(lots: u64, base_lot_decimals: i8) -> f64 {
43    lots as f64 / 10_f64.powi(base_lot_decimals as i32)
44}
45
46/// 24h percentage change from mark vs. prior-day mark; `0.0` when `prev_day` is
47/// zero.
48#[inline]
49pub fn pct_change_24h(mark: f64, prev_day: f64) -> f64 {
50    if prev_day != 0.0 {
51        ((mark - prev_day) / prev_day) * 100.0
52    } else {
53        0.0
54    }
55}
56
57/// Converts a user-entered base-asset size into on-chain base lots with explicit
58/// validation. Values are floored to whole lots, matching the previous
59/// truncate-toward-zero behavior, but invalid and overflowing inputs now return
60/// an error instead of silently saturating through `as u64`.
61#[inline]
62pub fn ui_size_to_num_base_lots(
63    size: f64,
64    base_lot_decimals: i8,
65) -> Result<u64, LotConversionError> {
66    if !size.is_finite() {
67        return Err(LotConversionError::NotFinite);
68    }
69    if size <= 0.0 {
70        return Err(LotConversionError::NonPositive);
71    }
72    if size > MAX_UI_ORDER_SIZE_UNITS {
73        return Err(LotConversionError::AboveUiLimit);
74    }
75
76    let lots = size * 10_f64.powi(base_lot_decimals as i32);
77    if !lots.is_finite() || lots > u64::MAX as f64 {
78        return Err(LotConversionError::TooLarge);
79    }
80    if lots < 1.0 {
81        return Err(LotConversionError::BelowMinimumLot);
82    }
83
84    Ok(lots.floor() as u64)
85}
86
87/// Convert Phoenix HTTP `Decimal` `(value, decimals)` into `num_base_lots` for
88/// a market's `base_lot_decimals` (equivalent to `size * 10^base_lot_decimals`
89/// with exact rationals). When scaling down (`base_lot_decimals <
90/// value_decimals`), truncates toward zero like an exact integer quotient.
91#[inline]
92pub fn phoenix_decimal_to_num_base_lots(
93    value: i64,
94    value_decimals: i8,
95    base_lot_decimals: i8,
96) -> Option<u64> {
97    let abs_val = value.unsigned_abs();
98    let exp = i32::from(base_lot_decimals) - i32::from(value_decimals);
99
100    match exp.cmp(&0) {
101        std::cmp::Ordering::Greater => {
102            let mult = 10_u64.checked_pow(exp as u32)?;
103            abs_val.checked_mul(mult)
104        }
105        std::cmp::Ordering::Less => {
106            let div = 10_u64.checked_pow((-exp) as u32)?;
107            Some(abs_val / div)
108        }
109        std::cmp::Ordering::Equal => Some(abs_val),
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn num_base_lots_from_decimal_sol_two_decimals() {
119        // 3.47 SOL with API decimals=3 → 3.470; base lots with bld=2 → 347 lots
120        assert_eq!(phoenix_decimal_to_num_base_lots(3470, 3, 2), Some(347));
121    }
122
123    #[test]
124    fn num_base_lots_scale_down_truncates() {
125        assert_eq!(phoenix_decimal_to_num_base_lots(1001, 3, 2), Some(100));
126    }
127
128    #[test]
129    fn num_base_lots_scale_up_multiplies() {
130        // value=5 with decimals=0 and bld=3 → 5_000 lots.
131        assert_eq!(phoenix_decimal_to_num_base_lots(5, 0, 3), Some(5_000));
132    }
133
134    #[test]
135    fn num_base_lots_negative_value_uses_absolute() {
136        assert_eq!(phoenix_decimal_to_num_base_lots(-3470, 3, 2), Some(347));
137    }
138
139    #[test]
140    fn num_base_lots_overflow_returns_none() {
141        // 10^20 won't fit in u64.
142        assert_eq!(phoenix_decimal_to_num_base_lots(1, 0, 20), None);
143    }
144
145    #[test]
146    fn ui_size_to_num_base_lots_matches_existing_scale() {
147        assert_eq!(ui_size_to_num_base_lots(3.47, 2), Ok(347));
148        assert_eq!(ui_size_to_num_base_lots(50.0, -1), Ok(5));
149    }
150
151    #[test]
152    fn ui_size_to_num_base_lots_rejects_bad_inputs() {
153        assert_eq!(
154            ui_size_to_num_base_lots(f64::NAN, 2),
155            Err(LotConversionError::NotFinite)
156        );
157        assert_eq!(
158            ui_size_to_num_base_lots(0.0, 2),
159            Err(LotConversionError::NonPositive)
160        );
161        assert_eq!(
162            ui_size_to_num_base_lots(0.001, 2),
163            Err(LotConversionError::BelowMinimumLot)
164        );
165        assert_eq!(
166            ui_size_to_num_base_lots(MAX_UI_ORDER_SIZE_UNITS + 1.0, 2),
167            Err(LotConversionError::AboveUiLimit)
168        );
169    }
170
171    #[test]
172    fn pct_change_zero_prev() {
173        assert_eq!(pct_change_24h(100.0, 0.0), 0.0);
174    }
175
176    #[test]
177    fn pct_change_matches_formula() {
178        assert!((pct_change_24h(110.0, 100.0) - 10.0).abs() < 1e-9);
179    }
180
181    #[test]
182    fn pct_change_handles_negative_direction() {
183        assert!((pct_change_24h(90.0, 100.0) - -10.0).abs() < 1e-9);
184    }
185
186    #[test]
187    fn base_lots_to_units_divides_by_decimal_power() {
188        // 1_000 lots with bld=3 → 1.000 units.
189        assert!((base_lots_to_units(1_000, 3) - 1.0).abs() < 1e-9);
190    }
191
192    #[test]
193    fn base_lots_to_units_handles_negative_decimals() {
194        // bld=-1 → each lot is 10 units; 5 lots → 50.0.
195        assert!((base_lots_to_units(5, -1) - 50.0).abs() < 1e-9);
196    }
197
198    #[test]
199    fn ticks_to_price_matches_known_market() {
200        // QUOTE_LOT_DECIMALS = 6. tick_size=1, bld=0 → ticks → ticks * 1e-6 USD.
201        assert!((ticks_to_price(150_000_000, 1, 0) - 150.0).abs() < 1e-9);
202    }
203
204    #[test]
205    fn ticks_to_price_scales_with_base_lot_decimals() {
206        // bld=2 multiplies by 1e2; 100 ticks * tick=10 * 100 / 1e6 = 0.1
207        assert!((ticks_to_price(100, 10, 2) - 0.1).abs() < 1e-9);
208    }
209}