Skip to main content

finance_query/models/screeners/
query.rs

1use crate::models::screeners::condition::{
2    LogicalOperator, QueryCondition, QueryGroup, QueryOperand, ScreenerField, ScreenerFieldExt,
3};
4use crate::models::screeners::fields::{EquityField, FundField};
5use serde::{Deserialize, Serialize};
6
7// ============================================================================
8// QuoteType
9// ============================================================================
10
11/// Quote type for custom screener queries.
12///
13/// Yahoo Finance only supports `EQUITY` and `MUTUALFUND` for custom screener queries.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
15#[serde(rename_all = "UPPERCASE")]
16pub enum QuoteType {
17    /// Equity (stocks) — use [`EquityScreenerQuery`] with [`EquityField`] conditions.
18    #[default]
19    #[serde(rename = "EQUITY")]
20    Equity,
21    /// Mutual funds — use [`FundScreenerQuery`] with [`FundField`] conditions.
22    #[serde(rename = "MUTUALFUND")]
23    MutualFund,
24}
25
26impl std::str::FromStr for QuoteType {
27    type Err = ();
28
29    fn from_str(s: &str) -> Result<Self, Self::Err> {
30        match s.to_lowercase().replace(['-', '_'], "").as_str() {
31            "equity" | "stock" | "stocks" => Ok(QuoteType::Equity),
32            "mutualfund" | "fund" | "funds" => Ok(QuoteType::MutualFund),
33            _ => Err(()),
34        }
35    }
36}
37
38// ============================================================================
39// SortType
40// ============================================================================
41
42/// Sort direction for screener results.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
44#[serde(rename_all = "UPPERCASE")]
45pub enum SortType {
46    /// Sort ascending (smallest first) — `"ASC"`
47    #[serde(rename = "ASC")]
48    Asc,
49    /// Sort descending (largest first) — `"DESC"`
50    #[default]
51    #[serde(rename = "DESC")]
52    Desc,
53}
54
55impl std::str::FromStr for SortType {
56    type Err = ();
57
58    fn from_str(s: &str) -> Result<Self, Self::Err> {
59        match s.to_lowercase().as_str() {
60            "asc" | "ascending" => Ok(SortType::Asc),
61            "desc" | "descending" => Ok(SortType::Desc),
62            _ => Err(()),
63        }
64    }
65}
66
67// ============================================================================
68// ScreenerQuery<F>
69// ============================================================================
70
71/// A typed custom screener query for Yahoo Finance.
72///
73/// The type parameter `F` determines which field set is valid for this query.
74/// Use the type aliases for the common cases:
75/// - [`EquityScreenerQuery`] — for stock screeners
76/// - [`FundScreenerQuery`] — for mutual fund screeners
77///
78/// # Example
79///
80/// ```
81/// use finance_query::{EquityField, EquityScreenerQuery, ScreenerFieldExt};
82///
83/// // Find US large-cap value stocks
84/// let query = EquityScreenerQuery::new()
85///     .size(25)
86///     .sort_by(EquityField::IntradayMarketCap, false)
87///     .add_condition(EquityField::Region.eq_str("us"))
88///     .add_condition(EquityField::AvgDailyVol3M.gt(200_000.0))
89///     .add_condition(EquityField::PeRatio.between(10.0, 25.0))
90///     .add_condition(EquityField::IntradayMarketCap.gt(10_000_000_000.0))
91///     .include_fields(vec![
92///         EquityField::Ticker,
93///         EquityField::CompanyShortName,
94///         EquityField::IntradayPrice,
95///         EquityField::PeRatio,
96///         EquityField::IntradayMarketCap,
97///     ]);
98/// ```
99#[derive(Debug, Clone, Serialize)]
100#[serde(rename_all = "camelCase")]
101pub struct ScreenerQuery<F: ScreenerField = EquityField> {
102    /// Number of results to return (default: 25, max: 250).
103    pub size: u32,
104
105    /// Starting offset for pagination (default: 0).
106    pub offset: u32,
107
108    /// Sort direction.
109    pub sort_type: SortType,
110
111    /// Field to sort by.
112    pub sort_field: F,
113
114    /// Fields to include in the response.
115    pub include_fields: Vec<F>,
116
117    /// Top-level logical operator combining all conditions.
118    pub top_operator: LogicalOperator,
119
120    /// The nested condition tree.
121    pub query: QueryGroup<F>,
122
123    /// Quote type — determines which Yahoo Finance screener endpoint is used.
124    pub quote_type: QuoteType,
125}
126
127/// Type alias for equity (stock) screener queries.
128///
129/// Use [`EquityField`] variants to build conditions.
130pub type EquityScreenerQuery = ScreenerQuery<EquityField>;
131
132/// Type alias for mutual fund screener queries.
133///
134/// Use [`FundField`] variants to build conditions.
135pub type FundScreenerQuery = ScreenerQuery<FundField>;
136
137// ============================================================================
138// Default impls
139// ============================================================================
140
141impl Default for ScreenerQuery<EquityField> {
142    fn default() -> Self {
143        Self {
144            size: 25,
145            offset: 0,
146            sort_type: SortType::Desc,
147            sort_field: EquityField::IntradayMarketCap,
148            include_fields: vec![
149                EquityField::Ticker,
150                EquityField::CompanyShortName,
151                EquityField::IntradayPrice,
152                EquityField::IntradayPriceChange,
153                EquityField::PercentChange,
154                EquityField::IntradayMarketCap,
155                EquityField::DayVolume,
156                EquityField::AvgDailyVol3M,
157                EquityField::PeRatio,
158                EquityField::FiftyTwoWkPctChange,
159            ],
160            top_operator: LogicalOperator::And,
161            query: QueryGroup::new(LogicalOperator::And),
162            quote_type: QuoteType::Equity,
163        }
164    }
165}
166
167impl Default for ScreenerQuery<FundField> {
168    fn default() -> Self {
169        Self {
170            size: 25,
171            offset: 0,
172            sort_type: SortType::Desc,
173            sort_field: FundField::IntradayPrice,
174            include_fields: vec![
175                FundField::Ticker,
176                FundField::CompanyShortName,
177                FundField::IntradayPrice,
178                FundField::IntradayPriceChange,
179                FundField::CategoryName,
180                FundField::PerformanceRating,
181                FundField::RiskRating,
182            ],
183            top_operator: LogicalOperator::And,
184            query: QueryGroup::new(LogicalOperator::And),
185            quote_type: QuoteType::MutualFund,
186        }
187    }
188}
189
190// ============================================================================
191// Shared builder methods
192// ============================================================================
193
194impl<F: ScreenerField> ScreenerQuery<F> {
195    /// Create a new screener query with default settings.
196    pub fn new() -> Self
197    where
198        Self: Default,
199    {
200        Self::default()
201    }
202
203    /// Set the number of results to return (capped at 250).
204    pub fn size(mut self, size: u32) -> Self {
205        self.size = size.min(250);
206        self
207    }
208
209    /// Set the pagination offset.
210    pub fn offset(mut self, offset: u32) -> Self {
211        self.offset = offset;
212        self
213    }
214
215    /// Set the field to sort by and the sort direction.
216    ///
217    /// # Example
218    ///
219    /// ```
220    /// use finance_query::{EquityField, EquityScreenerQuery};
221    ///
222    /// let query = EquityScreenerQuery::new()
223    ///     .sort_by(EquityField::PeRatio, true);  // ascending P/E
224    /// ```
225    pub fn sort_by(mut self, field: F, ascending: bool) -> Self {
226        self.sort_field = field;
227        self.sort_type = if ascending {
228            SortType::Asc
229        } else {
230            SortType::Desc
231        };
232        self
233    }
234
235    /// Set the top-level logical operator (AND or OR).
236    pub fn top_operator(mut self, op: LogicalOperator) -> Self {
237        self.top_operator = op;
238        self
239    }
240
241    /// Set which fields to include in the response.
242    pub fn include_fields(mut self, fields: Vec<F>) -> Self {
243        self.include_fields = fields;
244        self
245    }
246
247    /// Add a field to include in the response.
248    pub fn add_include_field(mut self, field: F) -> Self {
249        self.include_fields.push(field);
250        self
251    }
252
253    /// Add a typed filter condition to this query (ANDed with all others).
254    ///
255    /// Conditions are added directly as operands of the top-level AND group,
256    /// matching the format Yahoo Finance's screener API expects. Use
257    /// [`add_or_conditions`](Self::add_or_conditions) when you need to match
258    /// any of several values for the same field.
259    ///
260    /// # Example
261    ///
262    /// ```
263    /// use finance_query::{EquityField, EquityScreenerQuery, ScreenerFieldExt};
264    ///
265    /// let query = EquityScreenerQuery::new()
266    ///     .add_condition(EquityField::Region.eq_str("us"))
267    ///     .add_condition(EquityField::PeRatio.between(10.0, 25.0))
268    ///     .add_condition(EquityField::AvgDailyVol3M.gt(200_000.0));
269    /// ```
270    pub fn add_condition(mut self, condition: QueryCondition<F>) -> Self {
271        self.query.add_operand(QueryOperand::Condition(condition));
272        self
273    }
274
275    /// Add multiple conditions that are OR'd together.
276    ///
277    /// # Example
278    ///
279    /// ```
280    /// use finance_query::{EquityField, EquityScreenerQuery, ScreenerFieldExt};
281    ///
282    /// // Accept US or GB region
283    /// let query = EquityScreenerQuery::new()
284    ///     .add_or_conditions(vec![
285    ///         EquityField::Region.eq_str("us"),
286    ///         EquityField::Region.eq_str("gb"),
287    ///     ]);
288    /// ```
289    pub fn add_or_conditions(mut self, conditions: Vec<QueryCondition<F>>) -> Self {
290        let mut or_group = QueryGroup::new(LogicalOperator::Or);
291        for condition in conditions {
292            or_group.add_operand(QueryOperand::Condition(condition));
293        }
294        self.query.add_operand(QueryOperand::Group(or_group));
295        self
296    }
297}
298
299// ============================================================================
300// Equity preset constructors
301// ============================================================================
302
303impl ScreenerQuery<EquityField> {
304    /// Preset: US stocks sorted by short interest percentage of float.
305    ///
306    /// Filters: US region, average daily volume > 200K.
307    ///
308    /// ```no_run
309    /// use finance_query::{EquityScreenerQuery, finance};
310    ///
311    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
312    /// let results = finance::custom_screener(EquityScreenerQuery::most_shorted()).await?;
313    /// # Ok(())
314    /// # }
315    /// ```
316    pub fn most_shorted() -> Self {
317        Self::new()
318            .sort_by(EquityField::ShortPctFloat, false)
319            .add_condition(EquityField::Region.eq_str("us"))
320            .add_condition(EquityField::AvgDailyVol3M.gt(200_000.0))
321    }
322
323    /// Preset: US stocks with forward dividend yield > 3%, sorted by yield descending.
324    ///
325    /// Filters: US region, forward dividend yield > 3%, average daily volume > 100K.
326    ///
327    /// ```no_run
328    /// use finance_query::{EquityScreenerQuery, finance};
329    ///
330    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
331    /// let results = finance::custom_screener(EquityScreenerQuery::high_dividend()).await?;
332    /// # Ok(())
333    /// # }
334    /// ```
335    pub fn high_dividend() -> Self {
336        Self::new()
337            .sort_by(EquityField::ForwardDivYield, false)
338            .add_condition(EquityField::Region.eq_str("us"))
339            .add_condition(EquityField::ForwardDivYield.gt(3.0))
340            .add_condition(EquityField::AvgDailyVol3M.gt(100_000.0))
341    }
342
343    /// Preset: US large-cap stocks with positive EPS growth, sorted by market cap.
344    ///
345    /// Filters: US region, market cap > $10B, positive EPS growth.
346    ///
347    /// ```no_run
348    /// use finance_query::{EquityScreenerQuery, finance};
349    ///
350    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
351    /// let results = finance::custom_screener(EquityScreenerQuery::large_cap_growth()).await?;
352    /// # Ok(())
353    /// # }
354    /// ```
355    pub fn large_cap_growth() -> Self {
356        Self::new()
357            .sort_by(EquityField::IntradayMarketCap, false)
358            .add_condition(EquityField::Region.eq_str("us"))
359            .add_condition(EquityField::IntradayMarketCap.gt(10_000_000_000.0))
360            .add_condition(EquityField::EpsGrowth.gt(0.0))
361    }
362}
363
364// ============================================================================
365// Tests
366// ============================================================================
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::models::screeners::condition::ScreenerFieldExt;
372
373    #[test]
374    fn test_default_equity_query() {
375        let query = EquityScreenerQuery::new();
376        assert_eq!(query.size, 25);
377        assert_eq!(query.offset, 0);
378        assert_eq!(query.quote_type, QuoteType::Equity);
379        assert_eq!(query.sort_field, EquityField::IntradayMarketCap);
380    }
381
382    #[test]
383    fn test_default_fund_query() {
384        let query = FundScreenerQuery::new();
385        assert_eq!(query.size, 25);
386        assert_eq!(query.quote_type, QuoteType::MutualFund);
387        assert_eq!(query.sort_field, FundField::IntradayPrice);
388    }
389
390    #[test]
391    fn test_most_shorted_preset() {
392        let query = EquityScreenerQuery::most_shorted();
393        assert_eq!(query.sort_field, EquityField::ShortPctFloat);
394        assert_eq!(query.sort_type, SortType::Desc);
395    }
396
397    #[test]
398    fn test_high_dividend_preset() {
399        let query = EquityScreenerQuery::high_dividend();
400        assert_eq!(query.sort_field, EquityField::ForwardDivYield);
401    }
402
403    #[test]
404    fn test_large_cap_growth_preset() {
405        let query = EquityScreenerQuery::large_cap_growth();
406        assert_eq!(query.sort_field, EquityField::IntradayMarketCap);
407    }
408
409    #[test]
410    fn test_sort_by_typed_field() {
411        let query = EquityScreenerQuery::new().sort_by(EquityField::PeRatio, true);
412        assert_eq!(query.sort_field, EquityField::PeRatio);
413        assert_eq!(query.sort_type, SortType::Asc);
414    }
415
416    #[test]
417    fn test_size_capped_at_250() {
418        let query = EquityScreenerQuery::new().size(9999);
419        assert_eq!(query.size, 250);
420    }
421
422    #[test]
423    fn test_query_serializes_sort_field_as_string() {
424        let query = EquityScreenerQuery::new().sort_by(EquityField::PeRatio, false);
425        let json = serde_json::to_value(&query).unwrap();
426        assert_eq!(json["sortField"], "peratio.lasttwelvemonths");
427        assert_eq!(json["sortType"], "DESC");
428    }
429
430    #[test]
431    fn test_query_serializes_include_fields_as_strings() {
432        let query = EquityScreenerQuery::new()
433            .include_fields(vec![EquityField::Ticker, EquityField::PeRatio]);
434        let json = serde_json::to_value(&query).unwrap();
435        let fields = json["includeFields"].as_array().unwrap();
436        assert_eq!(fields[0], "ticker");
437        assert_eq!(fields[1], "peratio.lasttwelvemonths");
438    }
439
440    #[test]
441    fn test_add_condition_adds_directly_to_and_group() {
442        let query = EquityScreenerQuery::new().add_condition(EquityField::Region.eq_str("us"));
443        let json = serde_json::to_value(&query).unwrap();
444        // condition is a direct operand of the AND group (no OR wrapper)
445        let outer_operands = json["query"]["operands"].as_array().unwrap();
446        assert_eq!(outer_operands.len(), 1);
447        assert_eq!(outer_operands[0]["operator"], "eq");
448        assert_eq!(outer_operands[0]["operands"][0], "region");
449    }
450
451    #[test]
452    fn test_full_query_serialization() {
453        let query = EquityScreenerQuery::new()
454            .size(10)
455            .add_condition(EquityField::Region.eq_str("us"))
456            .add_condition(EquityField::AvgDailyVol3M.gt(200_000.0));
457
458        let json = serde_json::to_string(&query).unwrap();
459        assert!(json.contains("\"size\":10"));
460        assert!(json.contains("\"region\""));
461        assert!(json.contains("\"avgdailyvol3m\""));
462        assert!(json.contains("\"EQUITY\""));
463    }
464}