Skip to main content

nautilus_indicators/ratio/
spread_analyzer.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
16use std::fmt::Display;
17
18use nautilus_core::correctness::FAILED;
19use nautilus_model::{data::QuoteTick, identifiers::InstrumentId};
20
21use crate::indicator::Indicator;
22
23/// Calculates the current bid-ask spread and its average across a rolling window.
24///
25/// A zero capacity is accepted.
26#[repr(C)]
27#[derive(Debug)]
28#[cfg_attr(
29    feature = "python",
30    pyo3::pyclass(module = "nautilus_trader.indicators")
31)]
32#[cfg_attr(
33    feature = "python",
34    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
35)]
36pub struct SpreadAnalyzer {
37    pub capacity: usize,
38    pub instrument_id: InstrumentId,
39    pub current: f64,
40    pub average: f64,
41    pub initialized: bool,
42    has_inputs: bool,
43    spreads: Vec<f64>,
44}
45
46impl Display for SpreadAnalyzer {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(
49            f,
50            "{}({},{})",
51            self.name(),
52            self.capacity,
53            self.instrument_id
54        )
55    }
56}
57
58impl Indicator for SpreadAnalyzer {
59    fn name(&self) -> String {
60        stringify!(SpreadAnalyzer).to_string()
61    }
62
63    fn has_inputs(&self) -> bool {
64        self.has_inputs
65    }
66    fn initialized(&self) -> bool {
67        self.initialized
68    }
69
70    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
71        if quote.instrument_id != self.instrument_id {
72            return Ok(());
73        }
74
75        // Check initialization
76        if !self.initialized {
77            self.has_inputs = true;
78
79            if self.spreads.len() == self.capacity {
80                self.initialized = true;
81            }
82        }
83
84        let bid: f64 = quote.bid_price.into();
85        let ask: f64 = quote.ask_price.into();
86        let spread = ask - bid;
87
88        self.current = spread;
89        self.spreads.push(spread);
90
91        // Bound the rolling window to `capacity`. Without this the buffer grows unbounded and
92        // `fast_mean_iterated` errors (panicking on `unwrap`) once the length exceeds `capacity`.
93        if self.spreads.len() > self.capacity {
94            self.spreads.remove(0);
95        }
96
97        // Recompute the average over the bounded window. An incremental
98        // `fast_mean_iterated(..., drop_left=false)` update is cheaper, but at capacity
99        // that subtracts `values[length - 1]` (the spread just pushed) rather than the
100        // evicted oldest value, so the average freezes for non-constant spreads.
101        // Recomputing from the bounded window is O(capacity) and always correct.
102        self.average = fast_mean(&self.spreads);
103        Ok(())
104    }
105
106    fn reset(&mut self) {
107        self.current = 0.0;
108        self.average = 0.0;
109        self.spreads.clear();
110        self.initialized = false;
111        self.has_inputs = false;
112    }
113}
114
115impl SpreadAnalyzer {
116    /// Creates a new [`SpreadAnalyzer`] instance.
117    ///
118    /// A zero capacity is accepted.
119    ///
120    /// # Panics
121    ///
122    /// Panics if the spread window capacity overflows or its allocation fails.
123    #[must_use]
124    pub fn new(capacity: usize, instrument_id: InstrumentId) -> Self {
125        Self::new_checked(capacity, instrument_id).expect(FAILED)
126    }
127
128    pub(crate) fn new_checked(
129        capacity: usize,
130        instrument_id: InstrumentId,
131    ) -> anyhow::Result<Self> {
132        let mut spreads = Vec::new();
133        spreads.try_reserve_exact(capacity)?;
134        Ok(Self {
135            capacity,
136            instrument_id,
137            current: 0.0,
138            average: 0.0,
139            initialized: false,
140            has_inputs: false,
141            spreads,
142        })
143    }
144}
145
146fn fast_mean(values: &[f64]) -> f64 {
147    if values.is_empty() {
148        0.0
149    } else {
150        values.iter().sum::<f64>() / values.len() as f64
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use nautilus_model::identifiers::InstrumentId;
157    use rstest::rstest;
158
159    use crate::{
160        indicator::Indicator,
161        ratio::spread_analyzer::SpreadAnalyzer,
162        stubs::{spread_analyzer_10, *},
163        testing::assert_approx_equal,
164    };
165
166    #[rstest]
167    #[case(usize::MAX)]
168    #[case(isize::MAX as usize / size_of::<f64>() + 1)]
169    fn test_checked_constructor_rejects_capacity_overflow(#[case] capacity: usize) {
170        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
171        let error = SpreadAnalyzer::new_checked(capacity, instrument_id).unwrap_err();
172        assert_eq!(
173            error.to_string(),
174            "memory allocation failed because the computed capacity exceeded the collection's maximum"
175        );
176    }
177
178    #[rstest]
179    #[case(0)]
180    #[case(10)]
181    fn test_checked_constructor_preserves_capacity(#[case] capacity: usize) {
182        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
183        let ind = SpreadAnalyzer::new_checked(capacity, instrument_id).unwrap();
184        assert_eq!(ind.capacity, capacity);
185        assert_eq!(ind.instrument_id, instrument_id);
186        assert_eq!(ind.current, 0.0);
187        assert_eq!(ind.average, 0.0);
188        assert!(!ind.initialized());
189        assert!(!ind.has_inputs());
190    }
191
192    #[rstest]
193    fn test_efficiency_ratio_initialized(spread_analyzer_10: SpreadAnalyzer) {
194        let display_str = format!("{spread_analyzer_10}");
195        assert_eq!(display_str, "SpreadAnalyzer(10,ETHUSDT-PERP.BINANCE)");
196        assert_eq!(spread_analyzer_10.capacity, 10);
197        assert!(!spread_analyzer_10.initialized);
198    }
199
200    #[rstest]
201    fn test_with_correct_number_of_required_inputs(mut spread_analyzer_10: SpreadAnalyzer) {
202        let bid_price: [&str; 10] = [
203            "100.50", "100.45", "100.55", "100.60", "100.52", "100.48", "100.53", "100.57",
204            "100.49", "100.51",
205        ];
206
207        let ask_price: [&str; 10] = [
208            "100.55", "100.50", "100.60", "100.65", "100.57", "100.53", "100.58", "100.62",
209            "100.54", "100.56",
210        ];
211
212        for i in 1..10 {
213            spread_analyzer_10
214                .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
215                .unwrap();
216        }
217        assert!(!spread_analyzer_10.initialized);
218    }
219
220    #[rstest]
221    fn test_value_with_one_input(mut spread_analyzer_10: SpreadAnalyzer) {
222        spread_analyzer_10
223            .handle_quote(&stub_quote("100.50", "100.55"))
224            .unwrap();
225        assert_approx_equal(spread_analyzer_10.average, 0.05);
226    }
227
228    #[rstest]
229    fn test_value_with_all_higher_inputs_returns_expected_value(
230        mut spread_analyzer_10: SpreadAnalyzer,
231    ) {
232        let bid_price: [&str; 15] = [
233            "100.50", "100.45", "100.55", "100.60", "100.52", "100.48", "100.53", "100.57",
234            "100.49", "100.51", "100.54", "100.56", "100.58", "100.50", "100.52",
235        ];
236
237        let ask_price: [&str; 15] = [
238            "100.55", "100.50", "100.60", "100.65", "100.57", "100.53", "100.58", "100.62",
239            "100.54", "100.56", "100.59", "100.61", "100.63", "100.55", "100.57",
240        ];
241
242        for i in 0..10 {
243            spread_analyzer_10
244                .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
245                .unwrap();
246        }
247
248        assert_approx_equal(spread_analyzer_10.average, 0.05);
249    }
250
251    #[rstest]
252    fn test_handles_more_inputs_than_capacity_without_panic(
253        mut spread_analyzer_10: SpreadAnalyzer,
254    ) {
255        // Regression: feeding more than `capacity` quotes must not panic, and the
256        // internal window must stay bounded to `capacity`. Previously the unbounded buffer
257        // caused `fast_mean_iterated` to error and panic on the (capacity + 1)th quote.
258        let bid_price: [&str; 15] = [
259            "100.50", "100.45", "100.55", "100.60", "100.52", "100.48", "100.53", "100.57",
260            "100.49", "100.51", "100.54", "100.56", "100.58", "100.50", "100.52",
261        ];
262
263        let ask_price: [&str; 15] = [
264            "100.55", "100.50", "100.60", "100.65", "100.57", "100.53", "100.58", "100.62",
265            "100.54", "100.56", "100.59", "100.61", "100.63", "100.55", "100.57",
266        ];
267
268        for i in 0..15 {
269            spread_analyzer_10
270                .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
271                .unwrap();
272        }
273
274        assert!(spread_analyzer_10.initialized());
275        assert_eq!(spread_analyzer_10.spreads.len(), 10);
276        assert!((spread_analyzer_10.average - 0.05).abs() < 1e-9);
277    }
278
279    #[rstest]
280    fn test_average_tracks_varying_spreads_past_capacity(mut spread_analyzer_10: SpreadAnalyzer) {
281        // Regression: with non-constant spreads past `capacity`, the average must keep
282        // tracking the bounded window rather than freezing. Feeding 15 monotonically
283        // increasing spreads (0.01..=0.15) leaves the window holding the last 10
284        // (0.06..=0.15), whose mean is 0.105. The earlier incremental update froze the
285        // average at 0.055 (the mean of the first full window 0.01..=0.10).
286        let bid_price: [&str; 15] = ["100.00"; 15];
287        let ask_price: [&str; 15] = [
288            "100.01", "100.02", "100.03", "100.04", "100.05", "100.06", "100.07", "100.08",
289            "100.09", "100.10", "100.11", "100.12", "100.13", "100.14", "100.15",
290        ];
291
292        for i in 0..15 {
293            spread_analyzer_10
294                .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
295                .unwrap();
296        }
297
298        assert_eq!(spread_analyzer_10.spreads.len(), 10);
299        assert!((spread_analyzer_10.average - 0.105).abs() < 1e-9);
300    }
301
302    #[rstest]
303    fn test_reset_successfully_returns_indicator_to_fresh_state(
304        mut spread_analyzer_10: SpreadAnalyzer,
305    ) {
306        spread_analyzer_10
307            .handle_quote(&stub_quote("100.50", "100.55"))
308            .unwrap();
309        spread_analyzer_10.reset();
310        assert!(!spread_analyzer_10.initialized());
311        assert_eq!(spread_analyzer_10.current, 0.0);
312        assert_eq!(spread_analyzer_10.average, 0.0);
313        assert!(!spread_analyzer_10.has_inputs);
314        assert!(!spread_analyzer_10.initialized);
315    }
316}