feat: support explicit MSN chart history

This commit is contained in:
Rasyidan Akbar F. 2026-04-15 13:48:27 +07:00
commit a2d15d2125
13 changed files with 280 additions and 68 deletions

View file

@ -215,8 +215,8 @@ fn msn_capability_error(subject: &str) -> IdxError {
/// Resolves a history provider based on the selected market data provider and
/// history provider strategy.
///
/// `history_mode=auto` means: use the selected provider when it supports history,
/// otherwise transparently fallback to Yahoo.
/// `history_mode=auto` keeps using Yahoo for IDX history because Yahoo provides
/// full OHLCV candles. Explicit `msn` opts into MSN's price-only chart feed.
pub fn history_provider(
provider: ProviderKind,
history_mode: HistoryProviderKind,
@ -232,12 +232,6 @@ pub fn history_provider(
};
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
if matches!(resolved, ProviderKind::Msn) {
return Err(IdxError::Unsupported(
"MSN does not provide price history for IDX stocks. Use --history-provider yahoo or auto."
.into(),
));
}
return Ok((
resolved,
Box::new(MockProvider::from_fixtures_with_history_verbose(
@ -248,10 +242,7 @@ pub fn history_provider(
match resolved {
ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))),
ProviderKind::Msn => Err(IdxError::Unsupported(
"MSN does not provide price history for IDX stocks. Use --history-provider yahoo or auto."
.into(),
)),
ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))),
}
}
@ -317,10 +308,10 @@ impl MockProvider {
.map_err(|e| IdxError::ParseError(e.to_string()));
let fundamentals = msn::parse_fundamentals_from_str(&fundamentals_raw, Some(&quote_raw))
.map_err(|e| IdxError::ParseError(e.to_string()));
// MSN Finance/Charts returns 404 for IDX (XIDX) — history not supported
let history = Err(IdxError::Unsupported(
"MSN does not provide price history for IDX stocks. Use --history-provider yahoo or auto.".into(),
));
let history_raw = std::fs::read_to_string("tests/fixtures/msn_chart_bbca_3m.json")
.unwrap_or_else(|_| "[]".to_string());
let history = msn::parse_history_from_str("BBCA.JK", &history_raw)
.map_err(|e| IdxError::ParseError(e.to_string()));
Self {
quote,

View file

@ -6,10 +6,12 @@ use serde::de::DeserializeOwned;
use crate::error::IdxError;
use super::raw_types::{
KeyRatios, MsnQuote, RawEarningsResponse, RawEquity, RawFinancialStatement, RawInsight,
RawNewsFeed, RawScreenerResponse, RawSentiment, ScreenerFilter, ScreenerOrder, ScreenerRequest,
KeyRatios, MsnQuote, RawChartResponse, RawEarningsResponse, RawEquity, RawFinancialStatement,
RawInsight, RawNewsFeed, RawScreenerResponse, RawSentiment, ScreenerFilter, ScreenerOrder,
ScreenerRequest,
};
use super::symbols::resolve_msn_id;
use crate::api::types::{Interval, Period};
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/";
@ -58,6 +60,7 @@ impl MsnClient {
"insights" => include_str!("../../../tests/fixtures/msn_insights_bbca.json"),
"news" => include_str!("../../../tests/fixtures/msn_news_bbca.json"),
"screener" => include_str!("../../../tests/fixtures/msn_screener_id_topperfs.json"),
"chart" => include_str!("../../../tests/fixtures/msn_chart_bbca_3m.json"),
_ => return None,
})
}
@ -296,4 +299,68 @@ impl MsnClient {
self.post_json(&url, &req, "SCREENER", "screener")
}
pub(super) fn fetch_chart(
&self,
symbol: &str,
period: &Period,
interval: &Interval,
) -> Result<Vec<RawChartResponse>, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let chart_type = msn_chart_type(period, interval)?;
let url = format!(
"{MSN_ASSETS_BASE_URL}Finance/Charts?apikey={MSN_API_KEY}&cm=id-id&ids={id}&type={chart_type}&wrapodata=false"
);
self.get_json(&url, symbol, "chart")
}
}
fn msn_chart_type(period: &Period, interval: &Interval) -> Result<&'static str, IdxError> {
if !matches!(interval, Interval::Day) {
return Err(IdxError::Unsupported(
"MSN charts currently support only --interval 1d for IDX history".into(),
));
}
match period {
Period::OneMonth => Ok("1M"),
Period::ThreeMonths => Ok("3M"),
Period::OneYear => Ok("1Y"),
_ => Err(IdxError::Unsupported(
"MSN charts currently support --period 1mo, 3mo, or 1y with --interval 1d".into(),
)),
}
}
#[cfg(test)]
mod tests {
use super::msn_chart_type;
use crate::api::types::{Interval, Period};
use crate::error::IdxError;
#[test]
fn maps_supported_msn_chart_types() {
assert_eq!(
msn_chart_type(&Period::OneMonth, &Interval::Day).unwrap(),
"1M"
);
assert_eq!(
msn_chart_type(&Period::ThreeMonths, &Interval::Day).unwrap(),
"3M"
);
assert_eq!(
msn_chart_type(&Period::OneYear, &Interval::Day).unwrap(),
"1Y"
);
}
#[test]
fn rejects_unsupported_msn_chart_types() {
let err = msn_chart_type(&Period::ThreeMonths, &Interval::Week).unwrap_err();
assert!(matches!(err, IdxError::Unsupported(_)));
let err = msn_chart_type(&Period::SixMonths, &Interval::Day).unwrap_err();
assert!(matches!(err, IdxError::Unsupported(_)));
}
}

