Skip to main content

finance_query/models/screeners/
response.rs

1use super::quote::ScreenerQuote;
2use serde::{Deserialize, Serialize};
3
4/// Raw response structure from Yahoo Finance screener API (predefined screeners)
5///
6/// This matches Yahoo's nested response format with finance.result[] wrapper.
7/// Use `ScreenersResponse::from_response()` to convert to user-friendly format.
8#[derive(Debug, Clone, Deserialize)]
9struct RawScreenersResponse {
10    finance: RawFinance,
11}
12
13#[derive(Debug, Clone, Deserialize)]
14struct RawFinance {
15    result: Vec<RawResult>,
16}
17
18#[derive(Debug, Clone, Deserialize)]
19#[serde(rename_all = "camelCase")]
20struct RawResult {
21    canonical_name: String,
22    quotes: Vec<ScreenerQuote>,
23    #[serde(default)]
24    last_updated: Option<i64>,
25    #[serde(default)]
26    description: Option<String>,
27    // Skip internal Yahoo fields: count, id
28}
29
30/// Raw response structure from Yahoo Finance custom screener API (POST endpoint)
31///
32/// Uses records[] instead of quotes[] and has different nesting.
33#[derive(Debug, Clone, Deserialize)]
34struct RawCustomScreenerResponse {
35    finance: RawCustomFinance,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39struct RawCustomFinance {
40    result: Option<Vec<RawCustomResult>>,
41    error: Option<serde_json::Value>,
42}
43
44#[derive(Debug, Clone, Deserialize)]
45#[serde(rename_all = "camelCase")]
46struct RawCustomResult {
47    /// Total number of matches
48    #[serde(default)]
49    total: Option<i64>,
50    /// Records returned (custom screener uses "records" instead of "quotes")
51    #[serde(default)]
52    records: Vec<serde_json::Value>,
53    // Note: Custom screener doesn't have canonicalName, description, etc.
54}
55
56/// Flattened, user-friendly response for screener results
57///
58/// Returned by the screeners API with a clean structure:
59/// ```json
60/// {
61///   "quotes": [...],
62///   "type": "most_actives",
63///   "description": "Stocks ordered in descending order by intraday trade volume",
64///   "lastUpdated": 1234567890,
65///   "total": 100
66/// }
67/// ```
68///
69/// This removes Yahoo Finance's nested wrapper structure and internal metadata.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct ScreenerResults {
73    /// Array of quotes matching the screener criteria
74    pub quotes: Vec<ScreenerQuote>,
75
76    /// Screener type (e.g., "most_actives", "day_gainers", "custom")
77    #[serde(rename = "type")]
78    pub screener_type: String,
79
80    /// Human-readable description of the screener
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub description: Option<String>,
83
84    /// Last updated timestamp (Unix epoch)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub last_updated: Option<i64>,
87
88    /// Total number of matching results (for pagination)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub total: Option<i64>,
91}
92
93impl ScreenerResults {
94    /// Create a flattened response from raw Yahoo Finance JSON (predefined screeners)
95    ///
96    /// Converts the nested Yahoo Finance response structure into a clean,
97    /// user-friendly format by extracting data from finance.result[0].
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the response contains no screener data.
102    pub(crate) fn from_response(raw: &serde_json::Value) -> Result<Self, String> {
103        // Deserialize the raw response
104        let raw_response: RawScreenersResponse = serde_json::from_value(raw.clone())
105            .map_err(|e| format!("Failed to parse screener response: {}", e))?;
106
107        // Extract the first result
108        let result = raw_response
109            .finance
110            .result
111            .first()
112            .ok_or_else(|| "No screener data in response".to_string())?;
113
114        Ok(Self {
115            quotes: result.quotes.clone(),
116            screener_type: result.canonical_name.clone(),
117            description: result.description.clone(),
118            last_updated: result.last_updated,
119            total: None, // Predefined screeners don't provide total count
120        })
121    }
122
123    /// Create a flattened response from raw Yahoo Finance JSON (custom screeners)
124    ///
125    /// Custom screeners return records in a different format (raw JSON values)
126    /// that need to be mapped to ScreenerQuote fields.
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if the response contains no screener data or has an error.
131    pub(crate) fn from_custom_response(raw: &serde_json::Value) -> Result<Self, String> {
132        // Deserialize the raw response
133        let raw_response: RawCustomScreenerResponse = serde_json::from_value(raw.clone())
134            .map_err(|e| format!("Failed to parse custom screener response: {}", e))?;
135
136        // Check for error
137        if let Some(err) = raw_response.finance.error {
138            return Err(format!("Yahoo Finance error: {}", err));
139        }
140
141        // Extract the first result
142        let results = raw_response
143            .finance
144            .result
145            .ok_or_else(|| "No result in response".to_string())?;
146
147        let result = results
148            .first()
149            .ok_or_else(|| "No screener data in response".to_string())?;
150
151        // Convert records to ScreenerQuote using a custom mapping
152        // Custom screener returns flat records that need field mapping
153        let quotes: Vec<ScreenerQuote> = result
154            .records
155            .iter()
156            .filter_map(|record| map_custom_record_to_quote(record).ok())
157            .collect();
158
159        Ok(Self {
160            quotes,
161            screener_type: "custom".to_string(),
162            description: Some("Custom screener query results".to_string()),
163            last_updated: None,
164            total: result.total,
165        })
166    }
167}
168
169/// Map a custom screener record to a ScreenerQuote
170///
171/// Custom screener records use different field names than predefined screeners.
172#[cfg(feature = "dataframe")]
173impl ScreenerResults {
174    /// Converts the quotes to a polars DataFrame.
175    ///
176    /// Each quote becomes a row with all available columns.
177    pub fn to_dataframe(&self) -> ::polars::prelude::PolarsResult<::polars::prelude::DataFrame> {
178        ScreenerQuote::vec_to_dataframe(&self.quotes)
179    }
180}
181
182fn map_custom_record_to_quote(record: &serde_json::Value) -> Result<ScreenerQuote, String> {
183    use crate::models::quote::FormattedValue;
184
185    // Helper to extract FormattedValue from Yahoo's format
186    fn extract_formatted<T: serde::de::DeserializeOwned + Default>(
187        record: &serde_json::Value,
188        field: &str,
189    ) -> Option<FormattedValue<T>> {
190        record.get(field).and_then(|v| {
191            // Custom screener returns {raw, fmt} or just a value
192            if v.is_object() {
193                serde_json::from_value(v.clone()).ok()
194            } else {
195                // Wrap plain value in FormattedValue
196                serde_json::from_value::<T>(v.clone())
197                    .ok()
198                    .map(|raw| FormattedValue {
199                        raw: Some(raw),
200                        fmt: None,
201                        long_fmt: None,
202                    })
203            }
204        })
205    }
206
207    fn extract_string(record: &serde_json::Value, field: &str) -> Option<String> {
208        record
209            .get(field)
210            .and_then(|v| v.as_str())
211            .map(|s| s.to_string())
212    }
213
214    // Required fields
215    let symbol = extract_string(record, "ticker")
216        .or_else(|| extract_string(record, "symbol"))
217        .ok_or_else(|| "Missing symbol/ticker field".to_string())?;
218
219    let short_name = extract_string(record, "companyshortname")
220        .or_else(|| extract_string(record, "shortName"))
221        .unwrap_or_else(|| symbol.clone());
222
223    // Price fields - use intradayprice for custom screener
224    let regular_market_price = extract_formatted::<f64>(record, "intradayprice")
225        .or_else(|| extract_formatted::<f64>(record, "regularMarketPrice"))
226        .unwrap_or_default();
227
228    let regular_market_change = extract_formatted::<f64>(record, "intradaypricechange")
229        .or_else(|| extract_formatted::<f64>(record, "regularMarketChange"))
230        .unwrap_or_default();
231
232    let regular_market_change_percent = extract_formatted::<f64>(record, "percentchange")
233        .or_else(|| extract_formatted::<f64>(record, "regularMarketChangePercent"))
234        .unwrap_or_default();
235
236    Ok(ScreenerQuote {
237        symbol,
238        short_name,
239        long_name: extract_string(record, "longName"),
240        display_name: extract_string(record, "displayName"),
241        quote_type: extract_string(record, "quoteType").unwrap_or_else(|| "EQUITY".to_string()),
242        exchange: extract_string(record, "exchange").unwrap_or_default(),
243        regular_market_price,
244        regular_market_change,
245        regular_market_change_percent,
246        regular_market_open: extract_formatted(record, "day_open_price")
247            .or_else(|| extract_formatted(record, "regularMarketOpen")),
248        regular_market_day_high: extract_formatted(record, "dayhigh")
249            .or_else(|| extract_formatted(record, "regularMarketDayHigh")),
250        regular_market_day_low: extract_formatted(record, "daylow")
251            .or_else(|| extract_formatted(record, "regularMarketDayLow")),
252        regular_market_previous_close: extract_formatted(record, "regularMarketPreviousClose"),
253        regular_market_time: extract_formatted(record, "regularMarketTime"),
254        regular_market_volume: extract_formatted(record, "dayvolume")
255            .or_else(|| extract_formatted(record, "regularMarketVolume")),
256        average_daily_volume3_month: extract_formatted(record, "avgdailyvol3m")
257            .or_else(|| extract_formatted(record, "averageDailyVolume3Month")),
258        average_daily_volume10_day: extract_formatted(record, "averageDailyVolume10Day"),
259        market_cap: extract_formatted(record, "intradaymarketcap")
260            .or_else(|| extract_formatted(record, "marketCap")),
261        shares_outstanding: extract_formatted(record, "sharesOutstanding"),
262        fifty_two_week_high: extract_formatted(record, "fiftytwowkhigh")
263            .or_else(|| extract_formatted(record, "fiftyTwoWeekHigh")),
264        fifty_two_week_low: extract_formatted(record, "fiftytwowklow")
265            .or_else(|| extract_formatted(record, "fiftyTwoWeekLow")),
266        fifty_two_week_change: extract_formatted(record, "fiftyTwoWeekChange"),
267        fifty_two_week_change_percent: extract_formatted(record, "fiftyTwoWeekChangePercent"),
268        fifty_day_average: extract_formatted(record, "fiftyDayAverage"),
269        fifty_day_average_change: extract_formatted(record, "fiftyDayAverageChange"),
270        fifty_day_average_change_percent: extract_formatted(record, "fiftyDayAverageChangePercent"),
271        two_hundred_day_average: extract_formatted(record, "twoHundredDayAverage"),
272        two_hundred_day_average_change: extract_formatted(record, "twoHundredDayAverageChange"),
273        two_hundred_day_average_change_percent: extract_formatted(
274            record,
275            "twoHundredDayAverageChangePercent",
276        ),
277        average_analyst_rating: extract_string(record, "averageAnalystRating"),
278        trailing_pe: extract_formatted::<f64>(record, "peratio.lasttwelvemonths")
279            .or_else(|| extract_formatted(record, "trailingPE")),
280        forward_pe: extract_formatted(record, "forwardPE"),
281        price_to_book: extract_formatted(record, "priceToBook"),
282        book_value: extract_formatted(record, "bookValue"),
283        eps_trailing_twelve_months: extract_formatted::<f64>(record, "eps.lasttwelvemonths")
284            .or_else(|| extract_formatted(record, "epsTrailingTwelveMonths")),
285        eps_forward: extract_formatted(record, "epsForward"),
286        eps_current_year: extract_formatted(record, "epsCurrentYear"),
287        price_eps_current_year: extract_formatted(record, "priceEpsCurrentYear"),
288        dividend_yield: extract_formatted::<f64>(record, "annual_dividend_yield")
289            .or_else(|| extract_formatted(record, "dividendYield")),
290        dividend_rate: extract_formatted::<f64>(record, "annual_dividend_rate")
291            .or_else(|| extract_formatted(record, "dividendRate")),
292        dividend_date: extract_formatted(record, "dividendDate"),
293        trailing_annual_dividend_rate: extract_formatted(record, "trailingAnnualDividendRate"),
294        trailing_annual_dividend_yield: extract_formatted(record, "trailingAnnualDividendYield"),
295        bid: extract_formatted(record, "bid"),
296        bid_size: extract_formatted(record, "bidSize"),
297        ask: extract_formatted(record, "ask"),
298        ask_size: extract_formatted(record, "askSize"),
299        post_market_price: extract_formatted(record, "postMarketPrice"),
300        post_market_change: extract_formatted(record, "postMarketChange"),
301        post_market_change_percent: extract_formatted(record, "postMarketChangePercent"),
302        post_market_time: extract_formatted(record, "postMarketTime"),
303        pre_market_price: extract_formatted(record, "preMarketPrice"),
304        pre_market_change: extract_formatted(record, "preMarketChange"),
305        pre_market_change_percent: extract_formatted(record, "preMarketChangePercent"),
306        pre_market_time: extract_formatted(record, "preMarketTime"),
307        earnings_timestamp: extract_formatted(record, "earningsTimestamp"),
308        earnings_timestamp_start: extract_formatted(record, "earningsTimestampStart"),
309        earnings_timestamp_end: extract_formatted(record, "earningsTimestampEnd"),
310        currency: extract_string(record, "quotesCurrency")
311            .or_else(|| extract_string(record, "currency")),
312    })
313}