mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
MSN Finance/Charts does not serve OHLCV data for XIDX stocks (returns 404). Following the FP principle of not exposing capabilities a provider cannot fulfil: - Remove impl HistoryProvider for MsnProvider entirely - Remove fetch_charts from MsnClient - Remove parse_chart_history, resample_history, trim_history_to_period from map.rs - Remove MsnChart, ChartSeries, RawChart from raw_types.rs - Remove parse_history_from_str, parse_close_only_history from parse.rs - Decouple HistoryProvider from MarketDataProvider trait bound - Add history_provider() factory: returns None for MSN, Some(Yahoo) for Yahoo - CLI gates History/Technical on history_provider(), fails fast for MSN - MSN mock returns Err(Unsupported); tests verify the behaviour explicitly
243 lines
9.3 KiB
Rust
243 lines
9.3 KiB
Rust
use std::time::Duration;
|
|
|
|
use serde::Serialize;
|
|
use serde::de::DeserializeOwned;
|
|
|
|
use crate::error::IdxError;
|
|
|
|
use super::raw_types::{
|
|
KeyRatios, MsnQuote, 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";
|
|
const MSN_ASSETS_BASE_URL: &str = "https://assets.msn.com/service/";
|
|
const MSN_API_BASE_URL: &str = "https://api.msn.com/msn/v0/pages/finance/";
|
|
// Public API key from MSN Money website (embedded in frontend JS)
|
|
const MSN_API_KEY: &str = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM";
|
|
|
|
pub(super) struct MsnClient {
|
|
agent: ureq::Agent,
|
|
}
|
|
|
|
impl MsnClient {
|
|
pub(super) fn new() -> Self {
|
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
|
.timeout_connect(Some(Duration::from_secs(5)))
|
|
.timeout_recv_body(Some(Duration::from_secs(10)))
|
|
.build()
|
|
.into();
|
|
|
|
Self { agent }
|
|
}
|
|
|
|
fn get_json<T: DeserializeOwned>(
|
|
&self,
|
|
url: &str,
|
|
symbol: &str,
|
|
endpoint: &str,
|
|
) -> Result<T, IdxError> {
|
|
let mut wait = Duration::from_millis(500);
|
|
for attempt in 0..3 {
|
|
let response = self
|
|
.agent
|
|
.get(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/")
|
|
.call();
|
|
|
|
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)
|
|
}
|
|
|
|
fn post_json<B: Serialize, T: DeserializeOwned>(
|
|
&self,
|
|
url: &str,
|
|
body: &B,
|
|
symbol: &str,
|
|
endpoint: &str,
|
|
) -> Result<T, IdxError> {
|
|
let mut wait = Duration::from_millis(500);
|
|
for attempt in 0..3 {
|
|
let response = self
|
|
.agent
|
|
.post(url)
|
|
.header("User-Agent", USER_AGENT)
|
|
.header("Accept", "application/json")
|
|
.header("Accept-Language", "en-US,en;q=0.9,id;q=0.8")
|
|
.header("Origin", "https://www.msn.com")
|
|
.header("Referer", "https://www.msn.com/")
|
|
.header("Content-Type", "text/plain;charset=UTF-8")
|
|
.send_json(body);
|
|
|
|
match response {
|
|
Ok(ok) => {
|
|
return ok
|
|
.into_body()
|
|
.read_json::<T>()
|
|
.map_err(|e| IdxError::ParseError(format!("msn {endpoint}: {e}")));
|
|
}
|
|
Err(ureq::Error::StatusCode(404)) => {
|
|
return Err(IdxError::SymbolNotFound(symbol.to_string()));
|
|
}
|
|
Err(ureq::Error::StatusCode(429)) => {
|
|
if attempt < 2 {
|
|
std::thread::sleep(wait);
|
|
wait *= 2;
|
|
continue;
|
|
}
|
|
return Err(IdxError::RateLimited);
|
|
}
|
|
Err(ureq::Error::StatusCode(code)) if code >= 500 => {
|
|
if attempt < 2 {
|
|
std::thread::sleep(wait);
|
|
wait *= 2;
|
|
continue;
|
|
}
|
|
return Err(IdxError::Http(format!("msn {endpoint}: status {code}")));
|
|
}
|
|
Err(err) => return Err(IdxError::Http(format!("msn {endpoint}: {err}"))),
|
|
}
|
|
}
|
|
Err(IdxError::RateLimited)
|
|
}
|
|
|
|
pub(super) fn fetch_quotes(&self, symbol: &str) -> Result<Vec<MsnQuote>, IdxError> {
|
|
let id =
|
|
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
|
let url = format!(
|
|
"{MSN_ASSETS_BASE_URL}Finance/Quotes?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
|
|
);
|
|
self.get_json(&url, symbol, "quote")
|
|
}
|
|
|
|
pub(super) fn fetch_key_ratios(&self, symbol: &str) -> Result<Vec<KeyRatios>, IdxError> {
|
|
let id =
|
|
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
|
let url =
|
|
format!("{MSN_API_BASE_URL}keyratios?apikey={MSN_API_KEY}&ids={id}&wrapodata=false");
|
|
self.get_json(&url, symbol, "keyratios")
|
|
}
|
|
|
|
pub(super) fn fetch_equities(&self, symbol: &str) -> Result<Vec<RawEquity>, IdxError> {
|
|
let id =
|
|
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
|
let url = format!(
|
|
"{MSN_ASSETS_BASE_URL}Finance/Equities?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
|
|
);
|
|
self.get_json(&url, symbol, "equities")
|
|
}
|
|
|
|
pub(super) fn fetch_financial_statements(
|
|
&self,
|
|
symbol: &str,
|
|
) -> Result<Vec<RawFinancialStatement>, IdxError> {
|
|
let id =
|
|
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
|
let url = format!(
|
|
"{MSN_ASSETS_BASE_URL}Finance/Equities/financialstatements?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
|
|
);
|
|
self.get_json(&url, symbol, "financialstatements")
|
|
}
|
|
|
|
pub(super) fn fetch_earnings(&self, symbol: &str) -> Result<RawEarningsResponse, IdxError> {
|
|
let id =
|
|
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
|
let url = format!(
|
|
"{MSN_ASSETS_BASE_URL}Finance/Events/Earnings?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
|
|
);
|
|
self.get_json(&url, symbol, "earnings")
|
|
}
|
|
|
|
pub(super) fn fetch_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")
|
|
}
|
|
}
|