mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
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:
parent
3998e38ffc
commit
a0da0a3adb
8 changed files with 1049 additions and 49 deletions
|
|
@ -1,10 +1,15 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
use crate::error::IdxError;
|
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;
|
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";
|
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)
|
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> {
|
pub(super) fn fetch_quotes(&self, symbol: &str) -> Result<Vec<MsnQuote>, IdxError> {
|
||||||
let id =
|
let id =
|
||||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
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");
|
format!("{MSN_API_BASE_URL}keyratios?apikey={MSN_API_KEY}&ids={id}&wrapodata=false");
|
||||||
self.get_json(&url, symbol, "keyratios")
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,17 @@ use std::collections::BTreeMap;
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate};
|
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 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;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, 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 §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<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)]
|
#[allow(dead_code)]
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub(super) enum ResampleInterval {
|
pub(super) enum ResampleInterval {
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,25 @@ mod parse;
|
||||||
mod raw_types;
|
mod raw_types;
|
||||||
mod symbols;
|
mod symbols;
|
||||||
|
|
||||||
use crate::api::types::{Bar, Fundamentals, Interval, Period, Quote};
|
use crate::api::types::{
|
||||||
use crate::api::{FundamentalsProvider, HistoryProvider, QuoteProvider};
|
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 crate::error::IdxError;
|
||||||
|
|
||||||
use client::MsnClient;
|
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};
|
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 {
|
pub struct MsnProvider {
|
||||||
client: MsnClient,
|
client: MsnClient,
|
||||||
}
|
}
|
||||||
|
|
@ -25,6 +33,16 @@ impl MsnProvider {
|
||||||
client: MsnClient::new(),
|
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 {
|
impl QuoteProvider for MsnProvider {
|
||||||
|
|
@ -45,33 +63,67 @@ impl FundamentalsProvider for MsnProvider {
|
||||||
impl HistoryProvider for MsnProvider {
|
impl HistoryProvider for MsnProvider {
|
||||||
fn history(
|
fn history(
|
||||||
&self,
|
&self,
|
||||||
_symbol: &str,
|
symbol: &str,
|
||||||
_period: &Period,
|
period: &Period,
|
||||||
_interval: &Interval,
|
_interval: &Interval,
|
||||||
) -> Result<Vec<Bar>, IdxError> {
|
) -> Result<Vec<Bar>, IdxError> {
|
||||||
Err(IdxError::Unsupported(
|
let chart_type = period_to_chart_type(period);
|
||||||
HISTORY_UNSUPPORTED_REASON.to_string(),
|
let raw = self.client.fetch_charts(symbol, chart_type)?;
|
||||||
))
|
parse_chart_history(symbol, period, &raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
impl ProfileProvider for MsnProvider {
|
||||||
mod tests {
|
fn profile(&self, symbol: &str) -> Result<CompanyProfile, IdxError> {
|
||||||
use super::MsnProvider;
|
let raw = self.client.fetch_equities(symbol)?;
|
||||||
use crate::api::HistoryProvider;
|
parse_profile(symbol, &raw)
|
||||||
use crate::api::types::{Interval, Period};
|
}
|
||||||
use crate::error::IdxError;
|
}
|
||||||
|
|
||||||
#[test]
|
impl EarningsProvider for MsnProvider {
|
||||||
fn history_is_explicitly_unsupported() {
|
fn earnings(&self, symbol: &str) -> Result<EarningsReport, IdxError> {
|
||||||
let provider = MsnProvider::new(false);
|
let raw = self.client.fetch_earnings(symbol)?;
|
||||||
let err = provider
|
parse_earnings(symbol, &raw)
|
||||||
.history("BBCA.JK", &Period::OneMonth, &Interval::Day)
|
}
|
||||||
.expect_err("history should be unsupported");
|
}
|
||||||
assert!(matches!(err, IdxError::Unsupported(_)));
|
|
||||||
assert!(
|
impl FinancialsProvider for MsnProvider {
|
||||||
err.to_string()
|
fn financials(&self, symbol: &str) -> Result<FinancialStatements, IdxError> {
|
||||||
.contains("MSN provider does not currently support history or technical analysis")
|
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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
use serde::de::Error as _;
|
use std::collections::HashMap;
|
||||||
use serde::{Deserialize, Deserializer};
|
|
||||||
|
|
||||||
|
use serde::de::Error as _;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct MsnQuote {
|
pub(crate) struct MsnQuote {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) symbol: Option<String>,
|
pub(crate) symbol: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) short_name: Option<String>,
|
||||||
pub(crate) price: Option<f64>,
|
pub(crate) price: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) price_change: Option<f64>,
|
pub(crate) price_change: Option<f64>,
|
||||||
|
|
@ -23,6 +28,8 @@ pub(crate) struct MsnQuote {
|
||||||
pub(crate) average_volume: Option<f64>,
|
pub(crate) average_volume: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) market_cap: Option<f64>,
|
pub(crate) market_cap: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) return_ytd: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -74,6 +81,8 @@ pub(crate) struct MsnChart {
|
||||||
pub(crate) series: ChartSeries,
|
pub(crate) series: ChartSeries,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) type RawChart = MsnChart;
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct ChartSeries {
|
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>
|
fn de_opt_f64_lenient<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
|
||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,23 @@ use crate::analysis::fundamental::{
|
||||||
};
|
};
|
||||||
use crate::analysis::signals::{self, Signal, TechnicalSignal};
|
use crate::analysis::signals::{self, Signal, TechnicalSignal};
|
||||||
use crate::analysis::technical;
|
use crate::analysis::technical;
|
||||||
use crate::api::MarketDataProvider;
|
use crate::api::msn::MsnProvider;
|
||||||
use crate::api::types::{Fundamentals, Interval, Ohlc, Period};
|
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::cache::Cache;
|
||||||
use crate::config::IdxConfig;
|
use crate::config::IdxConfig;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use crate::output::{
|
use crate::output::{
|
||||||
MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_fundamental,
|
MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings,
|
||||||
render_growth, render_history, render_quotes, render_risk, render_technical, render_valuation,
|
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 {
|
struct FundamentalCacheSpec {
|
||||||
|
|
@ -95,6 +104,45 @@ pub enum StocksSubcommand {
|
||||||
/// Single ticker symbol (e.g. BBCA).
|
/// Single ticker symbol (e.g. BBCA).
|
||||||
symbol: String,
|
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(
|
#[command(
|
||||||
about = "Compare fundamentals across stocks",
|
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"
|
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)
|
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<NewsItem> = 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<Quote> = fetch_msn_only("screen", config.provider, || {
|
||||||
|
msn.screener(filter_key, region_key, *limit)
|
||||||
|
})?;
|
||||||
|
render_screener("es, &config.output, config.no_color)
|
||||||
|
}
|
||||||
StocksSubcommand::Compare { symbols } => {
|
StocksSubcommand::Compare { symbols } => {
|
||||||
let mut reports: Vec<FundamentalReport> = Vec::new();
|
let mut reports: Vec<FundamentalReport> = Vec::new();
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
|
|
@ -499,6 +612,44 @@ fn average_last(values: &[f64], period: usize) -> Option<f64> {
|
||||||
Some(values[start..].iter().sum::<f64>() / period as f64)
|
Some(values[start..].iter().sum::<f64>() / period as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fetch_msn_only<T>(
|
||||||
|
symbol: &str,
|
||||||
|
provider: crate::config::ProviderKind,
|
||||||
|
f: impl FnOnce() -> Result<T, IdxError>,
|
||||||
|
) -> Result<T, IdxError> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use chrono::{Days, NaiveDate};
|
use chrono::{Days, NaiveDate};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport};
|
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport};
|
||||||
use crate::analysis::signals::TechnicalSignal;
|
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;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
#[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) {
|
pub fn emit_error(err: &IdxError, format: &OutputFormat) {
|
||||||
match format {
|
match format {
|
||||||
OutputFormat::Table => eprintln!("Error: {err}"),
|
OutputFormat::Table => eprintln!("Error: {err}"),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ use owo_colors::OwoColorize;
|
||||||
|
|
||||||
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport};
|
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport};
|
||||||
use crate::analysis::signals::Signal;
|
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::error::IdxError;
|
||||||
use crate::output::TechnicalReport;
|
use crate::output::TechnicalReport;
|
||||||
|
|
||||||
|
|
@ -516,6 +519,130 @@ fn add_compare_row(table: &mut Table, label: &str, values: Vec<String>) {
|
||||||
table.add_row(row);
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{format_idr, format_signal, format_u64};
|
use super::{format_idr, format_signal, format_u64};
|
||||||
|
|
|
||||||
17
tests/cli.rs
17
tests/cli.rs
|
|
@ -107,28 +107,23 @@ fn technical_with_mock_provider_json_contains_fields() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn msn_history_reports_unsupported() {
|
fn msn_history_is_no_longer_unsupported() {
|
||||||
test_bin("msn-history-unsupported")
|
test_bin("msn-history-supported")
|
||||||
.env("IDX_PROVIDER", "msn")
|
.env("IDX_PROVIDER", "msn")
|
||||||
.args(["stocks", "history", "BBCA", "--period", "1mo"])
|
.args(["stocks", "history", "BBCA", "--period", "1mo"])
|
||||||
.assert()
|
.assert()
|
||||||
.failure()
|
.failure()
|
||||||
.stderr(predicate::str::contains(
|
.stderr(predicate::str::contains("MSN provider does not currently support").not());
|
||||||
"MSN provider does not currently support history or technical analysis",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn msn_technical_json_reports_unsupported() {
|
fn msn_technical_json_is_no_longer_unsupported() {
|
||||||
test_bin("msn-technical-unsupported")
|
test_bin("msn-technical-supported")
|
||||||
.env("IDX_PROVIDER", "msn")
|
.env("IDX_PROVIDER", "msn")
|
||||||
.args(["-o", "json", "stocks", "technical", "BBCA"])
|
.args(["-o", "json", "stocks", "technical", "BBCA"])
|
||||||
.assert()
|
.assert()
|
||||||
.failure()
|
.failure()
|
||||||
.stderr(predicate::str::contains("\"code\": \"UNSUPPORTED\""))
|
.stderr(predicate::str::contains("\"code\": \"UNSUPPORTED\"").not());
|
||||||
.stderr(predicate::str::contains(
|
|
||||||
"MSN provider does not currently support history or technical analysis",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue