Skip to main content

tvscreener/
filter.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Filter operators, [`Filter`] payloads, [`FieldCondition`], and [`ExtraFilter`].
5//!
6//! When two filters share the same `left` key,
7//! [`crate::core::Screener::add_filter`] merges values and may promote the operator
8//! to [`FilterOperator::InRange`].
9
10use serde_json::{json, Value};
11
12/// Comparison operators sent as `"operation"` in `TradingView` filter objects.
13///
14/// Wire `operation` strings for screener filter objects.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum FilterOperator {
17    /// Wire: `"less"`
18    Below,
19    /// Wire: `"eless"`
20    BelowOrEqual,
21    /// Wire: `"greater"`
22    Above,
23    /// Wire: `"egreater"`
24    AboveOrEqual,
25    /// Wire: `"crosses"`.
26    Crosses,
27    /// Wire: `"crosses_above"`
28    CrossesUp,
29    /// Wire: `"crosses_below"`
30    CrossesDown,
31    /// Wire: `"in_range"`.
32    InRange,
33    /// Wire: `"not_in_range"`.
34    NotInRange,
35    /// Wire: `"equal"`.
36    Equal,
37    /// Wire: `"nequal"`
38    NotEqual,
39    /// Wire: `"match"`.
40    Match,
41}
42
43impl FilterOperator {
44    /// `TradingView` scanner JSON `operation` string for this variant.
45    #[must_use]
46    pub const fn as_str(self) -> &'static str {
47        match self {
48            Self::Below => "less",
49            Self::BelowOrEqual => "eless",
50            Self::Above => "greater",
51            Self::AboveOrEqual => "egreater",
52            Self::Crosses => "crosses",
53            Self::CrossesUp => "crosses_above",
54            Self::CrossesDown => "crosses_below",
55            Self::InRange => "in_range",
56            Self::NotInRange => "not_in_range",
57            Self::Equal => "equal",
58            Self::NotEqual => "nequal",
59            Self::Match => "match",
60        }
61    }
62
63    /// Parses a wire or friendly `operation` string.
64    ///
65    /// Accepts `TradingView` wire tokens (`less`, `egreater`, …) and common aliases
66    /// (`<`, `>=`, `above`, `not_equal`, …).
67    #[must_use]
68    pub fn from_wire(s: &str) -> Option<Self> {
69        match s.trim() {
70            "less" | "<" | "below" => Some(Self::Below),
71            "eless" | "<=" | "below_or_equal" => Some(Self::BelowOrEqual),
72            "greater" | ">" | "above" => Some(Self::Above),
73            "egreater" | ">=" | "above_or_equal" => Some(Self::AboveOrEqual),
74            "crosses" => Some(Self::Crosses),
75            "crosses_above" | "crosses_up" => Some(Self::CrossesUp),
76            "crosses_below" | "crosses_down" => Some(Self::CrossesDown),
77            "in_range" => Some(Self::InRange),
78            "not_in_range" => Some(Self::NotInRange),
79            "equal" | "==" | "=" => Some(Self::Equal),
80            "nequal" | "!=" | "not_equal" => Some(Self::NotEqual),
81            "match" => Some(Self::Match),
82            _ => None,
83        }
84    }
85}
86
87impl std::fmt::Display for FilterOperator {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str(self.as_str())
90    }
91}
92
93impl std::str::FromStr for FilterOperator {
94    type Err = String;
95
96    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
97        Self::from_wire(s).ok_or_else(|| format!("unknown filter operation: {s}"))
98    }
99}
100
101/// Non-field filter targets
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum ExtraFilter {
104    /// Wire left key `"active_symbol"`.
105    CurrentTradingDay,
106    /// Wire left key `"name,description"`.
107    Search,
108    /// Wire left key `"is_primary"`.
109    Primary,
110}
111
112impl ExtraFilter {
113    /// Payload `left` key for this extra filter.
114    #[must_use]
115    pub const fn field_name(self) -> &'static str {
116        match self {
117            Self::CurrentTradingDay => "active_symbol",
118            Self::Search => "name,description",
119            Self::Primary => "is_primary",
120        }
121    }
122}
123
124/// One scanner filter clause (`left` / `operation` / `right`).
125#[derive(Debug, Clone, PartialEq)]
126#[allow(clippy::derive_partial_eq_without_eq)] // `serde_json::Value` is not `Eq`
127pub struct Filter {
128    /// Column key on the left side of the comparison.
129    pub left: String,
130    /// Operator applied to `left` vs `values`.
131    pub operation: FilterOperator,
132    /// Right-hand side value(s); field-to-field compares use column name strings.
133    pub values: Vec<Value>,
134}
135
136impl Filter {
137    /// Builds a filter from one or many right-hand values.
138    pub fn new(
139        left: impl Into<String>,
140        operation: FilterOperator,
141        values: impl IntoIterator<Item = Value>,
142    ) -> Self {
143        Self {
144            left: left.into(),
145            operation,
146            values: values.into_iter().collect(),
147        }
148    }
149
150    /// Builds a filter on an [`ExtraFilter`] left key.
151    pub fn on_extra(
152        extra: ExtraFilter,
153        operation: FilterOperator,
154        values: impl IntoIterator<Item = Value>,
155    ) -> Self {
156        Self::new(extra.field_name(), operation, values)
157    }
158
159    /// Serializes to one `{ "left", "operation", "right" }` object for the scan payload.
160    #[must_use]
161    pub fn to_json(&self) -> Value {
162        let right = match self.values.as_slice() {
163            [] => Value::Null,
164            [one] => one.clone(),
165            many => Value::Array(many.to_vec()),
166        };
167        json!({
168            "left": self.left,
169            "operation": self.operation.as_str(),
170            "right": right,
171        })
172    }
173}
174
175/// A single comparison before it is merged into the screener filter list
176#[derive(Debug, Clone, PartialEq)]
177#[allow(clippy::derive_partial_eq_without_eq)] // `serde_json::Value` is not `Eq`
178pub struct FieldCondition {
179    /// Left column key (field name).
180    pub left: String,
181    /// Comparison operator.
182    pub operation: FilterOperator,
183    /// Right-hand JSON value.
184    pub value: Value,
185}
186
187impl FieldCondition {
188    /// Creates a condition on `left` with the given operator and right-hand value.
189    pub fn new(left: impl Into<String>, operation: FilterOperator, value: Value) -> Self {
190        Self {
191            left: left.into(),
192            operation,
193            value,
194        }
195    }
196
197    /// Converts this condition into a [`Filter`]
198    #[must_use]
199    pub fn into_filter(self) -> Filter {
200        Filter::new(self.left, self.operation, [self.value])
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn filter_operator_wire_values() {
210        assert_eq!(FilterOperator::Below.as_str(), "less");
211        assert_eq!(FilterOperator::Match.as_str(), "match");
212        assert_eq!(FilterOperator::NotEqual.as_str(), "nequal");
213    }
214
215    #[test]
216    fn filter_operator_from_wire_aliases() {
217        assert_eq!(
218            FilterOperator::from_wire(">="),
219            Some(FilterOperator::AboveOrEqual)
220        );
221        assert_eq!(
222            FilterOperator::from_wire("above"),
223            Some(FilterOperator::Above)
224        );
225        assert_eq!(
226            FilterOperator::from_wire("!="),
227            Some(FilterOperator::NotEqual)
228        );
229        assert_eq!(
230            FilterOperator::from_wire("crosses_up"),
231            Some(FilterOperator::CrossesUp)
232        );
233        assert_eq!(FilterOperator::from_wire("nope"), None);
234    }
235
236    #[test]
237    fn filter_operator_display_from_str_roundtrip() {
238        for op in [
239            FilterOperator::Below,
240            FilterOperator::AboveOrEqual,
241            FilterOperator::InRange,
242            FilterOperator::Match,
243            FilterOperator::NotEqual,
244        ] {
245            let wire = op.to_string();
246            let parsed: FilterOperator = wire.parse().expect("parse");
247            assert_eq!(parsed, op);
248        }
249        assert!("nope".parse::<FilterOperator>().is_err());
250    }
251
252    #[test]
253    fn extra_filter_field_names() {
254        assert_eq!(ExtraFilter::Search.field_name(), "name,description");
255        assert_eq!(ExtraFilter::CurrentTradingDay.field_name(), "active_symbol");
256        assert_eq!(ExtraFilter::Primary.field_name(), "is_primary");
257    }
258
259    #[test]
260    fn filter_on_extra_multi_value_json() {
261        let f = Filter::on_extra(
262            ExtraFilter::Primary,
263            FilterOperator::InRange,
264            [json!(true), json!(false)],
265        );
266        let obj = f.to_json();
267        assert_eq!(obj["left"], "is_primary");
268        assert_eq!(obj["operation"], "in_range");
269        assert_eq!(obj["right"], json!([true, false]));
270    }
271
272    #[test]
273    fn filter_to_json_single_right() {
274        let f = Filter::new("close", FilterOperator::Above, [json!(100)]);
275        let obj = f.to_json();
276        assert_eq!(obj["left"], "close");
277        assert_eq!(obj["operation"], "greater");
278        assert_eq!(obj["right"], 100);
279    }
280}