use super::quote::ScreenerQuote;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
struct RawScreenersResponse {
finance: RawFinance,
}
#[derive(Debug, Clone, Deserialize)]
struct RawFinance {
result: Vec<RawResult>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawResult {
canonical_name: String,
quotes: Vec<ScreenerQuote>,
#[serde(default)]
last_updated: Option<i64>,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct RawCustomScreenerResponse {
finance: RawCustomFinance,
}
#[derive(Debug, Clone, Deserialize)]
struct RawCustomFinance {
result: Option<Vec<RawCustomResult>>,
error: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawCustomResult {
#[serde(default)]
total: Option<i64>,
#[serde(default)]
records: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenerResults {
pub quotes: Vec<ScreenerQuote>,
#[serde(rename = "type")]
pub screener_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_updated: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<i64>,
}
impl ScreenerResults {
pub(crate) fn from_response(raw: &serde_json::Value) -> Result<Self, String> {
let raw_response: RawScreenersResponse = serde_json::from_value(raw.clone())
.map_err(|e| format!("Failed to parse screener response: {}", e))?;
let result = raw_response
.finance
.result
.into_iter()
.next()
.ok_or_else(|| "No screener data in response".to_string())?;
Ok(Self {
quotes: result.quotes,
screener_type: result.canonical_name,
description: result.description,
last_updated: result.last_updated,
total: None, })
}
pub(crate) fn from_custom_response(raw: &serde_json::Value) -> Result<Self, String> {
let raw_response: RawCustomScreenerResponse = serde_json::from_value(raw.clone())
.map_err(|e| format!("Failed to parse custom screener response: {}", e))?;
if let Some(err) = raw_response.finance.error {
return Err(format!("Yahoo Finance error: {}", err));
}
let results = raw_response
.finance
.result
.ok_or_else(|| "No result in response".to_string())?;
let result = results
.first()
.ok_or_else(|| "No screener data in response".to_string())?;
let quotes: Vec<ScreenerQuote> = result
.records
.iter()
.filter_map(|record| map_custom_record_to_quote(record).ok())
.collect();
Ok(Self {
quotes,
screener_type: "custom".to_string(),
description: Some("Custom screener query results".to_string()),
last_updated: None,
total: result.total,
})
}
}
#[cfg(feature = "dataframe")]
impl ScreenerResults {
pub fn to_dataframe(&self) -> ::polars::prelude::PolarsResult<::polars::prelude::DataFrame> {
ScreenerQuote::vec_to_dataframe(&self.quotes)
}
}
fn map_custom_record_to_quote(record: &serde_json::Value) -> Result<ScreenerQuote, String> {
use crate::models::quote::FormattedValue;
fn extract_formatted<T: serde::de::DeserializeOwned + Default>(
record: &serde_json::Value,
field: &str,
) -> Option<FormattedValue<T>> {
record.get(field).and_then(|v| {
if v.is_object() {
serde_json::from_value(v.clone()).ok()
} else {
serde_json::from_value::<T>(v.clone())
.ok()
.map(|raw| FormattedValue {
raw: Some(raw),
fmt: None,
long_fmt: None,
})
}
})
}
fn extract_string(record: &serde_json::Value, field: &str) -> Option<String> {
record
.get(field)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
let symbol = extract_string(record, "ticker")
.or_else(|| extract_string(record, "symbol"))
.ok_or_else(|| "Missing symbol/ticker field".to_string())?;
let short_name = extract_string(record, "companyshortname")
.or_else(|| extract_string(record, "shortName"))
.unwrap_or_else(|| symbol.clone());
let regular_market_price = extract_formatted::<f64>(record, "intradayprice")
.or_else(|| extract_formatted::<f64>(record, "regularMarketPrice"))
.unwrap_or_default();
let regular_market_change = extract_formatted::<f64>(record, "intradaypricechange")
.or_else(|| extract_formatted::<f64>(record, "regularMarketChange"))
.unwrap_or_default();
let regular_market_change_percent = extract_formatted::<f64>(record, "percentchange")
.or_else(|| extract_formatted::<f64>(record, "regularMarketChangePercent"))
.unwrap_or_default();
Ok(ScreenerQuote {
symbol,
short_name,
long_name: extract_string(record, "longName"),
display_name: extract_string(record, "displayName"),
quote_type: extract_string(record, "quoteType").unwrap_or_else(|| "EQUITY".to_string()),
exchange: extract_string(record, "exchange").unwrap_or_default(),
regular_market_price,
regular_market_change,
regular_market_change_percent,
regular_market_open: extract_formatted(record, "day_open_price")
.or_else(|| extract_formatted(record, "regularMarketOpen")),
regular_market_day_high: extract_formatted(record, "dayhigh")
.or_else(|| extract_formatted(record, "regularMarketDayHigh")),
regular_market_day_low: extract_formatted(record, "daylow")
.or_else(|| extract_formatted(record, "regularMarketDayLow")),
regular_market_previous_close: extract_formatted(record, "regularMarketPreviousClose"),
regular_market_time: extract_formatted(record, "regularMarketTime"),
regular_market_volume: extract_formatted(record, "dayvolume")
.or_else(|| extract_formatted(record, "regularMarketVolume")),
average_daily_volume3_month: extract_formatted(record, "avgdailyvol3m")
.or_else(|| extract_formatted(record, "averageDailyVolume3Month")),
average_daily_volume10_day: extract_formatted(record, "averageDailyVolume10Day"),
market_cap: extract_formatted(record, "intradaymarketcap")
.or_else(|| extract_formatted(record, "marketCap")),
shares_outstanding: extract_formatted(record, "sharesOutstanding"),
fifty_two_week_high: extract_formatted(record, "fiftytwowkhigh")
.or_else(|| extract_formatted(record, "fiftyTwoWeekHigh")),
fifty_two_week_low: extract_formatted(record, "fiftytwowklow")
.or_else(|| extract_formatted(record, "fiftyTwoWeekLow")),
fifty_two_week_change: extract_formatted(record, "fiftyTwoWeekChange"),
fifty_two_week_change_percent: extract_formatted(record, "fiftyTwoWeekChangePercent"),
fifty_day_average: extract_formatted(record, "fiftyDayAverage"),
fifty_day_average_change: extract_formatted(record, "fiftyDayAverageChange"),
fifty_day_average_change_percent: extract_formatted(record, "fiftyDayAverageChangePercent"),
two_hundred_day_average: extract_formatted(record, "twoHundredDayAverage"),
two_hundred_day_average_change: extract_formatted(record, "twoHundredDayAverageChange"),
two_hundred_day_average_change_percent: extract_formatted(
record,
"twoHundredDayAverageChangePercent",
),
average_analyst_rating: extract_string(record, "averageAnalystRating"),
trailing_pe: extract_formatted::<f64>(record, "peratio.lasttwelvemonths")
.or_else(|| extract_formatted(record, "trailingPE")),
forward_pe: extract_formatted(record, "forwardPE"),
price_to_book: extract_formatted(record, "priceToBook"),
book_value: extract_formatted(record, "bookValue"),
eps_trailing_twelve_months: extract_formatted::<f64>(record, "eps.lasttwelvemonths")
.or_else(|| extract_formatted(record, "epsTrailingTwelveMonths")),
eps_forward: extract_formatted(record, "epsForward"),
eps_current_year: extract_formatted(record, "epsCurrentYear"),
price_eps_current_year: extract_formatted(record, "priceEpsCurrentYear"),
dividend_yield: extract_formatted::<f64>(record, "annual_dividend_yield")
.or_else(|| extract_formatted(record, "dividendYield")),
dividend_rate: extract_formatted::<f64>(record, "annual_dividend_rate")
.or_else(|| extract_formatted(record, "dividendRate")),
dividend_date: extract_formatted(record, "dividendDate"),
trailing_annual_dividend_rate: extract_formatted(record, "trailingAnnualDividendRate"),
trailing_annual_dividend_yield: extract_formatted(record, "trailingAnnualDividendYield"),
bid: extract_formatted(record, "bid"),
bid_size: extract_formatted(record, "bidSize"),
ask: extract_formatted(record, "ask"),
ask_size: extract_formatted(record, "askSize"),
post_market_price: extract_formatted(record, "postMarketPrice"),
post_market_change: extract_formatted(record, "postMarketChange"),
post_market_change_percent: extract_formatted(record, "postMarketChangePercent"),
post_market_time: extract_formatted(record, "postMarketTime"),
pre_market_price: extract_formatted(record, "preMarketPrice"),
pre_market_change: extract_formatted(record, "preMarketChange"),
pre_market_change_percent: extract_formatted(record, "preMarketChangePercent"),
pre_market_time: extract_formatted(record, "preMarketTime"),
earnings_timestamp: extract_formatted(record, "earningsTimestamp"),
earnings_timestamp_start: extract_formatted(record, "earningsTimestampStart"),
earnings_timestamp_end: extract_formatted(record, "earningsTimestampEnd"),
currency: extract_string(record, "quotesCurrency")
.or_else(|| extract_string(record, "currency")),
})
}