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::check_predicate_true;
21
22#[cfg(not(feature = "high-precision"))]
23use crate::types::fixed::f64_to_fixed_i64;
24#[cfg(feature = "high-precision")]
25use crate::types::fixed::f64_to_fixed_i128;
26use crate::types::{
27    Price,
28    fixed::FIXED_SCALAR,
29    price::{PRICE_MAX, PRICE_MIN, PriceRaw},
30};
31
32pub trait TickSchemeRule: Display {
33    fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price>;
34    fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price>;
35}
36
37pub const BETFAIR_TICK_SCHEME_NAME: &str = "BETFAIR";
38
39const BETFAIR_PRICE_TIERS: [(f64, f64, f64); 10] = [
40    (1.01, 2.0, 0.01),
41    (2.0, 3.0, 0.02),
42    (3.0, 4.0, 0.05),
43    (4.0, 6.0, 0.1),
44    (6.0, 10.0, 0.2),
45    (10.0, 20.0, 0.5),
46    (20.0, 30.0, 1.0),
47    (30.0, 50.0, 2.0),
48    (50.0, 100.0, 5.0),
49    (100.0, 1010.0, 10.0),
50];
51
52pub static BETFAIR_TICK_SCHEME: LazyLock<TieredTickScheme> = LazyLock::new(|| {
53    TieredTickScheme::new(&BETFAIR_PRICE_TIERS, 2, 100)
54        .expect("BETFAIR tick scheme tiers are valid by construction")
55});
56
57#[derive(Clone, Copy, Debug)]
58pub struct FixedTickScheme {
59    tick: f64,
60}
61
62impl PartialEq for FixedTickScheme {
63    fn eq(&self, other: &Self) -> bool {
64        self.tick == other.tick
65    }
66}
67impl Eq for FixedTickScheme {}
68
69impl FixedTickScheme {
70    #[expect(clippy::missing_errors_doc)]
71    pub fn new(tick: f64) -> anyhow::Result<Self> {
72        check_predicate_true(tick > 0.0, "tick must be positive")?;
73        Ok(Self { tick })
74    }
75}
76
77impl TickSchemeRule for FixedTickScheme {
78    #[inline(always)]
79    fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
80        let base = (value / self.tick).floor() * self.tick;
81        Some(Price::new(base - f64::from(n) * self.tick, precision))
82    }
83
84    #[inline(always)]
85    fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
86        let base = (value / self.tick).ceil() * self.tick;
87        Some(Price::new(base + f64::from(n) * self.tick, precision))
88    }
89}
90
91impl Display for FixedTickScheme {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(f, "FIXED")
94    }
95}
96
97/// Tick scheme with price-dependent tick sizes.
98///
99/// Stores expanded ticks as raw fixed-point integers for exact comparison
100/// and fast binary search. Each tier defines a (start, stop, step) range
101/// that is expanded at construction.
102#[derive(Clone, Debug)]
103pub struct TieredTickScheme {
104    ticks: Vec<PriceRaw>,
105    precision: u8,
106}
107
108impl PartialEq for TieredTickScheme {
109    fn eq(&self, other: &Self) -> bool {
110        self.precision == other.precision && self.ticks == other.ticks
111    }
112}
113impl Eq for TieredTickScheme {}
114
115impl TieredTickScheme {
116    /// Creates a new [`TieredTickScheme`] from tier definitions.
117    ///
118    /// Each tier is `(start, stop, step)` where `start < stop` and `step > 0`.
119    /// Use `f64::INFINITY` for the last tier's stop value.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if any tier is invalid or contains out-of-range values.
124    pub fn new(
125        tiers: &[(f64, f64, f64)],
126        price_precision: u8,
127        max_ticks_per_tier: usize,
128    ) -> anyhow::Result<Self> {
129        if tiers.is_empty() {
130            anyhow::bail!("tiers must not be empty");
131        }
132
133        for (i, &(start, stop, step)) in tiers.iter().enumerate() {
134            if start.is_nan() || stop.is_nan() || step.is_nan() {
135                anyhow::bail!("tier {i}: values must not be NaN");
136            }
137
138            if start >= stop {
139                anyhow::bail!("tier {i}: start ({start}) must be less than stop ({stop})");
140            }
141
142            if step <= 0.0 {
143                anyhow::bail!("tier {i}: step ({step}) must be positive");
144            }
145
146            if !stop.is_infinite() && step >= (stop - start) {
147                anyhow::bail!(
148                    "tier {i}: step ({step}) must be less than range ({} - {} = {})",
149                    stop,
150                    start,
151                    stop - start,
152                );
153            }
154
155            if i > 0 {
156                let prev_stop = tiers[i - 1].1;
157
158                if start < prev_stop {
159                    anyhow::bail!(
160                        "tier {i}: start ({start}) overlaps previous tier stop ({prev_stop})"
161                    );
162                }
163            }
164
165            if !(PRICE_MIN..=PRICE_MAX).contains(&start) {
166                anyhow::bail!("tier {i}: start ({start}) outside Price range");
167            }
168
169            if !stop.is_infinite() && !(PRICE_MIN..=PRICE_MAX).contains(&stop) {
170                anyhow::bail!("tier {i}: stop ({stop}) outside Price range");
171            }
172        }
173
174        let _ = Price::new_checked(0.0, price_precision)?;
175
176        let ticks = Self::build_ticks(tiers, price_precision, max_ticks_per_tier)?;
177
178        if ticks.is_empty() {
179            anyhow::bail!("tier expansion produced no ticks");
180        }
181        Ok(Self {
182            ticks,
183            precision: price_precision,
184        })
185    }
186
187    fn build_ticks(
188        tiers: &[(f64, f64, f64)],
189        precision: u8,
190        max_ticks_per_tier: usize,
191    ) -> anyhow::Result<Vec<PriceRaw>> {
192        let mut all_ticks = Vec::new();
193
194        for &(start, stop, step) in tiers {
195            let effective_stop = if stop.is_infinite() {
196                start + ((max_ticks_per_tier + 1) as f64) * step
197            } else {
198                stop
199            };
200            let mut i = 0;
201            while i < max_ticks_per_tier {
202                let value = start + (i as f64) * step;
203
204                if value >= effective_stop {
205                    break;
206                }
207
208                if !value.is_finite() || !(PRICE_MIN..=PRICE_MAX).contains(&value) {
209                    anyhow::bail!("expanded tick value {value} outside Price range");
210                }
211                let raw = f64_to_raw(value, precision);
212
213                if all_ticks.last() != Some(&raw) {
214                    all_ticks.push(raw);
215                }
216                i += 1;
217            }
218        }
219        Ok(all_ticks)
220    }
221
222    #[inline(always)]
223    fn price_at(&self, index: usize) -> Price {
224        Price {
225            raw: self.ticks[index],
226            precision: self.precision,
227        }
228    }
229
230    /// Returns the expanded ticks as `Price` objects.
231    #[must_use]
232    pub fn ticks(&self) -> Vec<Price> {
233        self.ticks
234            .iter()
235            .map(|&raw| Price {
236                raw,
237                precision: self.precision,
238            })
239            .collect()
240    }
241
242    /// Returns the number of ticks.
243    #[must_use]
244    pub fn tick_count(&self) -> usize {
245        self.ticks.len()
246    }
247
248    /// Returns the minimum tick price.
249    #[must_use]
250    pub fn min_price(&self) -> Price {
251        self.price_at(0)
252    }
253
254    /// Returns the maximum tick price.
255    #[must_use]
256    pub fn max_price(&self) -> Price {
257        self.price_at(self.ticks.len() - 1)
258    }
259
260    /// Returns the price precision.
261    #[must_use]
262    pub fn precision(&self) -> u8 {
263        self.precision
264    }
265
266    /// Creates the TOPIX100 tick scheme.
267    ///
268    /// # Panics
269    ///
270    /// Panics if the hardcoded TOPIX100 tiers fail validation (should not happen).
271    #[must_use]
272    pub fn topix100() -> Self {
273        Self::new(
274            &[
275                (0.1, 1_000.0, 0.1),
276                (1_000.0, 3_000.0, 0.5),
277                (3_000.0, 10_000.0, 1.0),
278                (10_000.0, 30_000.0, 5.0),
279                (30_000.0, 100_000.0, 10.0),
280                (100_000.0, 300_000.0, 50.0),
281                (300_000.0, 1_000_000.0, 100.0),
282                (1_000_000.0, 3_000_000.0, 500.0),
283                (3_000_000.0, 10_000_000.0, 1_000.0),
284                (10_000_000.0, 30_000_000.0, 5_000.0),
285                (30_000_000.0, f64::INFINITY, 10_000.0),
286            ],
287            4,
288            10_000,
289        )
290        // SAFETY: TOPIX100 tiers are valid by construction
291        .unwrap()
292    }
293
294    /// Creates the BETFAIR tick scheme.
295    ///
296    /// # Panics
297    ///
298    /// Panics if the hardcoded BETFAIR tiers fail validation (should not happen).
299    #[must_use]
300    pub fn betfair() -> Self {
301        BETFAIR_TICK_SCHEME.clone()
302    }
303}
304
305impl TickSchemeRule for TieredTickScheme {
306    fn next_bid_price(&self, value: f64, n: i32, _precision: u8) -> Option<Price> {
307        if n < 0 {
308            return None;
309        }
310
311        // Floor to get a raw value guaranteed <= true value
312        let raw_floor = (value * FIXED_SCALAR).floor() as PriceRaw;
313
314        if raw_floor < self.ticks[0] {
315            return None;
316        }
317
318        // First index where tick >= raw_floor
319        let idx = self.ticks.partition_point(|&t| t < raw_floor);
320
321        if idx < self.ticks.len() && self.ticks[idx] == raw_floor {
322            // Value converts exactly to a tick
323            let target = idx as i32 - n;
324
325            if target < 0 {
326                return None;
327            }
328            return Some(self.price_at(target as usize));
329        }
330
331        // Value is beyond or between ticks; bid is the tick below
332        let effective_idx = idx.min(self.ticks.len());
333        let target = effective_idx as i32 - 1 - n;
334
335        if target < 0 {
336            return None;
337        }
338        Some(self.price_at(target as usize))
339    }
340
341    fn next_ask_price(&self, value: f64, n: i32, _precision: u8) -> Option<Price> {
342        if n < 0 {
343            return None;
344        }
345
346        // Ceil to get a raw value guaranteed >= true value
347        let raw_ceil = (value * FIXED_SCALAR).ceil() as PriceRaw;
348
349        if raw_ceil > *self.ticks.last()? {
350            return None;
351        }
352
353        // First index where tick >= raw_ceil
354        let idx = self.ticks.partition_point(|&t| t < raw_ceil);
355        let target = idx as i32 + n;
356
357        if target < 0 || target >= self.ticks.len() as i32 {
358            return None;
359        }
360        Some(self.price_at(target as usize))
361    }
362}
363
364impl Display for TieredTickScheme {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        write!(f, "TIERED")
367    }
368}
369
370#[derive(Clone, Debug, PartialEq, Eq)]
371pub enum TickScheme {
372    Fixed(FixedTickScheme),
373    Tiered(TieredTickScheme),
374    Betfair,
375    Crypto,
376}
377
378impl TickSchemeRule for TickScheme {
379    #[inline(always)]
380    fn next_bid_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
381        match self {
382            Self::Fixed(scheme) => scheme.next_bid_price(value, n, precision),
383            Self::Tiered(scheme) => scheme.next_bid_price(value, n, precision),
384            Self::Betfair => BETFAIR_TICK_SCHEME.next_bid_price(value, n, precision),
385            Self::Crypto => {
386                let increment: f64 = 0.01;
387                let base = (value / increment).floor() * increment;
388                Some(Price::new(base - f64::from(n) * increment, precision))
389            }
390        }
391    }
392
393    #[inline(always)]
394    fn next_ask_price(&self, value: f64, n: i32, precision: u8) -> Option<Price> {
395        match self {
396            Self::Fixed(scheme) => scheme.next_ask_price(value, n, precision),
397            Self::Tiered(scheme) => scheme.next_ask_price(value, n, precision),
398            Self::Betfair => BETFAIR_TICK_SCHEME.next_ask_price(value, n, precision),
399            Self::Crypto => {
400                let increment: f64 = 0.01;
401                let base = (value / increment).ceil() * increment;
402                Some(Price::new(base + f64::from(n) * increment, precision))
403            }
404        }
405    }
406}
407
408impl Display for TickScheme {
409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        match self {
411            Self::Fixed(_) => write!(f, "FIXED"),
412            Self::Tiered(scheme) => write!(f, "{scheme}"),
413            Self::Betfair => write!(f, "{BETFAIR_TICK_SCHEME_NAME}"),
414            Self::Crypto => write!(f, "CRYPTO_0_01"),
415        }
416    }
417}
418
419impl FromStr for TickScheme {
420    type Err = anyhow::Error;
421
422    fn from_str(s: &str) -> Result<Self, Self::Err> {
423        match s.trim().to_ascii_uppercase().as_str() {
424            "FIXED" => Ok(Self::Fixed(FixedTickScheme::new(1.0)?)),
425            "TOPIX100" => Ok(Self::Tiered(TieredTickScheme::topix100())),
426            BETFAIR_TICK_SCHEME_NAME => Ok(Self::Betfair),
427            "CRYPTO_0_01" => Ok(Self::Crypto),
428            _ => anyhow::bail!("unknown tick scheme {s}"),
429        }
430    }
431}
432
433/// Converts an f64 value to a `PriceRaw` fixed-point integer.
434#[inline(always)]
435fn f64_to_raw(value: f64, precision: u8) -> PriceRaw {
436    #[cfg(feature = "high-precision")]
437    {
438        f64_to_fixed_i128(value, precision)
439    }
440    #[cfg(not(feature = "high-precision"))]
441    {
442        f64_to_fixed_i64(value, precision)
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use std::str::FromStr;
449
450    use proptest::prelude::*;
451    use rstest::rstest;
452
453    use super::*;
454
455    #[rstest]
456    fn fixed_tick_scheme_prices() {
457        let scheme = FixedTickScheme::new(0.5).unwrap();
458        let bid = scheme.next_bid_price(10.3, 0, 2).unwrap();
459        let ask = scheme.next_ask_price(10.3, 0, 2).unwrap();
460        assert!(bid < ask);
461    }
462
463    #[rstest]
464    #[should_panic(expected = "tick must be positive")]
465    fn fixed_tick_negative() {
466        FixedTickScheme::new(-0.01).unwrap();
467    }
468
469    #[rstest]
470    fn fixed_tick_boundary() {
471        let scheme = FixedTickScheme::new(0.5).unwrap();
472        let price = scheme.next_bid_price(10.5, 0, 2).unwrap();
473        assert_eq!(price, Price::new(10.5, 2));
474    }
475
476    #[rstest]
477    fn fixed_tick_multiple_steps() {
478        let scheme = FixedTickScheme::new(1.0).unwrap();
479        let bid = scheme.next_bid_price(10.0, 2, 1).unwrap();
480        let ask = scheme.next_ask_price(10.0, 3, 1).unwrap();
481        assert_eq!(bid, Price::new(8.0, 1));
482        assert_eq!(ask, Price::new(13.0, 1));
483    }
484
485    #[rstest]
486    fn tick_scheme_round_trip() {
487        let scheme = TickScheme::from_str("CRYPTO_0_01").unwrap();
488        assert_eq!(scheme.to_string(), "CRYPTO_0_01");
489    }
490
491    #[rstest]
492    fn tick_scheme_unknown() {
493        assert!(TickScheme::from_str("UNKNOWN").is_err());
494    }
495
496    #[rstest]
497    fn fixed_tick_zero() {
498        assert!(FixedTickScheme::new(0.0).is_err());
499    }
500
501    #[rstest]
502    fn tiered_tick_scheme_topix100_construction() {
503        let scheme = TieredTickScheme::topix100();
504        assert!(scheme.tick_count() > 0);
505        assert_eq!(scheme.precision(), 4);
506        assert_eq!(scheme.min_price(), Price::new(0.1, 4));
507    }
508
509    #[rstest]
510    fn tiered_tick_scheme_betfair_construction() {
511        let scheme = TieredTickScheme::betfair();
512        assert_eq!(scheme.tick_count(), 350);
513        assert_eq!(scheme.precision(), 2);
514        assert_eq!(scheme.min_price(), Price::new(1.01, 2));
515        assert_eq!(scheme.max_price(), Price::new(1000.0, 2));
516    }
517
518    #[rstest]
519    fn tiered_tick_scheme_ask_at_low_price() {
520        let scheme = TieredTickScheme::topix100();
521        let ask = scheme.next_ask_price(500.0, 0, 4).unwrap();
522        assert_eq!(ask, Price::new(500.0, 4));
523    }
524
525    #[rstest]
526    fn tiered_tick_scheme_bid_at_low_price() {
527        let scheme = TieredTickScheme::topix100();
528        let bid = scheme.next_bid_price(500.0, 0, 4).unwrap();
529        assert_eq!(bid, Price::new(500.0, 4));
530    }
531
532    #[rstest]
533    fn tiered_tick_scheme_ask_steps() {
534        let scheme = TieredTickScheme::topix100();
535        let ask0 = scheme.next_ask_price(500.0, 0, 4).unwrap();
536        let ask1 = scheme.next_ask_price(500.0, 1, 4).unwrap();
537        assert!(ask1 > ask0);
538        assert_eq!(ask1, Price::new(500.1, 4));
539    }
540
541    #[rstest]
542    fn tiered_tick_scheme_bid_steps() {
543        let scheme = TieredTickScheme::topix100();
544        let bid0 = scheme.next_bid_price(500.0, 0, 4).unwrap();
545        let bid1 = scheme.next_bid_price(500.0, 1, 4).unwrap();
546        assert!(bid1 < bid0);
547        assert_eq!(bid1, Price::new(499.9, 4));
548    }
549
550    #[rstest]
551    fn tiered_tick_scheme_tier_boundary_1000() {
552        let scheme = TieredTickScheme::topix100();
553        // At 1000.0 we cross from 0.1 step to 0.5 step
554        let ask = scheme.next_ask_price(1000.0, 1, 4).unwrap();
555        assert_eq!(ask, Price::new(1000.5, 4));
556    }
557
558    #[rstest]
559    #[case(3.90, 1, "3.95")]
560    #[case(4.0, 1, "4.10")]
561    fn tiered_tick_scheme_betfair_ask_transition(
562        #[case] value: f64,
563        #[case] n: i32,
564        #[case] expected: &str,
565    ) {
566        let scheme = TieredTickScheme::betfair();
567        let ask = scheme.next_ask_price(value, n, 2).unwrap();
568        assert_eq!(ask, Price::from(expected));
569    }
570
571    #[rstest]
572    #[case(1.499, 0, "1.49")]
573    #[case(2.011, 0, "2.00")]
574    #[case(2.027, 2, "1.99")]
575    fn tiered_tick_scheme_betfair_bid_transition(
576        #[case] value: f64,
577        #[case] n: i32,
578        #[case] expected: &str,
579    ) {
580        let scheme = TieredTickScheme::betfair();
581        let bid = scheme.next_bid_price(value, n, 2).unwrap();
582        assert_eq!(bid, Price::from(expected));
583    }
584
585    #[rstest]
586    fn tiered_tick_scheme_between_ticks() {
587        let scheme = TieredTickScheme::topix100();
588        // 1000.3 is between ticks in the 0.5-step tier (1000.0, 1000.5)
589        let ask = scheme.next_ask_price(1000.3, 0, 4).unwrap();
590        assert!(ask.as_f64() >= 1000.3);
591        let bid = scheme.next_bid_price(1000.3, 0, 4).unwrap();
592        assert!(bid.as_f64() <= 1000.3);
593    }
594
595    #[rstest]
596    fn tiered_tick_scheme_off_grid_bid_below_tick() {
597        // 1.049 is below the 1.05 tick; bid should be 1.00, not 1.05
598        let scheme = TieredTickScheme::new(&[(1.0, 2.0, 0.05)], 2, 100).unwrap();
599        let bid = scheme.next_bid_price(1.049, 0, 2).unwrap();
600        assert_eq!(bid, Price::new(1.0, 2));
601    }
602
603    #[rstest]
604    fn tiered_tick_scheme_off_grid_ask_above_tick() {
605        // 1.051 is above the 1.05 tick; ask should be 1.10, not 1.05
606        let scheme = TieredTickScheme::new(&[(1.0, 2.0, 0.05)], 2, 100).unwrap();
607        let ask = scheme.next_ask_price(1.051, 0, 2).unwrap();
608        assert_eq!(ask, Price::new(1.10, 2));
609    }
610
611    #[rstest]
612    fn tiered_tick_scheme_bid_below_min_returns_none() {
613        let scheme = TieredTickScheme::topix100();
614        assert!(scheme.next_bid_price(0.05, 0, 4).is_none());
615    }
616
617    #[rstest]
618    fn tiered_tick_scheme_ask_beyond_last_tick_returns_none() {
619        let scheme = TieredTickScheme::topix100();
620        let last = scheme.max_price().as_f64();
621        assert!(scheme.next_ask_price(last, 1, 4).is_none());
622    }
623
624    #[rstest]
625    fn tiered_tick_scheme_bid_beyond_last_tick_returns_last() {
626        let scheme = TieredTickScheme::new(&[(1.0, 10.0, 1.0)], 1, 100).unwrap();
627        // 9.5 is beyond last tick (9.0) but bid should be 9.0
628        let bid = scheme.next_bid_price(9.5, 0, 1).unwrap();
629        assert_eq!(bid, Price::new(9.0, 1));
630    }
631
632    #[rstest]
633    fn tiered_tick_scheme_negative_n_returns_none() {
634        let scheme = TieredTickScheme::topix100();
635        assert!(scheme.next_bid_price(500.0, -1, 4).is_none());
636        assert!(scheme.next_ask_price(500.0, -1, 4).is_none());
637    }
638
639    #[rstest]
640    fn tiered_tick_scheme_validation_start_ge_stop() {
641        let result = TieredTickScheme::new(&[(100.0, 50.0, 1.0)], 2, 100);
642        assert!(result.is_err());
643    }
644
645    #[rstest]
646    fn tiered_tick_scheme_validation_negative_step() {
647        let result = TieredTickScheme::new(&[(0.0, 100.0, -1.0)], 2, 100);
648        assert!(result.is_err());
649    }
650
651    #[rstest]
652    fn tiered_tick_scheme_validation_step_ge_range() {
653        let result = TieredTickScheme::new(&[(0.0, 100.0, 200.0)], 2, 100);
654        assert!(result.is_err());
655    }
656
657    #[rstest]
658    fn tiered_tick_scheme_validation_invalid_precision() {
659        let result = TieredTickScheme::new(&[(1.0, 10.0, 1.0)], 50, 100);
660        assert!(result.is_err());
661    }
662
663    #[rstest]
664    fn tiered_tick_scheme_validation_empty_tiers() {
665        let result = TieredTickScheme::new(&[], 2, 100);
666        assert!(result.is_err());
667    }
668
669    #[rstest]
670    fn tiered_tick_scheme_validation_non_monotonic_tiers() {
671        let result = TieredTickScheme::new(&[(10.0, 20.0, 1.0), (1.0, 10.0, 1.0)], 1, 100);
672        assert!(result.is_err());
673    }
674
675    #[rstest]
676    fn tiered_tick_scheme_validation_overlapping_tiers() {
677        let result = TieredTickScheme::new(&[(1.0, 10.0, 1.0), (5.0, 15.0, 1.0)], 1, 100);
678        assert!(result.is_err());
679    }
680
681    #[rstest]
682    fn tiered_tick_scheme_finite_tier_includes_all_ticks() {
683        // (0.0, 0.3, 0.1) should produce 0.0, 0.1, 0.2 (3 ticks, not 2)
684        let scheme = TieredTickScheme::new(&[(0.0, 0.3, 0.1)], 1, 100).unwrap();
685        assert_eq!(scheme.tick_count(), 3);
686    }
687
688    #[rstest]
689    fn tiered_tick_scheme_simple_two_tiers() {
690        let scheme =
691            TieredTickScheme::new(&[(1.0, 10.0, 1.0), (10.0, 100.0, 5.0)], 2, 100).unwrap();
692        let ticks = scheme.ticks();
693        // First tier: 1, 2, 3, ..., 9
694        // Second tier: 10, 15, 20, ..., 95
695        assert_eq!(ticks[0], Price::new(1.0, 2));
696        assert_eq!(ticks[8], Price::new(9.0, 2));
697        assert_eq!(ticks[9], Price::new(10.0, 2));
698        assert_eq!(ticks[10], Price::new(15.0, 2));
699    }
700
701    #[rstest]
702    fn tiered_tick_scheme_infinity_tier() {
703        let scheme = TieredTickScheme::new(&[(100.0, f64::INFINITY, 10.0)], 1, 5).unwrap();
704        assert_eq!(scheme.tick_count(), 5);
705        let ticks = scheme.ticks();
706        assert_eq!(ticks[0], Price::new(100.0, 1));
707        assert_eq!(ticks[4], Price::new(140.0, 1));
708    }
709
710    #[rstest]
711    fn tiered_tick_scheme_from_str_topix100() {
712        let scheme = TickScheme::from_str("TOPIX100").unwrap();
713        assert_eq!(scheme.to_string(), "TIERED");
714    }
715
716    #[rstest]
717    fn tiered_tick_scheme_from_str_betfair() {
718        let scheme = TickScheme::from_str("BETFAIR").unwrap();
719        assert_eq!(scheme.to_string(), BETFAIR_TICK_SCHEME_NAME);
720        assert_eq!(
721            scheme.next_ask_price(4.0, 1, 2).unwrap(),
722            Price::new(4.1, 2)
723        );
724    }
725
726    #[rstest]
727    fn tiered_tick_scheme_display() {
728        let scheme = TieredTickScheme::new(&[(1.0, 10.0, 1.0)], 2, 100).unwrap();
729        assert_eq!(scheme.to_string(), "TIERED");
730    }
731
732    #[rstest]
733    fn tiered_tick_scheme_validation_start_equals_stop() {
734        let result = TieredTickScheme::new(&[(2.0, 2.0, 0.1)], 2, 100);
735        assert!(result.is_err());
736    }
737
738    #[rstest]
739    fn tiered_tick_scheme_validation_nan_stop() {
740        let result = TieredTickScheme::new(&[(1.0, f64::NAN, 1.0)], 2, 100);
741        assert!(result.is_err());
742    }
743
744    #[rstest]
745    fn tiered_tick_scheme_validation_nan_start() {
746        let result = TieredTickScheme::new(&[(f64::NAN, 10.0, 1.0)], 2, 100);
747        assert!(result.is_err());
748    }
749
750    #[rstest]
751    fn tiered_tick_scheme_validation_nan_step() {
752        let result = TieredTickScheme::new(&[(1.0, 10.0, f64::NAN)], 2, 100);
753        assert!(result.is_err());
754    }
755
756    #[rstest]
757    fn tiered_tick_scheme_validation_zero_step() {
758        let result = TieredTickScheme::new(&[(1.0, 2.0, 0.0)], 2, 100);
759        assert!(result.is_err());
760    }
761
762    #[rstest]
763    fn tiered_tick_scheme_min_tick_bid() {
764        let scheme = TieredTickScheme::topix100();
765        let result = scheme.next_bid_price(0.1, 0, 4).unwrap();
766        assert_eq!(result, Price::new(0.1, 4));
767    }
768
769    #[rstest]
770    fn tiered_tick_scheme_min_tick_bid_n1_returns_none() {
771        let scheme = TieredTickScheme::topix100();
772        assert!(scheme.next_bid_price(0.1, 1, 4).is_none());
773    }
774
775    #[rstest]
776    fn tiered_tick_scheme_boundary_tick_equality() {
777        let scheme = TieredTickScheme::topix100();
778        let bid = scheme.next_bid_price(1000.0, 0, 4).unwrap();
779        assert_eq!(bid, Price::new(1000.0, 4));
780        let ask = scheme.next_ask_price(1000.0, 0, 4).unwrap();
781        assert_eq!(ask, Price::new(1000.0, 4));
782    }
783
784    #[rstest]
785    fn tiered_tick_scheme_tier_transition_ask_from_999_9() {
786        let scheme = TieredTickScheme::topix100();
787        let ask = scheme.next_ask_price(999.9, 0, 4).unwrap();
788        assert_eq!(ask, Price::new(999.9, 4));
789    }
790
791    #[rstest]
792    fn tiered_tick_scheme_tier_transition_bid_from_1000_5() {
793        let scheme = TieredTickScheme::topix100();
794        let bid = scheme.next_bid_price(1000.5, 1, 4).unwrap();
795        assert_eq!(bid, Price::new(1000.0, 4));
796    }
797
798    #[rstest]
799    fn tiered_tick_scheme_large_n_beyond_bounds_ask() {
800        let scheme = TieredTickScheme::topix100();
801        let max = scheme.max_price().as_f64();
802        assert!(scheme.next_ask_price(max - 1000.0, 100_000, 4).is_none());
803    }
804
805    #[rstest]
806    fn tiered_tick_scheme_large_n_beyond_bounds_bid() {
807        let scheme = TieredTickScheme::topix100();
808        let min = scheme.min_price().as_f64();
809        assert!(scheme.next_bid_price(min + 1000.0, 100_000, 4).is_none());
810    }
811
812    #[rstest]
813    fn tiered_tick_scheme_out_of_bounds_ask_far_above() {
814        let scheme = TieredTickScheme::topix100();
815        assert!(scheme.next_ask_price(999_999_999.0, 0, 4).is_none());
816    }
817
818    #[rstest]
819    fn tiered_tick_scheme_idempotent_on_tick() {
820        let scheme = TieredTickScheme::topix100();
821        let price = 500.0;
822        let ask = scheme.next_ask_price(price, 0, 4).unwrap();
823        let ask2 = scheme.next_ask_price(ask.as_f64(), 0, 4).unwrap();
824        assert_eq!(ask, ask2);
825        let bid = scheme.next_bid_price(price, 0, 4).unwrap();
826        let bid2 = scheme.next_bid_price(bid.as_f64(), 0, 4).unwrap();
827        assert_eq!(bid, bid2);
828    }
829
830    #[rstest]
831    fn tiered_tick_scheme_consistency_forward_backward() {
832        let scheme = TieredTickScheme::topix100();
833        let start = 5000.0;
834        let forward = scheme.next_ask_price(start, 10, 4).unwrap();
835        let back = scheme.next_bid_price(forward.as_f64(), 10, 4).unwrap();
836        assert!(back.as_f64() <= start);
837    }
838
839    #[rstest]
840    fn tiered_tick_scheme_cumulative_equals_direct() {
841        let scheme = TieredTickScheme::topix100();
842        let price = 1000.0;
843        let mut cumulative = price;
844        for _ in 0..5 {
845            if let Some(result) = scheme.next_ask_price(cumulative, 1, 4) {
846                cumulative = result.as_f64();
847            }
848        }
849        let direct = scheme.next_ask_price(price, 5, 4).unwrap();
850        assert!((cumulative - direct.as_f64()).abs() < 1e-10);
851    }
852
853    #[rstest]
854    #[case(1000.0, 0, 1000.0)]
855    #[case(1000.25, 0, 1000.5)]
856    #[case(10_001.0, 0, 10_005.0)]
857    #[case(10_000_001.0, 0, 10_005_000.0)]
858    #[case(9999.0, 2, 10_005.0)]
859    fn tiered_tick_scheme_topix100_ask_parametrized(
860        #[case] value: f64,
861        #[case] n: i32,
862        #[case] expected: f64,
863    ) {
864        let scheme = TieredTickScheme::topix100();
865        let ask = scheme.next_ask_price(value, n, 4).unwrap();
866        assert_eq!(ask, Price::new(expected, 4));
867    }
868
869    #[rstest]
870    #[case(1000.75, 0, 1000.5)]
871    #[case(10_007.0, 0, 10_005.0)]
872    #[case(10_000_001.0, 0, 10_000_000.0)]
873    #[case(10_006.0, 2, 9999.0)]
874    fn tiered_tick_scheme_topix100_bid_parametrized(
875        #[case] value: f64,
876        #[case] n: i32,
877        #[case] expected: f64,
878    ) {
879        let scheme = TieredTickScheme::topix100();
880        let bid = scheme.next_bid_price(value, n, 4).unwrap();
881        assert_eq!(bid, Price::new(expected, 4));
882    }
883
884    // Property: bid(value, 0) <= value for any value in range
885    proptest! {
886        #[rstest]
887        fn prop_tiered_bid_at_or_below_value(value in 0.1f64..100_000.0) {
888            let scheme = TieredTickScheme::topix100();
889            if let Some(bid) = scheme.next_bid_price(value, 0, 4) {
890                prop_assert!(bid.as_f64() <= value + 1e-9);
891            }
892        }
893    }
894
895    // Property: ask(value, 0) >= value for any value in range
896    proptest! {
897        #[rstest]
898        fn prop_tiered_ask_at_or_above_value(value in 0.1f64..100_000.0) {
899            let scheme = TieredTickScheme::topix100();
900            if let Some(ask) = scheme.next_ask_price(value, 0, 4) {
901                prop_assert!(ask.as_f64() >= value - 1e-9);
902            }
903        }
904    }
905
906    // Property: bid(value, 0) < ask(value, 0) when value is between ticks
907    proptest! {
908        #[rstest]
909        fn prop_tiered_bid_less_than_ask_off_grid(value in 0.15f64..99_999.0) {
910            let scheme = TieredTickScheme::topix100();
911
912            if let (Some(bid), Some(ask)) = (
913                scheme.next_bid_price(value, 0, 4),
914                scheme.next_ask_price(value, 0, 4),
915            ) {
916                prop_assert!(bid <= ask);
917            }
918        }
919    }
920
921    // Property: ask(value, n) is monotonically increasing in n
922    proptest! {
923        #[rstest]
924        fn prop_tiered_ask_monotonic_in_n(value in 1.0f64..10_000.0) {
925            let scheme = TieredTickScheme::topix100();
926            let mut prev: Option<Price> = None;
927
928            for n in 0..5 {
929                if let Some(ask) = scheme.next_ask_price(value, n, 4) {
930                    if let Some(p) = prev {
931                        prop_assert!(ask >= p);
932                    }
933                    prev = Some(ask);
934                }
935            }
936        }
937    }
938
939    // Property: ticks are strictly sorted
940    proptest! {
941        #[rstest]
942        fn prop_tiered_ticks_sorted(
943            start in 1.0f64..100.0,
944            step in 0.01f64..10.0,
945        ) {
946            let stop = start + step * 10.0;
947            if let Ok(scheme) = TieredTickScheme::new(&[(start, stop, step)], 2, 100) {
948                let ticks = scheme.ticks();
949                for i in 1..ticks.len() {
950                    prop_assert!(ticks[i] > ticks[i - 1]);
951                }
952            }
953        }
954    }
955}