Skip to main content

polyester/types/
money.rs

1//! Money scalar types for write/read surfaces.
2
3use crate::codecs::scalars::{
4    format_price_ticks, format_qty_scaled, parse_price_ticks, parse_price_ticks_str,
5    parse_qty_scaled, parse_qty_scaled_str,
6};
7use crate::errors::{Error, Result};
8use rust_decimal::Decimal;
9
10/// Quantity domain — mixing domains is a validation error.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
12pub enum QuantityDomain {
13    #[default]
14    OrderBase,
15    Asset,
16    LedgerE18,
17}
18
19/// Distinct newtype for protocol price ticks (compile-time mix-up prevention).
20///
21/// Construction is crate-private so invalid negative ticks cannot bypass
22/// [`Price::from_ticks`].
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct PriceTicks(i64);
25
26impl PriceTicks {
27    pub(crate) const fn new(ticks: i64) -> Self {
28        Self(ticks)
29    }
30    pub const fn get(self) -> i64 {
31        self.0
32    }
33}
34
35/// Distinct newtype for order/trigger qty_scaled.
36///
37/// Construction is crate-private so invalid negative values cannot bypass
38/// [`Quantity::from_scaled`].
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub struct QtyScaled(i64);
41
42impl QtyScaled {
43    pub(crate) const fn new(scaled: i64) -> Self {
44        Self(scaled)
45    }
46    pub const fn get(self) -> i64 {
47        self.0
48    }
49}
50
51/// Resolved protocol price units (protobuf `price_ticks`, fixed 1e6).
52///
53/// Fields are private so metadata cannot be changed independently of the
54/// validated ticks. Use [`Price::symbol`] to inspect the immutable metadata.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Price {
57    ticks: PriceTicks,
58    symbol: Option<String>,
59}
60
61impl Price {
62    pub fn from_ticks(ticks: i64, symbol: Option<String>) -> Result<Self> {
63        if ticks < 0 {
64            return Err(Error::validation("ticks must be non-negative"));
65        }
66        Ok(Self {
67            ticks: PriceTicks::new(ticks),
68            symbol,
69        })
70    }
71
72    pub fn from_decimal_str(raw: &str, symbol: Option<String>) -> Result<Self> {
73        let ticks = parse_price_ticks_str(raw, "price")?;
74        Self::from_ticks(ticks, symbol)
75    }
76
77    pub fn from_decimal(raw: Decimal, symbol: Option<String>) -> Result<Self> {
78        let ticks = parse_price_ticks(raw, "price")?;
79        Self::from_ticks(ticks, symbol)
80    }
81
82    pub fn as_ticks(&self) -> i64 {
83        self.ticks.get()
84    }
85
86    pub fn symbol(&self) -> Option<&str> {
87        self.symbol.as_deref()
88    }
89
90    pub fn as_decimal(&self) -> Decimal {
91        // Price ticks always use the protocol's fixed 1e6 scale, so this
92        // conversion is exact and cannot silently substitute Decimal::ZERO.
93        Decimal::new(self.ticks.get(), 6)
94    }
95
96    pub fn format(&self) -> String {
97        format_price_ticks(self.ticks.get())
98    }
99
100    pub fn compatible_with(&self, symbol: Option<&str>) -> Result<()> {
101        if let (Some(a), Some(b)) = (self.symbol(), symbol)
102            && a != b
103        {
104            return Err(Error::validation(format!(
105                "price symbol mismatch: value is for {a}, destination is {b}"
106            )));
107        }
108        Ok(())
109    }
110}
111
112/// Resolved order/trigger base quantity (protobuf `qty_scaled`).
113///
114/// Fields are private so metadata cannot be changed independently of the
115/// validated scaled value. Use the immutable metadata getters to inspect it.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct Quantity {
118    scaled: QtyScaled,
119    scale: Option<u32>,
120    domain: QuantityDomain,
121    symbol: Option<String>,
122    symbol_id: Option<u32>,
123}
124
125impl Quantity {
126    pub fn from_scaled(
127        scaled: i64,
128        scale: Option<u32>,
129        domain: QuantityDomain,
130        symbol: Option<String>,
131        symbol_id: Option<u32>,
132    ) -> Result<Self> {
133        if scaled < 0 {
134            return Err(Error::validation("scaled must be non-negative"));
135        }
136        if let Some(scale) = scale {
137            crate::codecs::scalars::validate_protocol_scale(scale)?;
138        }
139        Ok(Self {
140            scaled: QtyScaled::new(scaled),
141            scale,
142            domain,
143            symbol,
144            symbol_id,
145        })
146    }
147
148    pub fn from_decimal_str(
149        raw: &str,
150        scale: u32,
151        symbol: Option<String>,
152        symbol_id: Option<u32>,
153    ) -> Result<Self> {
154        let scaled = parse_qty_scaled_str(raw, scale, "qty")?;
155        Self::from_scaled(
156            scaled,
157            Some(scale),
158            QuantityDomain::OrderBase,
159            symbol,
160            symbol_id,
161        )
162    }
163
164    pub fn from_decimal(
165        raw: Decimal,
166        scale: u32,
167        symbol: Option<String>,
168        symbol_id: Option<u32>,
169    ) -> Result<Self> {
170        let scaled = parse_qty_scaled(raw, scale, "qty")?;
171        Self::from_scaled(
172            scaled,
173            Some(scale),
174            QuantityDomain::OrderBase,
175            symbol,
176            symbol_id,
177        )
178    }
179
180    pub fn as_scaled(&self) -> i64 {
181        self.scaled.get()
182    }
183
184    pub fn scale(&self) -> Option<u32> {
185        self.scale
186    }
187
188    pub fn domain(&self) -> QuantityDomain {
189        self.domain
190    }
191
192    pub fn symbol(&self) -> Option<&str> {
193        self.symbol.as_deref()
194    }
195
196    pub fn symbol_id(&self) -> Option<u32> {
197        self.symbol_id
198    }
199
200    pub fn format(&self, scale: Option<u32>) -> Result<String> {
201        let resolved = scale.or(self.scale()).ok_or_else(|| {
202            Error::validation("format requires a known scale; pass scale= or construct with scale=")
203        })?;
204        format_qty_scaled(self.scaled.get(), resolved)
205    }
206
207    pub fn compatible_with(
208        &self,
209        domain: QuantityDomain,
210        scale: Option<u32>,
211        symbol: Option<&str>,
212        symbol_id: Option<u32>,
213    ) -> Result<()> {
214        if self.domain() != domain {
215            return Err(Error::validation(format!(
216                "quantity domain mismatch: value is {:?}, destination is {domain:?}",
217                self.domain()
218            )));
219        }
220        if let (Some(a), Some(b)) = (self.scale(), scale)
221            && a != b
222        {
223            return Err(Error::validation(format!(
224                "quantity scale mismatch: value scale is {a}, destination is {b}"
225            )));
226        }
227        if let (Some(a), Some(b)) = (self.symbol(), symbol)
228            && a != b
229        {
230            return Err(Error::validation(format!(
231                "quantity symbol mismatch: value is for {a}, destination is {b}"
232            )));
233        }
234        if let (Some(a), Some(b)) = (self.symbol_id(), symbol_id)
235            && a != b
236        {
237            return Err(Error::validation(format!(
238                "quantity symbol_id mismatch: value is for {a}, destination is {b}"
239            )));
240        }
241        Ok(())
242    }
243}
244
245/// Resolved asset/ledger amount.
246///
247/// Fields are private so invariants from [`AssetAmount::from_scaled`] cannot be
248/// bypassed via struct literals.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct AssetAmount {
251    scaled: i128,
252    scale: Option<u32>,
253    domain: QuantityDomain,
254    asset_id: Option<u32>,
255}
256
257impl AssetAmount {
258    pub fn from_scaled(
259        scaled: i128,
260        scale: Option<u32>,
261        domain: QuantityDomain,
262        asset_id: Option<u32>,
263    ) -> Result<Self> {
264        if scaled < 0 {
265            return Err(Error::validation("scaled must be non-negative"));
266        }
267        if !matches!(domain, QuantityDomain::Asset | QuantityDomain::LedgerE18) {
268            return Err(Error::validation(
269                "AssetAmount domain must be asset or ledger_e18",
270            ));
271        }
272        if let Some(scale) = scale {
273            crate::codecs::scalars::validate_protocol_scale(scale)?;
274        }
275        if domain != QuantityDomain::LedgerE18 && scaled > crate::codecs::scalars::INT64_MAX {
276            return Err(Error::validation("scaled exceeds int64 range"));
277        }
278        Ok(Self {
279            scaled,
280            scale,
281            domain,
282            asset_id,
283        })
284    }
285
286    pub fn from_decimal_str(
287        raw: &str,
288        scale: u32,
289        domain: QuantityDomain,
290        asset_id: Option<u32>,
291    ) -> Result<Self> {
292        use crate::codecs::scalars::decimal_to_scaled_str;
293        let scaled = decimal_to_scaled_str(raw, scale, "amount")?;
294        Self::from_scaled(scaled, Some(scale), domain, asset_id)
295    }
296
297    pub fn from_decimal(
298        raw: Decimal,
299        scale: u32,
300        domain: QuantityDomain,
301        asset_id: Option<u32>,
302    ) -> Result<Self> {
303        use crate::codecs::scalars::decimal_to_scaled;
304        let scaled = decimal_to_scaled(raw, scale, "amount")?;
305        Self::from_scaled(scaled, Some(scale), domain, asset_id)
306    }
307
308    pub fn as_i64(&self) -> Result<i64> {
309        i64::try_from(self.scaled).map_err(|_| Error::validation("amount exceeds int64 range"))
310    }
311
312    pub fn as_scaled(&self) -> i128 {
313        self.scaled
314    }
315
316    pub fn scale(&self) -> Option<u32> {
317        self.scale
318    }
319
320    pub fn domain(&self) -> QuantityDomain {
321        self.domain
322    }
323
324    pub fn asset_id(&self) -> Option<u32> {
325        self.asset_id
326    }
327
328    pub fn compatible_with(
329        &self,
330        domain: QuantityDomain,
331        scale: Option<u32>,
332        asset_id: Option<u32>,
333    ) -> Result<()> {
334        if self.domain != domain {
335            return Err(Error::validation(format!(
336                "amount domain mismatch: value is {:?}, destination is {domain:?}",
337                self.domain
338            )));
339        }
340        if let (Some(a), Some(b)) = (self.scale, scale)
341            && a != b
342        {
343            return Err(Error::validation(format!(
344                "amount scale mismatch: value scale is {a}, destination is {b}"
345            )));
346        }
347        if let (Some(a), Some(b)) = (self.asset_id, asset_id)
348            && a != b
349        {
350            return Err(Error::validation(format!(
351                "amount asset_id mismatch: value is for {a}, destination is {b}"
352            )));
353        }
354        Ok(())
355    }
356}
357
358/// Resolve price for write paths.
359pub fn resolve_price_ticks(value: &Price, symbol: Option<&str>) -> Result<i64> {
360    value.compatible_with(symbol)?;
361    let ticks = value.as_ticks();
362    if ticks < 0 {
363        return Err(Error::validation("ticks must be non-negative"));
364    }
365    Ok(ticks)
366}
367
368/// Resolve qty for write paths. Requires a positive scaled value.
369pub fn resolve_qty_scaled(
370    value: &Quantity,
371    scale: u32,
372    symbol: Option<&str>,
373    symbol_id: Option<u32>,
374) -> Result<i64> {
375    value.compatible_with(QuantityDomain::OrderBase, Some(scale), symbol, symbol_id)?;
376    let scaled = value.as_scaled();
377    if scaled <= 0 {
378        return Err(Error::validation("qty must be positive"));
379    }
380    Ok(scaled)
381}
382
383/// Resolve asset/ledger amount for transfer/withdraw write paths.
384pub fn resolve_asset_amount_scaled(
385    value: &AssetAmount,
386    scale: u32,
387    domain: QuantityDomain,
388    asset_id: Option<u32>,
389) -> Result<i128> {
390    resolve_asset_amount_scaled_with_input_scale(value, None, scale, domain, asset_id)
391}
392
393/// Resolve an asset amount to `target_scale`, using `input_scale` only when the
394/// value does not already carry a scale.
395pub(crate) fn resolve_asset_amount_scaled_with_input_scale(
396    value: &AssetAmount,
397    input_scale: Option<u32>,
398    target_scale: u32,
399    domain: QuantityDomain,
400    asset_id: Option<u32>,
401) -> Result<i128> {
402    crate::codecs::scalars::validate_protocol_scale(target_scale)?;
403    if let Some(scale) = input_scale {
404        crate::codecs::scalars::validate_protocol_scale(scale)?;
405    }
406    value.compatible_with(domain, None, asset_id)?;
407    if let (Some(value_scale), Some(input_scale)) = (value.scale, input_scale)
408        && value_scale != input_scale
409    {
410        return Err(Error::validation(format!(
411            "amount scale mismatch: value scale is {value_scale}, input scale is {input_scale}"
412        )));
413    }
414    if value.scaled <= 0 {
415        return Err(Error::validation("amount must be positive"));
416    }
417    let source_scale = value.scale.or(input_scale).unwrap_or(target_scale);
418    let scaled = if source_scale < target_scale {
419        let factor = 10_i128
420            .checked_pow(target_scale - source_scale)
421            .ok_or_else(|| Error::validation("amount scale conversion overflow"))?;
422        value
423            .scaled
424            .checked_mul(factor)
425            .ok_or_else(|| Error::validation("amount scale conversion overflow"))?
426    } else if source_scale > target_scale {
427        let divisor = 10_i128
428            .checked_pow(source_scale - target_scale)
429            .ok_or_else(|| Error::validation("amount scale conversion overflow"))?;
430        if value.scaled % divisor != 0 {
431            return Err(Error::validation(format!(
432                "amount cannot be represented exactly at scale {target_scale}"
433            )));
434        }
435        value.scaled / divisor
436    } else {
437        value.scaled
438    };
439    if domain != QuantityDomain::LedgerE18 && scaled > crate::codecs::scalars::INT64_MAX {
440        return Err(Error::validation("amount exceeds int64 range"));
441    }
442    Ok(scaled)
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn price_from_ticks_rejects_negative() {
451        assert!(Price::from_ticks(-1, None).is_err());
452    }
453
454    #[test]
455    fn price_as_decimal_is_exact_at_the_maximum_tick_value() {
456        let price = Price::from_ticks(i64::MAX, None).unwrap();
457        assert_eq!(price.as_decimal(), Decimal::new(i64::MAX, 6));
458    }
459
460    #[test]
461    fn quantity_from_scaled_rejects_negative() {
462        assert!(Quantity::from_scaled(-1, Some(8), QuantityDomain::OrderBase, None, None).is_err());
463    }
464
465    #[test]
466    fn resolve_paths_round_trip() {
467        let price = Price::from_decimal_str("42.5", Some("BTC-USDT".into())).unwrap();
468        assert_eq!(
469            resolve_price_ticks(&price, Some("BTC-USDT")).unwrap(),
470            42_500_000
471        );
472        let qty = Quantity::from_decimal_str("1.25", 8, Some("BTC-USDT".into()), Some(1)).unwrap();
473        assert_eq!(
474            resolve_qty_scaled(&qty, 8, Some("BTC-USDT"), Some(1)).unwrap(),
475            125_000_000
476        );
477    }
478
479    #[test]
480    fn price_and_quantity_metadata_getters_preserve_compatibility() {
481        let price = Price::from_ticks(42_500_000, Some("BTC-USDT".into())).unwrap();
482        assert_eq!(price.symbol(), Some("BTC-USDT"));
483        assert_eq!(price.as_ticks(), 42_500_000);
484        assert_eq!(price.format(), "42.5");
485        assert_eq!(price.clone(), price);
486        assert!(format!("{price:?}").contains("BTC-USDT"));
487
488        let qty = Quantity::from_scaled(
489            125_000_000,
490            Some(8),
491            QuantityDomain::OrderBase,
492            Some("BTC-USDT".into()),
493            Some(7),
494        )
495        .unwrap();
496        assert_eq!(qty.scale(), Some(8));
497        assert_eq!(qty.domain(), QuantityDomain::OrderBase);
498        assert_eq!(qty.symbol(), Some("BTC-USDT"));
499        assert_eq!(qty.symbol_id(), Some(7));
500        assert_eq!(qty.as_scaled(), 125_000_000);
501        assert_eq!(qty.format(None).unwrap(), "1.25");
502        assert_eq!(qty.clone(), qty);
503        assert!(format!("{qty:?}").contains("BTC-USDT"));
504        assert!(
505            qty.compatible_with(
506                QuantityDomain::OrderBase,
507                Some(8),
508                Some("BTC-USDT"),
509                Some(7)
510            )
511            .is_ok()
512        );
513    }
514
515    #[test]
516    fn resolve_qty_rejects_zero() {
517        let qty = Quantity::from_scaled(0, Some(8), QuantityDomain::OrderBase, None, None).unwrap();
518        assert!(resolve_qty_scaled(&qty, 8, None, None).is_err());
519    }
520
521    #[test]
522    fn asset_amount_dual_path() {
523        let from_dec =
524            AssetAmount::from_decimal_str("0.5", 18, QuantityDomain::LedgerE18, Some(7)).unwrap();
525        let from_scaled = AssetAmount::from_scaled(
526            500_000_000_000_000_000,
527            Some(18),
528            QuantityDomain::LedgerE18,
529            Some(7),
530        )
531        .unwrap();
532        assert_eq!(
533            resolve_asset_amount_scaled(&from_dec, 18, QuantityDomain::LedgerE18, Some(7)).unwrap(),
534            resolve_asset_amount_scaled(&from_scaled, 18, QuantityDomain::LedgerE18, Some(7))
535                .unwrap()
536        );
537    }
538
539    #[test]
540    fn asset_amount_rejects_domain_scale_and_asset_mismatch() {
541        let amount =
542            AssetAmount::from_scaled(100, Some(18), QuantityDomain::LedgerE18, Some(7)).unwrap();
543        assert!(resolve_asset_amount_scaled(&amount, 18, QuantityDomain::Asset, Some(7)).is_err());
544        assert!(
545            resolve_asset_amount_scaled(&amount, 6, QuantityDomain::LedgerE18, Some(7)).is_err()
546        );
547        assert!(
548            resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, Some(8)).is_err()
549        );
550    }
551
552    #[test]
553    fn asset_amount_rescales_exactly_without_rounding() {
554        let asset_precision =
555            AssetAmount::from_scaled(125, Some(2), QuantityDomain::LedgerE18, Some(7)).unwrap();
556        assert_eq!(
557            resolve_asset_amount_scaled(&asset_precision, 18, QuantityDomain::LedgerE18, Some(7))
558                .unwrap(),
559            1_250_000_000_000_000_000
560        );
561
562        let exact_downscale = AssetAmount::from_scaled(
563            1_250_000_000_000_000_000,
564            Some(18),
565            QuantityDomain::LedgerE18,
566            Some(7),
567        )
568        .unwrap();
569        assert_eq!(
570            resolve_asset_amount_scaled(&exact_downscale, 2, QuantityDomain::LedgerE18, Some(7))
571                .unwrap(),
572            125
573        );
574
575        let inexact_downscale =
576            AssetAmount::from_scaled(126, Some(3), QuantityDomain::LedgerE18, Some(7)).unwrap();
577        assert!(
578            resolve_asset_amount_scaled(&inexact_downscale, 2, QuantityDomain::LedgerE18, Some(7))
579                .is_err()
580        );
581    }
582
583    #[test]
584    fn asset_amount_rescale_rejects_overflow() {
585        let amount =
586            AssetAmount::from_scaled(i128::MAX, Some(17), QuantityDomain::LedgerE18, None).unwrap();
587        assert!(resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, None).is_err());
588    }
589
590    #[test]
591    fn quantity_reuse_rejects_scale_symbol_and_symbol_id_mismatch() {
592        let qty = Quantity::from_scaled(
593            100,
594            Some(8),
595            QuantityDomain::OrderBase,
596            Some("BTC-USDT".into()),
597            Some(7),
598        )
599        .unwrap();
600        assert!(resolve_qty_scaled(&qty, 6, Some("BTC-USDT"), Some(7)).is_err());
601        assert!(resolve_qty_scaled(&qty, 8, Some("ETH-USDT"), Some(7)).is_err());
602        assert!(resolve_qty_scaled(&qty, 8, Some("BTC-USDT"), Some(8)).is_err());
603    }
604
605    #[test]
606    fn asset_amount_requires_positive_value_at_resolve() {
607        let amount =
608            AssetAmount::from_scaled(0, Some(18), QuantityDomain::LedgerE18, Some(7)).unwrap();
609        assert!(
610            resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, Some(7)).is_err()
611        );
612    }
613
614    #[test]
615    fn asset_amount_as_i64_rejects_overflow_not_truncate() {
616        let amount = AssetAmount::from_scaled(
617            i128::from(u64::MAX) + 1,
618            Some(18),
619            QuantityDomain::LedgerE18,
620            None,
621        )
622        .unwrap();
623        assert!(amount.as_i64().is_err());
624    }
625}