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}