1use super::quote::ScreenerQuote;
2use serde::{Deserialize, Serialize};
3
4#[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 }
29
30#[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 #[serde(default)]
49 total: Option<i64>,
50 #[serde(default)]
52 records: Vec<serde_json::Value>,
53 }
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct ScreenerResults {
73 pub quotes: Vec<ScreenerQuote>,
75
76 #[serde(rename = "type")]
78 pub screener_type: String,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub description: Option<String>,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub last_updated: Option<i64>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub total: Option<i64>,
91}
92
93impl ScreenerResults {
94 pub(crate) fn from_response(raw: &serde_json::Value) -> Result<Self, String> {
103 let raw_response: RawScreenersResponse = serde_json::from_value(raw.clone())
105 .map_err(|e| format!("Failed to parse screener response: {}", e))?;
106
107 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, })
121 }
122
123 pub(crate) fn from_custom_response(raw: &serde_json::Value) -> Result<Self, String> {
132 let raw_response: RawCustomScreenerResponse = serde_json::from_value(raw.clone())
134 .map_err(|e| format!("Failed to parse custom screener response: {}", e))?;
135
136 if let Some(err) = raw_response.finance.error {
138 return Err(format!("Yahoo Finance error: {}", err));
139 }
140
141 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 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#[cfg(feature = "dataframe")]
173impl ScreenerResults {
174 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 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 if v.is_object() {
193 serde_json::from_value(v.clone()).ok()
194 } else {
195 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 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 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}