Skip to main content

yfinance_rs/screener/
query.rs

1use std::{fmt, marker::PhantomData};
2
3use paft::domain::AssetKind;
4use serde::Serialize;
5use serde_json::{Value, json};
6
7use crate::{YfError, core::yahoo_vocab::parse_yahoo_quote_type};
8
9/// Marker type for predefined screen requests.
10#[derive(Debug, Clone, Copy)]
11pub struct Predefined;
12
13/// Marker type for equity screen queries.
14#[derive(Debug, Clone, Copy)]
15pub struct Equity;
16
17/// Marker type for mutual fund screen queries.
18#[derive(Debug, Clone, Copy)]
19pub struct Fund;
20
21/// Marker type for ETF screen queries.
22#[derive(Debug, Clone, Copy)]
23pub struct Etf;
24
25/// Equity screener query.
26pub type EquityQuery = ScreenerQuery<Equity>;
27
28/// Mutual fund screener query.
29pub type FundQuery = ScreenerQuery<Fund>;
30
31/// ETF screener query.
32pub type EtfQuery = ScreenerQuery<Etf>;
33
34/// Yahoo quote type used by the screener endpoint.
35#[non_exhaustive]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
37pub enum YahooQuoteType {
38    /// Common stock or equity-like instrument.
39    #[serde(rename = "EQUITY")]
40    Equity,
41    /// Mutual fund.
42    #[serde(rename = "MUTUALFUND")]
43    MutualFund,
44    /// Exchange-traded fund.
45    #[serde(rename = "ETF")]
46    Etf,
47}
48
49impl YahooQuoteType {
50    pub(crate) const fn as_str(self) -> &'static str {
51        match self {
52            Self::Equity => "EQUITY",
53            Self::MutualFund => "MUTUALFUND",
54            Self::Etf => "ETF",
55        }
56    }
57
58    pub(crate) fn asset_kind(self) -> AssetKind {
59        parse_yahoo_quote_type(self.as_str()).expect("typed screener quote type is valid")
60    }
61
62    pub(crate) fn parse(value: &str) -> Option<Self> {
63        match value {
64            "EQUITY" => Some(Self::Equity),
65            "MUTUALFUND" => Some(Self::MutualFund),
66            "ETF" => Some(Self::Etf),
67            _ => None,
68        }
69    }
70}
71
72/// Number of screener rows to request.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
74pub struct ScreenerCount(u32);
75
76impl ScreenerCount {
77    /// Maximum count Yahoo accepts.
78    pub const MAX: u32 = 250;
79
80    pub(crate) const DEFAULT: Self = Self(25);
81
82    /// Builds a validated screener count.
83    ///
84    /// # Errors
85    ///
86    /// Returns `YfError::InvalidParams` if the count is zero or greater than
87    /// Yahoo's maximum of 250.
88    pub fn new(value: u32) -> Result<Self, YfError> {
89        if value == 0 || value > Self::MAX {
90            return Err(YfError::InvalidParams(format!(
91                "screener count must be between 1 and {}",
92                Self::MAX
93            )));
94        }
95        Ok(Self(value))
96    }
97
98    pub(crate) const fn get(self) -> u32 {
99        self.0
100    }
101}
102
103/// Nonnegative result offset.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
105pub struct ResultOffset(u32);
106
107impl ResultOffset {
108    pub(crate) const ZERO: Self = Self(0);
109
110    /// Builds an offset from an unsigned value.
111    #[must_use]
112    pub const fn new(value: u32) -> Self {
113        Self(value)
114    }
115
116    /// Builds an offset from a signed value.
117    ///
118    /// # Errors
119    ///
120    /// Returns `YfError::InvalidParams` for negative values or values too large
121    /// for `u32`.
122    pub fn try_from_i64(value: i64) -> Result<Self, YfError> {
123        let value = u32::try_from(value)
124            .map_err(|_| YfError::InvalidParams("screener offset must be nonnegative".into()))?;
125        Ok(Self(value))
126    }
127
128    pub(crate) const fn get(self) -> u32 {
129        self.0
130    }
131}
132
133/// A finite numeric filter value.
134///
135/// Use [`ScreenerNumber::new`] for floating-point values. Integer values can be
136/// passed directly to numeric field builders.
137#[derive(Clone, Copy, PartialEq)]
138pub struct ScreenerNumber(ScreenerNumberKind);
139
140#[derive(Debug, Clone, Copy, PartialEq)]
141enum ScreenerNumberKind {
142    Float(FiniteF64),
143    Unsigned(u64),
144    Signed(i64),
145}
146
147#[derive(Debug, Clone, Copy, PartialEq)]
148struct FiniteF64(f64);
149
150impl FiniteF64 {
151    fn new(value: f64) -> Result<Self, YfError> {
152        if !value.is_finite() {
153            return Err(YfError::InvalidParams(
154                "screener numeric value must be finite".into(),
155            ));
156        }
157
158        Ok(Self(value))
159    }
160
161    fn into_json_number(self) -> serde_json::Number {
162        serde_json::Number::from_f64(self.0).expect("FiniteF64 stores finite values")
163    }
164}
165
166impl ScreenerNumber {
167    /// Builds a finite screener number.
168    ///
169    /// # Errors
170    ///
171    /// Returns `YfError::InvalidParams` for `NaN` or infinite values.
172    pub fn new(value: f64) -> Result<Self, YfError> {
173        Ok(Self(ScreenerNumberKind::Float(FiniteF64::new(value)?)))
174    }
175
176    fn to_value(self) -> Value {
177        match self.0 {
178            ScreenerNumberKind::Float(value) => Value::Number(value.into_json_number()),
179            ScreenerNumberKind::Unsigned(value) => Value::Number(serde_json::Number::from(value)),
180            ScreenerNumberKind::Signed(value) => Value::Number(serde_json::Number::from(value)),
181        }
182    }
183}
184
185impl fmt::Debug for ScreenerNumber {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        match self.0 {
188            ScreenerNumberKind::Float(value) => f.debug_tuple("Float").field(&value.0).finish(),
189            ScreenerNumberKind::Unsigned(value) => f.debug_tuple("Unsigned").field(&value).finish(),
190            ScreenerNumberKind::Signed(value) => f.debug_tuple("Signed").field(&value).finish(),
191        }
192    }
193}
194
195impl From<u32> for ScreenerNumber {
196    fn from(value: u32) -> Self {
197        Self(ScreenerNumberKind::Unsigned(u64::from(value)))
198    }
199}
200
201impl From<i32> for ScreenerNumber {
202    fn from(value: i32) -> Self {
203        Self(ScreenerNumberKind::Signed(i64::from(value)))
204    }
205}
206
207impl From<u64> for ScreenerNumber {
208    fn from(value: u64) -> Self {
209        Self(ScreenerNumberKind::Unsigned(value))
210    }
211}
212
213impl From<i64> for ScreenerNumber {
214    fn from(value: i64) -> Self {
215        Self(ScreenerNumberKind::Signed(value))
216    }
217}
218
219/// Percent-point filter value.
220///
221/// Yahoo screener percent fields expect `3` for 3%, not `0.03`.
222#[derive(Debug, Clone, Copy, PartialEq)]
223pub struct PercentPoints(ScreenerNumber);
224
225impl PercentPoints {
226    /// Builds a finite percent-point value.
227    ///
228    /// # Errors
229    ///
230    /// Returns `YfError::InvalidParams` for `NaN` or infinite values.
231    pub fn new(value: f64) -> Result<Self, YfError> {
232        Ok(Self(ScreenerNumber::new(value)?))
233    }
234}
235
236/// Sort direction for custom screener POST bodies.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
238pub enum SortDirection {
239    /// Ascending sort order.
240    Asc,
241    /// Descending sort order.
242    Desc,
243}
244
245impl SortDirection {
246    pub(crate) const fn as_str(self) -> &'static str {
247        match self {
248            Self::Asc => "ASC",
249            Self::Desc => "DESC",
250        }
251    }
252}
253
254/// Yahoo screener region code.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
256pub enum Region {
257    /// United States.
258    Us,
259}
260
261impl Region {
262    const fn as_query_value(self) -> &'static str {
263        match self {
264            Self::Us => "us",
265        }
266    }
267}
268
269/// Yahoo exchange code used by screener filters.
270#[non_exhaustive]
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
272pub enum YahooExchangeCode {
273    /// NASDAQ Global Select.
274    #[serde(rename = "NMS")]
275    Nms,
276    /// NASDAQ Global Market.
277    #[serde(rename = "NGM")]
278    Ngm,
279    /// NASDAQ Capital Market.
280    #[serde(rename = "NCM")]
281    Ncm,
282    /// NYSE.
283    #[serde(rename = "NYQ")]
284    Nyq,
285    /// NASDAQ mutual fund exchange code.
286    #[serde(rename = "NAS")]
287    Nas,
288    /// NYSE American.
289    #[serde(rename = "ASE")]
290    Ase,
291    /// BATS.
292    #[serde(rename = "BTS")]
293    Bts,
294    /// NYSE Arca.
295    #[serde(rename = "PCX")]
296    Pcx,
297    /// OTC Pink.
298    #[serde(rename = "PNK")]
299    Pnk,
300    /// OTCQB.
301    #[serde(rename = "OQB")]
302    Oqb,
303    /// OTCQX.
304    #[serde(rename = "OQX")]
305    Oqx,
306    /// Other OTC market.
307    #[serde(rename = "OEM")]
308    Oem,
309    /// Yahoo YHD venue code.
310    #[serde(rename = "YHD")]
311    Yhd,
312    /// Yahoo CXI venue code.
313    #[serde(rename = "CXI")]
314    Cxi,
315    /// Yahoo NAE venue code.
316    #[serde(rename = "NAE")]
317    Nae,
318    /// OTC Global Market.
319    #[serde(rename = "OGM")]
320    Ogm,
321    /// WCB venue code.
322    #[serde(rename = "WCB")]
323    Wcb,
324}
325
326impl YahooExchangeCode {
327    /// Yahoo wire code.
328    #[must_use]
329    pub const fn code(self) -> &'static str {
330        match self {
331            Self::Nms => "NMS",
332            Self::Ngm => "NGM",
333            Self::Ncm => "NCM",
334            Self::Nyq => "NYQ",
335            Self::Nas => "NAS",
336            Self::Ase => "ASE",
337            Self::Bts => "BTS",
338            Self::Pcx => "PCX",
339            Self::Pnk => "PNK",
340            Self::Oqb => "OQB",
341            Self::Oqx => "OQX",
342            Self::Oem => "OEM",
343            Self::Yhd => "YHD",
344            Self::Cxi => "CXI",
345            Self::Nae => "NAE",
346            Self::Ogm => "OGM",
347            Self::Wcb => "WCB",
348        }
349    }
350
351    pub(crate) fn parse(value: &str) -> Option<Self> {
352        match value {
353            "NMS" => Some(Self::Nms),
354            "NGM" => Some(Self::Ngm),
355            "NCM" => Some(Self::Ncm),
356            "NYQ" => Some(Self::Nyq),
357            "NAS" => Some(Self::Nas),
358            "ASE" => Some(Self::Ase),
359            "BTS" => Some(Self::Bts),
360            "PCX" => Some(Self::Pcx),
361            "PNK" => Some(Self::Pnk),
362            "OQB" => Some(Self::Oqb),
363            "OQX" => Some(Self::Oqx),
364            "OEM" => Some(Self::Oem),
365            "YHD" => Some(Self::Yhd),
366            "CXI" => Some(Self::Cxi),
367            "NAE" => Some(Self::Nae),
368            "OGM" => Some(Self::Ogm),
369            "WCB" => Some(Self::Wcb),
370            _ => None,
371        }
372    }
373}
374
375/// Equity sector values supported by Yahoo screeners.
376#[non_exhaustive]
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
378pub enum EquitySector {
379    /// Basic materials.
380    BasicMaterials,
381    /// Industrials.
382    Industrials,
383    /// Communication services.
384    CommunicationServices,
385    /// Healthcare.
386    Healthcare,
387    /// Real estate.
388    RealEstate,
389    /// Technology.
390    Technology,
391    /// Energy.
392    Energy,
393    /// Utilities.
394    Utilities,
395    /// Financial services.
396    FinancialServices,
397    /// Consumer defensive.
398    ConsumerDefensive,
399    /// Consumer cyclical.
400    ConsumerCyclical,
401}
402
403impl EquitySector {
404    const fn as_str(self) -> &'static str {
405        match self {
406            Self::BasicMaterials => "Basic Materials",
407            Self::Industrials => "Industrials",
408            Self::CommunicationServices => "Communication Services",
409            Self::Healthcare => "Healthcare",
410            Self::RealEstate => "Real Estate",
411            Self::Technology => "Technology",
412            Self::Energy => "Energy",
413            Self::Utilities => "Utilities",
414            Self::FinancialServices => "Financial Services",
415            Self::ConsumerDefensive => "Consumer Defensive",
416            Self::ConsumerCyclical => "Consumer Cyclical",
417        }
418    }
419}
420
421/// Mutual fund category values currently supported by the typed API.
422#[non_exhaustive]
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
424pub enum FundCategory {
425    /// Foreign large value.
426    ForeignLargeValue,
427    /// Foreign large blend.
428    ForeignLargeBlend,
429    /// Foreign large growth.
430    ForeignLargeGrowth,
431    /// Foreign small/mid growth.
432    ForeignSmallMidGrowth,
433    /// Foreign small/mid blend.
434    ForeignSmallMidBlend,
435    /// Foreign small/mid value.
436    ForeignSmallMidValue,
437    /// High yield bond.
438    HighYieldBond,
439    /// Large blend.
440    LargeBlend,
441    /// Large growth.
442    LargeGrowth,
443    /// Mid-cap growth.
444    MidCapGrowth,
445}
446
447impl FundCategory {
448    const fn as_str(self) -> &'static str {
449        match self {
450            Self::ForeignLargeValue => "Foreign Large Value",
451            Self::ForeignLargeBlend => "Foreign Large Blend",
452            Self::ForeignLargeGrowth => "Foreign Large Growth",
453            Self::ForeignSmallMidGrowth => "Foreign Small/Mid Growth",
454            Self::ForeignSmallMidBlend => "Foreign Small/Mid Blend",
455            Self::ForeignSmallMidValue => "Foreign Small/Mid Value",
456            Self::HighYieldBond => "High Yield Bond",
457            Self::LargeBlend => "Large Blend",
458            Self::LargeGrowth => "Large Growth",
459            Self::MidCapGrowth => "Mid-Cap Growth",
460        }
461    }
462}
463
464/// ETF category values currently supported by the typed API.
465#[non_exhaustive]
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
467pub enum EtfCategory {
468    /// Technology.
469    Technology,
470    /// Corporate bond.
471    CorporateBond,
472    /// Emerging markets bond.
473    EmergingMarketsBond,
474    /// Emerging-markets local-currency bond.
475    EmergingMarketsLocalCurrencyBond,
476    /// High yield bond.
477    HighYieldBond,
478    /// Intermediate-term bond.
479    IntermediateTermBond,
480    /// Long-term bond.
481    LongTermBond,
482    /// Inflation-protected bond.
483    InflationProtectedBond,
484    /// Multisector bond.
485    MultisectorBond,
486    /// Nontraditional bond.
487    NontraditionalBond,
488    /// Short-term bond.
489    ShortTermBond,
490    /// Ultrashort bond.
491    UltrashortBond,
492    /// World bond.
493    WorldBond,
494}
495
496impl EtfCategory {
497    const fn as_str(self) -> &'static str {
498        match self {
499            Self::Technology => "Technology",
500            Self::CorporateBond => "Corporate Bond",
501            Self::EmergingMarketsBond => "Emerging Markets Bond",
502            Self::EmergingMarketsLocalCurrencyBond => "Emerging-Markets Local-Currency Bond",
503            Self::HighYieldBond => "High Yield Bond",
504            Self::IntermediateTermBond => "Intermediate-Term Bond",
505            Self::LongTermBond => "Long-Term Bond",
506            Self::InflationProtectedBond => "Inflation-Protected Bond",
507            Self::MultisectorBond => "Multisector Bond",
508            Self::NontraditionalBond => "Nontraditional Bond",
509            Self::ShortTermBond => "Short-Term Bond",
510            Self::UltrashortBond => "Ultrashort Bond",
511            Self::WorldBond => "World Bond",
512        }
513    }
514}
515
516/// Yahoo fund performance or risk rating.
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
518pub enum Rating {
519    /// One-star rating.
520    One,
521    /// Two-star rating.
522    Two,
523    /// Three-star rating.
524    Three,
525    /// Four-star rating.
526    Four,
527    /// Five-star rating.
528    Five,
529}
530
531impl Rating {
532    const fn value(self) -> u8 {
533        match self {
534            Self::One => 1,
535            Self::Two => 2,
536            Self::Three => 3,
537            Self::Four => 4,
538            Self::Five => 5,
539        }
540    }
541}
542
543mod sealed {
544    pub trait Sealed {}
545}
546
547/// Sealed trait for values that can be used with typed enum screener fields.
548pub trait ScreenerValue: Copy + sealed::Sealed {
549    /// Converts this value into Yahoo's screener wire representation.
550    #[doc(hidden)]
551    fn to_wire_value(self) -> Value;
552}
553
554macro_rules! impl_screener_value {
555    ($type:ty, $body:expr) => {
556        impl sealed::Sealed for $type {}
557
558        impl ScreenerValue for $type {
559            fn to_wire_value(self) -> Value {
560                $body(self)
561            }
562        }
563    };
564}
565
566impl_screener_value!(ScreenerNumber, ScreenerNumber::to_value);
567impl_screener_value!(PercentPoints, |value: PercentPoints| value.0.to_value());
568impl_screener_value!(Region, |value: Region| {
569    Value::String(value.as_query_value().to_string())
570});
571impl_screener_value!(YahooExchangeCode, |value: YahooExchangeCode| {
572    Value::String(value.code().to_string())
573});
574impl_screener_value!(EquitySector, |value: EquitySector| {
575    Value::String(value.as_str().to_string())
576});
577impl_screener_value!(FundCategory, |value: FundCategory| {
578    Value::String(value.as_str().to_string())
579});
580impl_screener_value!(EtfCategory, |value: EtfCategory| {
581    Value::String(value.as_str().to_string())
582});
583impl_screener_value!(Rating, |value: Rating| {
584    Value::Number(serde_json::Number::from(value.value()))
585});
586
587/// Typed numeric screener field.
588#[derive(Debug, Clone, Copy)]
589pub struct NumericField<U> {
590    key: &'static str,
591    marker: PhantomData<U>,
592}
593
594impl<U> NumericField<U> {
595    pub(crate) const fn new(key: &'static str) -> Self {
596        Self {
597            key,
598            marker: PhantomData,
599        }
600    }
601
602    /// Builds an equality condition.
603    #[must_use]
604    pub fn eq<N: Into<ScreenerNumber>>(self, value: N) -> ScreenerQuery<U> {
605        ScreenerQuery::comparison("EQ", self.key, value.into().to_value())
606    }
607
608    /// Builds a one-of condition.
609    ///
610    /// # Errors
611    ///
612    /// Returns `YfError::InvalidParams` if no values are provided.
613    pub fn one_of<I, N>(self, values: I) -> Result<ScreenerQuery<U>, YfError>
614    where
615        I: IntoIterator<Item = N>,
616        N: Into<ScreenerNumber>,
617    {
618        ScreenerQuery::one_of_values(self.key, values.into_iter().map(|v| v.into().to_value()))
619    }
620
621    /// Builds a greater-than condition.
622    #[must_use]
623    pub fn gt<N: Into<ScreenerNumber>>(self, value: N) -> ScreenerQuery<U> {
624        ScreenerQuery::comparison("GT", self.key, value.into().to_value())
625    }
626
627    /// Builds a greater-than-or-equal condition.
628    #[must_use]
629    pub fn gte<N: Into<ScreenerNumber>>(self, value: N) -> ScreenerQuery<U> {
630        ScreenerQuery::comparison("GTE", self.key, value.into().to_value())
631    }
632
633    /// Builds a less-than condition.
634    #[must_use]
635    pub fn lt<N: Into<ScreenerNumber>>(self, value: N) -> ScreenerQuery<U> {
636        ScreenerQuery::comparison("LT", self.key, value.into().to_value())
637    }
638
639    /// Builds a less-than-or-equal condition.
640    #[must_use]
641    pub fn lte<N: Into<ScreenerNumber>>(self, value: N) -> ScreenerQuery<U> {
642        ScreenerQuery::comparison("LTE", self.key, value.into().to_value())
643    }
644
645    /// Builds a between condition.
646    #[must_use]
647    pub fn between<N1, N2>(self, lower: N1, upper: N2) -> ScreenerQuery<U>
648    where
649        N1: Into<ScreenerNumber>,
650        N2: Into<ScreenerNumber>,
651    {
652        ScreenerQuery::comparison(
653            "BTWN",
654            self.key,
655            json!([lower.into().to_value(), upper.into().to_value()]),
656        )
657    }
658}
659
660/// Typed percent-point screener field.
661#[derive(Debug, Clone, Copy)]
662pub struct PercentField<U> {
663    key: &'static str,
664    marker: PhantomData<U>,
665}
666
667impl<U> PercentField<U> {
668    pub(crate) const fn new(key: &'static str) -> Self {
669        Self {
670            key,
671            marker: PhantomData,
672        }
673    }
674
675    /// Builds a greater-than condition.
676    #[must_use]
677    pub fn gt(self, value: PercentPoints) -> ScreenerQuery<U> {
678        ScreenerQuery::comparison("GT", self.key, value.to_wire_value())
679    }
680
681    /// Builds a greater-than-or-equal condition.
682    #[must_use]
683    pub fn gte(self, value: PercentPoints) -> ScreenerQuery<U> {
684        ScreenerQuery::comparison("GTE", self.key, value.to_wire_value())
685    }
686
687    /// Builds a less-than condition.
688    #[must_use]
689    pub fn lt(self, value: PercentPoints) -> ScreenerQuery<U> {
690        ScreenerQuery::comparison("LT", self.key, value.to_wire_value())
691    }
692
693    /// Builds a less-than-or-equal condition.
694    #[must_use]
695    pub fn lte(self, value: PercentPoints) -> ScreenerQuery<U> {
696        ScreenerQuery::comparison("LTE", self.key, value.to_wire_value())
697    }
698}
699
700/// Typed enum-like screener field.
701#[derive(Debug, Clone, Copy)]
702pub struct EnumField<U, V> {
703    key: &'static str,
704    marker: PhantomData<(U, V)>,
705}
706
707impl<U, V> EnumField<U, V>
708where
709    V: ScreenerValue,
710{
711    pub(crate) const fn new(key: &'static str) -> Self {
712        Self {
713            key,
714            marker: PhantomData,
715        }
716    }
717
718    /// Builds an equality condition.
719    #[must_use]
720    pub fn eq(self, value: V) -> ScreenerQuery<U> {
721        ScreenerQuery::comparison("EQ", self.key, value.to_wire_value())
722    }
723
724    /// Builds a one-of condition.
725    ///
726    /// # Errors
727    ///
728    /// Returns `YfError::InvalidParams` if no values are provided.
729    pub fn one_of<I>(self, values: I) -> Result<ScreenerQuery<U>, YfError>
730    where
731        I: IntoIterator<Item = V>,
732    {
733        ScreenerQuery::one_of_values(
734            self.key,
735            values.into_iter().map(ScreenerValue::to_wire_value),
736        )
737    }
738}
739
740/// Typed sort field.
741#[derive(Debug, Clone, Copy)]
742pub struct SortField<U> {
743    key: &'static str,
744    marker: PhantomData<U>,
745}
746
747impl<U> SortField<U> {
748    pub(crate) const fn new(key: &'static str) -> Self {
749        Self {
750            key,
751            marker: PhantomData,
752        }
753    }
754
755    pub(crate) const fn key(self) -> &'static str {
756        self.key
757    }
758}
759
760#[derive(Debug, Clone)]
761enum QueryNode {
762    Comparison {
763        operator: &'static str,
764        field: &'static str,
765        values: Vec<Value>,
766    },
767    Logical {
768        operator: &'static str,
769        operands: Vec<Self>,
770    },
771}
772
773impl QueryNode {
774    fn to_wire_value(&self) -> Value {
775        match self {
776            Self::Comparison {
777                operator,
778                field,
779                values,
780            } => {
781                let mut operands = Vec::with_capacity(values.len() + 1);
782                operands.push(Value::String((*field).to_string()));
783                operands.extend(values.iter().cloned());
784                json!({
785                    "operator": *operator,
786                    "operands": operands,
787                })
788            }
789            Self::Logical { operator, operands } => json!({
790                "operator": *operator,
791                "operands": operands.iter().map(Self::to_wire_value).collect::<Vec<_>>(),
792            }),
793        }
794    }
795}
796
797/// Strongly typed Yahoo screener query.
798#[derive(Debug, Clone)]
799pub struct ScreenerQuery<U> {
800    node: QueryNode,
801    marker: PhantomData<U>,
802}
803
804impl<U> ScreenerQuery<U> {
805    fn comparison(operator: &'static str, field: &'static str, value: Value) -> Self {
806        let values = match (operator, value) {
807            ("BTWN", Value::Array(values)) => values,
808            (_, value) => vec![value],
809        };
810
811        Self {
812            node: QueryNode::Comparison {
813                operator,
814                field,
815                values,
816            },
817            marker: PhantomData,
818        }
819    }
820
821    fn one_of_values<I>(field: &'static str, values: I) -> Result<Self, YfError>
822    where
823        I: IntoIterator<Item = Value>,
824    {
825        let operands = values
826            .into_iter()
827            .map(|value| QueryNode::Comparison {
828                operator: "EQ",
829                field,
830                values: vec![value],
831            })
832            .collect::<Vec<_>>();
833
834        if operands.is_empty() {
835            return Err(YfError::InvalidParams(
836                "one_of requires at least one value".into(),
837            ));
838        }
839
840        Ok(Self {
841            node: QueryNode::Logical {
842                operator: "OR",
843                operands,
844            },
845            marker: PhantomData,
846        })
847    }
848
849    /// Combines queries with logical AND.
850    ///
851    /// # Errors
852    ///
853    /// Returns `YfError::InvalidParams` unless at least two child queries are
854    /// provided.
855    pub fn and<I>(queries: I) -> Result<Self, YfError>
856    where
857        I: IntoIterator<Item = Self>,
858    {
859        Self::logical("AND", queries)
860    }
861
862    /// Combines queries with logical OR.
863    ///
864    /// # Errors
865    ///
866    /// Returns `YfError::InvalidParams` unless at least two child queries are
867    /// provided.
868    pub fn or<I>(queries: I) -> Result<Self, YfError>
869    where
870        I: IntoIterator<Item = Self>,
871    {
872        Self::logical("OR", queries)
873    }
874
875    fn logical<I>(operator: &'static str, queries: I) -> Result<Self, YfError>
876    where
877        I: IntoIterator<Item = Self>,
878    {
879        let operands = queries.into_iter().map(|q| q.node).collect::<Vec<_>>();
880
881        if operands.len() <= 1 {
882            return Err(YfError::InvalidParams(format!(
883                "{operator} requires at least two operands"
884            )));
885        }
886
887        Ok(Self {
888            node: QueryNode::Logical { operator, operands },
889            marker: PhantomData,
890        })
891    }
892
893    pub(crate) fn into_wire_value(self) -> Value {
894        self.node.to_wire_value()
895    }
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use crate::screener::equity_fields;
902
903    #[test]
904    fn one_of_expands_to_or_of_eq_filters() {
905        let query = equity_fields::EXCHANGE
906            .one_of([YahooExchangeCode::Nms, YahooExchangeCode::Nyq])
907            .unwrap();
908
909        assert_eq!(
910            query.into_wire_value(),
911            json!({
912                "operator": "OR",
913                "operands": [
914                    {"operator": "EQ", "operands": ["exchange", "NMS"]},
915                    {"operator": "EQ", "operands": ["exchange", "NYQ"]}
916                ]
917            })
918        );
919    }
920
921    #[test]
922    fn and_rejects_empty_or_single_operand() {
923        assert!(EquityQuery::and(Vec::<EquityQuery>::new()).is_err());
924        assert!(EquityQuery::and([equity_fields::INTRADAY_PRICE.gt(1)]).is_err());
925    }
926
927    #[test]
928    fn screener_number_serializes_validated_float() {
929        let query = equity_fields::INTRADAY_PRICE.gt(ScreenerNumber::new(12.5).unwrap());
930
931        assert_eq!(
932            query.into_wire_value(),
933            json!({
934                "operator": "GT",
935                "operands": ["intradayprice", 12.5]
936            })
937        );
938    }
939
940    #[test]
941    fn bounded_values_reject_invalid_inputs() {
942        assert!(ScreenerCount::new(0).is_err());
943        assert!(ScreenerCount::new(251).is_err());
944        assert!(ScreenerNumber::new(f64::NAN).is_err());
945        assert!(ScreenerNumber::new(f64::INFINITY).is_err());
946        assert!(ResultOffset::try_from_i64(-1).is_err());
947    }
948}