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