Skip to main content

tvscreener/
ta.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Technical-analysis helpers for computed recommendation columns.
5//!
6//! Mirrors Python `tvscreener.ta` (ADX, Awesome Oscillator, Bollinger Bands).
7
8/// Buy / sell / neutral signal used by recommendation arrows.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Signal {
11    /// Bullish.
12    Buy,
13    /// Bearish.
14    Sell,
15    /// Neither.
16    Neutral,
17}
18
19impl Signal {
20    /// Arrow letter used by Python beautify (`↑ B` / `↓ S` / `- N`).
21    #[must_use]
22    pub const fn letter(self) -> &'static str {
23        match self {
24            Self::Buy => "↑ B",
25            Self::Sell => "↓ S",
26            Self::Neutral => "- N",
27        }
28    }
29}
30
31/// Returns true when `x` crossed above `y` between the previous and current bar.
32#[must_use]
33pub const fn crosses_up(x1: f64, x2: f64, y1: f64, y2: f64) -> bool {
34    x1 > y1 && x2 < y2
35}
36
37/// ADX recommendation from DI crossover and ADX strength.
38#[must_use]
39pub const fn adx(
40    adx_value: f64,
41    dminus: f64,
42    dplus: f64,
43    dminus_old: f64,
44    dplus_old: f64,
45) -> Signal {
46    if crosses_up(dplus, dplus_old, dminus, dminus_old) && adx_value > 20.0 {
47        Signal::Buy
48    } else if crosses_up(dminus, dminus_old, dplus, dplus_old) && adx_value > 20.0 {
49        Signal::Sell
50    } else {
51        Signal::Neutral
52    }
53}
54
55/// Awesome Oscillator recommendation (saucer / zero-line cross).
56#[must_use]
57pub const fn ao(ao_value: f64, ao_old_1: f64, ao_old_2: f64) -> Signal {
58    if is_ao_bullish_saucer(ao_value, ao_old_1, ao_old_2)
59        || is_ao_bullish_cross(ao_value, ao_old_1, ao_old_2)
60    {
61        Signal::Buy
62    } else if is_ao_bearish_saucer(ao_value, ao_old_1, ao_old_2)
63        || is_ao_bearish_cross(ao_value, ao_old_1, ao_old_2)
64    {
65        Signal::Sell
66    } else {
67        Signal::Neutral
68    }
69}
70
71/// Bollinger lower-band signal: buy when close is below the band.
72#[must_use]
73pub const fn bb_lower(low_limit: f64, close: f64) -> Signal {
74    if close < low_limit {
75        Signal::Buy
76    } else {
77        Signal::Neutral
78    }
79}
80
81/// Bollinger upper-band signal: sell when close is above the band.
82#[must_use]
83pub const fn bb_upper(up_limit: f64, close: f64) -> Signal {
84    if close > up_limit {
85        Signal::Sell
86    } else {
87        Signal::Neutral
88    }
89}
90
91const fn is_ao_bearish_cross(ao_value: f64, ao_old_1: f64, ao_old_2: f64) -> bool {
92    ao_value < 0.0 && ao_old_1 > 0.0 && ao_old_2 > 0.0
93}
94
95const fn is_ao_bullish_cross(ao_value: f64, ao_old_1: f64, ao_old_2: f64) -> bool {
96    ao_value > 0.0 && ao_old_1 < 0.0 && ao_old_2 < 0.0
97}
98
99const fn is_ao_bullish_saucer(ao_value: f64, ao_old_1: f64, ao_old_2: f64) -> bool {
100    ao_value > 0.0 && ao_value > ao_old_1 && ao_old_1 < ao_old_2
101}
102
103const fn is_ao_bearish_saucer(ao_value: f64, ao_old_1: f64, ao_old_2: f64) -> bool {
104    ao_value < 0.0 && ao_value < ao_old_1 && ao_old_1 > ao_old_2
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn adx_buy_on_plus_di_cross_with_strength() {
113        // +DI crosses above -DI, ADX > 20
114        assert_eq!(adx(25.0, 10.0, 15.0, 12.0, 8.0), Signal::Buy);
115    }
116
117    #[test]
118    fn adx_sell_on_minus_di_cross_with_strength() {
119        assert_eq!(adx(25.0, 15.0, 10.0, 8.0, 12.0), Signal::Sell);
120    }
121
122    #[test]
123    fn adx_neutral_when_weak() {
124        assert_eq!(adx(10.0, 10.0, 15.0, 12.0, 8.0), Signal::Neutral);
125    }
126
127    #[test]
128    fn ao_bullish_zero_cross() {
129        assert_eq!(ao(0.5, -0.2, -0.4), Signal::Buy);
130    }
131
132    #[test]
133    fn ao_bearish_zero_cross() {
134        assert_eq!(ao(-0.5, 0.2, 0.4), Signal::Sell);
135    }
136
137    #[test]
138    fn bb_signals() {
139        assert_eq!(bb_lower(100.0, 99.0), Signal::Buy);
140        assert_eq!(bb_lower(100.0, 101.0), Signal::Neutral);
141        assert_eq!(bb_upper(100.0, 101.0), Signal::Sell);
142        assert_eq!(bb_upper(100.0, 99.0), Signal::Neutral);
143    }
144}