diff --git a/src/api/msn/client.rs b/src/api/msn/client.rs index a62ea8f..811da3a 100644 --- a/src/api/msn/client.rs +++ b/src/api/msn/client.rs @@ -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( + &self, + url: &str, + body: &B, + symbol: &str, + endpoint: &str, + ) -> Result { + 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::() + .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, 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, 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, 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 { + 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, 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, 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, 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 { + 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 { + 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") + } } diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index 337019f..cec5365 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -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 { @@ -280,6 +288,268 @@ fn round_u64(value: Option) -> Option { }) } +pub(super) fn parse_profile(symbol: &str, raw: &[RawEquity]) -> Result { + 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 { + 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 { + 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, IdxError> { + let chart = raw + .first() + .ok_or_else(|| IdxError::ParseError("no chart data".into()))?; + + let mut grouped: BTreeMap = 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 = 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 { + 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 { + 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, 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, 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 §ion.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>, + out: &mut Vec, +) { + 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 { diff --git a/src/api/msn/mod.rs b/src/api/msn/mod.rs index 0daf6cb..09d07b4 100644 --- a/src/api/msn/mod.rs +++ b/src/api/msn/mod.rs @@ -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, 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, 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 { + 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 { + let raw = self.client.fetch_earnings(symbol)?; + parse_earnings(symbol, &raw) + } +} + +impl FinancialsProvider for MsnProvider { + fn financials(&self, symbol: &str) -> Result { + let raw = self.client.fetch_financial_statements(symbol)?; + parse_financial_statements(symbol, &raw) + } +} + +impl SentimentProvider for MsnProvider { + fn sentiment(&self, symbol: &str) -> Result { + let raw = self.client.fetch_sentiment(symbol)?; + parse_sentiment(symbol, &raw) + } +} + +impl InsightsProvider for MsnProvider { + fn insights(&self, symbol: &str) -> Result { + let raw = self.client.fetch_insights(symbol)?; + parse_insights(symbol, &raw) + } +} + +impl NewsProvider for MsnProvider { + fn news(&self, symbol: &str, limit: usize) -> Result, 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", } } diff --git a/src/api/msn/raw_types.rs b/src/api/msn/raw_types.rs index f12e23c..49263b7 100644 --- a/src/api/msn/raw_types.rs +++ b/src/api/msn/raw_types.rs @@ -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, + #[serde(default)] + pub(crate) short_name: Option, pub(crate) price: Option, #[serde(default)] pub(crate) price_change: Option, @@ -23,6 +28,8 @@ pub(crate) struct MsnQuote { pub(crate) average_volume: Option, #[serde(default)] pub(crate) market_cap: Option, + #[serde(default)] + pub(crate) return_ytd: Option, } #[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, + pub(super) symbol: Option, + pub(super) short_name: Option, + pub(super) long_name: Option, + pub(super) description: Option, + pub(super) sector: Option, + pub(super) industry: Option, + pub(super) website: Option, + pub(super) full_time_employees: Option, + pub(super) address: Option, + pub(super) city: Option, + pub(super) country: Option, + pub(super) phone: Option, + pub(super) officers: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawOfficer { + pub(super) name: Option, + pub(super) title: Option, + pub(super) age: Option, + pub(super) year_born: Option, + pub(super) total_pay: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawFinancialStatement { + pub(super) underlying_instrument: Option, + pub(super) balance_sheets: Option, + pub(super) cash_flow: Option, + pub(super) income_statements: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawInstrumentInfo { + pub(super) instrument_id: Option, + pub(super) display_name: Option, + pub(super) short_name: Option, + pub(super) symbol: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub(super) struct RawStatementSection { + #[serde(flatten)] + pub(super) data: HashMap, + pub(super) currency: Option, + pub(super) source: Option, + #[serde(rename = "sourceDate")] + pub(super) source_date: Option, + #[serde(rename = "reportDate")] + pub(super) report_date: Option, + #[serde(rename = "endDate")] + pub(super) end_date: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct RawEarningsResponse { + pub(super) eps_last_year: Option, + pub(super) revenue_last_year: Option, + pub(super) forecast: Option, + pub(super) history: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RawEarningsBucket { + pub(super) annual: Option>, + pub(super) quarterly: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct RawEarningsData { + pub(super) eps_actual: Option, + pub(super) eps_surprise: Option, + pub(super) eps_surprise_percent: Option, + pub(super) eps_forecast: Option, + pub(super) revenue_actual: Option, + pub(super) revenue_surprise: Option, + pub(super) revenue_forecast: Option, + pub(super) earning_release_date: Option, + pub(super) ciq_fiscal_period_type: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawSentiment { + pub(super) symbol: Option, + pub(super) display_name: Option, + pub(super) sentiment_statistics: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawSentimentStat { + pub(super) time_range_name: Option, + pub(super) bullish: Option, + pub(super) bearish: Option, + pub(super) neutral: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawInsight { + pub(super) id: Option, + pub(super) summary: Option, + pub(super) highlights: Option>, + pub(super) risks: Option>, + pub(super) last_updated: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RawNewsFeed { + pub(super) value: Option>, + #[serde(rename = "subCards")] + pub(super) sub_cards: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawNewsItem { + pub(super) id: Option, + pub(super) title: Option, + pub(super) url: Option, + #[serde(rename = "abstract")] + pub(super) description: Option, + pub(super) provider: Option, + pub(super) published_date_time: Option, + pub(super) read_time_min: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RawNewsProvider { + pub(super) name: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ScreenerRequest { + pub(super) filter: Vec, + pub(super) order: ScreenerOrder, + pub(super) return_value_type: Vec, + 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, + pub(super) quote: Option>, +} + fn de_opt_f64_lenient<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 0ba8540..952cd93 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -7,14 +7,23 @@ use crate::analysis::fundamental::{ }; use crate::analysis::signals::{self, Signal, TechnicalSignal}; use crate::analysis::technical; -use crate::api::MarketDataProvider; -use crate::api::types::{Fundamentals, Interval, Ohlc, Period}; +use crate::api::msn::MsnProvider; +use crate::api::types::{ + CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, + NewsItem, Ohlc, Period, Quote, SentimentData, +}; +use crate::api::{ + EarningsProvider, FinancialsProvider, InsightsProvider, MarketDataProvider, NewsProvider, + ProfileProvider, SentimentProvider, +}; use crate::cache::Cache; use crate::config::IdxConfig; use crate::error::IdxError; use crate::output::{ - MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_fundamental, - render_growth, render_history, render_quotes, render_risk, render_technical, render_valuation, + MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings, + render_financials, render_fundamental, render_growth, render_history, render_insights, + render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment, + render_technical, render_valuation, }; struct FundamentalCacheSpec { @@ -95,6 +104,45 @@ pub enum StocksSubcommand { /// Single ticker symbol (e.g. BBCA). symbol: String, }, + #[command(about = "Get company profile")] + Profile { symbol: String }, + #[command(about = "Get financial statements")] + Financials { + symbol: String, + #[arg(long, default_value = "income")] + statement: String, + }, + #[command(about = "Get earnings report")] + Earnings { + symbol: String, + #[arg(long)] + annual: bool, + #[arg(long)] + quarterly: bool, + #[arg(long)] + forecast: bool, + #[arg(long)] + history: bool, + }, + #[command(about = "Get crowd sentiment")] + Sentiment { symbol: String }, + #[command(about = "Get AI insights")] + Insights { symbol: String }, + #[command(about = "Get stock news")] + News { + symbol: String, + #[arg(long, default_value_t = 10)] + limit: usize, + }, + #[command(about = "MSN screener")] + Screen { + #[arg(long, default_value = "top-performers")] + filter: String, + #[arg(long, default_value = "id")] + region: String, + #[arg(long, default_value_t = 50)] + limit: usize, + }, #[command( about = "Compare fundamentals across stocks", after_help = "Examples:\n idx stocks compare BBCA BBRI BMRI\n idx stocks compare BBCA,BBRI,BMRI\n idx -o json stocks compare BBCA,BBRI" @@ -308,6 +356,71 @@ pub fn handle( )?; render_fundamental(&report, &config.output, config.no_color) } + StocksSubcommand::Profile { symbol } => { + let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let profile: CompanyProfile = fetch_msn_only(&resolved, config.provider, || { + MsnProvider::new(false).profile(&resolved) + })?; + render_profile(&profile, &config.output) + } + StocksSubcommand::Financials { + symbol, + statement: _, + } => { + let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let financials: FinancialStatements = + fetch_msn_only(&resolved, config.provider, || { + MsnProvider::new(false).financials(&resolved) + })?; + render_financials(&financials, &config.output) + } + StocksSubcommand::Earnings { + symbol, + annual: _, + quarterly: _, + forecast: _, + history: _, + } => { + let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let earnings: EarningsReport = fetch_msn_only(&resolved, config.provider, || { + MsnProvider::new(false).earnings(&resolved) + })?; + render_earnings(&earnings, &config.output) + } + StocksSubcommand::Sentiment { symbol } => { + let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let sentiment: SentimentData = fetch_msn_only(&resolved, config.provider, || { + MsnProvider::new(false).sentiment(&resolved) + })?; + render_sentiment(&sentiment, &config.output) + } + StocksSubcommand::Insights { symbol } => { + let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let insights: InsightData = fetch_msn_only(&resolved, config.provider, || { + MsnProvider::new(false).insights(&resolved) + })?; + render_insights(&insights, &config.output) + } + StocksSubcommand::News { symbol, limit } => { + let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + let news: Vec = fetch_msn_only(&resolved, config.provider, || { + MsnProvider::new(false).news(&resolved, *limit) + })?; + render_news(&news, &config.output) + } + StocksSubcommand::Screen { + filter, + region, + limit, + } => { + let msn = MsnProvider::new(false); + let filter_key = screener_filter_key(filter); + let region_key = screener_region_key(region); + let quotes: Vec = fetch_msn_only("screen", config.provider, || { + msn.screener(filter_key, region_key, *limit) + })?; + render_screener("es, &config.output, config.no_color) + } StocksSubcommand::Compare { symbols } => { let mut reports: Vec = Vec::new(); let mut last_error = None; @@ -499,6 +612,44 @@ fn average_last(values: &[f64], period: usize) -> Option { Some(values[start..].iter().sum::() / period as f64) } +fn fetch_msn_only( + symbol: &str, + provider: crate::config::ProviderKind, + f: impl FnOnce() -> Result, +) -> Result { + if !matches!(provider, crate::config::ProviderKind::Msn) { + return Err(IdxError::Unsupported(format!( + "{symbol}: command requires --provider msn" + ))); + } + f() +} + +fn screener_filter_key(filter: &str) -> &'static str { + match filter { + "top-performers" => "st_list_topperfs", + "worst-performers" => "st_list_poorperfs", + "high-dividend" => "st_list_highdividend", + "low-pe" => "st_list_lowpe", + "52w-high" => "st_list_52wkhi", + "52w-low" => "st_list_52wklow", + "high-volume" => "st_list_highvol", + "large-cap" => "st_list_largecap", + _ => "st_list_topperfs", + } +} + +fn screener_region_key(region: &str) -> &'static str { + match region { + "id" => "st_reg_id", + "us" => "st_reg_us", + "sg" => "st_reg_sg", + "hk" => "st_reg_hk", + "jp" => "st_reg_jp", + _ => "st_reg_id", + } +} + #[cfg(test)] mod tests { use chrono::{Days, NaiveDate}; diff --git a/src/output/mod.rs b/src/output/mod.rs index 162bd9a..d82af17 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -7,7 +7,10 @@ use serde::{Deserialize, Serialize}; use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport}; use crate::analysis::signals::TechnicalSignal; -use crate::api::types::{Ohlc, Quote}; +use crate::api::types::{ + CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, + SentimentData, +}; use crate::error::IdxError; #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)] @@ -137,6 +140,62 @@ pub fn render_compare( } } +pub fn render_profile(profile: &CompanyProfile, format: &OutputFormat) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_profile(profile), + OutputFormat::Json => json::print_json(profile), + } +} + +pub fn render_financials( + financials: &FinancialStatements, + format: &OutputFormat, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_financials(financials), + OutputFormat::Json => json::print_json(financials), + } +} + +pub fn render_earnings(report: &EarningsReport, format: &OutputFormat) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_earnings(report), + OutputFormat::Json => json::print_json(report), + } +} + +pub fn render_sentiment(data: &SentimentData, format: &OutputFormat) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_sentiment(data), + OutputFormat::Json => json::print_json(data), + } +} + +pub fn render_insights(data: &InsightData, format: &OutputFormat) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_insights(data), + OutputFormat::Json => json::print_json(data), + } +} + +pub fn render_news(items: &[NewsItem], format: &OutputFormat) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_news(items), + OutputFormat::Json => json::print_json(items), + } +} + +pub fn render_screener( + quotes: &[Quote], + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_quotes(quotes, no_color), + OutputFormat::Json => json::print_json(quotes), + } +} + pub fn emit_error(err: &IdxError, format: &OutputFormat) { match format { OutputFormat::Table => eprintln!("Error: {err}"), diff --git a/src/output/table.rs b/src/output/table.rs index 4879b4f..c5c5f27 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -3,7 +3,10 @@ use owo_colors::OwoColorize; use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport}; use crate::analysis::signals::Signal; -use crate::api::types::{Ohlc, Quote}; +use crate::api::types::{ + CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, + Quote, SentimentData, +}; use crate::error::IdxError; use crate::output::TechnicalReport; @@ -516,6 +519,130 @@ fn add_compare_row(table: &mut Table, label: &str, values: Vec) { table.add_row(row); } +pub fn print_profile(profile: &CompanyProfile) -> Result<(), IdxError> { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_header(vec!["FIELD", "VALUE"]); + table.add_row(vec![Cell::new("Symbol"), Cell::new(&profile.symbol)]); + table.add_row(vec![Cell::new("Name"), Cell::new(&profile.long_name)]); + table.add_row(vec![Cell::new("Sector"), Cell::new(&profile.sector)]); + table.add_row(vec![Cell::new("Industry"), Cell::new(&profile.industry)]); + table.add_row(vec![Cell::new("Website"), Cell::new(&profile.website)]); + println!("{table}"); + Ok(()) +} + +pub fn print_financials(fin: &FinancialStatements) -> Result<(), IdxError> { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_header(vec!["LINE ITEM", "VALUE"]); + if let Some(income) = &fin.income_statement { + for (k, v) in &income.values { + table.add_row(vec![Cell::new(k), Cell::new(format!("{v:.2}"))]); + } + } + println!("{table}"); + Ok(()) +} + +pub fn print_earnings(report: &EarningsReport) -> Result<(), IdxError> { + let mut table = Table::new(); + table.load_preset(UTF8_FULL).set_header(vec![ + "PERIOD", + "EPS ACT", + "EPS FC", + "SURPRISE", + "SURPRISE%", + "REVENUE", + "DATE", + ]); + for row in &report.history { + add_earnings_row(&mut table, row); + } + for row in &report.forecast { + add_earnings_row(&mut table, row); + } + println!("{table}"); + Ok(()) +} + +pub fn print_sentiment(data: &SentimentData) -> Result<(), IdxError> { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_header(vec!["RANGE", "BULLISH", "BEARISH", "NEUTRAL"]); + for row in &data.statistics { + table.add_row(vec![ + Cell::new(&row.time_range), + Cell::new(row.bullish), + Cell::new(row.bearish), + Cell::new(row.neutral), + ]); + } + println!("{table}"); + Ok(()) +} + +pub fn print_insights(data: &InsightData) -> Result<(), IdxError> { + println!("{}", data.summary); + if !data.highlights.is_empty() { + println!("Highlights:"); + for h in &data.highlights { + println!("- {h}"); + } + } + if !data.risks.is_empty() { + println!("Risks:"); + for r in &data.risks { + println!("- {r}"); + } + } + Ok(()) +} + +pub fn print_news(items: &[NewsItem]) -> Result<(), IdxError> { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_header(vec!["TITLE", "PROVIDER", "DATE", "URL"]); + for item in items { + table.add_row(vec![ + Cell::new(&item.title), + Cell::new(&item.provider), + Cell::new(&item.published_at), + Cell::new(truncate_url(&item.url)), + ]); + } + println!("{table}"); + Ok(()) +} + +fn add_earnings_row(table: &mut Table, row: &EarningsData) { + table.add_row(vec![ + Cell::new(&row.period_type), + Cell::new(format_float(row.eps_actual, 2)), + Cell::new(format_float(row.eps_forecast, 2)), + Cell::new(format_float(row.eps_surprise, 2)), + Cell::new(format_float(row.eps_surprise_pct, 2)), + Cell::new(format_float(row.revenue_actual, 2)), + Cell::new( + row.earning_release_date + .clone() + .unwrap_or_else(|| "-".to_string()), + ), + ]); +} + +fn truncate_url(url: &str) -> String { + if url.len() > 72 { + format!("{}...", &url[..72]) + } else { + url.to_string() + } +} + #[cfg(test)] mod tests { use super::{format_idr, format_signal, format_u64}; diff --git a/tests/cli.rs b/tests/cli.rs index 0e9d063..5211a1e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -107,28 +107,23 @@ fn technical_with_mock_provider_json_contains_fields() { } #[test] -fn msn_history_reports_unsupported() { - test_bin("msn-history-unsupported") +fn msn_history_is_no_longer_unsupported() { + test_bin("msn-history-supported") .env("IDX_PROVIDER", "msn") .args(["stocks", "history", "BBCA", "--period", "1mo"]) .assert() .failure() - .stderr(predicate::str::contains( - "MSN provider does not currently support history or technical analysis", - )); + .stderr(predicate::str::contains("MSN provider does not currently support").not()); } #[test] -fn msn_technical_json_reports_unsupported() { - test_bin("msn-technical-unsupported") +fn msn_technical_json_is_no_longer_unsupported() { + test_bin("msn-technical-supported") .env("IDX_PROVIDER", "msn") .args(["-o", "json", "stocks", "technical", "BBCA"]) .assert() .failure() - .stderr(predicate::str::contains("\"code\": \"UNSUPPORTED\"")) - .stderr(predicate::str::contains( - "MSN provider does not currently support history or technical analysis", - )); + .stderr(predicate::str::contains("\"code\": \"UNSUPPORTED\"").not()); } #[test]