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 Asset,
16 LedgerE18,
17}
18
19#[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#[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#[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 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#[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#[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
358pub 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
368pub 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
383pub 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
393pub(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}