1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
12pub enum QuantityDomain {
13 #[default]
14 OrderBase,
15 OrderQuote,
16 Asset,
17 LedgerE18,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub struct PriceTicks(i64);
26
27impl PriceTicks {
28 pub(crate) const fn new(ticks: i64) -> Self {
29 Self(ticks)
30 }
31 pub const fn get(self) -> i64 {
32 self.0
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
41pub struct QtyScaled(i64);
42
43impl QtyScaled {
44 pub(crate) const fn new(scaled: i64) -> Self {
45 Self(scaled)
46 }
47 pub const fn get(self) -> i64 {
48 self.0
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Price {
58 ticks: PriceTicks,
59 symbol: Option<String>,
60}
61
62impl Price {
63 pub fn from_ticks(ticks: i64, symbol: Option<String>) -> Result<Self> {
64 if ticks < 0 {
65 return Err(Error::validation("ticks must be non-negative"));
66 }
67 Ok(Self {
68 ticks: PriceTicks::new(ticks),
69 symbol,
70 })
71 }
72
73 pub fn from_decimal_str(raw: &str, symbol: Option<String>) -> Result<Self> {
74 let ticks = parse_price_ticks_str(raw, "price")?;
75 Self::from_ticks(ticks, symbol)
76 }
77
78 pub fn from_decimal(raw: Decimal, symbol: Option<String>) -> Result<Self> {
79 let ticks = parse_price_ticks(raw, "price")?;
80 Self::from_ticks(ticks, symbol)
81 }
82
83 pub fn as_ticks(&self) -> i64 {
84 self.ticks.get()
85 }
86
87 pub fn symbol(&self) -> Option<&str> {
88 self.symbol.as_deref()
89 }
90
91 pub fn as_decimal(&self) -> Decimal {
92 Decimal::new(self.ticks.get(), 6)
95 }
96
97 pub fn format(&self) -> String {
98 format_price_ticks(self.ticks.get())
99 }
100
101 pub fn compatible_with(&self, symbol: Option<&str>) -> Result<()> {
102 if let (Some(a), Some(b)) = (self.symbol(), symbol)
103 && a != b
104 {
105 return Err(Error::validation(format!(
106 "price symbol mismatch: value is for {a}, destination is {b}"
107 )));
108 }
109 Ok(())
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Quantity {
119 scaled: QtyScaled,
120 scale: Option<u32>,
121 domain: QuantityDomain,
122 symbol: Option<String>,
123 symbol_id: Option<u32>,
124}
125
126impl Quantity {
127 pub fn from_scaled(
128 scaled: i64,
129 scale: Option<u32>,
130 domain: QuantityDomain,
131 symbol: Option<String>,
132 symbol_id: Option<u32>,
133 ) -> Result<Self> {
134 if scaled < 0 {
135 return Err(Error::validation("scaled must be non-negative"));
136 }
137 if !matches!(
138 domain,
139 QuantityDomain::OrderBase | QuantityDomain::OrderQuote
140 ) {
141 return Err(Error::validation(
142 "Quantity domain must be order_base or order_quote",
143 ));
144 }
145 if let Some(scale) = scale {
146 crate::codecs::scalars::validate_protocol_scale(scale)?;
147 }
148 Ok(Self {
149 scaled: QtyScaled::new(scaled),
150 scale,
151 domain,
152 symbol,
153 symbol_id,
154 })
155 }
156
157 pub fn from_decimal_str(
158 raw: &str,
159 scale: u32,
160 symbol: Option<String>,
161 symbol_id: Option<u32>,
162 ) -> Result<Self> {
163 let scaled = parse_qty_scaled_str(raw, scale, "qty")?;
164 Self::from_scaled(
165 scaled,
166 Some(scale),
167 QuantityDomain::OrderBase,
168 symbol,
169 symbol_id,
170 )
171 }
172
173 pub fn from_decimal(
174 raw: Decimal,
175 scale: u32,
176 symbol: Option<String>,
177 symbol_id: Option<u32>,
178 ) -> Result<Self> {
179 let scaled = parse_qty_scaled(raw, scale, "qty")?;
180 Self::from_scaled(
181 scaled,
182 Some(scale),
183 QuantityDomain::OrderBase,
184 symbol,
185 symbol_id,
186 )
187 }
188
189 pub fn from_quote_scaled(
194 scaled: i64,
195 scale: u32,
196 symbol: Option<String>,
197 symbol_id: Option<u32>,
198 ) -> Result<Self> {
199 Self::from_scaled(
200 scaled,
201 Some(scale),
202 QuantityDomain::OrderQuote,
203 symbol,
204 symbol_id,
205 )
206 }
207
208 pub fn from_quote_decimal_str(
209 raw: &str,
210 scale: u32,
211 symbol: Option<String>,
212 symbol_id: Option<u32>,
213 ) -> Result<Self> {
214 let scaled = parse_qty_scaled_str(raw, scale, "quote amount")?;
215 Self::from_quote_scaled(scaled, scale, symbol, symbol_id)
216 }
217
218 pub fn from_quote_decimal(
219 raw: Decimal,
220 scale: u32,
221 symbol: Option<String>,
222 symbol_id: Option<u32>,
223 ) -> Result<Self> {
224 let scaled = parse_qty_scaled(raw, scale, "quote amount")?;
225 Self::from_quote_scaled(scaled, scale, symbol, symbol_id)
226 }
227
228 pub fn as_scaled(&self) -> i64 {
229 self.scaled.get()
230 }
231
232 pub fn scale(&self) -> Option<u32> {
233 self.scale
234 }
235
236 pub fn domain(&self) -> QuantityDomain {
237 self.domain
238 }
239
240 pub fn symbol(&self) -> Option<&str> {
241 self.symbol.as_deref()
242 }
243
244 pub fn symbol_id(&self) -> Option<u32> {
245 self.symbol_id
246 }
247
248 pub fn format(&self, scale: Option<u32>) -> Result<String> {
249 let resolved = scale.or(self.scale()).ok_or_else(|| {
250 Error::validation("format requires a known scale; pass scale= or construct with scale=")
251 })?;
252 format_qty_scaled(self.scaled.get(), resolved)
253 }
254
255 pub fn compatible_with(
256 &self,
257 domain: QuantityDomain,
258 scale: Option<u32>,
259 symbol: Option<&str>,
260 symbol_id: Option<u32>,
261 ) -> Result<()> {
262 if self.domain() != domain {
263 return Err(Error::validation(format!(
264 "quantity domain mismatch: value is {:?}, destination is {domain:?}",
265 self.domain()
266 )));
267 }
268 if let (Some(a), Some(b)) = (self.scale(), scale)
269 && a != b
270 {
271 return Err(Error::validation(format!(
272 "quantity scale mismatch: value scale is {a}, destination is {b}"
273 )));
274 }
275 if let (Some(a), Some(b)) = (self.symbol(), symbol)
276 && a != b
277 {
278 return Err(Error::validation(format!(
279 "quantity symbol mismatch: value is for {a}, destination is {b}"
280 )));
281 }
282 if let (Some(a), Some(b)) = (self.symbol_id(), symbol_id)
283 && a != b
284 {
285 return Err(Error::validation(format!(
286 "quantity symbol_id mismatch: value is for {a}, destination is {b}"
287 )));
288 }
289 Ok(())
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct AssetAmount {
299 scaled: i128,
300 scale: Option<u32>,
301 domain: QuantityDomain,
302 asset_id: Option<u32>,
303}
304
305impl AssetAmount {
306 pub fn from_scaled(
307 scaled: i128,
308 scale: Option<u32>,
309 domain: QuantityDomain,
310 asset_id: Option<u32>,
311 ) -> Result<Self> {
312 if scaled < 0 {
313 return Err(Error::validation("scaled must be non-negative"));
314 }
315 if !matches!(domain, QuantityDomain::Asset | QuantityDomain::LedgerE18) {
316 return Err(Error::validation(
317 "AssetAmount domain must be asset or ledger_e18",
318 ));
319 }
320 if let Some(scale) = scale {
321 crate::codecs::scalars::validate_protocol_scale(scale)?;
322 }
323 if domain != QuantityDomain::LedgerE18 && scaled > crate::codecs::scalars::INT64_MAX {
324 return Err(Error::validation("scaled exceeds int64 range"));
325 }
326 Ok(Self {
327 scaled,
328 scale,
329 domain,
330 asset_id,
331 })
332 }
333
334 pub fn from_decimal_str(
335 raw: &str,
336 scale: u32,
337 domain: QuantityDomain,
338 asset_id: Option<u32>,
339 ) -> Result<Self> {
340 use crate::codecs::scalars::decimal_to_scaled_str;
341 let scaled = decimal_to_scaled_str(raw, scale, "amount")?;
342 Self::from_scaled(scaled, Some(scale), domain, asset_id)
343 }
344
345 pub fn from_decimal(
346 raw: Decimal,
347 scale: u32,
348 domain: QuantityDomain,
349 asset_id: Option<u32>,
350 ) -> Result<Self> {
351 use crate::codecs::scalars::decimal_to_scaled;
352 let scaled = decimal_to_scaled(raw, scale, "amount")?;
353 Self::from_scaled(scaled, Some(scale), domain, asset_id)
354 }
355
356 pub fn as_i64(&self) -> Result<i64> {
357 i64::try_from(self.scaled).map_err(|_| Error::validation("amount exceeds int64 range"))
358 }
359
360 pub fn as_scaled(&self) -> i128 {
361 self.scaled
362 }
363
364 pub fn scale(&self) -> Option<u32> {
365 self.scale
366 }
367
368 pub fn domain(&self) -> QuantityDomain {
369 self.domain
370 }
371
372 pub fn asset_id(&self) -> Option<u32> {
373 self.asset_id
374 }
375
376 pub fn compatible_with(
377 &self,
378 domain: QuantityDomain,
379 scale: Option<u32>,
380 asset_id: Option<u32>,
381 ) -> Result<()> {
382 if self.domain != domain {
383 return Err(Error::validation(format!(
384 "amount domain mismatch: value is {:?}, destination is {domain:?}",
385 self.domain
386 )));
387 }
388 if let (Some(a), Some(b)) = (self.scale, scale)
389 && a != b
390 {
391 return Err(Error::validation(format!(
392 "amount scale mismatch: value scale is {a}, destination is {b}"
393 )));
394 }
395 if let (Some(a), Some(b)) = (self.asset_id, asset_id)
396 && a != b
397 {
398 return Err(Error::validation(format!(
399 "amount asset_id mismatch: value is for {a}, destination is {b}"
400 )));
401 }
402 Ok(())
403 }
404}
405
406pub fn resolve_price_ticks(value: &Price, symbol: Option<&str>) -> Result<i64> {
408 value.compatible_with(symbol)?;
409 let ticks = value.as_ticks();
410 if ticks < 0 {
411 return Err(Error::validation("ticks must be non-negative"));
412 }
413 Ok(ticks)
414}
415
416pub fn resolve_qty_scaled(
418 value: &Quantity,
419 scale: u32,
420 symbol: Option<&str>,
421 symbol_id: Option<u32>,
422) -> Result<i64> {
423 value.compatible_with(QuantityDomain::OrderBase, Some(scale), symbol, symbol_id)?;
424 let scaled = value.as_scaled();
425 if scaled <= 0 {
426 return Err(Error::validation("qty must be positive"));
427 }
428 Ok(scaled)
429}
430
431pub fn resolve_quote_qty_scaled(
433 value: &Quantity,
434 scale: u32,
435 symbol: Option<&str>,
436 symbol_id: Option<u32>,
437) -> Result<i64> {
438 if value.scale().is_none() {
439 return Err(Error::validation(
440 "quote amount scale is required; use Quantity::from_quote_scaled/from_quote_decimal",
441 ));
442 }
443 value.compatible_with(QuantityDomain::OrderQuote, Some(scale), symbol, symbol_id)?;
444 let scaled = value.as_scaled();
445 if scaled <= 0 {
446 return Err(Error::validation("quote amount must be positive"));
447 }
448 Ok(scaled)
449}
450
451pub fn resolve_asset_amount_scaled(
453 value: &AssetAmount,
454 scale: u32,
455 domain: QuantityDomain,
456 asset_id: Option<u32>,
457) -> Result<i128> {
458 resolve_asset_amount_scaled_with_input_scale(value, None, scale, domain, asset_id)
459}
460
461pub(crate) fn resolve_asset_amount_scaled_with_input_scale(
464 value: &AssetAmount,
465 input_scale: Option<u32>,
466 target_scale: u32,
467 domain: QuantityDomain,
468 asset_id: Option<u32>,
469) -> Result<i128> {
470 crate::codecs::scalars::validate_protocol_scale(target_scale)?;
471 if let Some(scale) = input_scale {
472 crate::codecs::scalars::validate_protocol_scale(scale)?;
473 }
474 value.compatible_with(domain, None, asset_id)?;
475 if let (Some(value_scale), Some(input_scale)) = (value.scale, input_scale)
476 && value_scale != input_scale
477 {
478 return Err(Error::validation(format!(
479 "amount scale mismatch: value scale is {value_scale}, input scale is {input_scale}"
480 )));
481 }
482 if value.scaled <= 0 {
483 return Err(Error::validation("amount must be positive"));
484 }
485 let source_scale = value.scale.or(input_scale).ok_or_else(|| {
486 Error::validation(
487 "amount scale is required; construct AssetAmount with an explicit scale or pass amount_scale/quantity_scale",
488 )
489 })?;
490 let scaled = if source_scale < target_scale {
491 let factor = 10_i128
492 .checked_pow(target_scale - source_scale)
493 .ok_or_else(|| Error::validation("amount scale conversion overflow"))?;
494 value
495 .scaled
496 .checked_mul(factor)
497 .ok_or_else(|| Error::validation("amount scale conversion overflow"))?
498 } else if source_scale > target_scale {
499 let divisor = 10_i128
500 .checked_pow(source_scale - target_scale)
501 .ok_or_else(|| Error::validation("amount scale conversion overflow"))?;
502 if value.scaled % divisor != 0 {
503 return Err(Error::validation(format!(
504 "amount cannot be represented exactly at scale {target_scale}"
505 )));
506 }
507 value.scaled / divisor
508 } else {
509 value.scaled
510 };
511 if domain != QuantityDomain::LedgerE18 && scaled > crate::codecs::scalars::INT64_MAX {
512 return Err(Error::validation("amount exceeds int64 range"));
513 }
514 Ok(scaled)
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn price_from_ticks_rejects_negative() {
523 assert!(Price::from_ticks(-1, None).is_err());
524 }
525
526 #[test]
527 fn price_as_decimal_is_exact_at_the_maximum_tick_value() {
528 let price = Price::from_ticks(i64::MAX, None).unwrap();
529 assert_eq!(price.as_decimal(), Decimal::new(i64::MAX, 6));
530 }
531
532 #[test]
533 fn quantity_from_scaled_rejects_negative() {
534 assert!(Quantity::from_scaled(-1, Some(8), QuantityDomain::OrderBase, None, None).is_err());
535 }
536
537 #[test]
538 fn resolve_paths_round_trip() {
539 let price = Price::from_decimal_str("42.5", Some("BTC-USDT".into())).unwrap();
540 assert_eq!(
541 resolve_price_ticks(&price, Some("BTC-USDT")).unwrap(),
542 42_500_000
543 );
544 let qty = Quantity::from_decimal_str("1.25", 8, Some("BTC-USDT".into()), Some(1)).unwrap();
545 assert_eq!(
546 resolve_qty_scaled(&qty, 8, Some("BTC-USDT"), Some(1)).unwrap(),
547 125_000_000
548 );
549 }
550
551 #[test]
552 fn price_and_quantity_metadata_getters_preserve_compatibility() {
553 let price = Price::from_ticks(42_500_000, Some("BTC-USDT".into())).unwrap();
554 assert_eq!(price.symbol(), Some("BTC-USDT"));
555 assert_eq!(price.as_ticks(), 42_500_000);
556 assert_eq!(price.format(), "42.5");
557 assert_eq!(price.clone(), price);
558 assert!(format!("{price:?}").contains("BTC-USDT"));
559
560 let qty = Quantity::from_scaled(
561 125_000_000,
562 Some(8),
563 QuantityDomain::OrderBase,
564 Some("BTC-USDT".into()),
565 Some(7),
566 )
567 .unwrap();
568 assert_eq!(qty.scale(), Some(8));
569 assert_eq!(qty.domain(), QuantityDomain::OrderBase);
570 assert_eq!(qty.symbol(), Some("BTC-USDT"));
571 assert_eq!(qty.symbol_id(), Some(7));
572 assert_eq!(qty.as_scaled(), 125_000_000);
573 assert_eq!(qty.format(None).unwrap(), "1.25");
574 assert_eq!(qty.clone(), qty);
575 assert!(format!("{qty:?}").contains("BTC-USDT"));
576 assert!(
577 qty.compatible_with(
578 QuantityDomain::OrderBase,
579 Some(8),
580 Some("BTC-USDT"),
581 Some(7)
582 )
583 .is_ok()
584 );
585 }
586
587 #[test]
588 fn resolve_qty_rejects_zero() {
589 let qty = Quantity::from_scaled(0, Some(8), QuantityDomain::OrderBase, None, None).unwrap();
590 assert!(resolve_qty_scaled(&qty, 8, None, None).is_err());
591 }
592
593 #[test]
594 fn quote_amount_requires_explicit_matching_scale() {
595 let quote =
596 Quantity::from_quote_decimal_str("12.5", 6, Some("BTC-USDT".into()), Some(1)).unwrap();
597 assert_eq!(
598 resolve_quote_qty_scaled("e, 6, Some("BTC-USDT"), Some(1)).unwrap(),
599 12_500_000
600 );
601 assert!(resolve_quote_qty_scaled("e, 8, Some("BTC-USDT"), Some(1)).is_err());
602
603 let missing_scale =
604 Quantity::from_scaled(12_500_000, None, QuantityDomain::OrderQuote, None, None)
605 .unwrap();
606 assert!(resolve_quote_qty_scaled(&missing_scale, 6, None, None).is_err());
607 }
608
609 #[test]
610 fn asset_amount_dual_path() {
611 let from_dec =
612 AssetAmount::from_decimal_str("0.5", 18, QuantityDomain::LedgerE18, Some(7)).unwrap();
613 let from_scaled = AssetAmount::from_scaled(
614 500_000_000_000_000_000,
615 Some(18),
616 QuantityDomain::LedgerE18,
617 Some(7),
618 )
619 .unwrap();
620 assert_eq!(
621 resolve_asset_amount_scaled(&from_dec, 18, QuantityDomain::LedgerE18, Some(7)).unwrap(),
622 resolve_asset_amount_scaled(&from_scaled, 18, QuantityDomain::LedgerE18, Some(7))
623 .unwrap()
624 );
625 }
626
627 #[test]
628 fn asset_amount_rejects_domain_scale_and_asset_mismatch() {
629 let amount =
630 AssetAmount::from_scaled(100, Some(18), QuantityDomain::LedgerE18, Some(7)).unwrap();
631 assert!(resolve_asset_amount_scaled(&amount, 18, QuantityDomain::Asset, Some(7)).is_err());
632 assert!(
633 resolve_asset_amount_scaled(&amount, 6, QuantityDomain::LedgerE18, Some(7)).is_err()
634 );
635 assert!(
636 resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, Some(8)).is_err()
637 );
638 }
639
640 #[test]
641 fn asset_amount_rescales_exactly_without_rounding() {
642 let asset_precision =
643 AssetAmount::from_scaled(125, Some(2), QuantityDomain::LedgerE18, Some(7)).unwrap();
644 assert_eq!(
645 resolve_asset_amount_scaled(&asset_precision, 18, QuantityDomain::LedgerE18, Some(7))
646 .unwrap(),
647 1_250_000_000_000_000_000
648 );
649
650 let exact_downscale = AssetAmount::from_scaled(
651 1_250_000_000_000_000_000,
652 Some(18),
653 QuantityDomain::LedgerE18,
654 Some(7),
655 )
656 .unwrap();
657 assert_eq!(
658 resolve_asset_amount_scaled(&exact_downscale, 2, QuantityDomain::LedgerE18, Some(7))
659 .unwrap(),
660 125
661 );
662
663 let inexact_downscale =
664 AssetAmount::from_scaled(126, Some(3), QuantityDomain::LedgerE18, Some(7)).unwrap();
665 assert!(
666 resolve_asset_amount_scaled(&inexact_downscale, 2, QuantityDomain::LedgerE18, Some(7))
667 .is_err()
668 );
669 }
670
671 #[test]
672 fn asset_amount_rescale_rejects_overflow() {
673 let amount =
674 AssetAmount::from_scaled(i128::MAX, Some(17), QuantityDomain::LedgerE18, None).unwrap();
675 assert!(resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, None).is_err());
676 }
677
678 #[test]
679 fn quantity_reuse_rejects_scale_symbol_and_symbol_id_mismatch() {
680 let qty = Quantity::from_scaled(
681 100,
682 Some(8),
683 QuantityDomain::OrderBase,
684 Some("BTC-USDT".into()),
685 Some(7),
686 )
687 .unwrap();
688 assert!(resolve_qty_scaled(&qty, 6, Some("BTC-USDT"), Some(7)).is_err());
689 assert!(resolve_qty_scaled(&qty, 8, Some("ETH-USDT"), Some(7)).is_err());
690 assert!(resolve_qty_scaled(&qty, 8, Some("BTC-USDT"), Some(8)).is_err());
691 }
692
693 #[test]
694 fn asset_amount_requires_positive_value_at_resolve() {
695 let amount =
696 AssetAmount::from_scaled(0, Some(18), QuantityDomain::LedgerE18, Some(7)).unwrap();
697 assert!(
698 resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, Some(7)).is_err()
699 );
700 }
701
702 #[test]
703 fn asset_amount_without_value_or_parameter_scale_fails_closed() {
704 let amount = AssetAmount::from_scaled(1, None, QuantityDomain::LedgerE18, Some(7)).unwrap();
705 let err = resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, Some(7))
706 .expect_err("missing source scale must not be treated as e18");
707 assert!(err.to_string().contains("amount scale is required"));
708
709 assert_eq!(
710 resolve_asset_amount_scaled_with_input_scale(
711 &amount,
712 Some(6),
713 18,
714 QuantityDomain::LedgerE18,
715 Some(7),
716 )
717 .unwrap(),
718 1_000_000_000_000
719 );
720 }
721
722 #[test]
723 fn asset_amount_as_i64_rejects_overflow_not_truncate() {
724 let amount = AssetAmount::from_scaled(
725 i128::from(u64::MAX) + 1,
726 Some(18),
727 QuantityDomain::LedgerE18,
728 None,
729 )
730 .unwrap();
731 assert!(amount.as_i64().is_err());
732 }
733}