1use std::{fmt::Display, str::FromStr, sync::LazyLock};
19
20use nautilus_core::correctness::{
21 CorrectnessError, CorrectnessResult, check_predicate_true, check_valid_string_ascii_optional,
22};
23use thiserror::Error;
24
25#[cfg(not(feature = "high-precision"))]
26use crate::types::fixed::f64_to_fixed_i64;
27#[cfg(feature = "high-precision")]
28use crate::types::fixed::f64_to_fixed_i128;
29use crate::types::{
30 Price,
31 fixed::{FIXED_PRECISION, FIXED_SCALAR},
32 price::{PRICE_MAX, PRICE_MIN, PRICE_RAW_MAX, PRICE_RAW_MIN, PriceRaw},
33};
34
35pub trait TickSchemeRule: Display {
36 fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price>;
37 fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price>;
38}
39
40#[derive(Clone, Debug, Error, PartialEq)]
42pub enum TickSchemeError {
43 #[error("tick must be finite")]
45 TickNotFinite {
46 tick: f64,
48 },
49 #[error("tick must be positive")]
51 TickNotPositive {
52 tick: f64,
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("tier expansion produced no ticks")]
137 EmptyTickExpansion,
138 #[error("expanded tick value {value} outside Price range")]
140 ExpandedTickOutsidePriceRange {
141 value: f64,
143 },
144 #[error("unknown tick scheme {name}")]
146 UnknownName {
147 name: String,
149 },
150}
151
152pub const BETFAIR_TICK_SCHEME_NAME: &str = "BETFAIR";
153pub const TOPIX100_TICK_SCHEME_NAME: &str = "TOPIX100";
154pub const CRYPTO_0_01_TICK_SCHEME_NAME: &str = "CRYPTO_0_01";
155pub const FOREX_3DECIMAL_TICK_SCHEME_NAME: &str = "FOREX_3DECIMAL";
156pub const FOREX_5DECIMAL_TICK_SCHEME_NAME: &str = "FOREX_5DECIMAL";
157pub const FIXED_TICK_SCHEME_NAME: &str = "FIXED";
158pub const FIXED_PRECISION_TICK_SCHEME_PREFIX: &str = "FIXED_PRECISION_";
159
160const BETFAIR_PRICE_TIERS: [(f64, f64, f64); 10] = [
161 (1.01, 2.0, 0.01),
162 (2.0, 3.0, 0.02),
163 (3.0, 4.0, 0.05),
164 (4.0, 6.0, 0.1),
165 (6.0, 10.0, 0.2),
166 (10.0, 20.0, 0.5),
167 (20.0, 30.0, 1.0),
168 (30.0, 50.0, 2.0),
169 (50.0, 100.0, 5.0),
170 (100.0, 1010.0, 10.0),
171];
172
173pub static BETFAIR_TICK_SCHEME: LazyLock<TieredTickScheme> = LazyLock::new(|| {
174 TieredTickScheme::new(&BETFAIR_PRICE_TIERS, 2, 100)
175 .expect("BETFAIR tick scheme tiers are valid by construction")
176});
177
178pub static TOPIX100_TICK_SCHEME: LazyLock<TieredTickScheme> = LazyLock::new(|| {
179 TieredTickScheme::new(
180 &[
181 (0.1, 1_000.0, 0.1),
182 (1_000.0, 3_000.0, 0.5),
183 (3_000.0, 10_000.0, 1.0),
184 (10_000.0, 30_000.0, 5.0),
185 (30_000.0, 100_000.0, 10.0),
186 (100_000.0, 300_000.0, 50.0),
187 (300_000.0, 1_000_000.0, 100.0),
188 (1_000_000.0, 3_000_000.0, 500.0),
189 (3_000_000.0, 10_000_000.0, 1_000.0),
190 (10_000_000.0, 30_000_000.0, 5_000.0),
191 (30_000_000.0, f64::INFINITY, 10_000.0),
192 ],
193 4,
194 10_000,
195 )
196 .expect("TOPIX100 tick scheme tiers are valid by construction")
197});
198
199static FIXED_TICK_SCHEME: LazyLock<FixedTickScheme> =
200 LazyLock::new(|| FixedTickScheme::new(1.0).expect("fixed tick scheme is valid"));
201
202static CRYPTO_0_01_TICK_SCHEME: LazyLock<FixedTickScheme> =
203 LazyLock::new(|| FixedTickScheme::new(0.01).expect("crypto tick scheme is valid"));
204
205static FIXED_PRECISION_TICK_SCHEMES: LazyLock<Vec<FixedTickScheme>> = LazyLock::new(|| {
206 (0..=FIXED_PRECISION)
207 .map(|precision| {
208 let tick = 10_f64.powi(-i32::from(precision));
209 FixedTickScheme::new(tick).expect("fixed precision tick scheme is valid")
210 })
211 .collect()
212});
213
214#[derive(Debug, Clone, Copy, PartialEq)]
215pub struct FixedTickScheme {
216 tick: f64,
217}
218
219impl Eq for FixedTickScheme {}
220
221impl FixedTickScheme {
222 pub fn new(tick: f64) -> Result<Self, TickSchemeError> {
228 if !tick.is_finite() {
229 return Err(TickSchemeError::TickNotFinite { tick });
230 }
231
232 if tick <= 0.0 {
233 return Err(TickSchemeError::TickNotPositive { tick });
234 }
235
236 Ok(Self { tick })
237 }
238}
239
240impl TickSchemeRule for FixedTickScheme {
241 #[inline(always)]
242 fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
243 fixed_next_bid_price(self.tick, value, n, precision)
244 }
245
246 #[inline(always)]
247 fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
248 fixed_next_ask_price(self.tick, value, n, precision)
249 }
250}
251
252impl Display for FixedTickScheme {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 f.write_str(FIXED_TICK_SCHEME_NAME)
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct TieredTickScheme {
265 ticks: Vec<PriceRaw>,
266 precision: u8,
267}
268
269impl TieredTickScheme {
270 pub fn new(
279 tiers: &[(f64, f64, f64)],
280 price_precision: u8,
281 max_ticks_per_tier: usize,
282 ) -> Result<Self, TickSchemeError> {
283 if tiers.is_empty() {
284 return Err(TickSchemeError::EmptyTiers);
285 }
286
287 for (index, &(start, stop, step)) in tiers.iter().enumerate() {
288 if start.is_nan() || stop.is_nan() || step.is_nan() {
289 return Err(TickSchemeError::TierValuesNaN {
290 index,
291 start,
292 stop,
293 step,
294 });
295 }
296
297 if start >= stop {
298 return Err(TickSchemeError::TierStartNotLessThanStop { index, start, stop });
299 }
300
301 if step <= 0.0 {
302 return Err(TickSchemeError::TierStepNotPositive { index, step });
303 }
304
305 if !stop.is_infinite() && step >= (stop - start) {
306 return Err(TickSchemeError::TierStepNotLessThanRange {
307 index,
308 start,
309 stop,
310 step,
311 range: stop - start,
312 });
313 }
314
315 if index > 0 {
316 let prev_stop = tiers[index - 1].1;
317
318 if start < prev_stop {
319 return Err(TickSchemeError::TierOverlapsPrevious {
320 index,
321 start,
322 prev_stop,
323 });
324 }
325 }
326
327 if !(PRICE_MIN..=PRICE_MAX).contains(&start) {
328 return Err(TickSchemeError::TierStartOutsidePriceRange { index, start });
329 }
330
331 if !stop.is_infinite() && !(PRICE_MIN..=PRICE_MAX).contains(&stop) {
332 return Err(TickSchemeError::TierStopOutsidePriceRange { index, stop });
333 }
334 }
335
336 let _ = Price::new_checked(0.0, price_precision)
337 .map_err(|source| TickSchemeError::InvalidPrecision { source })?;
338
339 let ticks = Self::build_ticks(tiers, price_precision, max_ticks_per_tier)?;
340
341 if ticks.is_empty() {
342 return Err(TickSchemeError::EmptyTickExpansion);
343 }
344 Ok(Self {
345 ticks,
346 precision: price_precision,
347 })
348 }
349
350 fn build_ticks(
351 tiers: &[(f64, f64, f64)],
352 precision: u8,
353 max_ticks_per_tier: usize,
354 ) -> Result<Vec<PriceRaw>, TickSchemeError> {
355 let mut all_ticks = Vec::new();
356
357 for &(start, stop, step) in tiers {
358 let effective_stop = if stop.is_infinite() {
359 start + (max_ticks_per_tier.saturating_add(1) as f64) * step
360 } else {
361 stop
362 };
363 let mut i = 0;
364 while i < max_ticks_per_tier {
365 let value = start + (i as f64) * step;
366
367 if value >= effective_stop {
368 break;
369 }
370
371 if !value.is_finite() || !(PRICE_MIN..=PRICE_MAX).contains(&value) {
372 return Err(TickSchemeError::ExpandedTickOutsidePriceRange { value });
373 }
374 let raw = f64_to_raw(value, precision);
375
376 if all_ticks.last() != Some(&raw) {
377 all_ticks.push(raw);
378 }
379 i += 1;
380 }
381 }
382 Ok(all_ticks)
383 }
384
385 #[inline(always)]
386 fn price_at(&self, index: usize) -> Price {
387 Price {
388 raw: self.ticks[index],
389 precision: self.precision,
390 }
391 }
392
393 #[must_use]
395 pub fn ticks(&self) -> Vec<Price> {
396 self.ticks
397 .iter()
398 .map(|&raw| Price {
399 raw,
400 precision: self.precision,
401 })
402 .collect()
403 }
404
405 #[must_use]
407 pub fn tick_count(&self) -> usize {
408 self.ticks.len()
409 }
410
411 #[must_use]
413 pub fn min_price(&self) -> Price {
414 self.price_at(0)
415 }
416
417 #[must_use]
419 pub fn max_price(&self) -> Price {
420 self.price_at(self.ticks.len() - 1)
421 }
422
423 #[must_use]
425 pub fn precision(&self) -> u8 {
426 self.precision
427 }
428
429 #[must_use]
435 pub fn topix100() -> Self {
436 TOPIX100_TICK_SCHEME.clone()
437 }
438
439 #[must_use]
445 pub fn betfair() -> Self {
446 BETFAIR_TICK_SCHEME.clone()
447 }
448}
449
450impl TickSchemeRule for TieredTickScheme {
451 fn next_bid_price(&self, value: f64, n: i32, _precision: u8) -> Option<Price> {
452 let n = usize::try_from(n).ok()?;
453
454 if value.is_nan() {
455 return None;
456 }
457
458 let raw_floor = (value * FIXED_SCALAR).floor() as PriceRaw;
461
462 if raw_floor < self.ticks[0] {
463 return None;
464 }
465
466 let idx = self.ticks.partition_point(|&t| t < raw_floor);
468
469 if idx < self.ticks.len() && self.ticks[idx] == raw_floor {
470 let target = idx.checked_sub(n)?;
472 return Some(self.price_at(target));
473 }
474
475 let target = idx.checked_sub(1)?.checked_sub(n)?;
477 Some(self.price_at(target))
478 }
479
480 fn next_ask_price(&self, value: f64, n: i32, _precision: u8) -> Option<Price> {
481 let n = usize::try_from(n).ok()?;
482
483 if value.is_nan() {
484 return None;
485 }
486
487 let raw_ceil = (value * FIXED_SCALAR).ceil() as PriceRaw;
490
491 if raw_ceil > *self.ticks.last()? {
492 return None;
493 }
494
495 let idx = self.ticks.partition_point(|&t| t < raw_ceil);
497 let target = idx.checked_add(n)?;
498
499 if target >= self.ticks.len() {
500 return None;
501 }
502 Some(self.price_at(target))
503 }
504}
505
506impl Display for TieredTickScheme {
507 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508 write!(f, "TIERED")
509 }
510}
511
512#[derive(Clone, Debug, PartialEq, Eq)]
513pub enum TickScheme {
514 Fixed(FixedTickScheme),
515 Tiered(TieredTickScheme),
516 Betfair,
517 Crypto,
518}
519
520impl TickSchemeRule for TickScheme {
521 #[inline(always)]
522 fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
523 match self {
524 Self::Fixed(scheme) => scheme.next_bid_price(value, n, precision),
525 Self::Tiered(scheme) => scheme.next_bid_price(value, n, precision),
526 Self::Betfair => BETFAIR_TICK_SCHEME.next_bid_price(value, n, precision),
527 Self::Crypto => CRYPTO_0_01_TICK_SCHEME.next_bid_price(value, n, precision),
528 }
529 }
530
531 #[inline(always)]
532 fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
533 match self {
534 Self::Fixed(scheme) => scheme.next_ask_price(value, n, precision),
535 Self::Tiered(scheme) => scheme.next_ask_price(value, n, precision),
536 Self::Betfair => BETFAIR_TICK_SCHEME.next_ask_price(value, n, precision),
537 Self::Crypto => CRYPTO_0_01_TICK_SCHEME.next_ask_price(value, n, precision),
538 }
539 }
540}
541
542impl Display for TickScheme {
543 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544 match self {
545 Self::Fixed(scheme) => write!(f, "{scheme}"),
546 Self::Tiered(scheme) => write!(f, "{scheme}"),
547 Self::Betfair => write!(f, "{BETFAIR_TICK_SCHEME_NAME}"),
548 Self::Crypto => write!(f, "{CRYPTO_0_01_TICK_SCHEME_NAME}"),
549 }
550 }
551}
552
553impl FromStr for TickScheme {
554 type Err = TickSchemeError;
555
556 fn from_str(s: &str) -> Result<Self, Self::Err> {
557 match s.trim().to_ascii_uppercase().as_str() {
558 FIXED_TICK_SCHEME_NAME => Ok(Self::Fixed(FixedTickScheme::new(1.0)?)),
559 FOREX_3DECIMAL_TICK_SCHEME_NAME => Ok(Self::Fixed(FixedTickScheme::new(0.001)?)),
560 FOREX_5DECIMAL_TICK_SCHEME_NAME => Ok(Self::Fixed(FixedTickScheme::new(0.00001)?)),
561 TOPIX100_TICK_SCHEME_NAME => Ok(Self::Tiered(TieredTickScheme::topix100())),
562 BETFAIR_TICK_SCHEME_NAME => Ok(Self::Betfair),
563 CRYPTO_0_01_TICK_SCHEME_NAME => Ok(Self::Crypto),
564 name => {
565 if let Some(precision) = parse_fixed_precision_name(name)
566 && precision <= FIXED_PRECISION
567 {
568 let tick = 10_f64.powi(-i32::from(precision));
569 return Ok(Self::Fixed(FixedTickScheme::new(tick)?));
570 }
571 Err(TickSchemeError::UnknownName {
572 name: s.to_string(),
573 })
574 }
575 }
576 }
577}
578
579#[must_use]
581pub fn tick_scheme_rule_from_name(name: &str) -> Option<&'static dyn TickSchemeRule> {
582 let name = name.trim();
583 if name.eq_ignore_ascii_case(FIXED_TICK_SCHEME_NAME) {
584 Some(&*FIXED_TICK_SCHEME)
585 } else if name.eq_ignore_ascii_case(FOREX_3DECIMAL_TICK_SCHEME_NAME) {
586 Some(&FIXED_PRECISION_TICK_SCHEMES[3])
587 } else if name.eq_ignore_ascii_case(FOREX_5DECIMAL_TICK_SCHEME_NAME) {
588 Some(&FIXED_PRECISION_TICK_SCHEMES[5])
589 } else if name.eq_ignore_ascii_case(TOPIX100_TICK_SCHEME_NAME) {
590 Some(&*TOPIX100_TICK_SCHEME)
591 } else if name.eq_ignore_ascii_case(BETFAIR_TICK_SCHEME_NAME) {
592 Some(&*BETFAIR_TICK_SCHEME)
593 } else if name.eq_ignore_ascii_case(CRYPTO_0_01_TICK_SCHEME_NAME) {
594 Some(&*CRYPTO_0_01_TICK_SCHEME)
595 } else {
596 parse_fixed_precision_name_ignore_ascii_case(name).and_then(|precision| {
597 FIXED_PRECISION_TICK_SCHEMES
598 .get(usize::from(precision))
599 .map(|scheme| scheme as &dyn TickSchemeRule)
600 })
601 }
602}
603
604pub fn check_tick_scheme<T: AsRef<str> + Copy>(tick_scheme: Option<T>) -> CorrectnessResult<()> {
610 check_valid_string_ascii_optional(tick_scheme, "tick_scheme")?;
611 if let Some(name) = tick_scheme {
612 check_predicate_true(
613 tick_scheme_rule_from_name(name.as_ref()).is_some(),
614 "tick_scheme not found in tick schemes",
615 )?;
616 }
617 Ok(())
618}
619
620#[inline(always)]
622fn f64_to_raw(value: f64, precision: u8) -> PriceRaw {
623 #[cfg(feature = "high-precision")]
624 {
625 f64_to_fixed_i128(value, precision)
626 }
627 #[cfg(not(feature = "high-precision"))]
628 {
629 f64_to_fixed_i64(value, precision)
630 }
631}
632
633fn parse_fixed_precision_name(name: &str) -> Option<u8> {
634 name.strip_prefix(FIXED_PRECISION_TICK_SCHEME_PREFIX)
635 .and_then(|precision| precision.parse::<u8>().ok())
636}
637
638fn parse_fixed_precision_name_ignore_ascii_case(name: &str) -> Option<u8> {
639 let prefix_len = FIXED_PRECISION_TICK_SCHEME_PREFIX.len();
640 let prefix = name.get(..prefix_len)?;
641 if !prefix.eq_ignore_ascii_case(FIXED_PRECISION_TICK_SCHEME_PREFIX) {
642 return None;
643 }
644
645 name.get(prefix_len..)?.parse::<u8>().ok()
646}
647
648fn fixed_next_bid_price(tick: f64, value: f64, n: i32, precision: u8) -> Option<Price> {
649 let n = PriceRaw::from(u32::try_from(n).ok()?);
650 let tick_raw = fixed_tick_raw(tick, precision)?;
651 let value_raw = value_to_raw(value)?;
652 let base = value_raw
653 .checked_div_euclid(tick_raw)?
654 .checked_mul(tick_raw)?;
655 let offset = tick_raw.checked_mul(n)?;
656 price_from_raw_checked(base.checked_sub(offset)?, precision)
657}
658
659fn fixed_next_ask_price(tick: f64, value: f64, n: i32, precision: u8) -> Option<Price> {
660 let n = PriceRaw::from(u32::try_from(n).ok()?);
661 let tick_raw = fixed_tick_raw(tick, precision)?;
662 let value_raw = value_to_raw(value)?;
663 let base = value_raw
664 .checked_neg()?
665 .checked_div_euclid(tick_raw)?
666 .checked_neg()?
667 .checked_mul(tick_raw)?;
668 let offset = tick_raw.checked_mul(n)?;
669 price_from_raw_checked(base.checked_add(offset)?, precision)
670}
671
672fn fixed_tick_raw(tick: f64, precision: u8) -> Option<PriceRaw> {
673 Price::new_checked(0.0, precision).ok()?;
674
675 if !tick.is_finite() || tick <= 0.0 {
676 return None;
677 }
678
679 let raw = f64_to_raw(tick, precision);
680 (raw > 0).then_some(raw)
681}
682
683fn value_to_raw(value: f64) -> Option<PriceRaw> {
684 if !value.is_finite() || !(PRICE_MIN..=PRICE_MAX).contains(&value) {
685 return None;
686 }
687 Some(f64_to_raw(value, FIXED_PRECISION))
688}
689
690fn price_from_raw_checked(raw: PriceRaw, precision: u8) -> Option<Price> {
691 if !(PRICE_RAW_MIN..=PRICE_RAW_MAX).contains(&raw) {
692 return None;
693 }
694 Some(Price { raw, precision })
695}
696
697#[cfg(test)]
698mod tests {
699 use std::str::FromStr;
700
701 use proptest::prelude::*;
702 use rstest::rstest;
703
704 use super::*;
705
706 #[rstest]
707 fn fixed_tick_scheme_prices() {
708 let scheme = FixedTickScheme::new(0.5).unwrap();
709 let bid = scheme.next_bid_price(10.3, 0, 2).unwrap();
710 let ask = scheme.next_ask_price(10.3, 0, 2).unwrap();
711 assert!(bid < ask);
712 }
713
714 #[rstest]
715 fn fixed_tick_negative_returns_typed_error_with_display() {
716 let error = FixedTickScheme::new(-0.01).unwrap_err();
717
718 assert_eq!(error, TickSchemeError::TickNotPositive { tick: -0.01 });
719 assert_eq!(error.to_string(), "tick must be positive");
720 }
721
722 #[rstest]
723 fn fixed_tick_boundary() {
724 let scheme = FixedTickScheme::new(0.5).unwrap();
725 let price = scheme.next_bid_price(10.5, 0, 2).unwrap();
726 assert_eq!(price, Price::new(10.5, 2));
727 }
728
729 #[rstest]
730 fn fixed_tick_scheme_preserves_decimal_boundaries() {
731 let tenth = FixedTickScheme::new(0.1).unwrap();
732 let cent = FixedTickScheme::new(0.01).unwrap();
733
734 assert_eq!(tenth.next_bid_price(0.3, 0, 1), Some(Price::new(0.3, 1)));
735 assert_eq!(tenth.next_ask_price(0.3, 0, 1), Some(Price::new(0.3, 1)));
736 assert_eq!(cent.next_bid_price(0.07, 0, 2), Some(Price::new(0.07, 2)));
737 assert_eq!(cent.next_ask_price(0.07, 0, 2), Some(Price::new(0.07, 2)));
738 }
739
740 #[rstest]
741 fn fixed_tick_multiple_steps() {
742 let scheme = FixedTickScheme::new(1.0).unwrap();
743 let bid = scheme.next_bid_price(10.0, 2, 1).unwrap();
744 let ask = scheme.next_ask_price(10.0, 3, 1).unwrap();
745 assert_eq!(bid, Price::new(8.0, 1));
746 assert_eq!(ask, Price::new(13.0, 1));
747 }
748
749 #[rstest]
750 fn tick_scheme_round_trip() {
751 let scheme = TickScheme::from_str("CRYPTO_0_01").unwrap();
752 assert_eq!(scheme.to_string(), "CRYPTO_0_01");
753 }
754
755 #[rstest]
756 fn tick_scheme_rule_from_fixed_precision_name() {
757 let scheme = tick_scheme_rule_from_name("fixed_precision_1").unwrap();
758
759 assert_eq!(scheme.next_bid_price(0.3, 0, 1), Some(Price::new(0.3, 1)));
760 assert_eq!(scheme.next_ask_price(0.31, 0, 1), Some(Price::new(0.4, 1)));
761 }
762
763 #[rstest]
764 fn tick_scheme_unknown() {
765 let error = TickScheme::from_str("UNKNOWN").unwrap_err();
766
767 assert_eq!(
768 error,
769 TickSchemeError::UnknownName {
770 name: "UNKNOWN".to_string(),
771 }
772 );
773 assert_eq!(error.to_string(), "unknown tick scheme UNKNOWN");
774 }
775
776 #[rstest]
777 fn tick_scheme_fixed_precision_above_max_returns_unknown_name() {
778 let name = format!("FIXED_PRECISION_{}", FIXED_PRECISION + 1);
779 let error = TickScheme::from_str(&name).unwrap_err();
780
781 assert_eq!(error, TickSchemeError::UnknownName { name: name.clone() });
782 assert_eq!(error.to_string(), format!("unknown tick scheme {name}"));
783 }
784
785 #[rstest]
786 fn fixed_tick_zero() {
787 let error = FixedTickScheme::new(0.0).unwrap_err();
788
789 assert_eq!(error, TickSchemeError::TickNotPositive { tick: 0.0 });
790 assert_eq!(error.to_string(), "tick must be positive");
791 }
792
793 #[rstest]
794 #[case(f64::INFINITY)]
795 #[case(f64::NAN)]
796 fn fixed_tick_non_finite_returns_error(#[case] tick: f64) {
797 let error = FixedTickScheme::new(tick).unwrap_err();
798
799 match &error {
800 TickSchemeError::TickNotFinite {
801 tick: returned_tick,
802 } => {
803 assert!(
804 *returned_tick == tick || returned_tick.is_nan() && tick.is_nan(),
805 "returned tick {returned_tick} did not match input {tick}",
806 );
807 }
808 _ => panic!("unexpected error variant: {error:?}"),
809 }
810 assert_eq!(error.to_string(), "tick must be finite");
811 }
812
813 #[rstest]
814 fn fixed_tick_scheme_nan_value_returns_none() {
815 let scheme = FixedTickScheme::new(1.0).unwrap();
816 assert!(scheme.next_bid_price(f64::NAN, 0, 2).is_none());
817 assert!(scheme.next_ask_price(f64::NAN, 0, 2).is_none());
818 }
819
820 #[rstest]
821 fn fixed_tick_scheme_out_of_range_returns_none() {
822 let scheme = FixedTickScheme::new(PRICE_MAX).unwrap();
824 assert!(scheme.next_ask_price(PRICE_MAX, 1, 2).is_none());
825 }
826
827 #[rstest]
828 fn tiered_tick_scheme_topix100_construction() {
829 let scheme = TieredTickScheme::topix100();
830 assert!(scheme.tick_count() > 0);
831 assert_eq!(scheme.precision(), 4);
832 assert_eq!(scheme.min_price(), Price::new(0.1, 4));
833 }
834
835 #[rstest]
836 fn tiered_tick_scheme_betfair_construction() {
837 let scheme = TieredTickScheme::betfair();
838 assert_eq!(scheme.tick_count(), 350);
839 assert_eq!(scheme.precision(), 2);
840 assert_eq!(scheme.min_price(), Price::new(1.01, 2));
841 assert_eq!(scheme.max_price(), Price::new(1000.0, 2));
842 }
843
844 #[rstest]
845 fn tiered_tick_scheme_ask_at_low_price() {
846 let scheme = TieredTickScheme::topix100();
847 let ask = scheme.next_ask_price(500.0, 0, 4).unwrap();
848 assert_eq!(ask, Price::new(500.0, 4));
849 }
850
851 #[rstest]
852 fn tiered_tick_scheme_bid_at_low_price() {
853 let scheme = TieredTickScheme::topix100();
854 let bid = scheme.next_bid_price(500.0, 0, 4).unwrap();
855 assert_eq!(bid, Price::new(500.0, 4));
856 }
857
858 #[rstest]
859 fn tiered_tick_scheme_ask_steps() {
860 let scheme = TieredTickScheme::topix100();
861 let ask0 = scheme.next_ask_price(500.0, 0, 4).unwrap();
862 let ask1 = scheme.next_ask_price(500.0, 1, 4).unwrap();
863 assert!(ask1 > ask0);
864 assert_eq!(ask1, Price::new(500.1, 4));
865 }
866
867 #[rstest]
868 fn tiered_tick_scheme_bid_steps() {
869 let scheme = TieredTickScheme::topix100();
870 let bid0 = scheme.next_bid_price(500.0, 0, 4).unwrap();
871 let bid1 = scheme.next_bid_price(500.0, 1, 4).unwrap();
872 assert!(bid1 < bid0);
873 assert_eq!(bid1, Price::new(499.9, 4));
874 }
875
876 #[rstest]
877 fn tiered_tick_scheme_tier_boundary_1000() {
878 let scheme = TieredTickScheme::topix100();
879 let ask = scheme.next_ask_price(1000.0, 1, 4).unwrap();
881 assert_eq!(ask, Price::new(1000.5, 4));
882 }
883
884 #[rstest]
885 #[case(3.90, 1, "3.95")]
886 #[case(4.0, 1, "4.10")]
887 fn tiered_tick_scheme_betfair_ask_transition(
888 #[case] value: f64,
889 #[case] n: i32,
890 #[case] expected: &str,
891 ) {
892 let scheme = TieredTickScheme::betfair();
893 let ask = scheme.next_ask_price(value, n, 2).unwrap();
894 assert_eq!(ask, Price::from(expected));
895 }
896
897 #[rstest]
898 #[case(1.499, 0, "1.49")]
899 #[case(2.011, 0, "2.00")]
900 #[case(2.027, 2, "1.99")]
901 fn tiered_tick_scheme_betfair_bid_transition(
902 #[case] value: f64,
903 #[case] n: i32,
904 #[case] expected: &str,
905 ) {
906 let scheme = TieredTickScheme::betfair();
907 let bid = scheme.next_bid_price(value, n, 2).unwrap();
908 assert_eq!(bid, Price::from(expected));
909 }
910
911 #[rstest]
912 fn tiered_tick_scheme_between_ticks() {
913 let scheme = TieredTickScheme::topix100();
914 let ask = scheme.next_ask_price(1000.3, 0, 4).unwrap();
916 assert!(ask.as_f64() >= 1000.3);
917 let bid = scheme.next_bid_price(1000.3, 0, 4).unwrap();
918 assert!(bid.as_f64() <= 1000.3);
919 }
920
921 #[rstest]
922 fn tiered_tick_scheme_off_grid_bid_below_tick() {
923 let scheme = TieredTickScheme::new(&[(1.0, 2.0, 0.05)], 2, 100).unwrap();
925 let bid = scheme.next_bid_price(1.049, 0, 2).unwrap();
926 assert_eq!(bid, Price::new(1.0, 2));
927 }
928
929 #[rstest]
930 fn tiered_tick_scheme_off_grid_ask_above_tick() {
931 let scheme = TieredTickScheme::new(&[(1.0, 2.0, 0.05)], 2, 100).unwrap();
933 let ask = scheme.next_ask_price(1.051, 0, 2).unwrap();
934 assert_eq!(ask, Price::new(1.10, 2));
935 }
936
937 #[rstest]
938 fn tiered_tick_scheme_bid_below_min_returns_none() {
939 let scheme = TieredTickScheme::topix100();
940 assert!(scheme.next_bid_price(0.05, 0, 4).is_none());
941 }
942
943 #[rstest]
944 fn tiered_tick_scheme_ask_beyond_last_tick_returns_none() {
945 let scheme = TieredTickScheme::topix100();
946 let last = scheme.max_price().as_f64();
947 assert!(scheme.next_ask_price(last, 1, 4).is_none());
948 }
949
950 #[rstest]
951 fn tiered_tick_scheme_bid_beyond_last_tick_returns_last() {
952 let scheme = TieredTickScheme::new(&[(1.0, 10.0, 1.0)], 1, 100).unwrap();
953 let bid = scheme.next_bid_price(9.5, 0, 1).unwrap();
955 assert_eq!(bid, Price::new(9.0, 1));
956 }
957
958 #[rstest]
959 fn tiered_tick_scheme_negative_n_returns_none() {
960 let scheme = TieredTickScheme::topix100();
961 assert!(scheme.next_bid_price(500.0, -1, 4).is_none());
962 assert!(scheme.next_ask_price(500.0, -1, 4).is_none());
963 }
964
965 #[rstest]
966 fn tiered_tick_scheme_nan_value_returns_none() {
967 let scheme = TieredTickScheme::topix100();
968 assert!(scheme.next_bid_price(f64::NAN, 0, 4).is_none());
969 assert!(scheme.next_ask_price(f64::NAN, 0, 4).is_none());
970 }
971
972 #[rstest]
973 fn tiered_tick_scheme_infinite_value_saturates() {
974 let scheme = TieredTickScheme::topix100();
975 assert_eq!(
976 scheme.next_bid_price(f64::INFINITY, 0, 4),
977 Some(scheme.max_price())
978 );
979 assert!(scheme.next_ask_price(f64::INFINITY, 0, 4).is_none());
980 assert!(scheme.next_bid_price(f64::NEG_INFINITY, 0, 4).is_none());
981 }
982
983 #[rstest]
984 fn crypto_tick_scheme_out_of_range_returns_none() {
985 let scheme = TickScheme::Crypto;
987 assert!(scheme.next_ask_price(PRICE_MAX * 2.0, 0, 2).is_none());
988 assert!(scheme.next_bid_price(PRICE_MIN * 2.0, 0, 2).is_none());
989 }
990
991 #[rstest]
992 fn tiered_tick_scheme_validation_empty_tiers() {
993 let error = TieredTickScheme::new(&[], 2, 100).unwrap_err();
994
995 assert_eq!(error, TickSchemeError::EmptyTiers);
996 assert_eq!(error.to_string(), "tiers must not be empty");
997 }
998
999 #[rstest]
1000 #[case(
1001 vec![(100.0, 50.0, 1.0)],
1002 TickSchemeError::TierStartNotLessThanStop {
1003 index: 0,
1004 start: 100.0,
1005 stop: 50.0,
1006 },
1007 "tier 0: start (100) must be less than stop (50)"
1008 )]
1009 #[case(
1010 vec![(2.0, 2.0, 0.1)],
1011 TickSchemeError::TierStartNotLessThanStop {
1012 index: 0,
1013 start: 2.0,
1014 stop: 2.0,
1015 },
1016 "tier 0: start (2) must be less than stop (2)"
1017 )]
1018 #[case(
1019 vec![(0.0, 100.0, -1.0)],
1020 TickSchemeError::TierStepNotPositive {
1021 index: 0,
1022 step: -1.0,
1023 },
1024 "tier 0: step (-1) must be positive"
1025 )]
1026 #[case(
1027 vec![(1.0, 2.0, 0.0)],
1028 TickSchemeError::TierStepNotPositive {
1029 index: 0,
1030 step: 0.0,
1031 },
1032 "tier 0: step (0) must be positive"
1033 )]
1034 #[case(
1035 vec![(0.0, 100.0, 200.0)],
1036 TickSchemeError::TierStepNotLessThanRange {
1037 index: 0,
1038 start: 0.0,
1039 stop: 100.0,
1040 step: 200.0,
1041 range: 100.0,
1042 },
1043 "tier 0: step (200) must be less than range (100 - 0 = 100)"
1044 )]
1045 #[case(
1046 vec![(10.0, 20.0, 1.0), (1.0, 10.0, 1.0)],
1047 TickSchemeError::TierOverlapsPrevious {
1048 index: 1,
1049 start: 1.0,
1050 prev_stop: 20.0,
1051 },
1052 "tier 1: start (1) overlaps previous tier stop (20)"
1053 )]
1054 #[case(
1055 vec![(1.0, 10.0, 1.0), (5.0, 15.0, 1.0)],
1056 TickSchemeError::TierOverlapsPrevious {
1057 index: 1,
1058 start: 5.0,
1059 prev_stop: 10.0,
1060 },
1061 "tier 1: start (5) overlaps previous tier stop (10)"
1062 )]
1063 fn tiered_tick_scheme_invalid_tiers_return_typed_errors(
1064 #[case] tiers: Vec<(f64, f64, f64)>,
1065 #[case] expected_error: TickSchemeError,
1066 #[case] expected_display: &str,
1067 ) {
1068 let error = TieredTickScheme::new(&tiers, 2, 100).unwrap_err();
1069
1070 assert_eq!(error, expected_error);
1071 assert_eq!(error.to_string(), expected_display);
1072 }
1073
1074 #[rstest]
1075 #[case(vec![(f64::NAN, 10.0, 1.0)])]
1076 #[case(vec![(1.0, f64::NAN, 1.0)])]
1077 #[case(vec![(1.0, 10.0, f64::NAN)])]
1078 fn tiered_tick_scheme_nan_tiers_return_typed_error(#[case] tiers: Vec<(f64, f64, f64)>) {
1079 let error = TieredTickScheme::new(&tiers, 2, 100).unwrap_err();
1080
1081 match &error {
1082 TickSchemeError::TierValuesNaN {
1083 index,
1084 start,
1085 stop,
1086 step,
1087 } => {
1088 assert_eq!(*index, 0);
1089 assert!(
1090 start.is_nan() || stop.is_nan() || step.is_nan(),
1091 "expected one NaN tier value in {error:?}",
1092 );
1093 }
1094 _ => panic!("unexpected error variant: {error:?}"),
1095 }
1096 assert_eq!(error.to_string(), "tier 0: values must not be NaN");
1097 }
1098
1099 #[rstest]
1100 fn tiered_tick_scheme_start_outside_price_range_returns_typed_error() {
1101 let start = PRICE_MIN - 1.0;
1102 let stop = PRICE_MIN + 1.0;
1103 let error = TieredTickScheme::new(&[(start, stop, 1.0)], 2, 100).unwrap_err();
1104
1105 assert_eq!(
1106 error,
1107 TickSchemeError::TierStartOutsidePriceRange { index: 0, start }
1108 );
1109 assert_eq!(
1110 error.to_string(),
1111 format!("tier 0: start ({start}) outside Price range")
1112 );
1113 }
1114
1115 #[rstest]
1116 fn tiered_tick_scheme_stop_outside_price_range_returns_typed_error() {
1117 let start = PRICE_MAX - 2.0;
1118 let stop = PRICE_MAX + 1.0;
1119 let error = TieredTickScheme::new(&[(start, stop, 1.0)], 2, 100).unwrap_err();
1120
1121 assert_eq!(
1122 error,
1123 TickSchemeError::TierStopOutsidePriceRange { index: 0, stop }
1124 );
1125 assert_eq!(
1126 error.to_string(),
1127 format!("tier 0: stop ({stop}) outside Price range")
1128 );
1129 }
1130
1131 #[rstest]
1132 fn tiered_tick_scheme_invalid_precision_wraps_source_error() {
1133 let invalid_precision = FIXED_PRECISION + 1;
1134 let source = Price::new_checked(0.0, invalid_precision).unwrap_err();
1135 let error = TieredTickScheme::new(&[(1.0, 10.0, 1.0)], invalid_precision, 100).unwrap_err();
1136
1137 assert_eq!(
1138 error,
1139 TickSchemeError::InvalidPrecision {
1140 source: source.clone(),
1141 }
1142 );
1143 assert_eq!(error.to_string(), source.to_string());
1144 }
1145
1146 #[rstest]
1147 fn tiered_tick_scheme_empty_expansion_returns_typed_error() {
1148 let error = TieredTickScheme::new(&[(1.0, f64::INFINITY, 1.0)], 2, 0).unwrap_err();
1149
1150 assert_eq!(error, TickSchemeError::EmptyTickExpansion);
1151 assert_eq!(error.to_string(), "tier expansion produced no ticks");
1152 }
1153
1154 #[rstest]
1155 fn tiered_tick_scheme_expanded_tick_outside_range_returns_typed_error() {
1156 let invalid_value = PRICE_MAX + 1.0;
1157 let error = TieredTickScheme::new(&[(PRICE_MAX, f64::INFINITY, 1.0)], 2, 2).unwrap_err();
1158
1159 assert_eq!(
1160 error,
1161 TickSchemeError::ExpandedTickOutsidePriceRange {
1162 value: invalid_value,
1163 }
1164 );
1165 assert_eq!(
1166 error.to_string(),
1167 format!("expanded tick value {invalid_value} outside Price range")
1168 );
1169 }
1170
1171 #[rstest]
1172 fn tiered_tick_scheme_finite_tier_includes_all_ticks() {
1173 let scheme = TieredTickScheme::new(&[(0.0, 0.3, 0.1)], 1, 100).unwrap();
1175 assert_eq!(scheme.tick_count(), 3);
1176 }
1177
1178 #[rstest]
1179 fn tiered_tick_scheme_simple_two_tiers() {
1180 let scheme =
1181 TieredTickScheme::new(&[(1.0, 10.0, 1.0), (10.0, 100.0, 5.0)], 2, 100).unwrap();
1182 let ticks = scheme.ticks();
1183 assert_eq!(ticks[0], Price::new(1.0, 2));
1186 assert_eq!(ticks[8], Price::new(9.0, 2));
1187 assert_eq!(ticks[9], Price::new(10.0, 2));
1188 assert_eq!(ticks[10], Price::new(15.0, 2));
1189 }
1190
1191 #[rstest]
1192 fn tiered_tick_scheme_infinity_tier() {
1193 let scheme = TieredTickScheme::new(&[(100.0, f64::INFINITY, 10.0)], 1, 5).unwrap();
1194 assert_eq!(scheme.tick_count(), 5);
1195 let ticks = scheme.ticks();
1196 assert_eq!(ticks[0], Price::new(100.0, 1));
1197 assert_eq!(ticks[4], Price::new(140.0, 1));
1198 }
1199
1200 #[rstest]
1201 fn tiered_tick_scheme_from_str_topix100() {
1202 let scheme = TickScheme::from_str("TOPIX100").unwrap();
1203 assert_eq!(scheme.to_string(), "TIERED");
1204 }
1205
1206 #[rstest]
1207 fn tiered_tick_scheme_from_str_betfair() {
1208 let scheme = TickScheme::from_str("BETFAIR").unwrap();
1209 assert_eq!(scheme.to_string(), BETFAIR_TICK_SCHEME_NAME);
1210 assert_eq!(
1211 scheme.next_ask_price(4.0, 1, 2).unwrap(),
1212 Price::new(4.1, 2)
1213 );
1214 }
1215
1216 #[rstest]
1217 fn tiered_tick_scheme_display() {
1218 let scheme = TieredTickScheme::new(&[(1.0, 10.0, 1.0)], 2, 100).unwrap();
1219 assert_eq!(scheme.to_string(), "TIERED");
1220 }
1221
1222 #[rstest]
1223 fn tiered_tick_scheme_min_tick_bid() {
1224 let scheme = TieredTickScheme::topix100();
1225 let result = scheme.next_bid_price(0.1, 0, 4).unwrap();
1226 assert_eq!(result, Price::new(0.1, 4));
1227 }
1228
1229 #[rstest]
1230 fn tiered_tick_scheme_min_tick_bid_n1_returns_none() {
1231 let scheme = TieredTickScheme::topix100();
1232 assert!(scheme.next_bid_price(0.1, 1, 4).is_none());
1233 }
1234
1235 #[rstest]
1236 fn tiered_tick_scheme_boundary_tick_equality() {
1237 let scheme = TieredTickScheme::topix100();
1238 let bid = scheme.next_bid_price(1000.0, 0, 4).unwrap();
1239 assert_eq!(bid, Price::new(1000.0, 4));
1240 let ask = scheme.next_ask_price(1000.0, 0, 4).unwrap();
1241 assert_eq!(ask, Price::new(1000.0, 4));
1242 }
1243
1244 #[rstest]
1245 fn tiered_tick_scheme_tier_transition_ask_from_999_9() {
1246 let scheme = TieredTickScheme::topix100();
1247 let ask = scheme.next_ask_price(999.9, 0, 4).unwrap();
1248 assert_eq!(ask, Price::new(999.9, 4));
1249 }
1250
1251 #[rstest]
1252 fn tiered_tick_scheme_tier_transition_bid_from_1000_5() {
1253 let scheme = TieredTickScheme::topix100();
1254 let bid = scheme.next_bid_price(1000.5, 1, 4).unwrap();
1255 assert_eq!(bid, Price::new(1000.0, 4));
1256 }
1257
1258 #[rstest]
1259 fn tiered_tick_scheme_large_n_beyond_bounds_ask() {
1260 let scheme = TieredTickScheme::topix100();
1261 let max = scheme.max_price().as_f64();
1262 assert!(scheme.next_ask_price(max - 1000.0, 100_000, 4).is_none());
1263 }
1264
1265 #[rstest]
1266 fn tiered_tick_scheme_large_n_beyond_bounds_bid() {
1267 let scheme = TieredTickScheme::topix100();
1268 let min = scheme.min_price().as_f64();
1269 assert!(scheme.next_bid_price(min + 1000.0, 100_000, 4).is_none());
1270 }
1271
1272 #[rstest]
1273 fn tiered_tick_scheme_out_of_bounds_ask_far_above() {
1274 let scheme = TieredTickScheme::topix100();
1275 assert!(scheme.next_ask_price(999_999_999.0, 0, 4).is_none());
1276 }
1277
1278 #[rstest]
1279 fn tiered_tick_scheme_idempotent_on_tick() {
1280 let scheme = TieredTickScheme::topix100();
1281 let price = 500.0;
1282 let ask = scheme.next_ask_price(price, 0, 4).unwrap();
1283 let ask2 = scheme.next_ask_price(ask.as_f64(), 0, 4).unwrap();
1284 assert_eq!(ask, ask2);
1285 let bid = scheme.next_bid_price(price, 0, 4).unwrap();
1286 let bid2 = scheme.next_bid_price(bid.as_f64(), 0, 4).unwrap();
1287 assert_eq!(bid, bid2);
1288 }
1289
1290 #[rstest]
1291 fn tiered_tick_scheme_consistency_forward_backward() {
1292 let scheme = TieredTickScheme::topix100();
1293 let start = 5000.0;
1294 let forward = scheme.next_ask_price(start, 10, 4).unwrap();
1295 let back = scheme.next_bid_price(forward.as_f64(), 10, 4).unwrap();
1296 assert!(back.as_f64() <= start);
1297 }
1298
1299 #[rstest]
1300 fn tiered_tick_scheme_cumulative_equals_direct() {
1301 let scheme = TieredTickScheme::topix100();
1302 let price = 1000.0;
1303 let mut cumulative = price;
1304 for _ in 0..5 {
1305 if let Some(result) = scheme.next_ask_price(cumulative, 1, 4) {
1306 cumulative = result.as_f64();
1307 }
1308 }
1309 let direct = scheme.next_ask_price(price, 5, 4).unwrap();
1310 assert!((cumulative - direct.as_f64()).abs() < 1e-10);
1311 }
1312
1313 #[rstest]
1314 #[case(1000.0, 0, 1000.0)]
1315 #[case(1000.25, 0, 1000.5)]
1316 #[case(10_001.0, 0, 10_005.0)]
1317 #[case(10_000_001.0, 0, 10_005_000.0)]
1318 #[case(9999.0, 2, 10_005.0)]
1319 fn tiered_tick_scheme_topix100_ask_parametrized(
1320 #[case] value: f64,
1321 #[case] n: i32,
1322 #[case] expected: f64,
1323 ) {
1324 let scheme = TieredTickScheme::topix100();
1325 let ask = scheme.next_ask_price(value, n, 4).unwrap();
1326 assert_eq!(ask, Price::new(expected, 4));
1327 }
1328
1329 #[rstest]
1330 #[case(1000.75, 0, 1000.5)]
1331 #[case(10_007.0, 0, 10_005.0)]
1332 #[case(10_000_001.0, 0, 10_000_000.0)]
1333 #[case(10_006.0, 2, 9999.0)]
1334 fn tiered_tick_scheme_topix100_bid_parametrized(
1335 #[case] value: f64,
1336 #[case] n: i32,
1337 #[case] expected: f64,
1338 ) {
1339 let scheme = TieredTickScheme::topix100();
1340 let bid = scheme.next_bid_price(value, n, 4).unwrap();
1341 assert_eq!(bid, Price::new(expected, 4));
1342 }
1343
1344 proptest! {
1346 #[rstest]
1347 fn prop_tiered_bid_at_or_below_value(value in 0.1f64..100_000.0) {
1348 let scheme = TieredTickScheme::topix100();
1349 if let Some(bid) = scheme.next_bid_price(value, 0, 4) {
1350 prop_assert!(bid.as_f64() <= value + 1e-9);
1351 }
1352 }
1353 }
1354
1355 proptest! {
1357 #[rstest]
1358 fn prop_tiered_ask_at_or_above_value(value in 0.1f64..100_000.0) {
1359 let scheme = TieredTickScheme::topix100();
1360 if let Some(ask) = scheme.next_ask_price(value, 0, 4) {
1361 prop_assert!(ask.as_f64() >= value - 1e-9);
1362 }
1363 }
1364 }
1365
1366 proptest! {
1367 #[rstest]
1368 fn prop_tiered_bid_ask_match_adjacent_ticks(
1369 raw_index in any::<usize>(),
1370 offset in 0usize..=4,
1371 ) {
1372 let scheme = TieredTickScheme::topix100();
1373 let ticks = scheme.ticks();
1374 let index = raw_index % (ticks.len() - 1);
1375 let lower = ticks[index];
1376 let upper = ticks[index + 1];
1377 let value = f64::midpoint(lower.as_f64(), upper.as_f64());
1378 let steps = i32::try_from(offset).unwrap();
1379
1380 let expected_bid = index
1381 .checked_sub(offset)
1382 .map(|target| ticks[target]);
1383 let expected_ask = index
1384 .checked_add(offset + 1)
1385 .filter(|target| *target < ticks.len())
1386 .map(|target| ticks[target]);
1387
1388 prop_assert_eq!(scheme.next_bid_price(value, steps, 4), expected_bid);
1389 prop_assert_eq!(scheme.next_ask_price(value, steps, 4), expected_ask);
1390 }
1391 }
1392
1393 proptest! {
1395 #[rstest]
1396 fn prop_tiered_ask_monotonic_in_n(value in 1.0f64..10_000.0) {
1397 let scheme = TieredTickScheme::topix100();
1398 let mut prev: Option<Price> = None;
1399
1400 for n in 0..5 {
1401 if let Some(ask) = scheme.next_ask_price(value, n, 4) {
1402 if let Some(p) = prev {
1403 prop_assert!(ask >= p);
1404 }
1405 prev = Some(ask);
1406 }
1407 }
1408 }
1409 }
1410
1411 proptest! {
1413 #[rstest]
1414 fn prop_tiered_ticks_sorted(
1415 start in 1.0f64..100.0,
1416 step in 0.01f64..10.0,
1417 ) {
1418 let stop = start + step * 10.0;
1419 if let Ok(scheme) = TieredTickScheme::new(&[(start, stop, step)], 2, 100) {
1420 let ticks = scheme.ticks();
1421 for i in 1..ticks.len() {
1422 prop_assert!(ticks[i] > ticks[i - 1]);
1423 }
1424 }
1425 }
1426 }
1427}