feat(msn): implement full MSN Finance endpoint coverage

- Finance/Equities: company profile + executives (idx stock profile)
- Finance/Equities/financialstatements: balance sheet, cash flow, income (idx stock financials)
- Finance/Events/Earnings: EPS history + forecast (idx stock earnings)
- Finance/Charts: OHLCV chart history, unblocks MSN history command
- Finance/SentimentBrowser: crowd sentiment (idx stock sentiment)
- api.msn.com/insights: AI insights (idx stock insights)
- MSN/Feed/me: stock news feed (idx stock news)
- Finance/Screener: IDX universe screener with 8 presets (idx screen)
- Wire ProfileProvider, EarningsProvider, FinancialsProvider, SentimentProvider,
  InsightsProvider, NewsProvider traits on MsnProvider
- Remove HISTORY_UNSUPPORTED_REASON — MSN history now works via charts API
This commit is contained in:
Ciphercat 2026-03-06 19:32:56 +00:00
commit a0da0a3adb
8 changed files with 1049 additions and 49 deletions

View file

@ -1,10 +1,15 @@
use std::time::Duration;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::error::IdxError;
use super::raw_types::{KeyRatios, MsnQuote};
use super::raw_types::{
KeyRatios, MsnQuote, RawChart, RawEarningsResponse, RawEquity, RawFinancialStatement,
RawInsight, RawNewsFeed, RawScreenerResponse, RawSentiment, ScreenerFilter, ScreenerOrder,
ScreenerRequest,
};
use super::symbols::resolve_msn_id;
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
@ -78,6 +83,58 @@ impl MsnClient {
Err(IdxError::RateLimited)
}
fn post_json<B: Serialize, T: DeserializeOwned>(
&self,
url: &str,
body: &B,
symbol: &str,
endpoint: &str,
) -> Result<T, IdxError> {
let mut wait = Duration::from_millis(500);
for attempt in 0..3 {
let response = self
.agent
.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
.header("Accept-Language", "en-US,en;q=0.9,id;q=0.8")
.header("Origin", "https://www.msn.com")
.header("Referer", "https://www.msn.com/")
.header("Content-Type", "text/plain;charset=UTF-8")
.send_json(body);
match response {
Ok(ok) => {
return ok
.into_body()
.read_json::<T>()
.map_err(|e| IdxError::ParseError(format!("msn {endpoint}: {e}")));
}
Err(ureq::Error::StatusCode(404)) => {
return Err(IdxError::SymbolNotFound(symbol.to_string()));
}
Err(ureq::Error::StatusCode(429)) => {
if attempt < 2 {
std::thread::sleep(wait);
wait *= 2;
continue;
}
return Err(IdxError::RateLimited);
}
Err(ureq::Error::StatusCode(code)) if code >= 500 => {
if attempt < 2 {
std::thread::sleep(wait);
wait *= 2;
continue;
}
return Err(IdxError::Http(format!("msn {endpoint}: status {code}")));
}
Err(err) => return Err(IdxError::Http(format!("msn {endpoint}: {err}"))),
}
}
Err(IdxError::RateLimited)
}
pub(super) fn fetch_quotes(&self, symbol: &str) -> Result<Vec<MsnQuote>, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
@ -94,4 +151,107 @@ impl MsnClient {
format!("{MSN_API_BASE_URL}keyratios?apikey={MSN_API_KEY}&ids={id}&wrapodata=false");
self.get_json(&url, symbol, "keyratios")
}
pub(super) fn fetch_equities(&self, symbol: &str) -> Result<Vec<RawEquity>, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let url = format!(
"{MSN_ASSETS_BASE_URL}Finance/Equities?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
);
self.get_json(&url, symbol, "equities")
}
pub(super) fn fetch_financial_statements(
&self,
symbol: &str,
) -> Result<Vec<RawFinancialStatement>, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let url = format!(
"{MSN_ASSETS_BASE_URL}Finance/Equities/financialstatements?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
);
self.get_json(&url, symbol, "financialstatements")
}
pub(super) fn fetch_earnings(&self, symbol: &str) -> Result<RawEarningsResponse, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let url = format!(
"{MSN_ASSETS_BASE_URL}Finance/Events/Earnings?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
);
self.get_json(&url, symbol, "earnings")
}
pub(super) fn fetch_charts(
&self,
symbol: &str,
chart_type: &str,
) -> Result<Vec<RawChart>, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let url = format!(
"{MSN_ASSETS_BASE_URL}Finance/Charts?apikey={MSN_API_KEY}&ids={id}&chartType={chart_type}&wrapodata=false"
);
self.get_json(&url, symbol, "charts")
}
pub(super) fn fetch_sentiment(&self, symbol: &str) -> Result<Vec<RawSentiment>, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let url = format!(
"{MSN_ASSETS_BASE_URL}Finance/SentimentBrowser?apikey={MSN_API_KEY}&cm=id-id&it=web&scn=ANON&ids={id}&wrapodata=false&flightId=INeedDau"
);
self.get_json(&url, symbol, "sentiment")
}
pub(super) fn fetch_insights(&self, symbol: &str) -> Result<Vec<RawInsight>, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let url =
format!("{MSN_API_BASE_URL}insights?apikey={MSN_API_KEY}&ids={id}&wrapodata=false");
self.get_json(&url, symbol, "insights")
}
pub(super) fn fetch_news(&self, symbol: &str, limit: usize) -> Result<RawNewsFeed, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let url = format!(
"{MSN_ASSETS_BASE_URL}MSN/Feed/me?$top={limit}&apikey={MSN_API_KEY}&cm=id-id&contentType=article,video,slideshow&it=web&query=ef_stock_{id}&queryType=entityfeed&responseSchema=cardview&scn=ANON&wrapodata=false"
);
self.get_json(&url, symbol, "news")
}
pub(super) fn fetch_screener(
&self,
filter: &str,
region: &str,
limit: usize,
) -> Result<RawScreenerResponse, IdxError> {
let url =
format!("{MSN_ASSETS_BASE_URL}Finance/Screener?apikey={MSN_API_KEY}&wrapodata=false");
let req = ScreenerRequest {
filter: vec![
ScreenerFilter {
key: filter.to_string(),
key_group: "st_list_".to_string(),
is_range: false,
},
ScreenerFilter {
key: region.to_string(),
key_group: "st_reg_".to_string(),
is_range: false,
},
],
order: ScreenerOrder {
key: "st_1yr_asc_order".to_string(),
dir: "desc".to_string(),
},
return_value_type: vec!["quote".to_string(), "equity".to_string()],
screener_type: "stock".to_string(),
limit,
page_index: 0,
};
self.post_json(&url, &req, "SCREENER", "screener")
}
}

