Skip to main content

nautilus_model/instruments/
tick_scheme.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Tick scheme definitions for price-level navigation.
17
18use 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/// Error returned when tick scheme construction or parsing fails.
47#[derive(Clone, Debug, Error, PartialEq)]
48pub enum TickSchemeError {
49    /// A fixed tick size was not positive.
50    #[error("tick must be positive")]
51    TickNotPositive {
52        /// The invalid tick size.
53        tick: Price,
54    },
55    /// No tier definitions were supplied.
56    #[error("tiers must not be empty")]
57    EmptyTiers,
58    /// A tier contained a NaN value.
59    #[error("tier {index}: values must not be NaN")]
60    TierValuesNaN {
61        /// The invalid tier index.
62        index: usize,
63        /// The tier start value.
64        start: f64,
65        /// The tier stop value.
66        stop: f64,
67        /// The tier step value.
68        step: f64,
69    },
70    /// A tier start was not less than its stop.
71    #[error("tier {index}: start ({start}) must be less than stop ({stop})")]
72    TierStartNotLessThanStop {
73        /// The invalid tier index.
74        index: usize,
75        /// The tier start value.
76        start: f64,
77        /// The tier stop value.
78        stop: f64,
79    },
80    /// A tier step was not positive.
81    #[error("tier {index}: step ({step}) must be positive")]
82    TierStepNotPositive {
83        /// The invalid tier index.
84        index: usize,
85        /// The tier step value.
86        step: f64,
87    },
88    /// A finite tier step was not smaller than the tier range.
89    #[error("tier {index}: step ({step}) must be less than range ({stop} - {start} = {range})")]
90    TierStepNotLessThanRange {
91        /// The invalid tier index.
92        index: usize,
93        /// The tier start value.
94        start: f64,
95        /// The tier stop value.
96        stop: f64,
97        /// The tier step value.
98        step: f64,
99        /// The tier range.
100        range: f64,
101    },
102    /// A tier overlaps the previous tier.
103    #[error("tier {index}: start ({start}) overlaps previous tier stop ({prev_stop})")]
104    TierOverlapsPrevious {
105        /// The invalid tier index.
106        index: usize,
107        /// The tier start value.
108        start: f64,
109        /// The previous tier stop value.
110        prev_stop: f64,
111    },
112    /// A tier start was outside the representable price range.
113    #[error("tier {index}: start ({start}) outside Price range")]
114    TierStartOutsidePriceRange {
115        /// The invalid tier index.
116        index: usize,
117        /// The tier start value.
118        start: f64,
119    },
120    /// A tier stop was outside the representable price range.
121    #[error("tier {index}: stop ({stop}) outside Price range")]
122    TierStopOutsidePriceRange {
123        /// The invalid tier index.
124        index: usize,
125        /// The tier stop value.
126        stop: f64,
127    },
128    /// The requested price precision is invalid.
129    #[error("{source}")]
130    InvalidPrecision {
131        /// The source correctness error.
132        #[source]
133        source: CorrectnessError,
134    },
135    /// The fixed tick cannot be represented at the requested precision.
136    #[error("tick {tick} cannot be represented at precision {precision}")]
137    TickNotRepresentable {
138        /// The invalid tick size.
139        tick: Price,
140        /// The requested price precision.
141        precision: u8,
142    },
143    /// A tick scheme name was invalid.
144    #[error("{source}")]
145    InvalidName {
146        /// The source correctness error.
147        #[source]
148        source: CorrectnessError,
149    },
150    /// A tick scheme name was already registered.
151    #[error("tick scheme {name} is already registered")]
152    DuplicateName {
153        /// The normalized duplicate name.
154        name: String,
155    },
156    /// Tier expansion produced no ticks.
157    #[error("tier expansion produced no ticks")]
158    EmptyTickExpansion,
159    /// An expanded tick value was outside the representable price range.
160    #[error("expanded tick value {value} outside Price range")]
161    ExpandedTickOutsidePriceRange {
162        /// The invalid expanded tick value.
163        value: f64,
164    },
165    /// The requested tick scheme name is not registered.
166    #[error("unknown tick scheme {name}")]
167    UnknownName {
168        /// The requested tick scheme name.
169        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/// Fixed price grid with an exact, positive tick size.
241///
242/// Navigation returns `None` if the requested output precision cannot represent the tick exactly.
243#[derive(Debug, Clone, Copy, Eq)]
244pub struct FixedTickScheme {
245    tick: Price,
246}
247
248impl FixedTickScheme {
249    /// Creates a new [`FixedTickScheme`] with the given tick size.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if the tick is not positive, is outside the representable price range,
254    /// has invalid precision, or contains fractional digits beyond its precision.
255    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    /// Creates a fixed tick scheme with an explicit default output precision.
274    ///
275    /// # Errors
276    ///
277    /// Returns an error if the tick is invalid, the output precision is invalid,
278    /// or the tick is not exactly representable at that precision.
279    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    /// Returns the fixed tick size.
297    #[must_use]
298    pub const fn tick(&self) -> Price {
299        self.tick
300    }
301
302    /// Returns the default output price precision.
303    #[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/// Tick scheme with price-dependent tick sizes.
334///
335/// Stores expanded ticks as raw fixed-point integers for exact comparison
336/// and fast binary search. Each tier defines a (start, stop, step) range
337/// that is expanded at construction.
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct TieredTickScheme {
340    ticks: Vec<PriceRaw>,
341    precision: u8,
342}
343
344impl TieredTickScheme {
345    /// Creates a new [`TieredTickScheme`] from tier definitions.
346    ///
347    /// Each tier is `(start, stop, step)` where `start < stop` and `step > 0`.
348    /// Use `f64::INFINITY` for the last tier's stop value.
349    ///
350    /// # Errors
351    ///
352    /// Returns an error if any tier is invalid or contains out-of-range values.
353    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    /// Returns the expanded ticks as `Price` objects.
469    #[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    /// Returns the number of ticks.
481    #[must_use]
482    pub fn tick_count(&self) -> usize {
483        self.ticks.len()
484    }
485
486    /// Returns the minimum tick price.
487    #[must_use]
488    pub fn min_price(&self) -> Price {
489        self.price_at(0)
490    }
491
492    /// Returns the maximum tick price.
493    #[must_use]
494    pub fn max_price(&self) -> Price {
495        self.price_at(self.ticks.len() - 1)
496    }
497
498    /// Returns the price precision.
499    #[must_use]
500    pub fn precision(&self) -> u8 {
501        self.precision
502    }
503
504    /// Creates the TOPIX100 tick scheme.
505    ///
506    /// # Panics
507    ///
508    /// Panics if the hardcoded TOPIX100 tiers fail validation (should not happen).
509    #[must_use]
510    pub fn topix100() -> Self {
511        TOPIX100_TICK_SCHEME.clone()
512    }
513
514    /// Creates the BETFAIR tick scheme.
515    ///
516    /// # Panics
517    ///
518    /// Panics if the hardcoded BETFAIR tiers fail validation (should not happen).
519    #[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        // Floor to get a raw value guaranteed <= true value, infinite values
534        // saturate at the integer bounds during the float-to-integer cast.
535        let raw_floor = (value * FIXED_SCALAR).floor() as PriceRaw;
536
537        if raw_floor < self.ticks[0] {
538            return None;
539        }
540
541        // First index where tick >= raw_floor
542        let idx = self.ticks.partition_point(|&t| t < raw_floor);
543
544        if idx < self.ticks.len() && self.ticks[idx] == raw_floor {
545            // Value converts exactly to a tick
546            let target = idx.checked_sub(n)?;
547            return Some(self.price_at(target));
548        }
549
550        // Value is beyond or between ticks; bid is the tick below
551        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        // Ceil to get a raw value guaranteed >= true value, infinite values
563        // saturate at the integer bounds during the float-to-integer cast.
564        let raw_ceil = (value * FIXED_SCALAR).ceil() as PriceRaw;
565
566        if raw_ceil > *self.ticks.last()? {
567            return None;
568        }
569
570        // First index where tick >= raw_ceil
571        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/// Returns a registered tick scheme rule by name.
665#[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
697/// Registers a named tick scheme for the lifetime of the process.
698///
699/// Names are trimmed and matched without regard to ASCII case. Registered names,
700/// including built-in names and aliases, cannot be replaced or removed.
701///
702/// # Errors
703///
704/// Returns an error if the name is invalid or already registered.
705pub 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    // The registry is append-only so borrowed rules remain valid after the lock is released
720    schemes.insert(name, Box::leak(Box::new(scheme)));
721    Ok(())
722}
723
724/// Returns a copy of a registered scheme, matching trimmed names without regard to ASCII case.
725#[must_use]
726pub fn get_tick_scheme(name: &str) -> Option<TickScheme> {
727    name.parse().ok()
728}
729
730/// Returns all registered names in uppercase, sorted lexicographically.
731#[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
759/// Validates an optional tick scheme name.
760///
761/// # Errors
762///
763/// Returns an error if the name is not valid ASCII or does not identify a registered scheme.
764pub 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/// Converts an f64 value to a `PriceRaw` fixed-point integer.
776#[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        // Stepping one tick above PRICE_MAX must yield None rather than panicking
1306        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        // At 1000.0 we cross from 0.1 step to 0.5 step
1363        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        // 1000.3 is between ticks in the 0.5-step tier (1000.0, 1000.5)
1398        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        // 1.049 is below the 1.05 tick; bid should be 1.00, not 1.05
1407        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        // 1.051 is above the 1.05 tick; ask should be 1.10, not 1.05
1415        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        // 9.5 is beyond last tick (9.0) but bid should be 9.0
1437        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        // Values beyond the Price range must yield None rather than panicking
1469        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        // (0.0, 0.3, 0.1) should produce 0.0, 0.1, 0.2 (3 ticks, not 2)
1657        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        // First tier: 1, 2, 3, ..., 9
1667        // Second tier: 10, 15, 20, ..., 95
1668        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    // Property: bid(value, 0) <= value for any value in range
1828    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    // Property: ask(value, 0) >= value for any value in range
1839    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    // Property: ask(value, n) is monotonically increasing in n
1877    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    // Property: ticks are strictly sorted
1895    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}