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_model::{data::QuoteTick, identifiers::InstrumentId};
19
20use crate::indicator::Indicator;
21
22/// An indicator which calculates the efficiency ratio across a rolling window.
23///
24/// The Kaufman Efficiency measures the ratio of the relative market speed in
25/// relation to the volatility, this could be thought of as a proxy for noise.
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    #[must_use]
118    pub fn new(capacity: usize, instrument_id: InstrumentId) -> Self {
119        Self {
120            capacity,
121            instrument_id,
122            current: 0.0,
123            average: 0.0,
124            initialized: false,
125            has_inputs: false,
126            spreads: Vec::with_capacity(capacity),
127        }
128    }
129}
130
131fn fast_mean(values: &[f64]) -> f64 {
132    if values.is_empty() {
133        0.0
134    } else {
135        values.iter().sum::<f64>() / values.len() as f64
136    }
137}
138
139#[cfg(test)]
140mod tests {
141
142    use rstest::rstest;
143
144    use crate::{
145        indicator::Indicator,
146        ratio::spread_analyzer::SpreadAnalyzer,
147        stubs::{spread_analyzer_10, *},
148        testing::assert_approx_equal,
149    };
150    #[rstest]
151    fn test_efficiency_ratio_initialized(spread_analyzer_10: SpreadAnalyzer) {
152        let display_str = format!("{spread_analyzer_10}");
153        assert_eq!(display_str, "SpreadAnalyzer(10,ETHUSDT-PERP.BINANCE)");
154        assert_eq!(spread_analyzer_10.capacity, 10);
155        assert!(!spread_analyzer_10.initialized);
156    }
157
158    #[rstest]
159    fn test_with_correct_number_of_required_inputs(mut spread_analyzer_10: SpreadAnalyzer) {
160        let bid_price: [&str; 10] = [
161            "100.50", "100.45", "100.55", "100.60", "100.52", "100.48", "100.53", "100.57",
162            "100.49", "100.51",
163        ];
164
165        let ask_price: [&str; 10] = [
166            "100.55", "100.50", "100.60", "100.65", "100.57", "100.53", "100.58", "100.62",
167            "100.54", "100.56",
168        ];
169
170        for i in 1..10 {
171            spread_analyzer_10
172                .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
173                .unwrap();
174        }
175        assert!(!spread_analyzer_10.initialized);
176    }
177
178    #[rstest]
179    fn test_value_with_one_input(mut spread_analyzer_10: SpreadAnalyzer) {
180        spread_analyzer_10
181            .handle_quote(&stub_quote("100.50", "100.55"))
182            .unwrap();
183        assert_approx_equal(spread_analyzer_10.average, 0.05);
184    }
185
186    #[rstest]
187    fn test_value_with_all_higher_inputs_returns_expected_value(
188        mut spread_analyzer_10: SpreadAnalyzer,
189    ) {
190        let bid_price: [&str; 15] = [
191            "100.50", "100.45", "100.55", "100.60", "100.52", "100.48", "100.53", "100.57",
192            "100.49", "100.51", "100.54", "100.56", "100.58", "100.50", "100.52",
193        ];
194
195        let ask_price: [&str; 15] = [
196            "100.55", "100.50", "100.60", "100.65", "100.57", "100.53", "100.58", "100.62",
197            "100.54", "100.56", "100.59", "100.61", "100.63", "100.55", "100.57",
198        ];
199
200        for i in 0..10 {
201            spread_analyzer_10
202                .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
203                .unwrap();
204        }
205
206        assert_approx_equal(spread_analyzer_10.average, 0.05);
207    }
208
209    #[rstest]
210    fn test_handles_more_inputs_than_capacity_without_panic(
211        mut spread_analyzer_10: SpreadAnalyzer,
212    ) {
213        // Regression: feeding more than `capacity` quotes must not panic, and the
214        // internal window must stay bounded to `capacity`. Previously the unbounded buffer
215        // caused `fast_mean_iterated` to error and panic on the (capacity + 1)th quote.
216        let bid_price: [&str; 15] = [
217            "100.50", "100.45", "100.55", "100.60", "100.52", "100.48", "100.53", "100.57",
218            "100.49", "100.51", "100.54", "100.56", "100.58", "100.50", "100.52",
219        ];
220
221        let ask_price: [&str; 15] = [
222            "100.55", "100.50", "100.60", "100.65", "100.57", "100.53", "100.58", "100.62",
223            "100.54", "100.56", "100.59", "100.61", "100.63", "100.55", "100.57",
224        ];
225
226        for i in 0..15 {
227            spread_analyzer_10
228                .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
229                .unwrap();
230        }
231
232        assert!(spread_analyzer_10.initialized());
233        assert_eq!(spread_analyzer_10.spreads.len(), 10);
234        assert!((spread_analyzer_10.average - 0.05).abs() < 1e-9);
235    }
236
237    #[rstest]
238    fn test_average_tracks_varying_spreads_past_capacity(mut spread_analyzer_10: SpreadAnalyzer) {
239        // Regression: with non-constant spreads past `capacity`, the average must keep
240        // tracking the bounded window rather than freezing. Feeding 15 monotonically
241        // increasing spreads (0.01..=0.15) leaves the window holding the last 10
242        // (0.06..=0.15), whose mean is 0.105. The earlier incremental update froze the
243        // average at 0.055 (the mean of the first full window 0.01..=0.10).
244        let bid_price: [&str; 15] = ["100.00"; 15];
245        let ask_price: [&str; 15] = [
246            "100.01", "100.02", "100.03", "100.04", "100.05", "100.06", "100.07", "100.08",
247            "100.09", "100.10", "100.11", "100.12", "100.13", "100.14", "100.15",
248        ];
249
250        for i in 0..15 {
251            spread_analyzer_10
252                .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
253                .unwrap();
254        }
255
256        assert_eq!(spread_analyzer_10.spreads.len(), 10);
257        assert!((spread_analyzer_10.average - 0.105).abs() < 1e-9);
258    }
259
260    #[rstest]
261    fn test_reset_successfully_returns_indicator_to_fresh_state(
262        mut spread_analyzer_10: SpreadAnalyzer,
263    ) {
264        spread_analyzer_10
265            .handle_quote(&stub_quote("100.50", "100.55"))
266            .unwrap();
267        spread_analyzer_10.reset();
268        assert!(!spread_analyzer_10.initialized());
269        assert_eq!(spread_analyzer_10.current, 0.0);
270        assert_eq!(spread_analyzer_10.average, 0.0);
271        assert!(!spread_analyzer_10.has_inputs);
272        assert!(!spread_analyzer_10.initialized);
273    }
274}