1use std::{
19 fmt::Display,
20 str::FromStr,
21 sync::{LazyLock, PoisonError, RwLock},
22};
23
24use ahash::AHashMap;
25use nautilus_core::correctness::{
26 CorrectnessError, CorrectnessResult, check_in_range_inclusive_u8, check_predicate_true,
27 check_valid_string_ascii, check_valid_string_ascii_optional,
28};
29use thiserror::Error;
30
31#[cfg(not(feature = "high-precision"))]
32use crate::types::fixed::{check_fixed_raw_i64 as check_fixed_raw, f64_to_fixed_i64};
33#[cfg(feature = "high-precision")]
34use crate::types::fixed::{check_fixed_raw_i128 as check_fixed_raw, f64_to_fixed_i128};
35use crate::types::{
36 Price,
37 fixed::{FIXED_PRECISION, FIXED_SCALAR},
38 price::{PRICE_MAX, PRICE_MIN, PRICE_RAW_MAX, PRICE_RAW_MIN, PriceRaw},
39};
40
41pub trait TickSchemeRule: Display {
42 fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price>;
43 fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price>;
44}
45
46#[derive(Clone, Debug, Error, PartialEq)]
48pub enum TickSchemeError {
49 #[error("tick must be positive")]
51 TickNotPositive {
52 tick: Price,
54 },
55 #[error("tiers must not be empty")]
57 EmptyTiers,
58 #[error("tier {index}: values must not be NaN")]
60 TierValuesNaN {
61 index: usize,
63 start: f64,
65 stop: f64,
67 step: f64,
69 },
70 #[error("tier {index}: start ({start}) must be less than stop ({stop})")]
72 TierStartNotLessThanStop {
73 index: usize,
75 start: f64,
77 stop: f64,
79 },
80 #[error("tier {index}: step ({step}) must be positive")]
82 TierStepNotPositive {
83 index: usize,
85 step: f64,
87 },
88 #[error("tier {index}: step ({step}) must be less than range ({stop} - {start} = {range})")]
90 TierStepNotLessThanRange {
91 index: usize,
93 start: f64,
95 stop: f64,
97 step: f64,
99 range: f64,
101 },
102 #[error("tier {index}: start ({start}) overlaps previous tier stop ({prev_stop})")]
104 TierOverlapsPrevious {
105 index: usize,
107 start: f64,
109 prev_stop: f64,
111 },
112 #[error("tier {index}: start ({start}) outside Price range")]
114 TierStartOutsidePriceRange {
115 index: usize,
117 start: f64,
119 },
120 #[error("tier {index}: stop ({stop}) outside Price range")]
122 TierStopOutsidePriceRange {
123 index: usize,
125 stop: f64,
127 },
128 #[error("{source}")]
130 InvalidPrecision {
131 #[source]
133 source: CorrectnessError,
134 },
135 #[error("tick {tick} cannot be represented at precision {precision}")]
137 TickNotRepresentable {
138 tick: Price,
140 precision: u8,
142 },
143 #[error("{source}")]
145 InvalidName {
146 #[source]
148 source: CorrectnessError,
149 },
150 #[error("tick scheme {name} is already registered")]
152 DuplicateName {
153 name: String,
155 },
156 #[error("tier expansion produced no ticks")]
158 EmptyTickExpansion,
159 #[error("expanded tick value {value} outside Price range")]
161 ExpandedTickOutsidePriceRange {
162 value: f64,
164 },
165 #[error("unknown tick scheme {name}")]
167 UnknownName {
168 name: String,
170 },
171}
172
173pub const BETFAIR_TICK_SCHEME_NAME: &str = "BETFAIR";
174pub const TOPIX100_TICK_SCHEME_NAME: &str = "TOPIX100";
175pub const CRYPTO_0_01_TICK_SCHEME_NAME: &str = "CRYPTO_0_01";
176pub const FOREX_3DECIMAL_TICK_SCHEME_NAME: &str = "FOREX_3DECIMAL";
177pub const FOREX_5DECIMAL_TICK_SCHEME_NAME: &str = "FOREX_5DECIMAL";
178pub const FIXED_TICK_SCHEME_NAME: &str = "FIXED";
179pub const FIXED_PRECISION_TICK_SCHEME_PREFIX: &str = "FIXED_PRECISION_";
180
181const BETFAIR_PRICE_TIERS: [(f64, f64, f64); 10] = [
182 (1.01, 2.0, 0.01),
183 (2.0, 3.0, 0.02),
184 (3.0, 4.0, 0.05),
185 (4.0, 6.0, 0.1),
186 (6.0, 10.0, 0.2),
187 (10.0, 20.0, 0.5),
188 (20.0, 30.0, 1.0),
189 (30.0, 50.0, 2.0),
190 (50.0, 100.0, 5.0),
191 (100.0, 1010.0, 10.0),
192];
193
194pub static BETFAIR_TICK_SCHEME: LazyLock<TieredTickScheme> = LazyLock::new(|| {
195 TieredTickScheme::new(&BETFAIR_PRICE_TIERS, 2, 100)
196 .expect("BETFAIR tick scheme tiers are valid by construction")
197});
198
199pub static TOPIX100_TICK_SCHEME: LazyLock<TieredTickScheme> = LazyLock::new(|| {
200 TieredTickScheme::new(
201 &[
202 (0.1, 1_000.0, 0.1),
203 (1_000.0, 3_000.0, 0.5),
204 (3_000.0, 10_000.0, 1.0),
205 (10_000.0, 30_000.0, 5.0),
206 (30_000.0, 100_000.0, 10.0),
207 (100_000.0, 300_000.0, 50.0),
208 (300_000.0, 1_000_000.0, 100.0),
209 (1_000_000.0, 3_000_000.0, 500.0),
210 (3_000_000.0, 10_000_000.0, 1_000.0),
211 (10_000_000.0, 30_000_000.0, 5_000.0),
212 (30_000_000.0, f64::INFINITY, 10_000.0),
213 ],
214 4,
215 10_000,
216 )
217 .expect("TOPIX100 tick scheme tiers are valid by construction")
218});
219
220static FIXED_TICK_SCHEME: LazyLock<FixedTickScheme> =
221 LazyLock::new(|| FixedTickScheme::new(Price::from("1")).expect("fixed tick scheme is valid"));
222
223pub(crate) static CRYPTO_0_01_TICK_SCHEME: LazyLock<FixedTickScheme> = LazyLock::new(|| {
224 FixedTickScheme::new(Price::from("0.01")).expect("crypto tick scheme is valid")
225});
226
227static FIXED_PRECISION_TICK_SCHEMES: LazyLock<Vec<FixedTickScheme>> = LazyLock::new(|| {
228 (0..=FIXED_PRECISION)
229 .map(|precision| {
230 let raw = PriceRaw::pow(10, u32::from(FIXED_PRECISION - precision));
231 let tick = Price::from_raw(raw, precision);
232 FixedTickScheme::new(tick).expect("fixed precision tick scheme is valid")
233 })
234 .collect()
235});
236
237static TICK_SCHEMES: LazyLock<RwLock<AHashMap<String, &'static TickScheme>>> =
238 LazyLock::new(|| RwLock::new(AHashMap::new()));
239
240#[derive(Debug, Clone, Copy, Eq)]
244pub struct FixedTickScheme {
245 tick: Price,
246}
247
248impl FixedTickScheme {
249 pub fn new(tick: Price) -> Result<Self, TickSchemeError> {
256 check_in_range_inclusive_u8(tick.precision, 0, FIXED_PRECISION, "precision")
257 .map_err(|source| TickSchemeError::InvalidPrecision { source })?;
258
259 if tick.is_zero() || tick.is_negative() {
260 return Err(TickSchemeError::TickNotPositive { tick });
261 }
262
263 if tick > Price::max(tick.precision) || fixed_tick_raw(tick, tick.precision).is_none() {
264 return Err(TickSchemeError::TickNotRepresentable {
265 tick,
266 precision: tick.precision,
267 });
268 }
269
270 Ok(Self { tick })
271 }
272
273 pub fn new_with_precision(tick: Price, price_precision: u8) -> Result<Self, TickSchemeError> {
280 Self::new(tick)?;
281 check_in_range_inclusive_u8(price_precision, 0, FIXED_PRECISION, "precision")
282 .map_err(|source| TickSchemeError::InvalidPrecision { source })?;
283
284 if fixed_tick_raw(tick, price_precision).is_none() {
285 return Err(TickSchemeError::TickNotRepresentable {
286 tick,
287 precision: price_precision,
288 });
289 }
290
291 Ok(Self {
292 tick: Price::from_raw(tick.raw(), price_precision),
293 })
294 }
295
296 #[must_use]
298 pub const fn tick(&self) -> Price {
299 self.tick
300 }
301
302 #[must_use]
304 pub const fn precision(&self) -> u8 {
305 self.tick.precision
306 }
307}
308
309impl PartialEq for FixedTickScheme {
310 fn eq(&self, other: &Self) -> bool {
311 self.tick == other.tick && self.precision() == other.precision()
312 }
313}
314
315impl TickSchemeRule for FixedTickScheme {
316 #[inline(always)]
317 fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
318 fixed_next_bid_price(self.tick, value, n, precision)
319 }
320
321 #[inline(always)]
322 fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
323 fixed_next_ask_price(self.tick, value, n, precision)
324 }
325}
326
327impl Display for FixedTickScheme {
328 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329 f.write_str(FIXED_TICK_SCHEME_NAME)
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct TieredTickScheme {
340 ticks: Vec<PriceRaw>,
341 precision: u8,
342}
343
344impl TieredTickScheme {
345 pub fn new(
354 tiers: &[(f64, f64, f64)],
355 price_precision: u8,
356 max_ticks_per_tier: usize,
357 ) -> Result<Self, TickSchemeError> {
358 if tiers.is_empty() {
359 return Err(TickSchemeError::EmptyTiers);
360 }
361
362 for (index, &(start, stop, step)) in tiers.iter().enumerate() {
363 if start.is_nan() || stop.is_nan() || step.is_nan() {
364 return Err(TickSchemeError::TierValuesNaN {
365 index,
366 start,
367 stop,
368 step,
369 });
370 }
371
372 if start >= stop {
373 return Err(TickSchemeError::TierStartNotLessThanStop { index, start, stop });
374 }
375
376 if step <= 0.0 {
377 return Err(TickSchemeError::TierStepNotPositive { index, step });
378 }
379
380 if !stop.is_infinite() && step >= (stop - start) {
381 return Err(TickSchemeError::TierStepNotLessThanRange {
382 index,
383 start,
384 stop,
385 step,
386 range: stop - start,
387 });
388 }
389
390 if index > 0 {
391 let prev_stop = tiers[index - 1].1;
392
393 if start < prev_stop {
394 return Err(TickSchemeError::TierOverlapsPrevious {
395 index,
396 start,
397 prev_stop,
398 });
399 }
400 }
401
402 if !(PRICE_MIN..=PRICE_MAX).contains(&start) {
403 return Err(TickSchemeError::TierStartOutsidePriceRange { index, start });
404 }
405
406 if !stop.is_infinite() && !(PRICE_MIN..=PRICE_MAX).contains(&stop) {
407 return Err(TickSchemeError::TierStopOutsidePriceRange { index, stop });
408 }
409 }
410
411 let _ = Price::new_checked(0.0, price_precision)
412 .map_err(|source| TickSchemeError::InvalidPrecision { source })?;
413
414 let ticks = Self::build_ticks(tiers, price_precision, max_ticks_per_tier)?;
415
416 if ticks.is_empty() {
417 return Err(TickSchemeError::EmptyTickExpansion);
418 }
419 Ok(Self {
420 ticks,
421 precision: price_precision,
422 })
423 }
424
425 fn build_ticks(
426 tiers: &[(f64, f64, f64)],
427 precision: u8,
428 max_ticks_per_tier: usize,
429 ) -> Result<Vec<PriceRaw>, TickSchemeError> {
430 let mut all_ticks = Vec::new();
431
432 for &(start, stop, step) in tiers {
433 let effective_stop = if stop.is_infinite() {
434 start + (max_ticks_per_tier.saturating_add(1) as f64) * step
435 } else {
436 stop
437 };
438 let mut i = 0;
439 while i < max_ticks_per_tier {
440 let value = start + (i as f64) * step;
441
442 if value >= effective_stop {
443 break;
444 }
445
446 if !value.is_finite() || !(PRICE_MIN..=PRICE_MAX).contains(&value) {
447 return Err(TickSchemeError::ExpandedTickOutsidePriceRange { value });
448 }
449 let raw = f64_to_raw(value, precision);
450
451 if all_ticks.last() != Some(&raw) {
452 all_ticks.push(raw);
453 }
454 i += 1;
455 }
456 }
457 Ok(all_ticks)
458 }
459
460 #[inline(always)]
461 fn price_at(&self, index: usize) -> Price {
462 Price {
463 raw: self.ticks[index],
464 precision: self.precision,
465 }
466 }
467
468 #[must_use]
470 pub fn ticks(&self) -> Vec<Price> {
471 self.ticks
472 .iter()
473 .map(|&raw| Price {
474 raw,
475 precision: self.precision,
476 })
477 .collect()
478 }
479
480 #[must_use]
482 pub fn tick_count(&self) -> usize {
483 self.ticks.len()
484 }
485
486 #[must_use]
488 pub fn min_price(&self) -> Price {
489 self.price_at(0)
490 }
491
492 #[must_use]
494 pub fn max_price(&self) -> Price {
495 self.price_at(self.ticks.len() - 1)
496 }
497
498 #[must_use]
500 pub fn precision(&self) -> u8 {
501 self.precision
502 }
503
504 #[must_use]
510 pub fn topix100() -> Self {
511 TOPIX100_TICK_SCHEME.clone()
512 }
513
514 #[must_use]
520 pub fn betfair() -> Self {
521 BETFAIR_TICK_SCHEME.clone()
522 }
523}
524
525impl TickSchemeRule for TieredTickScheme {
526 fn next_bid_price(&self, value: f64, n: i32, _precision: u8) -> Option<Price> {
527 let n = usize::try_from(n).ok()?;
528
529 if value.is_nan() {
530 return None;
531 }
532
533 let raw_floor = (value * FIXED_SCALAR).floor() as PriceRaw;
536
537 if raw_floor < self.ticks[0] {
538 return None;
539 }
540
541 let idx = self.ticks.partition_point(|&t| t < raw_floor);
543
544 if idx < self.ticks.len() && self.ticks[idx] == raw_floor {
545 let target = idx.checked_sub(n)?;
547 return Some(self.price_at(target));
548 }
549
550 let target = idx.checked_sub(1)?.checked_sub(n)?;
552 Some(self.price_at(target))
553 }
554
555 fn next_ask_price(&self, value: f64, n: i32, _precision: u8) -> Option<Price> {
556 let n = usize::try_from(n).ok()?;
557
558 if value.is_nan() {
559 return None;
560 }
561
562 let raw_ceil = (value * FIXED_SCALAR).ceil() as PriceRaw;
565
566 if raw_ceil > *self.ticks.last()? {
567 return None;
568 }
569
570 let idx = self.ticks.partition_point(|&t| t < raw_ceil);
572 let target = idx.checked_add(n)?;
573
574 if target >= self.ticks.len() {
575 return None;
576 }
577 Some(self.price_at(target))
578 }
579}
580
581impl Display for TieredTickScheme {
582 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583 write!(f, "TIERED")
584 }
585}
586
587#[derive(Clone, Debug, PartialEq, Eq)]
588pub enum TickScheme {
589 Fixed(FixedTickScheme),
590 Tiered(TieredTickScheme),
591 Betfair,
592 Crypto,
593}
594
595impl TickSchemeRule for TickScheme {
596 #[inline(always)]
597 fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
598 match self {
599 Self::Fixed(scheme) => scheme.next_bid_price(value, n, precision),
600 Self::Tiered(scheme) => scheme.next_bid_price(value, n, precision),
601 Self::Betfair => BETFAIR_TICK_SCHEME.next_bid_price(value, n, precision),
602 Self::Crypto => CRYPTO_0_01_TICK_SCHEME.next_bid_price(value, n, precision),
603 }
604 }
605
606 #[inline(always)]
607 fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
608 match self {
609 Self::Fixed(scheme) => scheme.next_ask_price(value, n, precision),
610 Self::Tiered(scheme) => scheme.next_ask_price(value, n, precision),
611 Self::Betfair => BETFAIR_TICK_SCHEME.next_ask_price(value, n, precision),
612 Self::Crypto => CRYPTO_0_01_TICK_SCHEME.next_ask_price(value, n, precision),
613 }
614 }
615}
616
617impl Display for TickScheme {
618 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619 match self {
620 Self::Fixed(scheme) => write!(f, "{scheme}"),
621 Self::Tiered(scheme) => write!(f, "{scheme}"),
622 Self::Betfair => write!(f, "{BETFAIR_TICK_SCHEME_NAME}"),
623 Self::Crypto => write!(f, "{CRYPTO_0_01_TICK_SCHEME_NAME}"),
624 }
625 }
626}
627
628impl FromStr for TickScheme {
629 type Err = TickSchemeError;
630
631 fn from_str(s: &str) -> Result<Self, Self::Err> {
632 match s.trim().to_ascii_uppercase().as_str() {
633 FIXED_TICK_SCHEME_NAME => Ok(Self::Fixed(*FIXED_TICK_SCHEME)),
634 FOREX_3DECIMAL_TICK_SCHEME_NAME => Ok(Self::Fixed(FIXED_PRECISION_TICK_SCHEMES[3])),
635 FOREX_5DECIMAL_TICK_SCHEME_NAME => Ok(Self::Fixed(FIXED_PRECISION_TICK_SCHEMES[5])),
636 TOPIX100_TICK_SCHEME_NAME => Ok(Self::Tiered(TieredTickScheme::topix100())),
637 BETFAIR_TICK_SCHEME_NAME => Ok(Self::Betfair),
638 CRYPTO_0_01_TICK_SCHEME_NAME => Ok(Self::Crypto),
639 name => {
640 if let Some(precision) = parse_fixed_precision_name(name)
641 && precision <= FIXED_PRECISION
642 {
643 return Ok(Self::Fixed(
644 FIXED_PRECISION_TICK_SCHEMES[usize::from(precision)],
645 ));
646 }
647
648 if let Some(scheme) = TICK_SCHEMES
649 .read()
650 .unwrap_or_else(PoisonError::into_inner)
651 .get(name)
652 {
653 return Ok((*scheme).clone());
654 }
655
656 Err(TickSchemeError::UnknownName {
657 name: s.to_string(),
658 })
659 }
660 }
661 }
662}
663
664#[must_use]
666pub fn tick_scheme_rule_from_name(name: &str) -> Option<&'static dyn TickSchemeRule> {
667 let name = name.trim();
668 if name.eq_ignore_ascii_case(FIXED_TICK_SCHEME_NAME) {
669 Some(&*FIXED_TICK_SCHEME)
670 } else if name.eq_ignore_ascii_case(FOREX_3DECIMAL_TICK_SCHEME_NAME) {
671 Some(&FIXED_PRECISION_TICK_SCHEMES[3])
672 } else if name.eq_ignore_ascii_case(FOREX_5DECIMAL_TICK_SCHEME_NAME) {
673 Some(&FIXED_PRECISION_TICK_SCHEMES[5])
674 } else if name.eq_ignore_ascii_case(TOPIX100_TICK_SCHEME_NAME) {
675 Some(&*TOPIX100_TICK_SCHEME)
676 } else if name.eq_ignore_ascii_case(BETFAIR_TICK_SCHEME_NAME) {
677 Some(&*BETFAIR_TICK_SCHEME)
678 } else if name.eq_ignore_ascii_case(CRYPTO_0_01_TICK_SCHEME_NAME) {
679 Some(&*CRYPTO_0_01_TICK_SCHEME)
680 } else {
681 parse_fixed_precision_name_ignore_ascii_case(name)
682 .and_then(|precision| {
683 FIXED_PRECISION_TICK_SCHEMES
684 .get(usize::from(precision))
685 .map(|scheme| scheme as &dyn TickSchemeRule)
686 })
687 .or_else(|| {
688 TICK_SCHEMES
689 .read()
690 .unwrap_or_else(PoisonError::into_inner)
691 .get(&name.to_ascii_uppercase())
692 .map(|scheme| *scheme as &dyn TickSchemeRule)
693 })
694 }
695}
696
697pub fn register_tick_scheme(name: &str, scheme: TickScheme) -> Result<(), TickSchemeError> {
706 let name = name.trim().to_ascii_uppercase();
707 check_valid_string_ascii(&name, "name")
708 .map_err(|source| TickSchemeError::InvalidName { source })?;
709
710 if tick_scheme_rule_from_name(&name).is_some() {
711 return Err(TickSchemeError::DuplicateName { name });
712 }
713
714 let mut schemes = TICK_SCHEMES.write().unwrap_or_else(PoisonError::into_inner);
715 if schemes.contains_key(&name) {
716 return Err(TickSchemeError::DuplicateName { name });
717 }
718
719 schemes.insert(name, Box::leak(Box::new(scheme)));
721 Ok(())
722}
723
724#[must_use]
726pub fn get_tick_scheme(name: &str) -> Option<TickScheme> {
727 name.parse().ok()
728}
729
730#[must_use]
732pub fn list_tick_schemes() -> Vec<String> {
733 let mut names: Vec<String> = [
734 FIXED_TICK_SCHEME_NAME,
735 FOREX_3DECIMAL_TICK_SCHEME_NAME,
736 FOREX_5DECIMAL_TICK_SCHEME_NAME,
737 TOPIX100_TICK_SCHEME_NAME,
738 BETFAIR_TICK_SCHEME_NAME,
739 CRYPTO_0_01_TICK_SCHEME_NAME,
740 ]
741 .into_iter()
742 .map(String::from)
743 .chain(
744 (0..=FIXED_PRECISION)
745 .map(|precision| format!("{FIXED_PRECISION_TICK_SCHEME_PREFIX}{precision}")),
746 )
747 .collect();
748 names.extend(
749 TICK_SCHEMES
750 .read()
751 .unwrap_or_else(PoisonError::into_inner)
752 .keys()
753 .cloned(),
754 );
755 names.sort_unstable();
756 names
757}
758
759pub fn check_tick_scheme<T: AsRef<str> + Copy>(tick_scheme: Option<T>) -> CorrectnessResult<()> {
765 check_valid_string_ascii_optional(tick_scheme, "tick_scheme")?;
766 if let Some(name) = tick_scheme {
767 check_predicate_true(
768 tick_scheme_rule_from_name(name.as_ref()).is_some(),
769 "tick_scheme not found in tick schemes",
770 )?;
771 }
772 Ok(())
773}
774
775#[inline(always)]
777fn f64_to_raw(value: f64, precision: u8) -> PriceRaw {
778 #[cfg(feature = "high-precision")]
779 {
780 f64_to_fixed_i128(value, precision)
781 }
782 #[cfg(not(feature = "high-precision"))]
783 {
784 f64_to_fixed_i64(value, precision)
785 }
786}
787
788fn parse_fixed_precision_name(name: &str) -> Option<u8> {
789 name.strip_prefix(FIXED_PRECISION_TICK_SCHEME_PREFIX)
790 .and_then(|precision| precision.parse::<u8>().ok())
791}
792
793fn parse_fixed_precision_name_ignore_ascii_case(name: &str) -> Option<u8> {
794 let prefix_len = FIXED_PRECISION_TICK_SCHEME_PREFIX.len();
795 let prefix = name.get(..prefix_len)?;
796 if !prefix.eq_ignore_ascii_case(FIXED_PRECISION_TICK_SCHEME_PREFIX) {
797 return None;
798 }
799
800 name.get(prefix_len..)?.parse::<u8>().ok()
801}
802
803fn fixed_next_bid_price(tick: Price, value: f64, n: i32, precision: u8) -> Option<Price> {
804 let n = PriceRaw::from(u32::try_from(n).ok()?);
805 let tick_raw = fixed_tick_raw(tick, precision)?;
806 let value_raw = value_to_raw(value)?;
807 let base = value_raw
808 .checked_div_euclid(tick_raw)?
809 .checked_mul(tick_raw)?;
810 let offset = tick_raw.checked_mul(n)?;
811 price_from_raw_checked(base.checked_sub(offset)?, precision)
812}
813
814fn fixed_next_ask_price(tick: Price, value: f64, n: i32, precision: u8) -> Option<Price> {
815 let n = PriceRaw::from(u32::try_from(n).ok()?);
816 let tick_raw = fixed_tick_raw(tick, precision)?;
817 let value_raw = value_to_raw(value)?;
818 let base = value_raw
819 .checked_neg()?
820 .checked_div_euclid(tick_raw)?
821 .checked_neg()?
822 .checked_mul(tick_raw)?;
823 let offset = tick_raw.checked_mul(n)?;
824 price_from_raw_checked(base.checked_add(offset)?, precision)
825}
826
827fn fixed_tick_raw(tick: Price, precision: u8) -> Option<PriceRaw> {
828 if precision > FIXED_PRECISION {
829 return None;
830 }
831
832 check_fixed_raw(tick.raw(), precision).ok()?;
833 Some(tick.raw())
834}
835
836fn value_to_raw(value: f64) -> Option<PriceRaw> {
837 if !value.is_finite() || !(PRICE_MIN..=PRICE_MAX).contains(&value) {
838 return None;
839 }
840 Some(f64_to_raw(value, FIXED_PRECISION))
841}
842
843fn price_from_raw_checked(raw: PriceRaw, precision: u8) -> Option<Price> {
844 if !(PRICE_RAW_MIN..=PRICE_RAW_MAX).contains(&raw) {
845 return None;
846 }
847 Some(Price { raw, precision })
848}
849
850#[cfg(test)]
851mod tests {
852 use std::{
853 str::FromStr,
854 sync::{Arc, Barrier},
855 };
856
857 use proptest::prelude::*;
858 use rstest::rstest;
859 use ustr::Ustr;
860
861 use super::*;
862 use crate::instruments::{CurrencyPair, Instrument, stubs::currency_pair_btcusdt};
863
864 #[rstest]
865 #[case(" test_builder_registered_scheme ", true)]
866 #[case(" test_builder_unknown_scheme ", false)]
867 fn instrument_builder_validates_registered_scheme_names(
868 mut currency_pair_btcusdt: CurrencyPair,
869 #[case] name: &str,
870 #[case] registered: bool,
871 ) {
872 if registered {
873 register_tick_scheme(
874 name,
875 TickScheme::Fixed(FixedTickScheme::new(Price::from("0.05")).unwrap()),
876 )
877 .unwrap();
878 }
879
880 currency_pair_btcusdt.tick_scheme = Some(Ustr::from(name));
881 let expected = currency_pair_btcusdt;
882 let result = CurrencyPair::builder()
883 .instrument_id(expected.id)
884 .raw_symbol(expected.raw_symbol)
885 .base_currency(expected.base_currency)
886 .quote_currency(expected.quote_currency)
887 .price_precision(expected.price_precision)
888 .size_precision(expected.size_precision)
889 .price_increment(expected.price_increment)
890 .size_increment(expected.size_increment)
891 .max_quantity(expected.max_quantity.unwrap())
892 .min_quantity(expected.min_quantity.unwrap())
893 .max_price(expected.max_price.unwrap())
894 .min_price(expected.min_price.unwrap())
895 .margin_init(expected.margin_init)
896 .margin_maint(expected.margin_maint)
897 .tick_scheme(Ustr::from(name))
898 .ts_event(expected.ts_event)
899 .ts_init(expected.ts_init)
900 .build();
901
902 if registered {
903 let instrument = result.unwrap();
904
905 assert_eq!(
906 serde_json::to_value(&instrument).unwrap(),
907 serde_json::to_value(&expected).unwrap()
908 );
909 assert_eq!(
910 instrument.next_bid_price(1.13, 0),
911 Some(Price::from("1.10"))
912 );
913 assert_eq!(
914 instrument.next_ask_price(1.13, 0),
915 Some(Price::from("1.15"))
916 );
917 } else {
918 assert_eq!(
919 result.unwrap_err(),
920 CorrectnessError::PredicateViolation {
921 message: "tick_scheme not found in tick schemes".into(),
922 }
923 );
924 }
925 }
926
927 #[rstest]
928 fn registered_fixed_scheme_rounds_and_rejects_duplicate_cases() {
929 let name = "TEST_REGISTERED_FIXED_CASES";
930 let scheme = FixedTickScheme::new_with_precision(Price::from("0.05"), 2).unwrap();
931 register_tick_scheme(name, TickScheme::Fixed(scheme)).unwrap();
932
933 let registered = get_tick_scheme(" test_registered_fixed_cases ").unwrap();
934 let rule = tick_scheme_rule_from_name(name).unwrap();
935 let duplicate = register_tick_scheme(
936 "test_registered_fixed_cases",
937 TickScheme::Fixed(FixedTickScheme::new(Price::from("1.0")).unwrap()),
938 );
939
940 assert_eq!(registered, TickScheme::Fixed(scheme));
941 assert_eq!(rule.next_bid_price(1.13, 0, 2), Some(Price::from("1.10")));
942 assert_eq!(rule.next_ask_price(1.13, 1, 2), Some(Price::from("1.20")));
943 assert_eq!(
944 duplicate,
945 Err(TickSchemeError::DuplicateName { name: name.into() })
946 );
947 assert_eq!(get_tick_scheme(name), Some(TickScheme::Fixed(scheme)));
948 let names = list_tick_schemes();
949 assert!(names.windows(2).all(|pair| pair[0] < pair[1]));
950 assert!(names.contains(&name.to_string()));
951 }
952
953 #[rstest]
954 #[case("FIXED")]
955 #[case("forex_3decimal")]
956 #[case("FOREX_5DECIMAL")]
957 #[case("TOPIX100")]
958 #[case("betfair")]
959 #[case("CRYPTO_0_01")]
960 #[case("FIXED_PRECISION_1")]
961 #[case(" fixed_precision_01 ")]
962 fn registration_protects_builtins(#[case] name: &str) {
963 let original = get_tick_scheme(name).unwrap();
964 let result = register_tick_scheme(
965 name,
966 TickScheme::Fixed(FixedTickScheme::new(Price::from("2.0")).unwrap()),
967 );
968
969 assert_eq!(
970 result,
971 Err(TickSchemeError::DuplicateName {
972 name: name.trim().to_ascii_uppercase()
973 })
974 );
975 assert_eq!(get_tick_scheme(name), Some(original));
976 }
977
978 #[rstest]
979 #[case("")]
980 #[case(" ")]
981 #[case("\u{e9}")]
982 fn registration_rejects_invalid_names(#[case] name: &str) {
983 let source = check_valid_string_ascii(name.trim(), "name").unwrap_err();
984 let result = register_tick_scheme(name, TickScheme::Betfair);
985
986 assert_eq!(result, Err(TickSchemeError::InvalidName { source }));
987 }
988
989 #[rstest]
990 #[case(PRICE_RAW_MAX + 1)]
991 #[case(crate::types::price::PRICE_UNDEF)]
992 fn fixed_tick_rejects_out_of_range_raw_values(#[case] raw: PriceRaw) {
993 let tick = Price { raw, precision: 0 };
994
995 assert_eq!(
996 FixedTickScheme::new(tick),
997 Err(TickSchemeError::TickNotRepresentable { tick, precision: 0 })
998 );
999 }
1000
1001 #[rstest]
1002 fn registration_is_atomic_across_threads() {
1003 let barrier = Arc::new(Barrier::new(8));
1004
1005 #[allow(
1006 clippy::needless_collect,
1007 reason = "all threads must start before any joins so the barrier can open"
1008 )]
1009 let handles: Vec<_> = (0..8)
1010 .map(|index| {
1011 let barrier = barrier.clone();
1012 std::thread::spawn(move || {
1013 barrier.wait();
1014
1015 let name = if index % 2 == 0 {
1016 "TEST_ATOMIC_REGISTRATION"
1017 } else {
1018 "test_atomic_registration"
1019 };
1020
1021 register_tick_scheme(name, TickScheme::Crypto)
1022 })
1023 })
1024 .collect();
1025
1026 let results: Vec<_> = handles
1027 .into_iter()
1028 .map(|handle| handle.join().unwrap())
1029 .collect();
1030
1031 assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1032 assert_eq!(
1033 results
1034 .iter()
1035 .filter(|result| **result
1036 == Err(TickSchemeError::DuplicateName {
1037 name: "TEST_ATOMIC_REGISTRATION".into()
1038 }))
1039 .count(),
1040 7
1041 );
1042 assert_eq!(
1043 get_tick_scheme("test_atomic_registration"),
1044 Some(TickScheme::Crypto)
1045 );
1046 }
1047
1048 #[rstest]
1049 fn registered_tiered_scheme_preserves_exact_transition_rounding() {
1050 let scheme =
1051 TieredTickScheme::new(&[(0.05, 10.0, 0.05), (10.0, f64::INFINITY, 0.25)], 2, 1000)
1052 .unwrap();
1053 register_tick_scheme(
1054 "TEST_REGISTERED_TIERED_TRANSITION",
1055 TickScheme::Tiered(scheme.clone()),
1056 )
1057 .unwrap();
1058 let rule = tick_scheme_rule_from_name("test_registered_tiered_transition").unwrap();
1059
1060 assert_eq!(rule.next_bid_price(9.99, 0, 2), Some(Price::from("9.95")));
1061 assert_eq!(rule.next_ask_price(9.99, 0, 2), Some(Price::from("10.00")));
1062 assert_eq!(rule.next_bid_price(10.63, 0, 2), Some(Price::from("10.50")));
1063 assert_eq!(rule.next_ask_price(10.63, 0, 2), Some(Price::from("10.75")));
1064 assert_eq!(
1065 get_tick_scheme("TEST_REGISTERED_TIERED_TRANSITION"),
1066 Some(TickScheme::Tiered(scheme))
1067 );
1068 }
1069
1070 #[rstest]
1071 fn fixed_explicit_precision_validates_and_preserves_tick() {
1072 let scheme = FixedTickScheme::new_with_precision(Price::from("0.25"), 3).unwrap();
1073
1074 assert_eq!(scheme.tick(), Price::from("0.250"));
1075 assert_eq!(scheme.precision(), 3);
1076 assert_eq!(scheme, FixedTickScheme::new(Price::from("0.250")).unwrap());
1077 assert_ne!(scheme, FixedTickScheme::new(Price::from("0.25")).unwrap());
1078 assert_eq!(
1079 scheme.next_bid_price(1.63, 0, 3),
1080 Some(Price::from("1.500"))
1081 );
1082 assert_eq!(
1083 scheme.next_ask_price(1.63, 0, 3),
1084 Some(Price::from("1.750"))
1085 );
1086 }
1087
1088 #[rstest]
1089 #[case("0.015", 2)]
1090 #[case("0.001", 2)]
1091 #[case("0.050000001", 2)]
1092 fn fixed_explicit_precision_rejects_unrepresentable_ticks(
1093 #[case] value: &str,
1094 #[case] precision: u8,
1095 ) {
1096 let tick = Price::from(value);
1097
1098 assert_eq!(
1099 FixedTickScheme::new_with_precision(tick, precision),
1100 Err(TickSchemeError::TickNotRepresentable { tick, precision })
1101 );
1102 }
1103
1104 #[rstest]
1105 fn fixed_explicit_precision_returns_typed_precision_error() {
1106 let precision = FIXED_PRECISION + 1;
1107 let source =
1108 check_in_range_inclusive_u8(precision, 0, FIXED_PRECISION, "precision").unwrap_err();
1109
1110 assert_eq!(
1111 FixedTickScheme::new_with_precision(Price::from("0.01"), precision),
1112 Err(TickSchemeError::InvalidPrecision { source })
1113 );
1114 }
1115
1116 #[rstest]
1117 fn fixed_explicit_precision_accepts_large_exact_tick() {
1118 let scheme = FixedTickScheme::new_with_precision(Price::from("1000000.25"), 2).unwrap();
1119
1120 assert_eq!(scheme.precision(), 2);
1121 assert_eq!(
1122 scheme.next_bid_price(1_000_000.26, 0, 2),
1123 Some(Price::from("1000000.25"))
1124 );
1125 }
1126
1127 #[rstest]
1128 fn fixed_tick_scheme_prices() {
1129 let scheme = FixedTickScheme::new(Price::from("0.5")).unwrap();
1130 let bid = scheme.next_bid_price(10.3, 0, 2).unwrap();
1131 let ask = scheme.next_ask_price(10.3, 0, 2).unwrap();
1132 assert_eq!(bid, Price::from("10.00"));
1133 assert_eq!(ask, Price::from("10.50"));
1134 }
1135
1136 #[rstest]
1137 fn fixed_tick_negative_returns_typed_error_with_display() {
1138 let error = FixedTickScheme::new(Price::from("-0.01")).unwrap_err();
1139
1140 assert_eq!(
1141 error,
1142 TickSchemeError::TickNotPositive {
1143 tick: Price::from("-0.01")
1144 }
1145 );
1146 assert_eq!(error.to_string(), "tick must be positive");
1147 }
1148
1149 #[rstest]
1150 fn fixed_tick_boundary() {
1151 let scheme = FixedTickScheme::new(Price::from("0.5")).unwrap();
1152 let price = scheme.next_bid_price(10.5, 0, 2).unwrap();
1153 assert_eq!(price, Price::new(10.5, 2));
1154 }
1155
1156 #[rstest]
1157 fn fixed_tick_scheme_preserves_decimal_boundaries() {
1158 let tenth = FixedTickScheme::new(Price::from("0.1")).unwrap();
1159 let cent = FixedTickScheme::new(Price::from("0.01")).unwrap();
1160
1161 assert_eq!(tenth.next_bid_price(0.3, 0, 1), Some(Price::new(0.3, 1)));
1162 assert_eq!(tenth.next_ask_price(0.3, 0, 1), Some(Price::new(0.3, 1)));
1163 assert_eq!(cent.next_bid_price(0.07, 0, 2), Some(Price::new(0.07, 2)));
1164 assert_eq!(cent.next_ask_price(0.07, 0, 2), Some(Price::new(0.07, 2)));
1165 }
1166
1167 #[rstest]
1168 fn fixed_tick_multiple_steps() {
1169 let scheme = FixedTickScheme::new(Price::from("1.0")).unwrap();
1170 let bid = scheme.next_bid_price(10.0, 2, 1).unwrap();
1171 let ask = scheme.next_ask_price(10.0, 3, 1).unwrap();
1172 assert_eq!(bid, Price::new(8.0, 1));
1173 assert_eq!(ask, Price::new(13.0, 1));
1174 }
1175
1176 #[rstest]
1177 fn tick_scheme_round_trip() {
1178 let scheme = TickScheme::from_str("CRYPTO_0_01").unwrap();
1179 assert_eq!(scheme.to_string(), "CRYPTO_0_01");
1180 }
1181
1182 #[rstest]
1183 fn tick_scheme_rule_from_fixed_precision_name() {
1184 let scheme = tick_scheme_rule_from_name("fixed_precision_1").unwrap();
1185
1186 assert_eq!(scheme.next_bid_price(0.3, 0, 1), Some(Price::new(0.3, 1)));
1187 assert_eq!(scheme.next_ask_price(0.31, 0, 1), Some(Price::new(0.4, 1)));
1188 }
1189
1190 #[rstest]
1191 fn tick_scheme_unknown() {
1192 let error = TickScheme::from_str("UNKNOWN").unwrap_err();
1193
1194 assert_eq!(
1195 error,
1196 TickSchemeError::UnknownName {
1197 name: "UNKNOWN".to_string(),
1198 }
1199 );
1200 assert_eq!(error.to_string(), "unknown tick scheme UNKNOWN");
1201 }
1202
1203 #[rstest]
1204 fn tick_scheme_fixed_precision_above_max_returns_unknown_name() {
1205 let name = format!("FIXED_PRECISION_{}", FIXED_PRECISION + 1);
1206 let error = TickScheme::from_str(&name).unwrap_err();
1207
1208 assert_eq!(error, TickSchemeError::UnknownName { name: name.clone() });
1209 assert_eq!(error.to_string(), format!("unknown tick scheme {name}"));
1210 }
1211
1212 #[rstest]
1213 fn fixed_tick_zero() {
1214 let error = FixedTickScheme::new(Price::from("0.0")).unwrap_err();
1215
1216 assert_eq!(
1217 error,
1218 TickSchemeError::TickNotPositive {
1219 tick: Price::from("0.0")
1220 }
1221 );
1222 assert_eq!(error.to_string(), "tick must be positive");
1223 }
1224
1225 #[rstest]
1226 fn fixed_tick_rejects_fractional_digits_beyond_precision() {
1227 let tick = Price::from_raw(1, 2);
1228
1229 assert_eq!(
1230 FixedTickScheme::new(tick),
1231 Err(TickSchemeError::TickNotRepresentable { tick, precision: 2 })
1232 );
1233 }
1234
1235 #[rstest]
1236 fn fixed_tick_rejects_invalid_input_precision() {
1237 let precision = FIXED_PRECISION + 1;
1238 let mut tick = Price::from("1");
1239 tick.precision = precision;
1240 let source =
1241 check_in_range_inclusive_u8(precision, 0, FIXED_PRECISION, "precision").unwrap_err();
1242
1243 assert_eq!(
1244 FixedTickScheme::new(tick),
1245 Err(TickSchemeError::InvalidPrecision { source })
1246 );
1247 }
1248
1249 #[rstest]
1250 #[case(0)]
1251 #[case(1)]
1252 #[case(FIXED_PRECISION + 1)]
1253 fn fixed_tick_rejects_output_precision_that_cannot_represent_tick(#[case] precision: u8) {
1254 let scheme = FixedTickScheme::new(Price::from("0.25")).unwrap();
1255
1256 assert_eq!(scheme.next_bid_price(1.63, 0, precision), None);
1257 assert_eq!(scheme.next_ask_price(1.63, 0, precision), None);
1258 }
1259
1260 #[rstest]
1261 fn fixed_tick_accepts_lower_output_precision_when_tick_is_representable() {
1262 let scheme = FixedTickScheme::new(Price::from("0.250")).unwrap();
1263 let rescaled = FixedTickScheme::new_with_precision(scheme.tick(), 2).unwrap();
1264
1265 assert_eq!(rescaled.tick(), Price::from("0.25"));
1266 assert_eq!(rescaled.precision(), 2);
1267 assert_eq!(scheme.next_bid_price(1.63, 0, 2), Some(Price::from("1.50")));
1268 assert_eq!(scheme.next_ask_price(1.63, 0, 2), Some(Price::from("1.75")));
1269 }
1270
1271 #[rstest]
1272 fn fixed_tick_preserves_digits_beyond_float_accuracy() {
1273 let tick = Price::from("9000000000.000000001");
1274 let scheme = FixedTickScheme::new(tick).unwrap();
1275
1276 assert_eq!(scheme.tick().raw, tick.raw);
1277 assert_eq!(scheme.precision(), 9);
1278 assert_eq!(scheme.next_ask_price(0.0, 1, 9), Some(tick));
1279 assert_eq!(scheme.next_bid_price(0.0, 1, 9), Some(-tick));
1280 }
1281
1282 #[rstest]
1283 fn fixed_tick_steps_at_max_precision() {
1284 let scheme = FixedTickScheme::new(Price::from_raw(1, FIXED_PRECISION)).unwrap();
1285
1286 assert_eq!(
1287 scheme.next_ask_price(0.0, 2, FIXED_PRECISION),
1288 Some(Price::from_raw(2, FIXED_PRECISION))
1289 );
1290 assert_eq!(
1291 scheme.next_bid_price(0.0, 2, FIXED_PRECISION),
1292 Some(Price::from_raw(-2, FIXED_PRECISION))
1293 );
1294 }
1295
1296 #[rstest]
1297 fn fixed_tick_scheme_nan_value_returns_none() {
1298 let scheme = FixedTickScheme::new(Price::from("1.0")).unwrap();
1299 assert!(scheme.next_bid_price(f64::NAN, 0, 2).is_none());
1300 assert!(scheme.next_ask_price(f64::NAN, 0, 2).is_none());
1301 }
1302
1303 #[rstest]
1304 fn fixed_tick_scheme_out_of_range_returns_none() {
1305 let scheme = FixedTickScheme::new(Price::from_raw(PRICE_RAW_MAX, 0)).unwrap();
1307 assert!(scheme.next_ask_price(PRICE_MAX, 1, 2).is_none());
1308 }
1309
1310 #[rstest]
1311 fn tiered_tick_scheme_topix100_construction() {
1312 let scheme = TieredTickScheme::topix100();
1313 assert!(scheme.tick_count() > 0);
1314 assert_eq!(scheme.precision(), 4);
1315 assert_eq!(scheme.min_price(), Price::new(0.1, 4));
1316 }
1317
1318 #[rstest]
1319 fn tiered_tick_scheme_betfair_construction() {
1320 let scheme = TieredTickScheme::betfair();
1321 assert_eq!(scheme.tick_count(), 350);
1322 assert_eq!(scheme.precision(), 2);
1323 assert_eq!(scheme.min_price(), Price::new(1.01, 2));
1324 assert_eq!(scheme.max_price(), Price::new(1000.0, 2));
1325 }
1326
1327 #[rstest]
1328 fn tiered_tick_scheme_ask_at_low_price() {
1329 let scheme = TieredTickScheme::topix100();
1330 let ask = scheme.next_ask_price(500.0, 0, 4).unwrap();
1331 assert_eq!(ask, Price::new(500.0, 4));
1332 }
1333
1334 #[rstest]
1335 fn tiered_tick_scheme_bid_at_low_price() {
1336 let scheme = TieredTickScheme::topix100();
1337 let bid = scheme.next_bid_price(500.0, 0, 4).unwrap();
1338 assert_eq!(bid, Price::new(500.0, 4));
1339 }
1340
1341 #[rstest]
1342 fn tiered_tick_scheme_ask_steps() {
1343 let scheme = TieredTickScheme::topix100();
1344 let ask0 = scheme.next_ask_price(500.0, 0, 4).unwrap();
1345 let ask1 = scheme.next_ask_price(500.0, 1, 4).unwrap();
1346 assert!(ask1 > ask0);
1347 assert_eq!(ask1, Price::new(500.1, 4));
1348 }
1349
1350 #[rstest]
1351 fn tiered_tick_scheme_bid_steps() {
1352 let scheme = TieredTickScheme::topix100();
1353 let bid0 = scheme.next_bid_price(500.0, 0, 4).unwrap();
1354 let bid1 = scheme.next_bid_price(500.0, 1, 4).unwrap();
1355 assert!(bid1 < bid0);
1356 assert_eq!(bid1, Price::new(499.9, 4));
1357 }
1358
1359 #[rstest]
1360 fn tiered_tick_scheme_tier_boundary_1000() {
1361 let scheme = TieredTickScheme::topix100();
1362 let ask = scheme.next_ask_price(1000.0, 1, 4).unwrap();
1364 assert_eq!(ask, Price::new(1000.5, 4));
1365 }
1366
1367 #[rstest]
1368 #[case(3.90, 1, "3.95")]
1369 #[case(4.0, 1, "4.10")]
1370 fn tiered_tick_scheme_betfair_ask_transition(
1371 #[case] value: f64,
1372 #[case] n: i32,
1373 #[case] expected: &str,
1374 ) {
1375 let scheme = TieredTickScheme::betfair();
1376 let ask = scheme.next_ask_price(value, n, 2).unwrap();
1377 assert_eq!(ask, Price::from(expected));
1378 }
1379
1380 #[rstest]
1381 #[case(1.499, 0, "1.49")]
1382 #[case(2.011, 0, "2.00")]
1383 #[case(2.027, 2, "1.99")]
1384 fn tiered_tick_scheme_betfair_bid_transition(
1385 #[case] value: f64,
1386 #[case] n: i32,
1387 #[case] expected: &str,
1388 ) {
1389 let scheme = TieredTickScheme::betfair();
1390 let bid = scheme.next_bid_price(value, n, 2).unwrap();
1391 assert_eq!(bid, Price::from(expected));
1392 }
1393
1394 #[rstest]
1395 fn tiered_tick_scheme_between_ticks() {
1396 let scheme = TieredTickScheme::topix100();
1397 let ask = scheme.next_ask_price(1000.3, 0, 4).unwrap();
1399 assert!(ask.as_f64() >= 1000.3);
1400 let bid = scheme.next_bid_price(1000.3, 0, 4).unwrap();
1401 assert!(bid.as_f64() <= 1000.3);
1402 }
1403
1404 #[rstest]
1405 fn tiered_tick_scheme_off_grid_bid_below_tick() {
1406 let scheme = TieredTickScheme::new(&[(1.0, 2.0, 0.05)], 2, 100).unwrap();
1408 let bid = scheme.next_bid_price(1.049, 0, 2).unwrap();
1409 assert_eq!(bid, Price::new(1.0, 2));
1410 }
1411
1412 #[rstest]
1413 fn tiered_tick_scheme_off_grid_ask_above_tick() {
1414 let scheme = TieredTickScheme::new(&[(1.0, 2.0, 0.05)], 2, 100).unwrap();
1416 let ask = scheme.next_ask_price(1.051, 0, 2).unwrap();
1417 assert_eq!(ask, Price::new(1.10, 2));
1418 }
1419
1420 #[rstest]
1421 fn tiered_tick_scheme_bid_below_min_returns_none() {
1422 let scheme = TieredTickScheme::topix100();
1423 assert!(scheme.next_bid_price(0.05, 0, 4).is_none());
1424 }
1425
1426 #[rstest]
1427 fn tiered_tick_scheme_ask_beyond_last_tick_returns_none() {
1428 let scheme = TieredTickScheme::topix100();
1429 let last = scheme.max_price().as_f64();
1430 assert!(scheme.next_ask_price(last, 1, 4).is_none());
1431 }
1432
1433 #[rstest]
1434 fn tiered_tick_scheme_bid_beyond_last_tick_returns_last() {
1435 let scheme = TieredTickScheme::new(&[(1.0, 10.0, 1.0)], 1, 100).unwrap();
1436 let bid = scheme.next_bid_price(9.5, 0, 1).unwrap();
1438 assert_eq!(bid, Price::new(9.0, 1));
1439 }
1440
1441 #[rstest]
1442 fn tiered_tick_scheme_negative_n_returns_none() {
1443 let scheme = TieredTickScheme::topix100();
1444 assert!(scheme.next_bid_price(500.0, -1, 4).is_none());
1445 assert!(scheme.next_ask_price(500.0, -1, 4).is_none());
1446 }
1447
1448 #[rstest]
1449 fn tiered_tick_scheme_nan_value_returns_none() {
1450 let scheme = TieredTickScheme::topix100();
1451 assert!(scheme.next_bid_price(f64::NAN, 0, 4).is_none());
1452 assert!(scheme.next_ask_price(f64::NAN, 0, 4).is_none());
1453 }
1454
1455 #[rstest]
1456 fn tiered_tick_scheme_infinite_value_saturates() {
1457 let scheme = TieredTickScheme::topix100();
1458 assert_eq!(
1459 scheme.next_bid_price(f64::INFINITY, 0, 4),
1460 Some(scheme.max_price())
1461 );
1462 assert!(scheme.next_ask_price(f64::INFINITY, 0, 4).is_none());
1463 assert!(scheme.next_bid_price(f64::NEG_INFINITY, 0, 4).is_none());
1464 }
1465
1466 #[rstest]
1467 fn crypto_tick_scheme_out_of_range_returns_none() {
1468 let scheme = TickScheme::Crypto;
1470 assert!(scheme.next_ask_price(PRICE_MAX * 2.0, 0, 2).is_none());
1471 assert!(scheme.next_bid_price(PRICE_MIN * 2.0, 0, 2).is_none());
1472 }
1473
1474 #[rstest]
1475 fn tiered_tick_scheme_validation_empty_tiers() {
1476 let error = TieredTickScheme::new(&[], 2, 100).unwrap_err();
1477
1478 assert_eq!(error, TickSchemeError::EmptyTiers);
1479 assert_eq!(error.to_string(), "tiers must not be empty");
1480 }
1481
1482 #[rstest]
1483 #[case(
1484 vec![(100.0, 50.0, 1.0)],
1485 TickSchemeError::TierStartNotLessThanStop {
1486 index: 0,
1487 start: 100.0,
1488 stop: 50.0,
1489 },
1490 "tier 0: start (100) must be less than stop (50)"
1491 )]
1492 #[case(
1493 vec![(2.0, 2.0, 0.1)],
1494 TickSchemeError::TierStartNotLessThanStop {
1495 index: 0,
1496 start: 2.0,
1497 stop: 2.0,
1498 },
1499 "tier 0: start (2) must be less than stop (2)"
1500 )]
1501 #[case(
1502 vec![(0.0, 100.0, -1.0)],
1503 TickSchemeError::TierStepNotPositive {
1504 index: 0,
1505 step: -1.0,
1506 },
1507 "tier 0: step (-1) must be positive"
1508 )]
1509 #[case(
1510 vec![(1.0, 2.0, 0.0)],
1511 TickSchemeError::TierStepNotPositive {
1512 index: 0,
1513 step: 0.0,
1514 },
1515 "tier 0: step (0) must be positive"
1516 )]
1517 #[case(
1518 vec![(0.0, 100.0, 200.0)],
1519 TickSchemeError::TierStepNotLessThanRange {
1520 index: 0,
1521 start: 0.0,
1522 stop: 100.0,
1523 step: 200.0,
1524 range: 100.0,
1525 },
1526 "tier 0: step (200) must be less than range (100 - 0 = 100)"
1527 )]
1528 #[case(
1529 vec![(10.0, 20.0, 1.0), (1.0, 10.0, 1.0)],
1530 TickSchemeError::TierOverlapsPrevious {
1531 index: 1,
1532 start: 1.0,
1533 prev_stop: 20.0,
1534 },
1535 "tier 1: start (1) overlaps previous tier stop (20)"
1536 )]
1537 #[case(
1538 vec![(1.0, 10.0, 1.0), (5.0, 15.0, 1.0)],
1539 TickSchemeError::TierOverlapsPrevious {
1540 index: 1,
1541 start: 5.0,
1542 prev_stop: 10.0,
1543 },
1544 "tier 1: start (5) overlaps previous tier stop (10)"
1545 )]
1546 fn tiered_tick_scheme_invalid_tiers_return_typed_errors(
1547 #[case] tiers: Vec<(f64, f64, f64)>,
1548 #[case] expected_error: TickSchemeError,
1549 #[case] expected_display: &str,
1550 ) {
1551 let error = TieredTickScheme::new(&tiers, 2, 100).unwrap_err();
1552
1553 assert_eq!(error, expected_error);
1554 assert_eq!(error.to_string(), expected_display);
1555 }
1556
1557 #[rstest]
1558 #[case(vec![(f64::NAN, 10.0, 1.0)])]
1559 #[case(vec![(1.0, f64::NAN, 1.0)])]
1560 #[case(vec![(1.0, 10.0, f64::NAN)])]
1561 fn tiered_tick_scheme_nan_tiers_return_typed_error(#[case] tiers: Vec<(f64, f64, f64)>) {
1562 let error = TieredTickScheme::new(&tiers, 2, 100).unwrap_err();
1563
1564 match &error {
1565 TickSchemeError::TierValuesNaN {
1566 index,
1567 start,
1568 stop,
1569 step,
1570 } => {
1571 assert_eq!(*index, 0);
1572 assert!(
1573 start.is_nan() || stop.is_nan() || step.is_nan(),
1574 "expected one NaN tier value in {error:?}",
1575 );
1576 }
1577 _ => panic!("unexpected error variant: {error:?}"),
1578 }
1579 assert_eq!(error.to_string(), "tier 0: values must not be NaN");
1580 }
1581
1582 #[rstest]
1583 fn tiered_tick_scheme_start_outside_price_range_returns_typed_error() {
1584 let start = PRICE_MIN - 1.0;
1585 let stop = PRICE_MIN + 1.0;
1586 let error = TieredTickScheme::new(&[(start, stop, 1.0)], 2, 100).unwrap_err();
1587
1588 assert_eq!(
1589 error,
1590 TickSchemeError::TierStartOutsidePriceRange { index: 0, start }
1591 );
1592 assert_eq!(
1593 error.to_string(),
1594 format!("tier 0: start ({start}) outside Price range")
1595 );
1596 }
1597
1598 #[rstest]
1599 fn tiered_tick_scheme_stop_outside_price_range_returns_typed_error() {
1600 let start = PRICE_MAX - 2.0;
1601 let stop = PRICE_MAX + 1.0;
1602 let error = TieredTickScheme::new(&[(start, stop, 1.0)], 2, 100).unwrap_err();
1603
1604 assert_eq!(
1605 error,
1606 TickSchemeError::TierStopOutsidePriceRange { index: 0, stop }
1607 );
1608 assert_eq!(
1609 error.to_string(),
1610 format!("tier 0: stop ({stop}) outside Price range")
1611 );
1612 }
1613
1614 #[rstest]
1615 fn tiered_tick_scheme_invalid_precision_wraps_source_error() {
1616 let invalid_precision = FIXED_PRECISION + 1;
1617 let source = Price::new_checked(0.0, invalid_precision).unwrap_err();
1618 let error = TieredTickScheme::new(&[(1.0, 10.0, 1.0)], invalid_precision, 100).unwrap_err();
1619
1620 assert_eq!(
1621 error,
1622 TickSchemeError::InvalidPrecision {
1623 source: source.clone(),
1624 }
1625 );
1626 assert_eq!(error.to_string(), source.to_string());
1627 }
1628
1629 #[rstest]
1630 fn tiered_tick_scheme_empty_expansion_returns_typed_error() {
1631 let error = TieredTickScheme::new(&[(1.0, f64::INFINITY, 1.0)], 2, 0).unwrap_err();
1632
1633 assert_eq!(error, TickSchemeError::EmptyTickExpansion);
1634 assert_eq!(error.to_string(), "tier expansion produced no ticks");
1635 }
1636
1637 #[rstest]
1638 fn tiered_tick_scheme_expanded_tick_outside_range_returns_typed_error() {
1639 let invalid_value = PRICE_MAX + 1.0;
1640 let error = TieredTickScheme::new(&[(PRICE_MAX, f64::INFINITY, 1.0)], 2, 2).unwrap_err();
1641
1642 assert_eq!(
1643 error,
1644 TickSchemeError::ExpandedTickOutsidePriceRange {
1645 value: invalid_value,
1646 }
1647 );
1648 assert_eq!(
1649 error.to_string(),
1650 format!("expanded tick value {invalid_value} outside Price range")
1651 );
1652 }
1653
1654 #[rstest]
1655 fn tiered_tick_scheme_finite_tier_includes_all_ticks() {
1656 let scheme = TieredTickScheme::new(&[(0.0, 0.3, 0.1)], 1, 100).unwrap();
1658 assert_eq!(scheme.tick_count(), 3);
1659 }
1660
1661 #[rstest]
1662 fn tiered_tick_scheme_simple_two_tiers() {
1663 let scheme =
1664 TieredTickScheme::new(&[(1.0, 10.0, 1.0), (10.0, 100.0, 5.0)], 2, 100).unwrap();
1665 let ticks = scheme.ticks();
1666 assert_eq!(ticks[0], Price::new(1.0, 2));
1669 assert_eq!(ticks[8], Price::new(9.0, 2));
1670 assert_eq!(ticks[9], Price::new(10.0, 2));
1671 assert_eq!(ticks[10], Price::new(15.0, 2));
1672 }
1673
1674 #[rstest]
1675 fn tiered_tick_scheme_infinity_tier() {
1676 let scheme = TieredTickScheme::new(&[(100.0, f64::INFINITY, 10.0)], 1, 5).unwrap();
1677 assert_eq!(scheme.tick_count(), 5);
1678 let ticks = scheme.ticks();
1679 assert_eq!(ticks[0], Price::new(100.0, 1));
1680 assert_eq!(ticks[4], Price::new(140.0, 1));
1681 }
1682
1683 #[rstest]
1684 fn tiered_tick_scheme_from_str_topix100() {
1685 let scheme = TickScheme::from_str("TOPIX100").unwrap();
1686 assert_eq!(scheme.to_string(), "TIERED");
1687 }
1688
1689 #[rstest]
1690 fn tiered_tick_scheme_from_str_betfair() {
1691 let scheme = TickScheme::from_str("BETFAIR").unwrap();
1692 assert_eq!(scheme.to_string(), BETFAIR_TICK_SCHEME_NAME);
1693 assert_eq!(
1694 scheme.next_ask_price(4.0, 1, 2).unwrap(),
1695 Price::new(4.1, 2)
1696 );
1697 }
1698
1699 #[rstest]
1700 fn tiered_tick_scheme_display() {
1701 let scheme = TieredTickScheme::new(&[(1.0, 10.0, 1.0)], 2, 100).unwrap();
1702 assert_eq!(scheme.to_string(), "TIERED");
1703 }
1704
1705 #[rstest]
1706 fn tiered_tick_scheme_min_tick_bid() {
1707 let scheme = TieredTickScheme::topix100();
1708 let result = scheme.next_bid_price(0.1, 0, 4).unwrap();
1709 assert_eq!(result, Price::new(0.1, 4));
1710 }
1711
1712 #[rstest]
1713 fn tiered_tick_scheme_min_tick_bid_n1_returns_none() {
1714 let scheme = TieredTickScheme::topix100();
1715 assert!(scheme.next_bid_price(0.1, 1, 4).is_none());
1716 }
1717
1718 #[rstest]
1719 fn tiered_tick_scheme_boundary_tick_equality() {
1720 let scheme = TieredTickScheme::topix100();
1721 let bid = scheme.next_bid_price(1000.0, 0, 4).unwrap();
1722 assert_eq!(bid, Price::new(1000.0, 4));
1723 let ask = scheme.next_ask_price(1000.0, 0, 4).unwrap();
1724 assert_eq!(ask, Price::new(1000.0, 4));
1725 }
1726
1727 #[rstest]
1728 fn tiered_tick_scheme_tier_transition_ask_from_999_9() {
1729 let scheme = TieredTickScheme::topix100();
1730 let ask = scheme.next_ask_price(999.9, 0, 4).unwrap();
1731 assert_eq!(ask, Price::new(999.9, 4));
1732 }
1733
1734 #[rstest]
1735 fn tiered_tick_scheme_tier_transition_bid_from_1000_5() {
1736 let scheme = TieredTickScheme::topix100();
1737 let bid = scheme.next_bid_price(1000.5, 1, 4).unwrap();
1738 assert_eq!(bid, Price::new(1000.0, 4));
1739 }
1740
1741 #[rstest]
1742 fn tiered_tick_scheme_large_n_beyond_bounds_ask() {
1743 let scheme = TieredTickScheme::topix100();
1744 let max = scheme.max_price().as_f64();
1745 assert!(scheme.next_ask_price(max - 1000.0, 100_000, 4).is_none());
1746 }
1747
1748 #[rstest]
1749 fn tiered_tick_scheme_large_n_beyond_bounds_bid() {
1750 let scheme = TieredTickScheme::topix100();
1751 let min = scheme.min_price().as_f64();
1752 assert!(scheme.next_bid_price(min + 1000.0, 100_000, 4).is_none());
1753 }
1754
1755 #[rstest]
1756 fn tiered_tick_scheme_out_of_bounds_ask_far_above() {
1757 let scheme = TieredTickScheme::topix100();
1758 assert!(scheme.next_ask_price(999_999_999.0, 0, 4).is_none());
1759 }
1760
1761 #[rstest]
1762 fn tiered_tick_scheme_idempotent_on_tick() {
1763 let scheme = TieredTickScheme::topix100();
1764 let price = 500.0;
1765 let ask = scheme.next_ask_price(price, 0, 4).unwrap();
1766 let ask2 = scheme.next_ask_price(ask.as_f64(), 0, 4).unwrap();
1767 assert_eq!(ask, ask2);
1768 let bid = scheme.next_bid_price(price, 0, 4).unwrap();
1769 let bid2 = scheme.next_bid_price(bid.as_f64(), 0, 4).unwrap();
1770 assert_eq!(bid, bid2);
1771 }
1772
1773 #[rstest]
1774 fn tiered_tick_scheme_consistency_forward_backward() {
1775 let scheme = TieredTickScheme::topix100();
1776 let start = 5000.0;
1777 let forward = scheme.next_ask_price(start, 10, 4).unwrap();
1778 let back = scheme.next_bid_price(forward.as_f64(), 10, 4).unwrap();
1779 assert!(back.as_f64() <= start);
1780 }
1781
1782 #[rstest]
1783 fn tiered_tick_scheme_cumulative_equals_direct() {
1784 let scheme = TieredTickScheme::topix100();
1785 let price = 1000.0;
1786 let mut cumulative = price;
1787 for _ in 0..5 {
1788 if let Some(result) = scheme.next_ask_price(cumulative, 1, 4) {
1789 cumulative = result.as_f64();
1790 }
1791 }
1792 let direct = scheme.next_ask_price(price, 5, 4).unwrap();
1793 assert!((cumulative - direct.as_f64()).abs() < 1e-10);
1794 }
1795
1796 #[rstest]
1797 #[case(1000.0, 0, 1000.0)]
1798 #[case(1000.25, 0, 1000.5)]
1799 #[case(10_001.0, 0, 10_005.0)]
1800 #[case(10_000_001.0, 0, 10_005_000.0)]
1801 #[case(9999.0, 2, 10_005.0)]
1802 fn tiered_tick_scheme_topix100_ask_parametrized(
1803 #[case] value: f64,
1804 #[case] n: i32,
1805 #[case] expected: f64,
1806 ) {
1807 let scheme = TieredTickScheme::topix100();
1808 let ask = scheme.next_ask_price(value, n, 4).unwrap();
1809 assert_eq!(ask, Price::new(expected, 4));
1810 }
1811
1812 #[rstest]
1813 #[case(1000.75, 0, 1000.5)]
1814 #[case(10_007.0, 0, 10_005.0)]
1815 #[case(10_000_001.0, 0, 10_000_000.0)]
1816 #[case(10_006.0, 2, 9999.0)]
1817 fn tiered_tick_scheme_topix100_bid_parametrized(
1818 #[case] value: f64,
1819 #[case] n: i32,
1820 #[case] expected: f64,
1821 ) {
1822 let scheme = TieredTickScheme::topix100();
1823 let bid = scheme.next_bid_price(value, n, 4).unwrap();
1824 assert_eq!(bid, Price::new(expected, 4));
1825 }
1826
1827 proptest! {
1829 #[rstest]
1830 fn prop_tiered_bid_at_or_below_value(value in 0.1f64..100_000.0) {
1831 let scheme = TieredTickScheme::topix100();
1832 if let Some(bid) = scheme.next_bid_price(value, 0, 4) {
1833 prop_assert!(bid.as_f64() <= value + 1e-9);
1834 }
1835 }
1836 }
1837
1838 proptest! {
1840 #[rstest]
1841 fn prop_tiered_ask_at_or_above_value(value in 0.1f64..100_000.0) {
1842 let scheme = TieredTickScheme::topix100();
1843 if let Some(ask) = scheme.next_ask_price(value, 0, 4) {
1844 prop_assert!(ask.as_f64() >= value - 1e-9);
1845 }
1846 }
1847 }
1848
1849 proptest! {
1850 #[rstest]
1851 fn prop_tiered_bid_ask_match_adjacent_ticks(
1852 raw_index in any::<usize>(),
1853 offset in 0usize..=4,
1854 ) {
1855 let scheme = TieredTickScheme::topix100();
1856 let ticks = scheme.ticks();
1857 let index = raw_index % (ticks.len() - 1);
1858 let lower = ticks[index];
1859 let upper = ticks[index + 1];
1860 let value = f64::midpoint(lower.as_f64(), upper.as_f64());
1861 let steps = i32::try_from(offset).unwrap();
1862
1863 let expected_bid = index
1864 .checked_sub(offset)
1865 .map(|target| ticks[target]);
1866 let expected_ask = index
1867 .checked_add(offset + 1)
1868 .filter(|target| *target < ticks.len())
1869 .map(|target| ticks[target]);
1870
1871 prop_assert_eq!(scheme.next_bid_price(value, steps, 4), expected_bid);
1872 prop_assert_eq!(scheme.next_ask_price(value, steps, 4), expected_ask);
1873 }
1874 }
1875
1876 proptest! {
1878 #[rstest]
1879 fn prop_tiered_ask_monotonic_in_n(value in 1.0f64..10_000.0) {
1880 let scheme = TieredTickScheme::topix100();
1881 let mut prev: Option<Price> = None;
1882
1883 for n in 0..5 {
1884 if let Some(ask) = scheme.next_ask_price(value, n, 4) {
1885 if let Some(p) = prev {
1886 prop_assert!(ask >= p);
1887 }
1888 prev = Some(ask);
1889 }
1890 }
1891 }
1892 }
1893
1894 proptest! {
1896 #[rstest]
1897 fn prop_tiered_ticks_sorted(
1898 start in 1.0f64..100.0,
1899 step in 0.01f64..10.0,
1900 ) {
1901 let stop = start + step * 10.0;
1902 if let Ok(scheme) = TieredTickScheme::new(&[(start, stop, step)], 2, 100) {
1903 let ticks = scheme.ticks();
1904 for i in 1..ticks.len() {
1905 prop_assert!(ticks[i] > ticks[i - 1]);
1906 }
1907 }
1908 }
1909 }
1910}