Skip to main content

finance_query/models/discovery/screeners/
query.rs

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