View file

@ -2,9 +2,17 @@ use std::collections::BTreeMap;
use chrono::{Datelike, NaiveDate};
use super::raw_types::{IndustryMetric, KeyRatios, MsnChart, MsnQuote};
use super::raw_types::{
IndustryMetric, KeyRatios, MsnChart, MsnQuote, RawChart, RawEarningsData, RawEarningsResponse,
RawEquity, RawFinancialStatement, RawInsight, RawNewsFeed, RawScreenerResponse, RawSentiment,
RawStatementSection,
};
use super::symbols::{normalized_symbol, ticker_from_symbol};
use crate::api::types::{Fundamentals, Ohlc, Period, Quote};
use crate::api::types::{
Bar, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals,
InsightData, InstrumentInfo, NewsItem, Officer, Ohlc, Period, Quote, SentimentData,
SentimentPeriod, StatementSection,
};
use crate::error::IdxError;
pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, IdxError> {
@ -280,6 +288,268 @@ fn round_u64(value: Option<f64>) -> Option<u64> {
})
}
pub(super) fn parse_profile(symbol: &str, raw: &[RawEquity]) -> Result<CompanyProfile, IdxError> {
let equity = raw
.first()
.ok_or_else(|| IdxError::ParseError("no profile data".into()))?;
Ok(CompanyProfile {
id: equity.id.clone().unwrap_or_default(),
symbol: equity.symbol.clone().unwrap_or_else(|| symbol.to_string()),
short_name: equity.short_name.clone().unwrap_or_default(),
long_name: equity.long_name.clone().unwrap_or_default(),
description: equity.description.clone().unwrap_or_default(),
sector: equity.sector.clone().unwrap_or_default(),
industry: equity.industry.clone().unwrap_or_default(),
website: equity.website.clone().unwrap_or_default(),
employees: equity.full_time_employees.unwrap_or_default(),
address: equity.address.clone().unwrap_or_default(),
city: equity.city.clone().unwrap_or_default(),
country: equity.country.clone().unwrap_or_default(),
phone: equity.phone.clone().unwrap_or_default(),
officers: equity
.officers
.as_ref()
.map(|items| {
items
.iter()
.map(|officer| Officer {
name: officer.name.clone().unwrap_or_default(),
title: officer.title.clone().unwrap_or_default(),
age: officer.age,
year_born: officer.year_born,
total_pay: officer.total_pay,
})
.collect()
})
.unwrap_or_default(),
})
}
pub(super) fn parse_financial_statements(
symbol: &str,
raw: &[RawFinancialStatement],
) -> Result<FinancialStatements, IdxError> {
let item = raw
.first()
.ok_or_else(|| IdxError::ParseError("no financial statements".into()))?;
let instrument = item.underlying_instrument.as_ref();
Ok(FinancialStatements {
instrument: InstrumentInfo {
id: instrument
.and_then(|v| v.instrument_id.clone())
.unwrap_or_default(),
symbol: instrument
.and_then(|v| v.symbol.clone())
.unwrap_or_else(|| symbol.to_string()),
name: instrument
.and_then(|v| v.display_name.clone().or_else(|| v.short_name.clone()))
.unwrap_or_default(),
},
balance_sheet: item.balance_sheets.as_ref().map(parse_statement_section),
cash_flow: item.cash_flow.as_ref().map(parse_statement_section),
income_statement: item.income_statements.as_ref().map(parse_statement_section),
})
}
pub(super) fn parse_earnings(
_symbol: &str,
raw: &RawEarningsResponse,
) -> Result<EarningsReport, IdxError> {
let mut forecast = Vec::new();
let mut history = Vec::new();
if let Some(bucket) = &raw.forecast {
collect_earnings(bucket.annual.as_ref(), &mut forecast);
collect_earnings(bucket.quarterly.as_ref(), &mut forecast);
}
if let Some(bucket) = &raw.history {
collect_earnings(bucket.annual.as_ref(), &mut history);
collect_earnings(bucket.quarterly.as_ref(), &mut history);
}
forecast.sort_by_key(|row| row.earning_release_date.clone().unwrap_or_default());
history.sort_by_key(|row| row.earning_release_date.clone().unwrap_or_default());
Ok(EarningsReport {
eps_last_year: raw.eps_last_year.unwrap_or_default(),
revenue_last_year: raw.revenue_last_year.unwrap_or_default(),
forecast,
history,
})
}
pub(super) fn parse_chart_history(
_symbol: &str,
period: &Period,
raw: &[RawChart],
) -> Result<Vec<Bar>, IdxError> {
let chart = raw
.first()
.ok_or_else(|| IdxError::ParseError("no chart data".into()))?;
let mut grouped: BTreeMap<NaiveDate, Bar> = BTreeMap::new();
for (idx, ts) in chart.series.time_stamps.iter().enumerate() {
let Some(date) = parse_chart_date(ts) else {
continue;
};
let close = chart.series.prices.get(idx).copied();
let open = chart.series.open_prices.get(idx).copied().or(close);
let high = chart.series.prices_high.get(idx).copied().or(close);
let low = chart.series.prices_low.get(idx).copied().or(close);
let volume = chart.series.volumes.get(idx).copied().unwrap_or(0.0);
let (Some(open), Some(high), Some(low), Some(close)) = (open, high, low, close) else {
continue;
};
grouped.insert(
date,
Bar {
date,
open: round_price(open),
high: round_price(high),
low: round_price(low),
close: round_price(close),
volume: round_u64(Some(volume)).unwrap_or(0),
},
);
}
let mut out: Vec<Bar> = grouped.into_values().collect();
trim_history_to_period(period, &mut out);
if out.is_empty() {
return Err(IdxError::ParseError("no chart data".into()));
}
if matches!(period, Period::FiveDays) {
Ok(resample_history(&out, ResampleInterval::Week))
} else {
Ok(out)
}
}
pub(super) fn parse_sentiment(
symbol: &str,
raw: &[RawSentiment],
) -> Result<SentimentData, IdxError> {
let item = raw
.first()
.ok_or_else(|| IdxError::ParseError("no sentiment data".into()))?;
let stats = item
.sentiment_statistics
.as_ref()
.map(|items| {
items
.iter()
.map(|it| SentimentPeriod {
time_range: it.time_range_name.clone().unwrap_or_default(),
bullish: it.bullish.unwrap_or_default(),
bearish: it.bearish.unwrap_or_default(),
neutral: it.neutral.unwrap_or_default(),
})
.collect()
})
.unwrap_or_default();
Ok(SentimentData {
symbol: item.symbol.clone().unwrap_or_else(|| symbol.to_string()),
statistics: stats,
})
}
pub(super) fn parse_insights(_symbol: &str, raw: &[RawInsight]) -> Result<InsightData, IdxError> {
let item = raw
.first()
.ok_or_else(|| IdxError::ParseError("no insights data".into()))?;
Ok(InsightData {
id: item.id.clone().unwrap_or_default(),
summary: item.summary.clone().unwrap_or_default(),
highlights: item.highlights.clone().unwrap_or_default(),
risks: item.risks.clone().unwrap_or_default(),
last_updated: item.last_updated.clone().unwrap_or_default(),
})
}
pub(super) fn parse_news(raw: &RawNewsFeed) -> Result<Vec<NewsItem>, IdxError> {
let source = raw
.sub_cards
.as_ref()
.or(raw.value.as_ref())
.ok_or_else(|| IdxError::ParseError("no news data".into()))?;
Ok(source
.iter()
.map(|item| NewsItem {
id: item.id.clone().unwrap_or_default(),
title: item.title.clone().unwrap_or_default(),
url: item.url.clone().unwrap_or_default(),
description: item.description.clone().unwrap_or_default(),
provider: item
.provider
.as_ref()
.and_then(|p| p.name.clone())
.unwrap_or_default(),
published_at: item.published_date_time.clone().unwrap_or_default(),
read_time_min: item.read_time_min,
})
.collect())
}
pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Quote>, IdxError> {
let quotes = raw
.quote
.as_ref()
.ok_or_else(|| IdxError::ParseError("no screener data".into()))?;
quotes
.iter()
.map(|q| parse_quote(q.symbol.as_deref().unwrap_or(""), std::slice::from_ref(q)))
.collect()
}
fn parse_statement_section(section: &RawStatementSection) -> StatementSection {
let mut values = std::collections::HashMap::new();
for (k, v) in &section.data {
if ["currency", "source", "sourceDate", "reportDate", "endDate"].contains(&k.as_str()) {
continue;
}
if let Some(num) = v.as_f64() {
values.insert(k.to_string(), num);
}
}
StatementSection {
values,
currency: section.currency.clone().unwrap_or_default(),
report_date: section.report_date.clone().unwrap_or_default(),
end_date: section.end_date.clone().unwrap_or_default(),
}
}
fn collect_earnings(
values: Option<&std::collections::HashMap<String, RawEarningsData>>,
out: &mut Vec<EarningsData>,
) {
let Some(values) = values else {
return;
};
let mut rows: Vec<(&String, &RawEarningsData)> = values.iter().collect();
rows.sort_by_key(|(k, _)| (*k).clone());
for (_, v) in rows {
out.push(EarningsData {
eps_actual: v.eps_actual,
eps_forecast: v.eps_forecast,
eps_surprise: v.eps_surprise,
eps_surprise_pct: v.eps_surprise_percent,
revenue_actual: v.revenue_actual,
revenue_forecast: v.revenue_forecast,
revenue_surprise: v.revenue_surprise,
earning_release_date: v.earning_release_date.clone(),
period_type: v.ciq_fiscal_period_type.clone().unwrap_or_default(),
});
}
}
#[allow(dead_code)]
#[derive(Clone, Copy)]
pub(super) enum ResampleInterval {

View file

@ -4,17 +4,25 @@ mod parse;
mod raw_types;
mod symbols;
use crate::api::types::{Bar, Fundamentals, Interval, Period, Quote};
use crate::api::{FundamentalsProvider, HistoryProvider, QuoteProvider};
use crate::api::types::{
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
NewsItem, Period, Quote, SentimentData,
};
use crate::api::{
EarningsProvider, FinancialsProvider, FundamentalsProvider, HistoryProvider, InsightsProvider,
NewsProvider, ProfileProvider, QuoteProvider, SentimentProvider,
};
use crate::error::IdxError;
use client::MsnClient;
use map::{parse_fundamentals, parse_quote};
use map::{
parse_chart_history, parse_earnings, parse_financial_statements, parse_fundamentals,
parse_insights, parse_news, parse_profile, parse_quote, parse_screener_results,
parse_sentiment,
};
pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
const HISTORY_UNSUPPORTED_REASON: &str = "MSN provider does not currently support history or technical analysis because MSN charts do not consistently expose real OHLCV data";
pub struct MsnProvider {
client: MsnClient,
}
@ -25,6 +33,16 @@ impl MsnProvider {
client: MsnClient::new(),
}
}
pub fn screener(
&self,
filter: &str,
region: &str,
limit: usize,
) -> Result<Vec<Quote>, IdxError> {
let raw = self.client.fetch_screener(filter, region, limit)?;
parse_screener_results(&raw)
}
}
impl QuoteProvider for MsnProvider {
@ -45,33 +63,67 @@ impl FundamentalsProvider for MsnProvider {
impl HistoryProvider for MsnProvider {
fn history(
&self,
_symbol: &str,
_period: &Period,
symbol: &str,
period: &Period,
_interval: &Interval,
) -> Result<Vec<Bar>, IdxError> {
Err(IdxError::Unsupported(
HISTORY_UNSUPPORTED_REASON.to_string(),
))
let chart_type = period_to_chart_type(period);
let raw = self.client.fetch_charts(symbol, chart_type)?;
parse_chart_history(symbol, period, &raw)
}
}
#[cfg(test)]
mod tests {
use super::MsnProvider;
use crate::api::HistoryProvider;
use crate::api::types::{Interval, Period};
use crate::error::IdxError;
impl ProfileProvider for MsnProvider {
fn profile(&self, symbol: &str) -> Result<CompanyProfile, IdxError> {
let raw = self.client.fetch_equities(symbol)?;
parse_profile(symbol, &raw)
}
}
#[test]
fn history_is_explicitly_unsupported() {
let provider = MsnProvider::new(false);
let err = provider
.history("BBCA.JK", &Period::OneMonth, &Interval::Day)
.expect_err("history should be unsupported");
assert!(matches!(err, IdxError::Unsupported(_)));
assert!(
err.to_string()
.contains("MSN provider does not currently support history or technical analysis")
);
impl EarningsProvider for MsnProvider {
fn earnings(&self, symbol: &str) -> Result<EarningsReport, IdxError> {
let raw = self.client.fetch_earnings(symbol)?;
parse_earnings(symbol, &raw)
}
}
impl FinancialsProvider for MsnProvider {
fn financials(&self, symbol: &str) -> Result<FinancialStatements, IdxError> {
let raw = self.client.fetch_financial_statements(symbol)?;
parse_financial_statements(symbol, &raw)
}
}
impl SentimentProvider for MsnProvider {
fn sentiment(&self, symbol: &str) -> Result<SentimentData, IdxError> {
let raw = self.client.fetch_sentiment(symbol)?;
parse_sentiment(symbol, &raw)
}
}
impl InsightsProvider for MsnProvider {
fn insights(&self, symbol: &str) -> Result<InsightData, IdxError> {
let raw = self.client.fetch_insights(symbol)?;
parse_insights(symbol, &raw)
}
}
impl NewsProvider for MsnProvider {
fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError> {
let raw = self.client.fetch_news(symbol, limit)?;
parse_news(&raw)
}
}
fn period_to_chart_type(period: &Period) -> &'static str {
match period {
Period::OneDay => "1D",
Period::FiveDays => "1W",
Period::OneMonth => "1M",
Period::ThreeMonths => "3M",
Period::SixMonths => "6M",
Period::OneYear => "1Y",
Period::TwoYears => "3Y",
Period::FiveYears => "5Y",
}
}

View file

@ -1,11 +1,16 @@
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use std::collections::HashMap;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MsnQuote {
#[serde(default)]
pub(crate) symbol: Option<String>,
#[serde(default)]
pub(crate) short_name: Option<String>,
pub(crate) price: Option<f64>,
#[serde(default)]
pub(crate) price_change: Option<f64>,
@ -23,6 +28,8 @@ pub(crate) struct MsnQuote {
pub(crate) average_volume: Option<f64>,
#[serde(default)]
pub(crate) market_cap: Option<f64>,
#[serde(default)]
pub(crate) return_ytd: Option<f64>,
}
#[derive(Debug, Deserialize)]
@ -74,6 +81,8 @@ pub(crate) struct MsnChart {
pub(crate) series: ChartSeries,
}
pub(crate) type RawChart = MsnChart;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChartSeries {
@ -102,6 +111,183 @@ impl ChartSeries {
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawEquity {
pub(super) id: Option<String>,
pub(super) symbol: Option<String>,
pub(super) short_name: Option<String>,
pub(super) long_name: Option<String>,
pub(super) description: Option<String>,
pub(super) sector: Option<String>,
pub(super) industry: Option<String>,
pub(super) website: Option<String>,
pub(super) full_time_employees: Option<i64>,
pub(super) address: Option<String>,
pub(super) city: Option<String>,
pub(super) country: Option<String>,
pub(super) phone: Option<String>,
pub(super) officers: Option<Vec<RawOfficer>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawOfficer {
pub(super) name: Option<String>,
pub(super) title: Option<String>,
pub(super) age: Option<i32>,
pub(super) year_born: Option<i32>,
pub(super) total_pay: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawFinancialStatement {
pub(super) underlying_instrument: Option<RawInstrumentInfo>,
pub(super) balance_sheets: Option<RawStatementSection>,
pub(super) cash_flow: Option<RawStatementSection>,
pub(super) income_statements: Option<RawStatementSection>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawInstrumentInfo {
pub(super) instrument_id: Option<String>,
pub(super) display_name: Option<String>,
pub(super) short_name: Option<String>,
pub(super) symbol: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(super) struct RawStatementSection {
#[serde(flatten)]
pub(super) data: HashMap<String, serde_json::Value>,
pub(super) currency: Option<String>,
pub(super) source: Option<String>,
#[serde(rename = "sourceDate")]
pub(super) source_date: Option<String>,
#[serde(rename = "reportDate")]
pub(super) report_date: Option<String>,
#[serde(rename = "endDate")]
pub(super) end_date: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub(super) struct RawEarningsResponse {
pub(super) eps_last_year: Option<f64>,
pub(super) revenue_last_year: Option<f64>,
pub(super) forecast: Option<RawEarningsBucket>,
pub(super) history: Option<RawEarningsBucket>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawEarningsBucket {
pub(super) annual: Option<HashMap<String, RawEarningsData>>,
pub(super) quarterly: Option<HashMap<String, RawEarningsData>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub(super) struct RawEarningsData {
pub(super) eps_actual: Option<f64>,
pub(super) eps_surprise: Option<f64>,
pub(super) eps_surprise_percent: Option<f64>,
pub(super) eps_forecast: Option<f64>,
pub(super) revenue_actual: Option<f64>,
pub(super) revenue_surprise: Option<f64>,
pub(super) revenue_forecast: Option<f64>,
pub(super) earning_release_date: Option<String>,
pub(super) ciq_fiscal_period_type: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawSentiment {
pub(super) symbol: Option<String>,
pub(super) display_name: Option<String>,
pub(super) sentiment_statistics: Option<Vec<RawSentimentStat>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawSentimentStat {
pub(super) time_range_name: Option<String>,
pub(super) bullish: Option<i32>,
pub(super) bearish: Option<i32>,
pub(super) neutral: Option<i32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawInsight {
pub(super) id: Option<String>,
pub(super) summary: Option<String>,
pub(super) highlights: Option<Vec<String>>,
pub(super) risks: Option<Vec<String>>,
pub(super) last_updated: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawNewsFeed {
pub(super) value: Option<Vec<RawNewsItem>>,
#[serde(rename = "subCards")]
pub(super) sub_cards: Option<Vec<RawNewsItem>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawNewsItem {
pub(super) id: Option<String>,
pub(super) title: Option<String>,
pub(super) url: Option<String>,
#[serde(rename = "abstract")]
pub(super) description: Option<String>,
pub(super) provider: Option<RawNewsProvider>,
pub(super) published_date_time: Option<String>,
pub(super) read_time_min: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawNewsProvider {
pub(super) name: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ScreenerRequest {
pub(super) filter: Vec<ScreenerFilter>,
pub(super) order: ScreenerOrder,
pub(super) return_value_type: Vec<String>,
pub(super) screener_type: String,
pub(super) limit: usize,
pub(super) page_index: usize,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ScreenerFilter {
pub(super) key: String,
pub(super) key_group: String,
pub(super) is_range: bool,
}
#[derive(Debug, Serialize)]
pub(super) struct ScreenerOrder {
pub(super) key: String,
pub(super) dir: String,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawScreenerResponse {
pub(super) count: Option<i32>,
pub(super) quote: Option<Vec<MsnQuote>>,
}
fn de_opt_f64_lenient<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,