finance_query/models/screeners/condition.rs
1//! Typed screener query condition types.
2//!
3//! This module defines the core traits and types for building type-safe screener
4//! filter conditions. The key design is:
5//!
6//! - [`ScreenerField`] trait — implemented by [`EquityField`](super::fields::EquityField) and
7//! [`FundField`](super::fields::FundField)
8//! - [`ScreenerFieldExt`] blanket trait — fluent condition builders on any `ScreenerField`
9//! - [`QueryCondition<F>`] — a typed condition with serialization matching Yahoo's API format
10//! - [`QueryGroup<F>`] and [`QueryOperand<F>`] — for composing nested AND/OR logic
11use serde::Serialize;
12use serde::ser::SerializeStruct;
13
14// ============================================================================
15// Operator
16// ============================================================================
17
18/// Comparison operator for screener query conditions.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum Operator {
22 /// Equal to (`"eq"`)
23 #[serde(rename = "eq")]
24 Eq,
25 /// Greater than (`"gt"`)
26 #[serde(rename = "gt")]
27 Gt,
28 /// Greater than or equal to (`"gte"`)
29 #[serde(rename = "gte")]
30 Gte,
31 /// Less than (`"lt"`)
32 #[serde(rename = "lt")]
33 Lt,
34 /// Less than or equal to (`"lte"`)
35 #[serde(rename = "lte")]
36 Lte,
37 /// Between two values, inclusive (`"btwn"`)
38 #[serde(rename = "btwn")]
39 Between,
40}
41
42impl std::str::FromStr for Operator {
43 type Err = ();
44
45 fn from_str(s: &str) -> Result<Self, Self::Err> {
46 match s.to_lowercase().as_str() {
47 "eq" | "=" | "==" => Ok(Operator::Eq),
48 "gt" | ">" => Ok(Operator::Gt),
49 "gte" | ">=" => Ok(Operator::Gte),
50 "lt" | "<" => Ok(Operator::Lt),
51 "lte" | "<=" => Ok(Operator::Lte),
52 "btwn" | "between" => Ok(Operator::Between),
53 _ => Err(()),
54 }
55 }
56}
57
58// ============================================================================
59// LogicalOperator
60// ============================================================================
61
62/// Logical operator for combining multiple screener conditions.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, serde::Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum LogicalOperator {
66 /// All conditions must match (AND logic)
67 #[default]
68 And,
69 /// Any condition can match (OR logic)
70 Or,
71}
72
73impl std::str::FromStr for LogicalOperator {
74 type Err = ();
75
76 fn from_str(s: &str) -> Result<Self, Self::Err> {
77 match s.to_lowercase().as_str() {
78 "and" | "&&" => Ok(LogicalOperator::And),
79 "or" | "||" => Ok(LogicalOperator::Or),
80 _ => Err(()),
81 }
82 }
83}
84
85// ============================================================================
86// ScreenerField trait
87// ============================================================================
88
89/// A typed screener field usable in custom query conditions, sorting, and
90/// response field selection.
91///
92/// Both [`EquityField`](super::fields::EquityField) and
93/// [`FundField`](super::fields::FundField) implement this trait.
94///
95/// The `Serialize` bound ensures field values can be included in the JSON body
96/// sent to Yahoo Finance (e.g., in `sortField` and `includeFields`). The
97/// serialization always produces the raw Yahoo API field name string.
98pub trait ScreenerField: Clone + Serialize + 'static {
99 /// Returns the Yahoo Finance API field name string.
100 ///
101 /// For example, `EquityField::PeRatio.as_str()` returns
102 /// `"peratio.lasttwelvemonths"`.
103 fn as_str(&self) -> &'static str;
104}
105
106// ============================================================================
107// ConditionValue
108// ============================================================================
109
110/// The value portion of a typed screener condition.
111///
112/// This cleanly separates the filter value from the field name, replacing the
113/// old `Vec<QueryValue>` approach where the field string was mixed into the same
114/// array as the comparison values.
115#[non_exhaustive]
116#[derive(Debug, Clone)]
117pub enum ConditionValue {
118 /// A single numeric value.
119 ///
120 /// Used with `Gt`, `Lt`, `Gte`, `Lte`, and `Eq` operators on numeric fields.
121 Number(f64),
122 /// A numeric range for `Between` conditions (inclusive).
123 Between(f64, f64),
124 /// A single string equality value.
125 ///
126 /// Used with `Eq` on categorical fields like `Region`, `Sector`, `Industry`.
127 StringEq(String),
128}
129
130// ============================================================================
131// QueryCondition<F>
132// ============================================================================
133
134/// A typed filter condition for a screener query.
135///
136/// Created via [`ScreenerFieldExt`] methods on a field enum variant. The custom
137/// [`Serialize`] impl produces the exact format Yahoo Finance expects:
138/// `{"operator": "gt", "operands": ["fieldname", value]}`.
139///
140/// # Example
141///
142/// ```
143/// use finance_query::{EquityField, ScreenerFieldExt};
144///
145/// let volume_filter = EquityField::AvgDailyVol3M.gt(200_000.0);
146/// let region_filter = EquityField::Region.eq_str("us");
147/// let pe_filter = EquityField::PeRatio.between(10.0, 25.0);
148/// ```
149#[derive(Debug, Clone)]
150pub struct QueryCondition<F: ScreenerField> {
151 /// The field to filter on.
152 pub field: F,
153 /// The comparison operator.
154 pub operator: Operator,
155 /// The filter value(s).
156 pub value: ConditionValue,
157}
158
159impl<F: ScreenerField> Serialize for QueryCondition<F> {
160 /// Serializes to Yahoo Finance's format:
161 /// `{"operator": "gt", "operands": ["fieldname", val]}`
162 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
163 let mut s = serializer.serialize_struct("QueryCondition", 2)?;
164 s.serialize_field("operator", &self.operator)?;
165
166 let field_str = self.field.as_str();
167 let operands: serde_json::Value = match &self.value {
168 ConditionValue::Number(v) => serde_json::json!([field_str, v]),
169 ConditionValue::Between(min, max) => serde_json::json!([field_str, min, max]),
170 ConditionValue::StringEq(v) => serde_json::json!([field_str, v]),
171 };
172
173 s.serialize_field("operands", &operands)?;
174 s.end()
175 }
176}
177
178// ============================================================================
179// QueryGroup<F> and QueryOperand<F>
180// ============================================================================
181
182/// A group of query operands combined with a logical operator.
183///
184/// Groups can be nested to form complex AND/OR trees.
185#[derive(Debug, Clone, Serialize)]
186pub struct QueryGroup<F: ScreenerField> {
187 /// The logical operator combining all operands in this group.
188 pub operator: LogicalOperator,
189 /// The operands — each is either a [`QueryCondition`] or a nested [`QueryGroup`].
190 pub operands: Vec<QueryOperand<F>>,
191}
192
193impl<F: ScreenerField> QueryGroup<F> {
194 /// Create a new empty group with the given logical operator.
195 pub fn new(operator: LogicalOperator) -> Self {
196 Self {
197 operator,
198 operands: Vec::new(),
199 }
200 }
201
202 /// Add an operand to this group.
203 pub fn add_operand(&mut self, operand: QueryOperand<F>) {
204 self.operands.push(operand);
205 }
206}
207
208/// An operand within a query group — either a leaf condition or a nested group.
209#[derive(Debug, Clone, Serialize)]
210#[serde(untagged)]
211pub enum QueryOperand<F: ScreenerField> {
212 /// A single filter condition.
213 Condition(QueryCondition<F>),
214 /// A nested group of conditions.
215 Group(QueryGroup<F>),
216}
217
218// ============================================================================
219// ScreenerFieldExt — fluent condition builders
220// ============================================================================
221
222/// Fluent condition-building methods on any [`ScreenerField`] type.
223///
224/// This blanket trait is automatically implemented for all types that implement
225/// [`ScreenerField`], including [`EquityField`](super::fields::EquityField) and
226/// [`FundField`](super::fields::FundField).
227///
228/// # Example
229///
230/// ```
231/// use finance_query::{EquityField, ScreenerFieldExt};
232///
233/// // Numeric comparisons
234/// let cond = EquityField::PeRatio.between(10.0, 25.0);
235/// let cond = EquityField::AvgDailyVol3M.gt(500_000.0);
236/// let cond = EquityField::EsgScore.gte(50.0);
237///
238/// // String equality
239/// let cond = EquityField::Region.eq_str("us");
240/// let cond = EquityField::Exchange.eq_str("NMS");
241/// ```
242pub trait ScreenerFieldExt: ScreenerField + Sized {
243 /// Filter where this field is greater than `v`.
244 fn gt(self, v: f64) -> QueryCondition<Self> {
245 QueryCondition {
246 field: self,
247 operator: Operator::Gt,
248 value: ConditionValue::Number(v),
249 }
250 }
251
252 /// Filter where this field is less than `v`.
253 fn lt(self, v: f64) -> QueryCondition<Self> {
254 QueryCondition {
255 field: self,
256 operator: Operator::Lt,
257 value: ConditionValue::Number(v),
258 }
259 }
260
261 /// Filter where this field is greater than or equal to `v`.
262 fn gte(self, v: f64) -> QueryCondition<Self> {
263 QueryCondition {
264 field: self,
265 operator: Operator::Gte,
266 value: ConditionValue::Number(v),
267 }
268 }
269
270 /// Filter where this field is less than or equal to `v`.
271 fn lte(self, v: f64) -> QueryCondition<Self> {
272 QueryCondition {
273 field: self,
274 operator: Operator::Lte,
275 value: ConditionValue::Number(v),
276 }
277 }
278
279 /// Filter where this field equals the numeric value `v`.
280 fn eq_num(self, v: f64) -> QueryCondition<Self> {
281 QueryCondition {
282 field: self,
283 operator: Operator::Eq,
284 value: ConditionValue::Number(v),
285 }
286 }
287
288 /// Filter where this field is between `min` and `max` (inclusive).
289 fn between(self, min: f64, max: f64) -> QueryCondition<Self> {
290 QueryCondition {
291 field: self,
292 operator: Operator::Between,
293 value: ConditionValue::Between(min, max),
294 }
295 }
296
297 /// Filter where this field equals the string value `v`.
298 ///
299 /// Use for categorical fields like `Region`, `Sector`, `Industry`, `Exchange`.
300 fn eq_str(self, v: impl Into<String>) -> QueryCondition<Self> {
301 QueryCondition {
302 field: self,
303 operator: Operator::Eq,
304 value: ConditionValue::StringEq(v.into()),
305 }
306 }
307}
308
309/// Blanket implementation: every `ScreenerField` automatically gets all the
310/// fluent condition-building methods from `ScreenerFieldExt`.
311impl<T: ScreenerField + Sized> ScreenerFieldExt for T {}
312
313// ============================================================================
314// Tests
315// ============================================================================
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::models::screeners::fields::EquityField;
321
322 #[test]
323 fn test_condition_gt_serializes_correctly() {
324 let condition = EquityField::AvgDailyVol3M.gt(200_000.0);
325 let json = serde_json::to_value(&condition).unwrap();
326 assert_eq!(json["operator"], "gt");
327 assert_eq!(json["operands"][0], "avgdailyvol3m");
328 assert_eq!(json["operands"][1], 200_000.0);
329 }
330
331 #[test]
332 fn test_condition_lt_serializes_correctly() {
333 let condition = EquityField::PeRatio.lt(30.0);
334 let json = serde_json::to_value(&condition).unwrap();
335 assert_eq!(json["operator"], "lt");
336 assert_eq!(json["operands"][0], "peratio.lasttwelvemonths");
337 assert_eq!(json["operands"][1], 30.0);
338 }
339
340 #[test]
341 fn test_condition_between_serializes_correctly() {
342 let condition = EquityField::PeRatio.between(10.0, 25.0);
343 let json = serde_json::to_value(&condition).unwrap();
344 assert_eq!(json["operator"], "btwn");
345 assert_eq!(json["operands"][0], "peratio.lasttwelvemonths");
346 assert_eq!(json["operands"][1], 10.0);
347 assert_eq!(json["operands"][2], 25.0);
348 }
349
350 #[test]
351 fn test_condition_eq_str_serializes_correctly() {
352 let condition = EquityField::Region.eq_str("us");
353 let json = serde_json::to_value(&condition).unwrap();
354 assert_eq!(json["operator"], "eq");
355 assert_eq!(json["operands"][0], "region");
356 assert_eq!(json["operands"][1], "us");
357 }
358
359 #[test]
360 fn test_query_group_serializes_correctly() {
361 let mut group = QueryGroup::new(LogicalOperator::And);
362 group.add_operand(QueryOperand::Condition(EquityField::Region.eq_str("us")));
363 group.add_operand(QueryOperand::Condition(
364 EquityField::AvgDailyVol3M.gt(200_000.0),
365 ));
366
367 let json = serde_json::to_value(&group).unwrap();
368 assert_eq!(json["operator"], "and");
369 assert_eq!(json["operands"].as_array().unwrap().len(), 2);
370 }
371}