1use crate::{
15 error::{OrderField, PrecisionLimit, PrecisionViolation},
16 order::OrderKind,
17};
18use hyperliquid_rust_sdk::{ClientLimit, ClientOrder, ClientTrigger};
19use rust_decimal::{Decimal, RoundingStrategy, prelude::ToPrimitive};
20use std::str::FromStr;
21
22const PERP_MAX_DECIMALS: u32 = 6;
24
25const SPOT_MAX_DECIMALS: u32 = 8;
27
28const SDK_WIRE_DECIMALS: usize = 8;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub struct OrderPrecision {
45 sz_decimals: u32,
46 max_decimals: u32,
47}
48
49impl OrderPrecision {
50 pub const MAX_SIGNIFICANT_FIGURES: u32 = 5;
52
53 pub const fn perp(sz_decimals: u32) -> Self {
55 Self {
56 sz_decimals,
57 max_decimals: PERP_MAX_DECIMALS,
58 }
59 }
60
61 pub const fn spot(base_sz_decimals: u32) -> Self {
63 Self {
64 sz_decimals: base_sz_decimals,
65 max_decimals: SPOT_MAX_DECIMALS,
66 }
67 }
68
69 pub const fn quantity_decimals(&self) -> u32 {
71 self.sz_decimals
72 }
73
74 pub const fn price_decimals(&self) -> u32 {
77 self.max_decimals.saturating_sub(self.sz_decimals)
78 }
79
80 pub fn check_quantity(&self, quantity: Decimal) -> Result<(), PrecisionViolation> {
87 self.wire_quantity(quantity).map(drop)
88 }
89
90 pub fn check_price(&self, price: Decimal) -> Result<(), PrecisionViolation> {
96 self.wire_price(OrderField::Price, price).map(drop)
97 }
98
99 pub fn check_trigger_price(&self, trigger_price: Decimal) -> Result<(), PrecisionViolation> {
107 self.wire_price(OrderField::TriggerPrice, trigger_price)
108 .map(drop)
109 }
110
111 pub fn round_quantity(&self, quantity: Decimal, strategy: RoundingStrategy) -> Decimal {
116 quantity
117 .round_dp_with_strategy(self.quantity_decimals(), strategy)
118 .normalize()
119 }
120
121 pub fn round_price(&self, price: Decimal, strategy: RoundingStrategy) -> Decimal {
127 let price = price.normalize();
128 if price.scale() == 0 {
129 return price;
130 }
131 let significant = (price.scale() + Self::MAX_SIGNIFICANT_FIGURES)
134 .saturating_sub(significant_digits(price));
135 price
136 .round_dp_with_strategy(significant.min(self.price_decimals()), strategy)
137 .normalize()
138 }
139
140 fn wire_quantity(&self, quantity: Decimal) -> Result<f64, PrecisionViolation> {
142 let violation = |limit| PrecisionViolation::new(OrderField::Quantity, quantity, limit);
143 if quantity <= Decimal::ZERO {
144 return Err(violation(PrecisionLimit::NotPositive));
145 }
146 if quantity.normalize().scale() > self.quantity_decimals() {
147 return Err(violation(PrecisionLimit::DecimalPlaces {
148 max: self.quantity_decimals(),
149 }));
150 }
151 to_wire(quantity).ok_or_else(|| violation(PrecisionLimit::NotRepresentable))
152 }
153
154 fn wire_price(&self, field: OrderField, price: Decimal) -> Result<f64, PrecisionViolation> {
156 let violation = |limit| PrecisionViolation::new(field, price, limit);
157 if price <= Decimal::ZERO {
158 return Err(violation(PrecisionLimit::NotPositive));
159 }
160 let normalized = price.normalize();
161 if normalized.scale() > 0 {
162 if normalized.scale() > self.price_decimals() {
163 return Err(violation(PrecisionLimit::DecimalPlaces {
164 max: self.price_decimals(),
165 }));
166 }
167 if significant_digits(normalized) > Self::MAX_SIGNIFICANT_FIGURES {
168 return Err(violation(PrecisionLimit::SignificantFigures {
169 max: Self::MAX_SIGNIFICANT_FIGURES,
170 }));
171 }
172 }
173 to_wire(price).ok_or_else(|| violation(PrecisionLimit::NotRepresentable))
174 }
175}
176
177fn significant_digits(value: Decimal) -> u32 {
180 value
181 .mantissa()
182 .unsigned_abs()
183 .checked_ilog10()
184 .map_or(1, |log| log + 1)
185}
186
187fn to_wire(value: Decimal) -> Option<f64> {
189 let float = value.to_f64()?;
190 let sent = Decimal::from_str(&format!("{float:.SDK_WIRE_DECIMALS$}")).ok()?;
191 (sent == value).then_some(float)
192}
193
194#[derive(Debug)]
196pub(super) struct WireOrder {
197 pub(super) limit_px: f64,
198 pub(super) sz: f64,
199 pub(super) order_type: ClientOrder,
200}
201
202#[derive(Debug, PartialEq)]
204pub(super) enum WireOrderError {
205 MissingPrice,
207 UnsupportedKind,
209 Precision(PrecisionViolation),
211}
212
213impl From<PrecisionViolation> for WireOrderError {
214 fn from(violation: PrecisionViolation) -> Self {
215 Self::Precision(violation)
216 }
217}
218
219pub(super) fn wire_order(
225 precision: &OrderPrecision,
226 kind: OrderKind,
227 tif: String,
228 price: Option<Decimal>,
229 quantity: Decimal,
230) -> Result<WireOrder, WireOrderError> {
231 let sz = precision.wire_quantity(quantity)?;
232 let limit_price = || -> Result<f64, WireOrderError> {
233 let price = price.ok_or(WireOrderError::MissingPrice)?;
234 Ok(precision.wire_price(OrderField::Price, price)?)
235 };
236 let trigger = |trigger_price, is_market, tpsl: &str| -> Result<_, WireOrderError> {
237 let trigger_px = precision.wire_price(OrderField::TriggerPrice, trigger_price)?;
238 let order_type = ClientOrder::Trigger(ClientTrigger {
239 is_market,
240 trigger_px,
241 tpsl: tpsl.to_owned(),
242 });
243 let limit_px = if is_market {
244 trigger_px
245 } else {
246 limit_price()?
247 };
248 Ok(WireOrder {
249 limit_px,
250 sz,
251 order_type,
252 })
253 };
254
255 match kind {
256 OrderKind::Limit => Ok(WireOrder {
257 limit_px: limit_price()?,
258 sz,
259 order_type: ClientOrder::Limit(ClientLimit { tif }),
260 }),
261 OrderKind::Stop { trigger_price } => trigger(trigger_price, true, "sl"),
262 OrderKind::StopLimit { trigger_price } => trigger(trigger_price, false, "sl"),
263 OrderKind::TakeProfit { trigger_price } => trigger(trigger_price, true, "tp"),
264 OrderKind::TakeProfitLimit { trigger_price } => trigger(trigger_price, false, "tp"),
265 OrderKind::Market
266 | OrderKind::TrailingStop { .. }
267 | OrderKind::TrailingStopLimit { .. }
268 | OrderKind::TrailingStopFixedLimit { .. }
269 | OrderKind::ConditionalUnresolved { .. } => Err(WireOrderError::UnsupportedKind),
270 }
271}
272
273#[cfg(test)]
274#[allow(clippy::unwrap_used)]
276mod tests {
277 use super::*;
278 use rust_decimal_macros::dec;
279
280 fn violation(field: OrderField, value: Decimal, limit: PrecisionLimit) -> PrecisionViolation {
281 PrecisionViolation {
282 field,
283 value,
284 limit,
285 }
286 }
287
288 #[test]
289 fn a_quantity_may_have_at_most_sz_decimals_places() {
290 let three = OrderPrecision::perp(3);
291 assert_eq!(three.check_quantity(dec!(1.001)), Ok(()));
292 assert_eq!(
293 three.check_quantity(dec!(1.00100)),
294 Ok(()),
295 "trailing zeros do not count"
296 );
297 assert_eq!(
298 three.check_quantity(dec!(1.0001)),
299 Err(violation(
300 OrderField::Quantity,
301 dec!(1.0001),
302 PrecisionLimit::DecimalPlaces { max: 3 }
303 ))
304 );
305
306 let zero = OrderPrecision::perp(0);
307 assert_eq!(
308 zero.check_quantity(dec!(123456)),
309 Ok(()),
310 "an integer quantity is not rounded to significant figures"
311 );
312 assert!(zero.check_quantity(dec!(1.5)).is_err());
313 }
314
315 #[test]
316 fn an_integer_price_is_valid_whatever_its_significant_figures() {
317 let perp = OrderPrecision::perp(5);
318 assert_eq!(perp.check_price(dec!(123456)), Ok(()));
319 assert_eq!(perp.check_price(dec!(123456.0)), Ok(()));
320 assert_eq!(
321 perp.check_price(dec!(12345.6)),
322 Err(violation(
323 OrderField::Price,
324 dec!(12345.6),
325 PrecisionLimit::SignificantFigures { max: 5 }
326 ))
327 );
328 }
329
330 #[test]
332 fn a_price_follows_the_documented_examples() {
333 let perp = OrderPrecision::perp(0);
334 assert_eq!(perp.check_price(dec!(1234.5)), Ok(()));
335 assert_eq!(
336 perp.check_price(dec!(1234.56)),
337 Err(violation(
338 OrderField::Price,
339 dec!(1234.56),
340 PrecisionLimit::SignificantFigures { max: 5 }
341 ))
342 );
343 assert_eq!(perp.check_price(dec!(0.001234)), Ok(()));
344 assert_eq!(
345 perp.check_price(dec!(0.0012345)),
346 Err(violation(
347 OrderField::Price,
348 dec!(0.0012345),
349 PrecisionLimit::DecimalPlaces { max: 6 }
350 ))
351 );
352
353 let perp_one = OrderPrecision::perp(1);
354 assert_eq!(perp_one.check_price(dec!(0.01234)), Ok(()));
355 assert_eq!(
356 perp_one.check_price(dec!(0.012345)),
357 Err(violation(
358 OrderField::Price,
359 dec!(0.012345),
360 PrecisionLimit::DecimalPlaces { max: 5 }
361 ))
362 );
363
364 assert_eq!(OrderPrecision::spot(0).check_price(dec!(0.0001234)), Ok(()));
366 assert_eq!(OrderPrecision::spot(1).check_price(dec!(0.0001234)), Ok(()));
367 assert!(
368 OrderPrecision::spot(3)
369 .check_price(dec!(0.0001234))
370 .is_err()
371 );
372 }
373
374 #[test]
375 fn a_value_that_is_not_positive_is_refused() {
376 let perp = OrderPrecision::perp(2);
377 for value in [dec!(0), dec!(0.00), dec!(-1), dec!(-0.5)] {
378 assert_eq!(
379 perp.check_quantity(value),
380 Err(violation(
381 OrderField::Quantity,
382 value,
383 PrecisionLimit::NotPositive
384 ))
385 );
386 assert_eq!(
387 perp.check_price(value),
388 Err(violation(
389 OrderField::Price,
390 value,
391 PrecisionLimit::NotPositive
392 ))
393 );
394 assert_eq!(
395 perp.check_trigger_price(value),
396 Err(violation(
397 OrderField::TriggerPrice,
398 value,
399 PrecisionLimit::NotPositive
400 ))
401 );
402 }
403 }
404
405 #[test]
406 fn a_value_rounded_to_zero_is_refused() {
407 let quantity =
408 OrderPrecision::perp(2).round_quantity(dec!(0.004), RoundingStrategy::ToZero);
409 assert_eq!(quantity, Decimal::ZERO);
410 assert!(OrderPrecision::perp(2).check_quantity(quantity).is_err());
411
412 let precision = OrderPrecision::perp(6);
414 let price = precision.round_price(dec!(0.4), RoundingStrategy::ToZero);
415 assert_eq!(price, Decimal::ZERO);
416 assert_eq!(
417 precision.check_price(price),
418 Err(violation(
419 OrderField::Price,
420 price,
421 PrecisionLimit::NotPositive
422 ))
423 );
424 }
425
426 #[test]
427 fn a_trigger_price_follows_the_price_rules_under_its_own_name() {
428 let perp = OrderPrecision::perp(0);
429 assert_eq!(perp.check_trigger_price(dec!(1234.5)), Ok(()));
430 assert_eq!(
431 perp.check_trigger_price(dec!(1234.56)),
432 Err(violation(
433 OrderField::TriggerPrice,
434 dec!(1234.56),
435 PrecisionLimit::SignificantFigures { max: 5 }
436 ))
437 );
438 }
439
440 #[test]
441 fn the_price_cap_never_goes_below_zero_decimal_places() {
442 let precision = OrderPrecision::perp(8);
443 assert_eq!(precision.price_decimals(), 0);
444 assert_eq!(precision.check_price(dec!(42)), Ok(()));
445 assert!(precision.check_price(dec!(42.5)).is_err());
446 }
447
448 #[test]
449 fn rounding_meets_the_rules_in_the_direction_asked() {
450 let perp = OrderPrecision::perp(2);
451 assert_eq!(
452 perp.round_quantity(dec!(1.239), RoundingStrategy::ToZero),
453 dec!(1.23)
454 );
455 assert_eq!(
456 perp.round_quantity(dec!(1.231), RoundingStrategy::AwayFromZero),
457 dec!(1.24)
458 );
459
460 assert_eq!(
462 perp.round_price(dec!(1234.56), RoundingStrategy::ToZero),
463 dec!(1234.5)
464 );
465 assert_eq!(
467 perp.round_price(dec!(0.0012345), RoundingStrategy::MidpointNearestEven),
468 dec!(0.0012)
469 );
470 assert_eq!(
471 perp.round_price(dec!(123456.7), RoundingStrategy::ToZero),
472 dec!(123456)
473 );
474 assert_eq!(
475 perp.round_price(dec!(123456), RoundingStrategy::ToZero),
476 dec!(123456),
477 "an integer price is returned unchanged"
478 );
479 assert_eq!(
480 perp.round_price(dec!(9.99999), RoundingStrategy::AwayFromZero),
481 dec!(10)
482 );
483
484 for value in [
485 dec!(1234.56),
486 dec!(0.0012345),
487 dec!(9.99999),
488 dec!(0.123456789),
489 ] {
490 for strategy in [RoundingStrategy::ToZero, RoundingStrategy::AwayFromZero] {
491 let rounded = perp.round_price(value, strategy);
492 assert_eq!(perp.check_price(rounded), Ok(()), "{value} -> {rounded}");
493 }
494 }
495 }
496
497 #[test]
498 fn a_value_the_sdk_would_change_is_not_representable() {
499 let huge = dec!(12345678901234567890123);
501 assert_eq!(
502 OrderPrecision::perp(0).check_price(huge),
503 Err(violation(
504 OrderField::Price,
505 huge,
506 PrecisionLimit::NotRepresentable
507 ))
508 );
509 let long = dec!(12345678901234.12345678);
511 assert_eq!(
512 OrderPrecision::spot(8).check_quantity(long),
513 Err(violation(
514 OrderField::Quantity,
515 long,
516 PrecisionLimit::NotRepresentable
517 ))
518 );
519 assert_eq!(to_wire(dec!(0.00000001)), Some(0.00000001));
520 assert_eq!(to_wire(dec!(123456.12345678)), Some(123456.12345678));
521 }
522
523 #[test]
524 fn a_wire_order_carries_the_values_unchanged() {
525 let precision = OrderPrecision::perp(2);
526 let order = wire_order(
527 &precision,
528 OrderKind::StopLimit {
529 trigger_price: dec!(95),
530 },
531 "Gtc".to_owned(),
532 Some(dec!(94.5)),
533 dec!(1.25),
534 )
535 .unwrap();
536 assert_eq!((order.limit_px, order.sz), (94.5, 1.25));
537 let ClientOrder::Trigger(trigger) = order.order_type else {
538 panic!("expected a trigger order, got {:?}", order.order_type);
539 };
540 assert_eq!(
541 (trigger.trigger_px, trigger.is_market, trigger.tpsl.as_str()),
542 (95.0, false, "sl")
543 );
544
545 let market_trigger = wire_order(
546 &precision,
547 OrderKind::TakeProfit {
548 trigger_price: dec!(120),
549 },
550 "Gtc".to_owned(),
551 None,
552 dec!(1),
553 )
554 .unwrap();
555 assert_eq!(
556 market_trigger.limit_px, 120.0,
557 "a market trigger's limit price is its trigger price"
558 );
559 }
560
561 #[test]
562 fn a_wire_order_refuses_each_value_that_breaks_the_rules() {
563 let precision = OrderPrecision::perp(2);
564 let refused = |kind, price, quantity| {
565 wire_order(&precision, kind, "Gtc".to_owned(), price, quantity).unwrap_err()
566 };
567
568 assert_eq!(
569 refused(OrderKind::Limit, Some(dec!(100)), dec!(1.234)),
570 WireOrderError::Precision(violation(
571 OrderField::Quantity,
572 dec!(1.234),
573 PrecisionLimit::DecimalPlaces { max: 2 }
574 ))
575 );
576 assert_eq!(
577 refused(OrderKind::Limit, Some(dec!(100.123456)), dec!(1)),
578 WireOrderError::Precision(violation(
579 OrderField::Price,
580 dec!(100.123456),
581 PrecisionLimit::DecimalPlaces { max: 4 }
582 ))
583 );
584 assert_eq!(
585 refused(
586 OrderKind::Stop {
587 trigger_price: dec!(1234.56)
588 },
589 None,
590 dec!(1)
591 ),
592 WireOrderError::Precision(violation(
593 OrderField::TriggerPrice,
594 dec!(1234.56),
595 PrecisionLimit::SignificantFigures { max: 5 }
596 ))
597 );
598 assert_eq!(
599 refused(OrderKind::Limit, None, dec!(1)),
600 WireOrderError::MissingPrice
601 );
602 assert_eq!(
603 refused(OrderKind::Market, None, dec!(1)),
604 WireOrderError::UnsupportedKind
605 );
606 assert_eq!(
607 refused(
608 OrderKind::TrailingStopFixedLimit {
609 offset: dec!(1),
610 offset_type: crate::order::TrailingOffsetType::Percentage,
611 activation_price: None,
612 },
613 Some(dec!(100)),
614 dec!(1)
615 ),
616 WireOrderError::UnsupportedKind
617 );
618 assert_eq!(
619 refused(
620 OrderKind::ConditionalUnresolved {
621 family: crate::order::ConditionalFamily::StopLimit,
622 trigger_price: Some(dec!(95)),
623 },
624 Some(dec!(100)),
625 dec!(1)
626 ),
627 WireOrderError::UnsupportedKind
628 );
629 }
630}