use std::{fmt, marker::PhantomData};
use paft::domain::AssetKind;
use serde::Serialize;
use serde_json::{Value, json};
use crate::{YfError, core::yahoo_vocab::parse_yahoo_quote_type};
#[derive(Debug, Clone, Copy)]
pub struct Predefined;
#[derive(Debug, Clone, Copy)]
pub struct Equity;
#[derive(Debug, Clone, Copy)]
pub struct Fund;
#[derive(Debug, Clone, Copy)]
pub struct Etf;
pub type EquityQuery = ScreenerQuery<Equity>;
pub type FundQuery = ScreenerQuery<Fund>;
pub type EtfQuery = ScreenerQuery<Etf>;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
pub enum YahooQuoteType {
#[serde(rename = "EQUITY")]
Equity,
#[serde(rename = "MUTUALFUND")]
MutualFund,
#[serde(rename = "ETF")]
Etf,
}
impl YahooQuoteType {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Equity => "EQUITY",
Self::MutualFund => "MUTUALFUND",
Self::Etf => "ETF",
}
}
pub(crate) fn asset_kind(self) -> AssetKind {
parse_yahoo_quote_type(self.as_str()).expect("typed screener quote type is valid")
}
pub(crate) fn parse(value: &str) -> Option<Self> {
match value {
"EQUITY" => Some(Self::Equity),
"MUTUALFUND" => Some(Self::MutualFund),
"ETF" => Some(Self::Etf),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ScreenerCount(u32);
impl ScreenerCount {
pub const MAX: u32 = 250;
pub(crate) const DEFAULT: Self = Self(25);
pub fn new(value: u32) -> Result<Self, YfError> {
if value == 0 || value > Self::MAX {
return Err(YfError::InvalidParams(format!(
"screener count must be between 1 and {}",
Self::MAX
)));
}
Ok(Self(value))
}
pub(crate) const fn get(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ResultOffset(u32);
impl ResultOffset {
pub(crate) const ZERO: Self = Self(0);
#[must_use]
pub const fn new(value: u32) -> Self {
Self(value)
}
pub fn try_from_i64(value: i64) -> Result<Self, YfError> {
let value = u32::try_from(value)
.map_err(|_| YfError::InvalidParams("screener offset must be nonnegative".into()))?;
Ok(Self(value))
}
pub(crate) const fn get(self) -> u32 {
self.0
}
}
#[derive(Clone, Copy, PartialEq)]
pub struct ScreenerNumber(ScreenerNumberKind);
#[derive(Debug, Clone, Copy, PartialEq)]
enum ScreenerNumberKind {
Float(FiniteF64),
Unsigned(u64),
Signed(i64),
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct FiniteF64(f64);
impl FiniteF64 {
fn new(value: f64) -> Result<Self, YfError> {
if !value.is_finite() {
return Err(YfError::InvalidParams(
"screener numeric value must be finite".into(),
));
}
Ok(Self(value))
}
fn into_json_number(self) -> serde_json::Number {
serde_json::Number::from_f64(self.0).expect("FiniteF64 stores finite values")
}
}
impl ScreenerNumber {
pub fn new(value: f64) -> Result<Self, YfError> {
Ok(Self(ScreenerNumberKind::Float(FiniteF64::new(value)?)))
}
fn to_value(self) -> Value {
match self.0 {
ScreenerNumberKind::Float(value) => Value::Number(value.into_json_number()),
ScreenerNumberKind::Unsigned(value) => Value::Number(serde_json::Number::from(value)),
ScreenerNumberKind::Signed(value) => Value::Number(serde_json::Number::from(value)),
}
}
}
impl fmt::Debug for ScreenerNumber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
ScreenerNumberKind::Float(value) => f.debug_tuple("Float").field(&value.0).finish(),
ScreenerNumberKind::Unsigned(value) => f.debug_tuple("Unsigned").field(&value).finish(),
ScreenerNumberKind::Signed(value) => f.debug_tuple("Signed").field(&value).finish(),
}
}
}
impl From<u32> for ScreenerNumber {
fn from(value: u32) -> Self {
Self(ScreenerNumberKind::Unsigned(u64::from(value)))
}
}
impl From<i32> for ScreenerNumber {
fn from(value: i32) -> Self {
Self(ScreenerNumberKind::Signed(i64::from(value)))
}
}
impl From<u64> for ScreenerNumber {
fn from(value: u64) -> Self {
Self(ScreenerNumberKind::Unsigned(value))
}
}
impl From<i64> for ScreenerNumber {
fn from(value: i64) -> Self {
Self(ScreenerNumberKind::Signed(value))
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PercentPoints(ScreenerNumber);
impl PercentPoints {
pub fn new(value: f64) -> Result<Self, YfError> {
Ok(Self(ScreenerNumber::new(value)?))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SortDirection {
Asc,
Desc,
}
impl SortDirection {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Asc => "ASC",
Self::Desc => "DESC",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region {
Us,
}
impl Region {
const fn as_query_value(self) -> &'static str {
match self {
Self::Us => "us",
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
pub enum YahooExchangeCode {
#[serde(rename = "NMS")]
Nms,
#[serde(rename = "NGM")]
Ngm,
#[serde(rename = "NCM")]
Ncm,
#[serde(rename = "NYQ")]
Nyq,
#[serde(rename = "NAS")]
Nas,
#[serde(rename = "ASE")]
Ase,
#[serde(rename = "BTS")]
Bts,
#[serde(rename = "PCX")]
Pcx,
#[serde(rename = "PNK")]
Pnk,
#[serde(rename = "OQB")]
Oqb,
#[serde(rename = "OQX")]
Oqx,
#[serde(rename = "OEM")]
Oem,
#[serde(rename = "YHD")]
Yhd,
#[serde(rename = "CXI")]
Cxi,
#[serde(rename = "NAE")]
Nae,
#[serde(rename = "OGM")]
Ogm,
#[serde(rename = "WCB")]
Wcb,
}
impl YahooExchangeCode {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Nms => "NMS",
Self::Ngm => "NGM",
Self::Ncm => "NCM",
Self::Nyq => "NYQ",
Self::Nas => "NAS",
Self::Ase => "ASE",
Self::Bts => "BTS",
Self::Pcx => "PCX",
Self::Pnk => "PNK",
Self::Oqb => "OQB",
Self::Oqx => "OQX",
Self::Oem => "OEM",
Self::Yhd => "YHD",
Self::Cxi => "CXI",
Self::Nae => "NAE",
Self::Ogm => "OGM",
Self::Wcb => "WCB",
}
}
pub(crate) fn parse(value: &str) -> Option<Self> {
match value {
"NMS" => Some(Self::Nms),
"NGM" => Some(Self::Ngm),
"NCM" => Some(Self::Ncm),
"NYQ" => Some(Self::Nyq),
"NAS" => Some(Self::Nas),
"ASE" => Some(Self::Ase),
"BTS" => Some(Self::Bts),
"PCX" => Some(Self::Pcx),
"PNK" => Some(Self::Pnk),
"OQB" => Some(Self::Oqb),
"OQX" => Some(Self::Oqx),
"OEM" => Some(Self::Oem),
"YHD" => Some(Self::Yhd),
"CXI" => Some(Self::Cxi),
"NAE" => Some(Self::Nae),
"OGM" => Some(Self::Ogm),
"WCB" => Some(Self::Wcb),
_ => None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EquitySector {
BasicMaterials,
Industrials,
CommunicationServices,
Healthcare,
RealEstate,
Technology,
Energy,
Utilities,
FinancialServices,
ConsumerDefensive,
ConsumerCyclical,
}
impl EquitySector {
const fn as_str(self) -> &'static str {
match self {
Self::BasicMaterials => "Basic Materials",
Self::Industrials => "Industrials",
Self::CommunicationServices => "Communication Services",
Self::Healthcare => "Healthcare",
Self::RealEstate => "Real Estate",
Self::Technology => "Technology",
Self::Energy => "Energy",
Self::Utilities => "Utilities",
Self::FinancialServices => "Financial Services",
Self::ConsumerDefensive => "Consumer Defensive",
Self::ConsumerCyclical => "Consumer Cyclical",
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FundCategory {
ForeignLargeValue,
ForeignLargeBlend,
ForeignLargeGrowth,
ForeignSmallMidGrowth,
ForeignSmallMidBlend,
ForeignSmallMidValue,
HighYieldBond,
LargeBlend,
LargeGrowth,
MidCapGrowth,
}
impl FundCategory {
const fn as_str(self) -> &'static str {
match self {
Self::ForeignLargeValue => "Foreign Large Value",
Self::ForeignLargeBlend => "Foreign Large Blend",
Self::ForeignLargeGrowth => "Foreign Large Growth",
Self::ForeignSmallMidGrowth => "Foreign Small/Mid Growth",
Self::ForeignSmallMidBlend => "Foreign Small/Mid Blend",
Self::ForeignSmallMidValue => "Foreign Small/Mid Value",
Self::HighYieldBond => "High Yield Bond",
Self::LargeBlend => "Large Blend",
Self::LargeGrowth => "Large Growth",
Self::MidCapGrowth => "Mid-Cap Growth",
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EtfCategory {
Technology,
CorporateBond,
EmergingMarketsBond,
EmergingMarketsLocalCurrencyBond,
HighYieldBond,
IntermediateTermBond,
LongTermBond,
InflationProtectedBond,
MultisectorBond,
NontraditionalBond,
ShortTermBond,
UltrashortBond,
WorldBond,
}
impl EtfCategory {
const fn as_str(self) -> &'static str {
match self {
Self::Technology => "Technology",
Self::CorporateBond => "Corporate Bond",
Self::EmergingMarketsBond => "Emerging Markets Bond",
Self::EmergingMarketsLocalCurrencyBond => "Emerging-Markets Local-Currency Bond",
Self::HighYieldBond => "High Yield Bond",
Self::IntermediateTermBond => "Intermediate-Term Bond",
Self::LongTermBond => "Long-Term Bond",
Self::InflationProtectedBond => "Inflation-Protected Bond",
Self::MultisectorBond => "Multisector Bond",
Self::NontraditionalBond => "Nontraditional Bond",
Self::ShortTermBond => "Short-Term Bond",
Self::UltrashortBond => "Ultrashort Bond",
Self::WorldBond => "World Bond",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Rating {
One,
Two,
Three,
Four,
Five,
}
impl Rating {
const fn value(self) -> u8 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
Self::Five => 5,
}
}
}
mod sealed {
pub trait Sealed {}
}
pub trait ScreenerValue: Copy + sealed::Sealed {
#[doc(hidden)]
fn to_wire_value(self) -> Value;
}
macro_rules! impl_screener_value {
($type:ty, $body:expr) => {
impl sealed::Sealed for $type {}
impl ScreenerValue for $type {
fn to_wire_value(self) -> Value {
$body(self)
}
}
};
}
impl_screener_value!(ScreenerNumber, ScreenerNumber::to_value);
impl_screener_value!(PercentPoints, |value: PercentPoints| value.0.to_value());
impl_screener_value!(Region, |value: Region| {
Value::String(value.as_query_value().to_string())
});
impl_screener_value!(YahooExchangeCode, |value: YahooExchangeCode| {
Value::String(value.code().to_string())
});
impl_screener_value!(EquitySector, |value: EquitySector| {
Value::String(value.as_str().to_string())
});
impl_screener_value!(FundCategory, |value: FundCategory| {
Value::String(value.as_str().to_string())
});
impl_screener_value!(EtfCategory, |value: EtfCategory| {
Value::String(value.as_str().to_string())
});
impl_screener_value!(Rating, |value: Rating| {
Value::Number(serde_json::Number::from(value.value()))
});
#[derive(Debug, Clone, Copy)]
pub struct NumericField<U> {
key: &'static str,
marker: PhantomData<U>,
}
impl<U> NumericField<U> {
pub(crate) const fn new(key: &'static str) -> Self {
Self {
key,
marker: PhantomData,
}
}
#[must_use]
pub fn eq<N: Into<ScreenerNumber>>(self, value: N) -> ScreenerQuery<U> {
ScreenerQuery::comparison("EQ", self.key, value.into().to_value())
}
pub fn one_of<I, N>(self, values: I) -> Result<ScreenerQuery<U>, YfError>
where
I: IntoIterator<Item = N>,
N: Into<ScreenerNumber>,
{
ScreenerQuery::one_of_values(self.key, values.into_iter().map(|v| v.into().to_value()))
}
#[must_use]
pub fn gt<N: Into<ScreenerNumber>>(self, value: N) -> ScreenerQuery<U> {
ScreenerQuery::comparison("GT", self.key, value.into().to_value())
}
#[must_use]
pub fn gte<N: Into<ScreenerNumber>>(self, value: N) -> ScreenerQuery<U> {
ScreenerQuery::comparison("GTE", self.key, value.into().to_value())
}
#[must_use]
pub fn lt<N: Into<ScreenerNumber>>(self, value: N) -> ScreenerQuery<U> {
ScreenerQuery::comparison("LT", self.key, value.into().to_value())
}
#[must_use]
pub fn lte<N: Into<ScreenerNumber>>(self, value: N) -> ScreenerQuery<U> {
ScreenerQuery::comparison("LTE", self.key, value.into().to_value())
}
#[must_use]
pub fn between<N1, N2>(self, lower: N1, upper: N2) -> ScreenerQuery<U>
where
N1: Into<ScreenerNumber>,
N2: Into<ScreenerNumber>,
{
ScreenerQuery::comparison(
"BTWN",
self.key,
json!([lower.into().to_value(), upper.into().to_value()]),
)
}
}
#[derive(Debug, Clone, Copy)]
pub struct PercentField<U> {
key: &'static str,
marker: PhantomData<U>,
}
impl<U> PercentField<U> {
pub(crate) const fn new(key: &'static str) -> Self {
Self {
key,
marker: PhantomData,
}
}
#[must_use]
pub fn gt(self, value: PercentPoints) -> ScreenerQuery<U> {
ScreenerQuery::comparison("GT", self.key, value.to_wire_value())
}
#[must_use]
pub fn gte(self, value: PercentPoints) -> ScreenerQuery<U> {
ScreenerQuery::comparison("GTE", self.key, value.to_wire_value())
}
#[must_use]
pub fn lt(self, value: PercentPoints) -> ScreenerQuery<U> {
ScreenerQuery::comparison("LT", self.key, value.to_wire_value())
}
#[must_use]
pub fn lte(self, value: PercentPoints) -> ScreenerQuery<U> {
ScreenerQuery::comparison("LTE", self.key, value.to_wire_value())
}
}
#[derive(Debug, Clone, Copy)]
pub struct EnumField<U, V> {
key: &'static str,
marker: PhantomData<(U, V)>,
}
impl<U, V> EnumField<U, V>
where
V: ScreenerValue,
{
pub(crate) const fn new(key: &'static str) -> Self {
Self {
key,
marker: PhantomData,
}
}
#[must_use]
pub fn eq(self, value: V) -> ScreenerQuery<U> {
ScreenerQuery::comparison("EQ", self.key, value.to_wire_value())
}
pub fn one_of<I>(self, values: I) -> Result<ScreenerQuery<U>, YfError>
where
I: IntoIterator<Item = V>,
{
ScreenerQuery::one_of_values(
self.key,
values.into_iter().map(ScreenerValue::to_wire_value),
)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SortField<U> {
key: &'static str,
marker: PhantomData<U>,
}
impl<U> SortField<U> {
pub(crate) const fn new(key: &'static str) -> Self {
Self {
key,
marker: PhantomData,
}
}
pub(crate) const fn key(self) -> &'static str {
self.key
}
}
#[derive(Debug, Clone)]
enum QueryNode {
Comparison {
operator: &'static str,
field: &'static str,
values: Vec<Value>,
},
Logical {
operator: &'static str,
operands: Vec<Self>,
},
}
impl QueryNode {
fn to_wire_value(&self) -> Value {
match self {
Self::Comparison {
operator,
field,
values,
} => {
let mut operands = Vec::with_capacity(values.len() + 1);
operands.push(Value::String((*field).to_string()));
operands.extend(values.iter().cloned());
json!({
"operator": *operator,
"operands": operands,
})
}
Self::Logical { operator, operands } => json!({
"operator": *operator,
"operands": operands.iter().map(Self::to_wire_value).collect::<Vec<_>>(),
}),
}
}
}
#[derive(Debug, Clone)]
pub struct ScreenerQuery<U> {
node: QueryNode,
marker: PhantomData<U>,
}
impl<U> ScreenerQuery<U> {
fn comparison(operator: &'static str, field: &'static str, value: Value) -> Self {
let values = match (operator, value) {
("BTWN", Value::Array(values)) => values,
(_, value) => vec![value],
};
Self {
node: QueryNode::Comparison {
operator,
field,
values,
},
marker: PhantomData,
}
}
fn one_of_values<I>(field: &'static str, values: I) -> Result<Self, YfError>
where
I: IntoIterator<Item = Value>,
{
let operands = values
.into_iter()
.map(|value| QueryNode::Comparison {
operator: "EQ",
field,
values: vec![value],
})
.collect::<Vec<_>>();
if operands.is_empty() {
return Err(YfError::InvalidParams(
"one_of requires at least one value".into(),
));
}
Ok(Self {
node: QueryNode::Logical {
operator: "OR",
operands,
},
marker: PhantomData,
})
}
pub fn and<I>(queries: I) -> Result<Self, YfError>
where
I: IntoIterator<Item = Self>,
{
Self::logical("AND", queries)
}
pub fn or<I>(queries: I) -> Result<Self, YfError>
where
I: IntoIterator<Item = Self>,
{
Self::logical("OR", queries)
}
fn logical<I>(operator: &'static str, queries: I) -> Result<Self, YfError>
where
I: IntoIterator<Item = Self>,
{
let operands = queries.into_iter().map(|q| q.node).collect::<Vec<_>>();
if operands.len() <= 1 {
return Err(YfError::InvalidParams(format!(
"{operator} requires at least two operands"
)));
}
Ok(Self {
node: QueryNode::Logical { operator, operands },
marker: PhantomData,
})
}
pub(crate) fn into_wire_value(self) -> Value {
self.node.to_wire_value()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::screener::equity_fields;
#[test]
fn one_of_expands_to_or_of_eq_filters() {
let query = equity_fields::EXCHANGE
.one_of([YahooExchangeCode::Nms, YahooExchangeCode::Nyq])
.unwrap();
assert_eq!(
query.into_wire_value(),
json!({
"operator": "OR",
"operands": [
{"operator": "EQ", "operands": ["exchange", "NMS"]},
{"operator": "EQ", "operands": ["exchange", "NYQ"]}
]
})
);
}
#[test]
fn and_rejects_empty_or_single_operand() {
assert!(EquityQuery::and(Vec::<EquityQuery>::new()).is_err());
assert!(EquityQuery::and([equity_fields::INTRADAY_PRICE.gt(1)]).is_err());
}
#[test]
fn screener_number_serializes_validated_float() {
let query = equity_fields::INTRADAY_PRICE.gt(ScreenerNumber::new(12.5).unwrap());
assert_eq!(
query.into_wire_value(),
json!({
"operator": "GT",
"operands": ["intradayprice", 12.5]
})
);
}
#[test]
fn bounded_values_reject_invalid_inputs() {
assert!(ScreenerCount::new(0).is_err());
assert!(ScreenerCount::new(251).is_err());
assert!(ScreenerNumber::new(f64::NAN).is_err());
assert!(ScreenerNumber::new(f64::INFINITY).is_err());
assert!(ResultOffset::try_from_i64(-1).is_err());
}
}