View file

@ -1,14 +1,16 @@
use super::raw_types::{
IndustryMetric, KeyRatios, MsnQuote, RawEarningsData, RawEarningsResponse, RawEquity,
RawFinancialStatement, RawInsight, RawInsightItem, RawLocalizedAttribute, RawNewsFeed,
RawScreenerResponse, RawSentiment, RawStatementSection,
IndustryMetric, KeyRatios, MsnQuote, RawChartResponse, RawChartSeries, RawEarningsData,
RawEarningsResponse, RawEquity, RawFinancialStatement, RawInsight, RawInsightItem,
RawLocalizedAttribute, RawNewsFeed, RawScreenerResponse, RawSentiment, RawStatementSection,
};
use super::symbols::{normalized_symbol, ticker_from_symbol};
use crate::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals, InsightData,
InstrumentInfo, NewsItem, Officer, Quote, SentimentData, SentimentPeriod, StatementSection,
InstrumentInfo, NewsItem, Officer, Ohlc, Quote, SentimentData, SentimentPeriod,
StatementSection,
};
use crate::error::IdxError;
use chrono::DateTime;
use std::collections::HashMap;
pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, IdxError> {
@ -628,6 +630,74 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
Ok(results)
}
pub(super) fn parse_history(
symbol: &str,
charts: &[RawChartResponse],
) -> Result<Vec<Ohlc>, IdxError> {
let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?;
let series = chart.series.as_ref().ok_or(IdxError::ProviderUnavailable)?;
let mut out = Vec::new();
for (idx, raw_ts) in series.time_stamps.iter().enumerate() {
let Some(close_raw) = series.prices.get(idx).copied().flatten() else {
continue;
};
if !close_raw.is_finite() {
continue;
}
let timestamp = DateTime::parse_from_rfc3339(raw_ts)
.map_err(|e| IdxError::ParseError(format!("msn chart timestamp '{raw_ts}': {e}")))?;
let close = round_price(close_raw);
let open = series_price_at(&series.open_prices, idx).unwrap_or(close);
let high = series_price_at(&series.prices_high, idx).unwrap_or(close);
let low = series_price_at(&series.prices_low, idx).unwrap_or(close);
let volume = series_volume_at(series, idx);
out.push(Ohlc {
date: timestamp.date_naive(),
open,
high,
low,
close,
volume,
});
}
if out.is_empty() {
return Err(IdxError::ProviderUnavailable);
}
if let Some(raw_symbol) = chart.symbol.as_deref()
&& let (Some(expected), Some(actual)) =
(ticker_from_symbol(symbol), ticker_from_symbol(raw_symbol))
&& expected != actual
{
return Err(IdxError::SymbolNotFound(symbol.to_string()));
}
Ok(out)
}
fn series_price_at(values: &[Option<f64>], idx: usize) -> Option<i64> {
values
.get(idx)
.copied()
.flatten()
.filter(|value| value.is_finite())
.map(round_price)
}
fn series_volume_at(series: &RawChartSeries, idx: usize) -> u64 {
series
.volumes
.get(idx)
.copied()
.flatten()
.filter(|value| value.is_finite() && !value.is_sign_negative())
.map(|value| value.round() as u64)
.unwrap_or(0)
}
fn parse_statement_section(section: &RawStatementSection) -> StatementSection {
// MSN financial statement values are nested one level deep inside sub-objects
// (e.g., incomeStatement.income.{lineItems}, incomeStatement.revenue.{lineItems})
@ -705,9 +775,9 @@ fn collect_earnings(
#[cfg(test)]
mod tests {
use super::{
KeyRatios, RawFinancialStatement, RawNewsFeed, RawScreenerResponse, RawSentiment,
parse_financial_statements, parse_fundamentals, parse_news, parse_screener_results,
parse_sentiment,
KeyRatios, RawChartResponse, RawFinancialStatement, RawNewsFeed, RawScreenerResponse,
RawSentiment, parse_financial_statements, parse_fundamentals, parse_history, parse_news,
parse_screener_results, parse_sentiment,
};
use crate::error::IdxError;
@ -861,4 +931,21 @@ mod tests {
"unsupported: company fundamentals unavailable from MSN; industry fallback is disabled"
);
}
#[test]
fn parses_msn_chart_price_only_fixture_as_synthetic_ohlc() {
let raw: Vec<RawChartResponse> = serde_json::from_str(include_str!(
"../../../tests/fixtures/msn_chart_bbca_3m.json"
))
.expect("chart fixture should deserialize");
let history = parse_history("BBCA.JK", &raw).expect("chart history should parse");
assert_eq!(history.len(), 3);
assert_eq!(history[0].date.to_string(), "2026-01-13");
assert_eq!(history[0].open, 8000);
assert_eq!(history[0].high, 8000);
assert_eq!(history[0].low, 8000);
assert_eq!(history[0].close, 8000);
assert_eq!(history[0].volume, 0);
}
}

View file

@ -7,22 +7,22 @@ mod raw_types;
mod symbols;
use crate::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, NewsItem,
Quote, SentimentData,
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
NewsItem, Period, Quote, SentimentData,
};
use crate::api::{
EarningsProvider, FinancialsProvider, FundamentalsProvider, InsightsProvider, NewsProvider,
ProfileProvider, QuoteProvider, ScreenerProvider, SentimentProvider,
EarningsProvider, FinancialsProvider, FundamentalsProvider, HistoryProvider, InsightsProvider,
NewsProvider, ProfileProvider, QuoteProvider, ScreenerProvider, SentimentProvider,
};
use crate::error::IdxError;
use client::MsnClient;
use map::{
parse_earnings, parse_financial_statements, parse_fundamentals, parse_insights, parse_news,
parse_profile, parse_quote, parse_screener_results, parse_sentiment,
parse_earnings, parse_financial_statements, parse_fundamentals, parse_history, parse_insights,
parse_news, parse_profile, parse_quote, parse_screener_results, parse_sentiment,
};
pub(crate) use parse::{parse_fundamentals_from_str, parse_quote_from_str};
pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
pub struct MsnProvider {
client: MsnClient,
@ -51,6 +51,18 @@ impl FundamentalsProvider for MsnProvider {
}
}
impl HistoryProvider for MsnProvider {
fn history(
&self,
symbol: &str,
period: &Period,
interval: &Interval,
) -> Result<Vec<Bar>, IdxError> {
let raw = self.client.fetch_chart(symbol, period, interval)?;
parse_history(symbol, &raw)
}
}
impl ProfileProvider for MsnProvider {
fn profile(&self, symbol: &str) -> Result<CompanyProfile, IdxError> {
let raw = self.client.fetch_equities(symbol)?;

View file

@ -1,6 +1,6 @@
use super::map::{parse_fundamentals, parse_quote};
use super::raw_types::{KeyRatios, MsnQuote};
use crate::api::types::{Fundamentals, Quote};
use super::map::{parse_fundamentals, parse_history, parse_quote};
use super::raw_types::{KeyRatios, MsnQuote, RawChartResponse};
use crate::api::types::{Fundamentals, Ohlc, Quote};
use crate::error::IdxError;
#[cfg_attr(not(test), allow(dead_code))]
@ -25,11 +25,18 @@ pub(crate) fn parse_fundamentals_from_str(
parse_fundamentals(&ratios, quote.as_ref())
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn parse_history_from_str(symbol: &str, raw: &str) -> Result<Vec<Ohlc>, IdxError> {
let charts: Vec<RawChartResponse> =
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
parse_history(symbol, &charts)
}
#[cfg_attr(not(test), allow(dead_code))]
#[allow(dead_code)]
#[cfg(test)]
mod tests {
use super::{parse_fundamentals_from_str, parse_quote_from_str};
use super::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
fn minimal_quote_raw() -> &'static str {
r#"[{"symbol":"BBCA","marketCap":1215200000000000}]"#
@ -64,6 +71,17 @@ mod tests {
assert_eq!(fundamentals.market_cap, Some(1_215_200_000_000_000));
}
#[test]
fn parses_history_fixture_json() {
let raw = std::fs::read_to_string("tests/fixtures/msn_chart_bbca_3m.json")
.expect("chart fixture exists");
let history = parse_history_from_str("BBCA.JK", &raw).expect("chart fixture parsed");
assert_eq!(history.len(), 3);
assert_eq!(history[0].date.to_string(), "2026-01-13");
assert_eq!(history[0].close, 8000);
}
#[test]
fn parses_fundamentals_with_infinity_string_as_missing_data() {
let raw = r#"[

View file

@ -319,6 +319,37 @@ pub(super) struct RawScreenerResponse {
pub(super) quote: Option<Vec<MsnQuote>>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawChartResponse {
#[serde(rename = "_p")]
pub(super) id: Option<String>,
pub(super) chart_type: Option<String>,
pub(super) symbol: Option<String>,
pub(super) series: Option<RawChartSeries>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawChartSeries {
#[serde(default)]
pub(super) time_stamps: Vec<String>,
#[serde(default)]
pub(super) prices: Vec<Option<f64>>,
#[serde(default)]
pub(super) open_prices: Vec<Option<f64>>,
#[serde(default)]
pub(super) prices_high: Vec<Option<f64>>,
#[serde(default)]
pub(super) prices_low: Vec<Option<f64>>,
#[serde(default)]
pub(super) volumes: Vec<Option<f64>>,
pub(super) start_time: Option<String>,
pub(super) end_time: Option<String>,
}
fn de_opt_f64_lenient<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,