Skip to main content

nautilus_indicators/momentum/
psl.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::{Debug, Display};
17
18use nautilus_core::correctness::FAILED;
19use nautilus_model::data::Bar;
20
21use crate::{
22    average::{MovingAverageFactory, MovingAverageType},
23    indicator::{Indicator, MovingAverage},
24    support::MAX_PERIOD,
25};
26
27#[repr(C)]
28#[derive(Debug)]
29#[cfg_attr(
30    feature = "python",
31    pyo3::pyclass(module = "nautilus_trader.indicators", unsendable)
32)]
33#[cfg_attr(
34    feature = "python",
35    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
36)]
37pub struct PsychologicalLine {
38    pub period: usize,
39    pub ma_type: MovingAverageType,
40    pub value: f64,
41    pub initialized: bool,
42    ma: Box<dyn MovingAverage + Send + 'static>,
43    has_inputs: bool,
44    diff: f64,
45    previous_close: f64,
46}
47
48impl Display for PsychologicalLine {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(f, "{}({},{})", self.name(), self.period, self.ma_type)
51    }
52}
53
54impl Indicator for PsychologicalLine {
55    fn name(&self) -> String {
56        stringify!(PsychologicalLine).to_string()
57    }
58
59    fn has_inputs(&self) -> bool {
60        self.has_inputs
61    }
62
63    fn initialized(&self) -> bool {
64        self.initialized
65    }
66
67    fn handle_bar(&mut self, bar: &Bar) {
68        self.update_raw((&bar.close).into());
69    }
70
71    fn reset(&mut self) {
72        self.ma.reset();
73        self.diff = 0.0;
74        self.previous_close = 0.0;
75        self.value = 0.0;
76        self.has_inputs = false;
77        self.initialized = false;
78    }
79}
80
81impl PsychologicalLine {
82    /// Creates a new [`PsychologicalLine`] instance.
83    ///
84    /// # Panics
85    ///
86    /// Panics if `period` is zero or exceeds the supported indicator period limit.
87    #[must_use]
88    pub fn new(period: usize, ma_type: Option<MovingAverageType>) -> Self {
89        Self::new_checked(period, ma_type).expect(FAILED)
90    }
91
92    pub(crate) fn new_checked(
93        period: usize,
94        ma_type: Option<MovingAverageType>,
95    ) -> anyhow::Result<Self> {
96        anyhow::ensure!(
97            (1..=MAX_PERIOD).contains(&period),
98            "period must be in [1, {MAX_PERIOD}]"
99        );
100        Ok(Self {
101            period,
102            ma_type: ma_type.unwrap_or(MovingAverageType::Simple),
103            value: 0.0,
104            previous_close: 0.0,
105            ma: MovingAverageFactory::create(ma_type.unwrap_or(MovingAverageType::Simple), period),
106            has_inputs: false,
107            initialized: false,
108            diff: 0.0,
109        })
110    }
111
112    pub fn update_raw(&mut self, close: f64) {
113        if !self.has_inputs {
114            self.previous_close = close;
115        }
116
117        self.diff = close - self.previous_close;
118        if self.diff <= 0.0 {
119            self.ma.update_raw(0.0);
120        } else {
121            self.ma.update_raw(1.0);
122        }
123        self.value = 100.0 * self.ma.value();
124
125        if !self.initialized {
126            self.has_inputs = true;
127
128            if self.ma.initialized() {
129                self.initialized = true;
130            }
131        }
132
133        self.previous_close = close;
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use nautilus_model::data::Bar;
140    use rstest::rstest;
141
142    use super::{MAX_PERIOD, MovingAverageType};
143    use crate::{
144        indicator::Indicator,
145        momentum::psl::PsychologicalLine,
146        stubs::{bar_ethusdt_binance_minute_bid, psl_10},
147    };
148
149    #[rstest]
150    #[case(0)]
151    #[case(MAX_PERIOD + 1)]
152    #[case(usize::MAX)]
153    fn test_checked_constructor_rejects_invalid_periods(
154        #[case] period: usize,
155        #[values(
156            MovingAverageType::Simple,
157            MovingAverageType::Exponential,
158            MovingAverageType::DoubleExponential,
159            MovingAverageType::Wilder,
160            MovingAverageType::Hull
161        )]
162        ma_type: MovingAverageType,
163    ) {
164        let error = PsychologicalLine::new_checked(period, Some(ma_type)).unwrap_err();
165        assert_eq!(
166            error.to_string(),
167            format!("period must be in [1, {MAX_PERIOD}]")
168        );
169    }
170
171    #[rstest]
172    fn test_checked_constructor_accepts_maximum_period() {
173        let ind = PsychologicalLine::new_checked(MAX_PERIOD, Some(MovingAverageType::Exponential))
174            .unwrap();
175        assert_eq!(ind.period, MAX_PERIOD);
176        assert_eq!(ind.ma_type, MovingAverageType::Exponential);
177        assert_eq!(ind.value, 0.0);
178        assert!(!ind.initialized());
179        assert!(!ind.has_inputs());
180    }
181
182    #[rstest]
183    fn test_psl_initialized(psl_10: PsychologicalLine) {
184        let display_str = format!("{psl_10}");
185        assert_eq!(display_str, "PsychologicalLine(10,SIMPLE)");
186        assert_eq!(psl_10.period, 10);
187        assert!(!psl_10.initialized);
188        assert!(!psl_10.has_inputs);
189    }
190
191    #[rstest]
192    fn test_value_with_one_input(mut psl_10: PsychologicalLine) {
193        psl_10.update_raw(1.0);
194        assert_eq!(psl_10.value, 0.0);
195    }
196
197    #[rstest]
198    fn test_value_with_three_inputs(mut psl_10: PsychologicalLine) {
199        psl_10.update_raw(1.0);
200        psl_10.update_raw(2.0);
201        psl_10.update_raw(3.0);
202        assert_eq!(psl_10.value, 0.0);
203        assert!(!psl_10.initialized());
204    }
205
206    #[rstest]
207    fn test_value_with_ten_inputs(mut psl_10: PsychologicalLine) {
208        psl_10.update_raw(1.00000);
209        psl_10.update_raw(1.00010);
210        psl_10.update_raw(1.00020);
211        psl_10.update_raw(1.00030);
212        psl_10.update_raw(1.00040);
213        psl_10.update_raw(1.00050);
214        psl_10.update_raw(1.00040);
215        psl_10.update_raw(1.00030);
216        psl_10.update_raw(1.00020);
217        psl_10.update_raw(1.00010);
218        psl_10.update_raw(1.00000);
219        assert_eq!(psl_10.value, 50.0);
220    }
221
222    #[rstest]
223    fn test_initialized_with_required_input(mut psl_10: PsychologicalLine) {
224        for i in 1..10 {
225            psl_10.update_raw(f64::from(i));
226        }
227        assert!(!psl_10.initialized);
228        psl_10.update_raw(10.0);
229        assert!(psl_10.initialized);
230    }
231
232    #[rstest]
233    fn test_handle_bar(mut psl_10: PsychologicalLine, bar_ethusdt_binance_minute_bid: Bar) {
234        psl_10.handle_bar(&bar_ethusdt_binance_minute_bid);
235        assert_eq!(psl_10.value, 0.0);
236        assert!(psl_10.has_inputs);
237        assert!(!psl_10.initialized);
238    }
239
240    #[rstest]
241    fn test_reset(mut psl_10: PsychologicalLine) {
242        psl_10.update_raw(1.0);
243        psl_10.reset();
244        assert_eq!(psl_10.value, 0.0);
245        assert_eq!(psl_10.previous_close, 0.0);
246        assert_eq!(psl_10.diff, 0.0);
247        assert!(!psl_10.has_inputs);
248        assert!(!psl_10.initialized);
249    }
250}