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#[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::super::condition::ScreenerFieldExt;
371 use super::*;
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}