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::{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/// Error returned when tick scheme construction or parsing fails.
41#[derive(Clone, Debug, Error, PartialEq)]
42pub enum TickSchemeError {
43    /// A fixed tick size was not finite.
44    #[error("tick must be finite")]
45    TickNotFinite {
46        /// The invalid tick size.
47        tick: f64,
48    },
49    /// A fixed tick size was not positive.
50    #[error("tick must be positive")]
51    TickNotPositive {
52        /// The invalid tick size.
53        tick: f64,
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    /// Tier expansion produced no ticks.
136    #[error("tier expansion produced no ticks")]
137    EmptyTickExpansion,
138    /// An expanded tick value was outside the representable price range.
139    #[error("expanded tick value {value} outside Price range")]
140    ExpandedTickOutsidePriceRange {
141        /// The invalid expanded tick value.
142        value: f64,
143    },
144    /// The requested tick scheme name is not registered.
145    #[error("unknown tick scheme {name}")]
146    UnknownName {
147        /// The requested tick scheme name.
148        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    /// Creates a new [`FixedTickScheme`] with the given tick size.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if `tick` is not finite or not positive.
227    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/// Tick scheme with price-dependent tick sizes.
259///
260/// Stores expanded ticks as raw fixed-point integers for exact comparison
261/// and fast binary search. Each tier defines a (start, stop, step) range
262/// that is expanded at construction.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct TieredTickScheme {
265    ticks: Vec<PriceRaw>,
266    precision: u8,
267}
268
269impl TieredTickScheme {
270    /// Creates a new [`TieredTickScheme`] from tier definitions.
271    ///
272    /// Each tier is `(start, stop, step)` where `start < stop` and `step > 0`.
273    /// Use `f64::INFINITY` for the last tier's stop value.
274    ///
275    /// # Errors
276    ///
277    /// Returns an error if any tier is invalid or contains out-of-range values.
278    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    /// Returns the expanded ticks as `Price` objects.
394    #[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    /// Returns the number of ticks.
406    #[must_use]
407    pub fn tick_count(&self) -> usize {
408        self.ticks.len()
409    }
410
411    /// Returns the minimum tick price.
412    #[must_use]
413    pub fn min_price(&self) -> Price {
414        self.price_at(0)
415    }
416
417    /// Returns the maximum tick price.
418    #[must_use]
419    pub fn max_price(&self) -> Price {
420        self.price_at(self.ticks.len() - 1)
421    }
422
423    /// Returns the price precision.
424    #[must_use]
425    pub fn precision(&self) -> u8 {
426        self.precision
427    }
428
429    /// Creates the TOPIX100 tick scheme.
430    ///
431    /// # Panics
432    ///
433    /// Panics if the hardcoded TOPIX100 tiers fail validation (should not happen).
434    #[must_use]
435    pub fn topix100() -> Self {
436        TOPIX100_TICK_SCHEME.clone()
437    }
438
439    /// Creates the BETFAIR tick scheme.
440    ///
441    /// # Panics
442    ///
443    /// Panics if the hardcoded BETFAIR tiers fail validation (should not happen).
444    #[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        // Floor to get a raw value guaranteed <= true value, infinite values
459        // saturate at the integer bounds during the float-to-integer cast.
460        let raw_floor = (value * FIXED_SCALAR).floor() as PriceRaw;
461
462        if raw_floor < self.ticks[0] {
463            return None;
464        }
465
466        // First index where tick >= raw_floor
467        let idx = self.ticks.partition_point(|&t| t < raw_floor);
468
469        if idx < self.ticks.len() && self.ticks[idx] == raw_floor {
470            // Value converts exactly to a tick
471            let target = idx.checked_sub(n)?;
472            return Some(self.price_at(target));
473        }
474
475        // Value is beyond or between ticks; bid is the tick below
476        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        // Ceil to get a raw value guaranteed >= true value, infinite values
488        // saturate at the integer bounds during the float-to-integer cast.
489        let raw_ceil = (value * FIXED_SCALAR).ceil() as PriceRaw;
490
491        if raw_ceil > *self.ticks.last()? {
492            return None;
493        }
494
495        // First index where tick >= raw_ceil
496        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/// Returns a registered tick scheme rule by name.
580#[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
604/// Validates an optional tick scheme name.
605///
606/// # Errors
607///
608/// Returns an error if the name is not valid ASCII or does not identify a registered scheme.
609pub 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/// Converts an f64 value to a `PriceRaw` fixed-point integer.
621#[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        // Stepping one tick above PRICE_MAX must yield None rather than panicking
823        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        // At 1000.0 we cross from 0.1 step to 0.5 step
880        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        // 1000.3 is between ticks in the 0.5-step tier (1000.0, 1000.5)
915        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        // 1.049 is below the 1.05 tick; bid should be 1.00, not 1.05
924        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        // 1.051 is above the 1.05 tick; ask should be 1.10, not 1.05
932        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        // 9.5 is beyond last tick (9.0) but bid should be 9.0
954        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        // Values beyond the Price range must yield None rather than panicking
986        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        // (0.0, 0.3, 0.1) should produce 0.0, 0.1, 0.2 (3 ticks, not 2)
1174        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        // First tier: 1, 2, 3, ..., 9
1184        // Second tier: 10, 15, 20, ..., 95
1185        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    // Property: bid(value, 0) <= value for any value in range
1345    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    // Property: ask(value, 0) >= value for any value in range
1356    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    // Property: ask(value, n) is monotonically increasing in n
1394    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    // Property: ticks are strictly sorted
1412    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}