finance_query/models/screeners/
query.rs

1use crate::constants::screener_query::{
2    LogicalOperator, Operator, QuoteType, SortType, equity_fields, fund_fields,
3};
4use serde::{Deserialize, Serialize};
5
6/// A custom screener query for Yahoo Finance
7///
8/// Allows building flexible queries to filter stocks/funds/ETFs based on
9/// various criteria like price, volume, market cap, and more.
10///
11/// # Example
12///
13/// ```
14/// use finance_query::{ScreenerQuery, QueryCondition, screener_query::{Operator, QuoteType}};
15///
16/// // Find US stocks with high volume and market cap > $10B
17/// let query = ScreenerQuery::new()
18///     .quote_type(QuoteType::Equity)
19///     .size(25)
20///     .sort_by("intradaymarketcap", false)  // Sort by market cap descending
21///     .add_condition(QueryCondition::new("region", Operator::Eq).value_str("us"))
22///     .add_condition(QueryCondition::new("avgdailyvol3m", Operator::Gt).value(200000))
23///     .add_condition(QueryCondition::new("intradaymarketcap", Operator::Gt).value(10_000_000_000.0));
24/// ```
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct ScreenerQuery {
28    /// Number of results to return (default: 25, max: 250)
29    pub size: u32,
30
31    /// Starting offset for pagination (default: 0)
32    pub offset: u32,
33
34    /// Sort direction (ASC or DESC)
35    pub sort_type: SortType,
36
37    /// Field to sort by (e.g., "intradaymarketcap", "percentchange")
38    pub sort_field: String,
39
40    /// Fields to include in the response
41    pub include_fields: Vec<String>,
42
43    /// Top-level logical operator (AND or OR)
44    pub top_operator: LogicalOperator,
45
46    /// Query filter conditions
47    pub query: QueryGroup,
48
49    /// Type of quote to screen (EQUITY, ETF, MUTUALFUND, etc.)
50    pub quote_type: QuoteType,
51}
52
53/// Default fields to include in screener results for equities
54const DEFAULT_EQUITY_FIELDS: &[&str] = &[
55    "ticker",
56    "companyshortname",
57    equity_fields::INTRADAY_PRICE,
58    equity_fields::INTRADAY_PRICE_CHANGE,
59    equity_fields::PERCENT_CHANGE,
60    equity_fields::INTRADAY_MARKET_CAP,
61    equity_fields::DAY_VOLUME,
62    equity_fields::AVG_DAILY_VOL_3M,
63    equity_fields::PE_RATIO,
64    equity_fields::FIFTY_TWO_WK_PCT_CHANGE,
65];
66
67/// Default fields to include in screener results for mutual funds
68const DEFAULT_FUND_FIELDS: &[&str] = &[
69    "ticker",
70    "companyshortname",
71    fund_fields::INTRADAY_PRICE,
72    fund_fields::INTRADAY_PRICE_CHANGE,
73    fund_fields::CATEGORY_NAME,
74    fund_fields::PERFORMANCE_RATING,
75    fund_fields::RISK_RATING,
76];
77
78impl Default for ScreenerQuery {
79    fn default() -> Self {
80        Self {
81            size: 25,
82            offset: 0,
83            sort_type: SortType::Desc,
84            sort_field: equity_fields::INTRADAY_MARKET_CAP.to_string(),
85            include_fields: DEFAULT_EQUITY_FIELDS
86                .iter()
87                .map(|s| s.to_string())
88                .collect(),
89            top_operator: LogicalOperator::And,
90            query: QueryGroup::new(LogicalOperator::And),
91            quote_type: QuoteType::Equity,
92        }
93    }
94}
95
96impl ScreenerQuery {
97    /// Create a new screener query with default settings
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Set the number of results to return (max 250)
103    pub fn size(mut self, size: u32) -> Self {
104        self.size = size.min(250);
105        self
106    }
107
108    /// Set the pagination offset
109    pub fn offset(mut self, offset: u32) -> Self {
110        self.offset = offset;
111        self
112    }
113
114    /// Set the sort field and direction
115    ///
116    /// # Arguments
117    ///
118    /// * `field` - Field to sort by (e.g., "intradaymarketcap", "percentchange")
119    /// * `ascending` - If true, sort ascending; if false, sort descending
120    pub fn sort_by(mut self, field: impl Into<String>, ascending: bool) -> Self {
121        self.sort_field = field.into();
122        self.sort_type = if ascending {
123            SortType::Asc
124        } else {
125            SortType::Desc
126        };
127        self
128    }
129
130    /// Set the quote type (EQUITY or MUTUALFUND)
131    ///
132    /// This also updates the default include_fields and sort_field
133    /// to use appropriate fields for the quote type.
134    pub fn quote_type(mut self, quote_type: QuoteType) -> Self {
135        self.quote_type = quote_type;
136        // Update default fields based on quote type
137        let (default_fields, default_sort) = match quote_type {
138            QuoteType::Equity => (DEFAULT_EQUITY_FIELDS, equity_fields::INTRADAY_MARKET_CAP),
139            QuoteType::MutualFund => (DEFAULT_FUND_FIELDS, fund_fields::INTRADAY_PRICE),
140        };
141        self.include_fields = default_fields.iter().map(|s| s.to_string()).collect();
142        self.sort_field = default_sort.to_string();
143        self
144    }
145
146    /// Set the fields to include in the response
147    pub fn include_fields(mut self, fields: Vec<String>) -> Self {
148        self.include_fields = fields;
149        self
150    }
151
152    /// Add a field to include in the response
153    pub fn add_include_field(mut self, field: impl Into<String>) -> Self {
154        self.include_fields.push(field.into());
155        self
156    }
157
158    /// Set the top-level operator (AND or OR)
159    pub fn top_operator(mut self, op: LogicalOperator) -> Self {
160        self.top_operator = op;
161        self
162    }
163
164    /// Add a filter condition
165    ///
166    /// # Example
167    ///
168    /// ```
169    /// use finance_query::{ScreenerQuery, QueryCondition, screener_query::Operator};
170    ///
171    /// let query = ScreenerQuery::new()
172    ///     .add_condition(QueryCondition::new("region", Operator::Eq).value_str("us"))
173    ///     .add_condition(QueryCondition::new("avgdailyvol3m", Operator::Gt).value(200000));
174    /// ```
175    pub fn add_condition(mut self, condition: QueryCondition) -> Self {
176        // Wrap in OR group (Yahoo's expected format)
177        let mut or_group = QueryGroup::new(LogicalOperator::Or);
178        or_group.add_operand(QueryOperand::Condition(condition));
179        self.query.add_operand(QueryOperand::Group(or_group));
180        self
181    }
182
183    /// Add multiple conditions that will be OR'd together
184    ///
185    /// # Example
186    ///
187    /// ```
188    /// use finance_query::{ScreenerQuery, QueryCondition, screener_query::Operator};
189    ///
190    /// // Filter for region being US OR GB
191    /// let query = ScreenerQuery::new()
192    ///     .add_or_conditions(vec![
193    ///         QueryCondition::new("region", Operator::Eq).value_str("us"),
194    ///         QueryCondition::new("region", Operator::Eq).value_str("gb"),
195    ///     ]);
196    /// ```
197    pub fn add_or_conditions(mut self, conditions: Vec<QueryCondition>) -> Self {
198        let mut or_group = QueryGroup::new(LogicalOperator::Or);
199        for condition in conditions {
200            or_group.add_operand(QueryOperand::Condition(condition));
201        }
202        self.query.add_operand(QueryOperand::Group(or_group));
203        self
204    }
205
206    /// Create a "most shorted stocks" screener preset
207    ///
208    /// Finds US stocks sorted by short interest percentage.
209    pub fn most_shorted() -> Self {
210        Self::new()
211            .sort_by(equity_fields::SHORT_PCT_FLOAT, false)
212            .add_condition(QueryCondition::new(equity_fields::REGION, Operator::Eq).value_str("us"))
213            .add_condition(
214                QueryCondition::new(equity_fields::AVG_DAILY_VOL_3M, Operator::Gt).value(200000),
215            )
216    }
217
218    /// Create a "high dividend yield" screener preset
219    ///
220    /// Finds US stocks with dividend yield > 3%.
221    pub fn high_dividend() -> Self {
222        Self::new()
223            .sort_by(equity_fields::FORWARD_DIV_YIELD, false)
224            .add_condition(QueryCondition::new(equity_fields::REGION, Operator::Eq).value_str("us"))
225            .add_condition(
226                QueryCondition::new(equity_fields::FORWARD_DIV_YIELD, Operator::Gt).value(3.0),
227            )
228            .add_condition(
229                QueryCondition::new(equity_fields::AVG_DAILY_VOL_3M, Operator::Gt).value(100000),
230            )
231    }
232
233    /// Create a "large cap growth" screener preset
234    ///
235    /// Finds large cap stocks with positive earnings growth.
236    pub fn large_cap_growth() -> Self {
237        Self::new()
238            .sort_by(equity_fields::INTRADAY_MARKET_CAP, false)
239            .add_condition(QueryCondition::new(equity_fields::REGION, Operator::Eq).value_str("us"))
240            .add_condition(
241                QueryCondition::new(equity_fields::INTRADAY_MARKET_CAP, Operator::Gt)
242                    .value(10_000_000_000.0f64),
243            )
244            .add_condition(QueryCondition::new(equity_fields::EPS_GROWTH, Operator::Gt).value(0.0))
245    }
246}
247
248/// A group of query operands combined with a logical operator
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct QueryGroup {
251    /// The logical operator (AND or OR)
252    pub operator: LogicalOperator,
253    /// The operands in this group
254    pub operands: Vec<QueryOperand>,
255}
256
257impl QueryGroup {
258    /// Create a new empty query group
259    pub fn new(operator: LogicalOperator) -> Self {
260        Self {
261            operator,
262            operands: Vec::new(),
263        }
264    }
265
266    /// Add an operand to this group
267    pub fn add_operand(&mut self, operand: QueryOperand) {
268        self.operands.push(operand);
269    }
270}
271
272/// An operand in a query - either a condition or a nested group
273#[derive(Debug, Clone, Serialize, Deserialize)]
274#[serde(untagged)]
275pub enum QueryOperand {
276    /// A filter condition
277    Condition(QueryCondition),
278    /// A nested group of conditions
279    Group(QueryGroup),
280}
281
282/// A single filter condition
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct QueryCondition {
285    /// The comparison operator
286    pub operator: Operator,
287    /// The operands: [field_name, value(s)...]
288    pub operands: Vec<QueryValue>,
289}
290
291impl QueryCondition {
292    /// Create a new condition for a field
293    ///
294    /// # Arguments
295    ///
296    /// * `field` - The field to filter on (e.g., "region", "avgdailyvol3m")
297    /// * `operator` - The comparison operator (Eq, Gt, Lt, etc.)
298    pub fn new(field: impl Into<String>, operator: Operator) -> Self {
299        Self {
300            operator,
301            operands: vec![QueryValue::String(field.into())],
302        }
303    }
304
305    /// Set a numeric value for the condition
306    pub fn value<T: Into<f64>>(mut self, value: T) -> Self {
307        self.operands.push(QueryValue::Number(value.into()));
308        self
309    }
310
311    /// Set a string value for the condition
312    pub fn value_str(mut self, value: impl Into<String>) -> Self {
313        self.operands.push(QueryValue::String(value.into()));
314        self
315    }
316
317    /// Set two values for a BETWEEN condition
318    pub fn between<T: Into<f64>>(mut self, min: T, max: T) -> Self {
319        self.operands.push(QueryValue::Number(min.into()));
320        self.operands.push(QueryValue::Number(max.into()));
321        self
322    }
323}
324
325/// A value in a query condition (can be string or number)
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(untagged)]
328pub enum QueryValue {
329    /// A string value
330    String(String),
331    /// A numeric value
332    Number(f64),
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_default_query() {
341        let query = ScreenerQuery::new();
342        assert_eq!(query.size, 25);
343        assert_eq!(query.offset, 0);
344        assert_eq!(query.quote_type, QuoteType::Equity);
345    }
346
347    #[test]
348    fn test_most_shorted_preset() {
349        let query = ScreenerQuery::most_shorted();
350        assert_eq!(query.sort_field, equity_fields::SHORT_PCT_FLOAT);
351        assert_eq!(query.sort_type, SortType::Desc);
352    }
353
354    #[test]
355    fn test_condition_builder() {
356        let condition = QueryCondition::new("region", Operator::Eq).value_str("us");
357        assert_eq!(condition.operator, Operator::Eq);
358        assert_eq!(condition.operands.len(), 2);
359    }
360
361    #[test]
362    fn test_query_serialization() {
363        let query = ScreenerQuery::new()
364            .size(10)
365            .add_condition(QueryCondition::new("region", Operator::Eq).value_str("us"));
366
367        let json = serde_json::to_string(&query).unwrap();
368        assert!(json.contains("\"size\":10"));
369        assert!(json.contains("\"region\""));
370    }
371}