mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
refactor(msn): remove HistoryProvider — Finance/Charts 404s on all IDX stocks
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
This commit is contained in:
parent
2207c928b2
commit
6d6d51a04f
8 changed files with 71 additions and 504 deletions
|
|
@ -26,8 +26,11 @@ pub trait FundamentalsProvider {
|
||||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait MarketDataProvider: QuoteProvider + FundamentalsProvider + HistoryProvider {}
|
/// Core provider trait — quote + fundamentals only.
|
||||||
impl<T> MarketDataProvider for T where T: QuoteProvider + FundamentalsProvider + HistoryProvider {}
|
/// History is a separate capability (`HistoryProvider`) not all providers support
|
||||||
|
/// (e.g. MSN Finance/Charts returns 404 for IDX/XIDX stocks).
|
||||||
|
pub trait MarketDataProvider: QuoteProvider + FundamentalsProvider {}
|
||||||
|
impl<T> MarketDataProvider for T where T: QuoteProvider + FundamentalsProvider {}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub trait ProfileProvider {
|
pub trait ProfileProvider {
|
||||||
|
|
@ -81,6 +84,18 @@ pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box<dyn Market
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a history-capable provider, or `None` if the selected provider doesn't
|
||||||
|
/// support price history (e.g. MSN Finance/Charts returns 404 for IDX/XIDX stocks).
|
||||||
|
pub fn history_provider(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> {
|
||||||
|
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
|
||||||
|
return Some(Box::new(MockProvider::from_fixtures(provider)));
|
||||||
|
}
|
||||||
|
match provider {
|
||||||
|
ProviderKind::Yahoo => Some(Box::new(yahoo::YahooProvider::new(verbose))),
|
||||||
|
ProviderKind::Msn => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MockProvider {
|
pub struct MockProvider {
|
||||||
quote: Result<Quote, IdxError>,
|
quote: Result<Quote, IdxError>,
|
||||||
fundamentals: Result<Fundamentals, IdxError>,
|
fundamentals: Result<Fundamentals, IdxError>,
|
||||||
|
|
@ -124,8 +139,6 @@ impl MockProvider {
|
||||||
fn from_msn_fixtures() -> Self {
|
fn from_msn_fixtures() -> Self {
|
||||||
let quote_raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json")
|
let quote_raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json")
|
||||||
.unwrap_or_else(|_| "[]".to_string());
|
.unwrap_or_else(|_| "[]".to_string());
|
||||||
let history_raw = std::fs::read_to_string("tests/fixtures/msn_chart_bbca_3mo.json")
|
|
||||||
.unwrap_or_else(|_| "[]".to_string());
|
|
||||||
let fundamentals_raw = std::fs::read_to_string("tests/fixtures/msn_keyratios_bbca.json")
|
let fundamentals_raw = std::fs::read_to_string("tests/fixtures/msn_keyratios_bbca.json")
|
||||||
.unwrap_or_else(|_| "[]".to_string());
|
.unwrap_or_else(|_| "[]".to_string());
|
||||||
|
|
||||||
|
|
@ -133,9 +146,10 @@ impl MockProvider {
|
||||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||||
let fundamentals = msn::parse_fundamentals_from_str(&fundamentals_raw, Some("e_raw))
|
let fundamentals = msn::parse_fundamentals_from_str(&fundamentals_raw, Some("e_raw))
|
||||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||||
let history =
|
// MSN Finance/Charts returns 404 for IDX (XIDX) — history not supported
|
||||||
msn::parse_history_from_str(&crate::api::types::Period::ThreeMonths, &history_raw)
|
let history = Err(IdxError::Unsupported(
|
||||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
"MSN does not provide price history for IDX stocks. Use --provider yahoo.".into(),
|
||||||
|
));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
quote,
|
quote,
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@ use serde::de::DeserializeOwned;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
use super::raw_types::{
|
use super::raw_types::{
|
||||||
KeyRatios, MsnQuote, RawChart, RawEarningsResponse, RawEquity, RawFinancialStatement,
|
KeyRatios, MsnQuote, RawEarningsResponse, RawEquity, RawFinancialStatement, RawInsight,
|
||||||
RawInsight, RawNewsFeed, RawScreenerResponse, RawSentiment, ScreenerFilter, ScreenerOrder,
|
RawNewsFeed, RawScreenerResponse, RawSentiment, ScreenerFilter, ScreenerOrder, ScreenerRequest,
|
||||||
ScreenerRequest,
|
|
||||||
};
|
};
|
||||||
use super::symbols::resolve_msn_id;
|
use super::symbols::resolve_msn_id;
|
||||||
|
|
||||||
|
|
@ -182,19 +181,6 @@ impl MsnClient {
|
||||||
self.get_json(&url, symbol, "earnings")
|
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> {
|
pub(super) fn fetch_sentiment(&self, symbol: &str) -> Result<Vec<RawSentiment>, 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()))?;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate};
|
|
||||||
|
|
||||||
use super::raw_types::{
|
use super::raw_types::{
|
||||||
IndustryMetric, KeyRatios, MsnChart, MsnQuote, RawChart, RawEarningsData, RawEarningsResponse,
|
IndustryMetric, KeyRatios, MsnQuote, RawEarningsData, RawEarningsResponse, RawEquity,
|
||||||
RawEquity, RawFinancialStatement, RawInsight, RawNewsFeed, RawScreenerResponse, RawSentiment,
|
RawFinancialStatement, RawInsight, RawNewsFeed, RawScreenerResponse, RawSentiment,
|
||||||
RawStatementSection,
|
RawStatementSection,
|
||||||
};
|
};
|
||||||
use super::symbols::{normalized_symbol, ticker_from_symbol};
|
use super::symbols::{normalized_symbol, ticker_from_symbol};
|
||||||
use crate::api::types::{
|
use crate::api::types::{
|
||||||
Bar, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals,
|
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals, InsightData,
|
||||||
InsightData, InstrumentInfo, NewsItem, Officer, Ohlc, Period, Quote, SentimentData,
|
InstrumentInfo, NewsItem, Officer, Quote, SentimentData, SentimentPeriod, StatementSection,
|
||||||
SentimentPeriod, StatementSection,
|
|
||||||
};
|
};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
|
@ -109,94 +104,6 @@ pub(super) fn parse_fundamentals(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn parse_history(period: &Period, charts: &[MsnChart]) -> Result<Vec<Ohlc>, IdxError> {
|
|
||||||
parse_history_with_drop_count(period, charts).map(|v| v.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn parse_history_with_drop_count(
|
|
||||||
period: &Period,
|
|
||||||
charts: &[MsnChart],
|
|
||||||
) -> Result<(Vec<Ohlc>, usize), IdxError> {
|
|
||||||
let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?;
|
|
||||||
|
|
||||||
if !chart.series.has_real_ohlcv() {
|
|
||||||
return Err(IdxError::ParseError(
|
|
||||||
"msn does not expose real OHLC/volume for this history range".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let timestamps = &chart.series.time_stamps;
|
|
||||||
|
|
||||||
let mut grouped: BTreeMap<NaiveDate, Ohlc> = BTreeMap::new();
|
|
||||||
let mut dropped = 0usize;
|
|
||||||
|
|
||||||
for (idx, raw_ts) in timestamps.iter().enumerate() {
|
|
||||||
let Some(date) = parse_chart_date(raw_ts) else {
|
|
||||||
dropped += 1;
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let point = (
|
|
||||||
chart.series.open_prices.get(idx).copied(),
|
|
||||||
chart.series.prices_high.get(idx).copied(),
|
|
||||||
chart.series.prices_low.get(idx).copied(),
|
|
||||||
chart.series.prices.get(idx).copied(),
|
|
||||||
chart.series.volumes.get(idx).copied(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let (Some(open), Some(high), Some(low), Some(close), Some(volume)) = point else {
|
|
||||||
dropped += 1;
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let candle = Ohlc {
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
|
|
||||||
grouped
|
|
||||||
.entry(date)
|
|
||||||
.and_modify(|existing| {
|
|
||||||
existing.high = existing.high.max(candle.high);
|
|
||||||
existing.low = existing.low.min(candle.low);
|
|
||||||
existing.close = candle.close;
|
|
||||||
existing.volume = existing.volume.saturating_add(candle.volume);
|
|
||||||
})
|
|
||||||
.or_insert(candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out: Vec<Ohlc> = grouped.into_values().collect();
|
|
||||||
trim_history_to_period(period, &mut out);
|
|
||||||
|
|
||||||
if out.is_empty() {
|
|
||||||
return Err(IdxError::ProviderUnavailable);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((out, dropped))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn trim_history_to_period(period: &Period, rows: &mut Vec<Ohlc>) {
|
|
||||||
let days: i64 = match period {
|
|
||||||
Period::OneDay => return,
|
|
||||||
Period::FiveDays => 5,
|
|
||||||
Period::OneMonth => 31,
|
|
||||||
Period::ThreeMonths => 92,
|
|
||||||
Period::SixMonths => 183,
|
|
||||||
Period::OneYear => 366,
|
|
||||||
Period::TwoYears => 731,
|
|
||||||
Period::FiveYears => 1826,
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(last_date) = rows.last().map(|item| item.date) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let cutoff = last_date - chrono::Duration::days(days.saturating_sub(1));
|
|
||||||
rows.retain(|item| item.date >= cutoff);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preferred_metric(metrics: &[IndustryMetric]) -> Option<&IndustryMetric> {
|
fn preferred_metric(metrics: &[IndustryMetric]) -> Option<&IndustryMetric> {
|
||||||
metrics.iter().max_by_key(|metric| metric_rank(metric))
|
metrics.iter().max_by_key(|metric| metric_rank(metric))
|
||||||
}
|
}
|
||||||
|
|
@ -264,16 +171,6 @@ fn sanitize_current_ratio(value: Option<f64>) -> Option<f64> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_chart_date(raw: &str) -> Option<NaiveDate> {
|
|
||||||
if let Ok(date) = chrono::DateTime::parse_from_rfc3339(raw) {
|
|
||||||
return Some(date.date_naive());
|
|
||||||
}
|
|
||||||
if let Ok(timestamp) = raw.parse::<i64>() {
|
|
||||||
return chrono::DateTime::from_timestamp(timestamp, 0).map(|dt| dt.date_naive());
|
|
||||||
}
|
|
||||||
NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn round_price(value: f64) -> i64 {
|
fn round_price(value: f64) -> i64 {
|
||||||
value.round() as i64
|
value.round() as i64
|
||||||
}
|
}
|
||||||
|
|
@ -378,57 +275,6 @@ pub(super) fn parse_earnings(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
pub(super) fn parse_sentiment(
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
raw: &[RawSentiment],
|
raw: &[RawSentiment],
|
||||||
|
|
@ -673,38 +519,3 @@ fn collect_earnings(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(super) enum ResampleInterval {
|
|
||||||
Week,
|
|
||||||
Month,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(super) fn resample_history(rows: &[Ohlc], interval: ResampleInterval) -> Vec<Ohlc> {
|
|
||||||
let mut grouped: BTreeMap<(i32, u32), Ohlc> = BTreeMap::new();
|
|
||||||
|
|
||||||
for row in rows {
|
|
||||||
let key = match interval {
|
|
||||||
ResampleInterval::Week => {
|
|
||||||
let iso = row.date.iso_week();
|
|
||||||
(iso.year(), iso.week())
|
|
||||||
}
|
|
||||||
ResampleInterval::Month => (row.date.year(), row.date.month()),
|
|
||||||
};
|
|
||||||
|
|
||||||
grouped
|
|
||||||
.entry(key)
|
|
||||||
.and_modify(|existing| {
|
|
||||||
existing.high = existing.high.max(row.high);
|
|
||||||
existing.low = existing.low.min(row.low);
|
|
||||||
existing.close = row.close;
|
|
||||||
existing.volume = existing.volume.saturating_add(row.volume);
|
|
||||||
existing.date = row.date;
|
|
||||||
})
|
|
||||||
.or_insert_with(|| row.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
grouped.into_values().collect()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,22 @@ mod raw_types;
|
||||||
mod symbols;
|
mod symbols;
|
||||||
|
|
||||||
use crate::api::types::{
|
use crate::api::types::{
|
||||||
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
|
CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, NewsItem,
|
||||||
NewsItem, Period, Quote, SentimentData,
|
Quote, SentimentData,
|
||||||
};
|
};
|
||||||
use crate::api::{
|
use crate::api::{
|
||||||
EarningsProvider, FinancialsProvider, FundamentalsProvider, HistoryProvider, InsightsProvider,
|
EarningsProvider, FinancialsProvider, FundamentalsProvider, InsightsProvider, NewsProvider,
|
||||||
NewsProvider, ProfileProvider, QuoteProvider, SentimentProvider,
|
ProfileProvider, QuoteProvider, SentimentProvider,
|
||||||
};
|
};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
use client::MsnClient;
|
use client::MsnClient;
|
||||||
use map::{
|
use map::{
|
||||||
parse_chart_history, parse_earnings, parse_financial_statements, parse_fundamentals,
|
parse_earnings, parse_financial_statements, parse_fundamentals, parse_insights, parse_news,
|
||||||
parse_insights, parse_news, parse_profile, parse_quote, parse_screener_results,
|
parse_profile, parse_quote, parse_screener_results, parse_sentiment,
|
||||||
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_quote_from_str};
|
||||||
|
|
||||||
pub struct MsnProvider {
|
pub struct MsnProvider {
|
||||||
client: MsnClient,
|
client: MsnClient,
|
||||||
|
|
@ -60,31 +59,6 @@ impl FundamentalsProvider for MsnProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HistoryProvider for MsnProvider {
|
|
||||||
fn history(
|
|
||||||
&self,
|
|
||||||
symbol: &str,
|
|
||||||
period: &Period,
|
|
||||||
_interval: &Interval,
|
|
||||||
) -> Result<Vec<Bar>, IdxError> {
|
|
||||||
let chart_type = period_to_chart_type(period);
|
|
||||||
let raw = self.client.fetch_charts(symbol, chart_type).map_err(|e| {
|
|
||||||
// Finance/Charts returns 404 for IDX stocks — MSN doesn't provide
|
|
||||||
// OHLCV chart history for the Indonesian exchange (XIDX).
|
|
||||||
if matches!(e, IdxError::SymbolNotFound(_)) {
|
|
||||||
IdxError::Unsupported(
|
|
||||||
"MSN Finance/Charts does not provide OHLCV history for IDX (XIDX) stocks. \
|
|
||||||
Use --provider yahoo for historical data."
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
e
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
parse_chart_history(symbol, period, &raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProfileProvider for MsnProvider {
|
impl ProfileProvider for MsnProvider {
|
||||||
fn profile(&self, symbol: &str) -> Result<CompanyProfile, IdxError> {
|
fn profile(&self, symbol: &str) -> Result<CompanyProfile, IdxError> {
|
||||||
let raw = self.client.fetch_equities(symbol)?;
|
let raw = self.client.fetch_equities(symbol)?;
|
||||||
|
|
@ -126,16 +100,3 @@ impl NewsProvider for MsnProvider {
|
||||||
parse_news(&raw)
|
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,8 +1,6 @@
|
||||||
use chrono::NaiveDate;
|
use super::map::{parse_fundamentals, parse_quote};
|
||||||
|
use super::raw_types::{KeyRatios, MsnQuote};
|
||||||
use super::map::{parse_fundamentals, parse_history, parse_history_with_drop_count, parse_quote};
|
use crate::api::types::{Fundamentals, Quote};
|
||||||
use super::raw_types::{KeyRatios, MsnChart, MsnQuote};
|
|
||||||
use crate::api::types::{Fundamentals, Ohlc, Period, Quote};
|
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
|
|
@ -28,113 +26,11 @@ pub(crate) fn parse_fundamentals_from_str(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
pub(crate) fn parse_history_from_str(period: &Period, raw: &str) -> Result<Vec<Ohlc>, IdxError> {
|
|
||||||
let charts: Vec<MsnChart> =
|
|
||||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
|
||||||
parse_history(period, &charts)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)] // retained for verbose history path, wired once MSN charts are exposed
|
|
||||||
pub(super) fn parse_history_with_verbose(
|
|
||||||
period: &Period,
|
|
||||||
charts: &[MsnChart],
|
|
||||||
verbose: bool,
|
|
||||||
) -> Result<Vec<Ohlc>, IdxError> {
|
|
||||||
let (history, dropped) = parse_history_with_drop_count(period, charts)?;
|
|
||||||
if dropped > 0 && verbose {
|
|
||||||
eprintln!("warning: dropped {dropped} OHLC row(s) from MSN response due to missing fields");
|
|
||||||
}
|
|
||||||
Ok(history)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn parse_close_only_history_from_str(
|
|
||||||
period: &Period,
|
|
||||||
raw: &str,
|
|
||||||
) -> Result<Vec<ClosePoint>, IdxError> {
|
|
||||||
let charts: Vec<MsnChart> =
|
|
||||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
|
||||||
parse_close_only_history(period, &charts)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_close_only_history(
|
|
||||||
period: &Period,
|
|
||||||
charts: &[MsnChart],
|
|
||||||
) -> Result<Vec<ClosePoint>, IdxError> {
|
|
||||||
let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?;
|
|
||||||
let timestamps = &chart.series.time_stamps;
|
|
||||||
let mut grouped: std::collections::BTreeMap<NaiveDate, ClosePoint> =
|
|
||||||
std::collections::BTreeMap::new();
|
|
||||||
|
|
||||||
for (idx, raw_ts) in timestamps.iter().enumerate() {
|
|
||||||
let Some(date) = chrono::DateTime::parse_from_rfc3339(raw_ts)
|
|
||||||
.map(|d| d.date_naive())
|
|
||||||
.ok()
|
|
||||||
.or_else(|| {
|
|
||||||
raw_ts
|
|
||||||
.parse::<i64>()
|
|
||||||
.ok()
|
|
||||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0).map(|d| d.date_naive()))
|
|
||||||
})
|
|
||||||
.or_else(|| NaiveDate::parse_from_str(raw_ts, "%Y-%m-%d").ok())
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(close) = chart.series.prices.get(idx).copied() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
grouped.insert(
|
|
||||||
date,
|
|
||||||
ClosePoint {
|
|
||||||
date,
|
|
||||||
close: close.round() as i64,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out: Vec<ClosePoint> = grouped.into_values().collect();
|
|
||||||
trim_close_history_to_period(period, &mut out);
|
|
||||||
|
|
||||||
if out.is_empty() {
|
|
||||||
return Err(IdxError::ProviderUnavailable);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn trim_close_history_to_period(period: &Period, rows: &mut Vec<ClosePoint>) {
|
|
||||||
let days: i64 = match period {
|
|
||||||
Period::OneDay => return,
|
|
||||||
Period::FiveDays => 5,
|
|
||||||
Period::OneMonth => 31,
|
|
||||||
Period::ThreeMonths => 92,
|
|
||||||
Period::SixMonths => 183,
|
|
||||||
Period::OneYear => 366,
|
|
||||||
Period::TwoYears => 731,
|
|
||||||
Period::FiveYears => 1826,
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(last_date) = rows.last().map(|item| item.date) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let cutoff = last_date - chrono::Duration::days(days.saturating_sub(1));
|
|
||||||
rows.retain(|item| item.date >= cutoff);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
struct ClosePoint {
|
|
||||||
date: NaiveDate,
|
|
||||||
close: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{parse_fundamentals_from_str, parse_quote_from_str};
|
||||||
parse_close_only_history_from_str, parse_fundamentals_from_str, parse_history_from_str,
|
use crate::api::types::Period;
|
||||||
parse_quote_from_str,
|
|
||||||
};
|
|
||||||
use crate::api::types::{Ohlc, Period};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_quote_fixture_json() {
|
fn parses_quote_fixture_json() {
|
||||||
|
|
@ -164,89 +60,4 @@ mod tests {
|
||||||
assert_eq!(fundamentals.earnings_growth, Some(0.121));
|
assert_eq!(fundamentals.earnings_growth, Some(0.121));
|
||||||
assert_eq!(fundamentals.market_cap, Some(1_215_200_000_000_000));
|
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_3mo.json")
|
|
||||||
.expect("history fixture exists");
|
|
||||||
let history = parse_history_from_str(&Period::ThreeMonths, &raw).expect("history parsed");
|
|
||||||
assert_eq!(history.len(), 6);
|
|
||||||
assert_eq!(history[0].date.to_string(), "2025-01-06");
|
|
||||||
assert_eq!(history[0].open, 9800);
|
|
||||||
assert_eq!(history[0].close, 9875);
|
|
||||||
assert_eq!(history[5].close, 9940);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_close_only_chart_series_for_public_history() {
|
|
||||||
let raw = r#"[
|
|
||||||
{
|
|
||||||
"series": {
|
|
||||||
"prices": [7100.0, 7200.0],
|
|
||||||
"timeStamps": ["2026-03-03T17:00:00Z", "2026-03-04T17:00:00Z"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]"#;
|
|
||||||
let err =
|
|
||||||
parse_history_from_str(&Period::ThreeMonths, raw).expect_err("history should fail");
|
|
||||||
assert_eq!(
|
|
||||||
err.to_string(),
|
|
||||||
"parse error: msn does not expose real OHLC/volume for this history range"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_close_only_series_for_internal_use() {
|
|
||||||
let raw = r#"[
|
|
||||||
{
|
|
||||||
"series": {
|
|
||||||
"prices": [7100.0, 7200.0],
|
|
||||||
"timeStamps": ["2026-03-03T17:00:00Z", "2026-03-04T17:00:00Z"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]"#;
|
|
||||||
let history =
|
|
||||||
parse_close_only_history_from_str(&Period::ThreeMonths, raw).expect("history parsed");
|
|
||||||
assert_eq!(history.len(), 2);
|
|
||||||
assert_eq!(history[0].close, 7100);
|
|
||||||
assert_eq!(history[1].close, 7200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resamples_history_to_weekly_bars() {
|
|
||||||
let rows = vec![
|
|
||||||
Ohlc {
|
|
||||||
date: chrono::NaiveDate::from_ymd_opt(2025, 1, 6).expect("date"),
|
|
||||||
open: 100,
|
|
||||||
high: 110,
|
|
||||||
low: 90,
|
|
||||||
close: 105,
|
|
||||||
volume: 10,
|
|
||||||
},
|
|
||||||
Ohlc {
|
|
||||||
date: chrono::NaiveDate::from_ymd_opt(2025, 1, 7).expect("date"),
|
|
||||||
open: 106,
|
|
||||||
high: 111,
|
|
||||||
low: 101,
|
|
||||||
close: 109,
|
|
||||||
volume: 11,
|
|
||||||
},
|
|
||||||
Ohlc {
|
|
||||||
date: chrono::NaiveDate::from_ymd_opt(2025, 1, 13).expect("date"),
|
|
||||||
open: 110,
|
|
||||||
high: 115,
|
|
||||||
low: 108,
|
|
||||||
close: 114,
|
|
||||||
volume: 12,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let weekly =
|
|
||||||
super::super::map::resample_history(&rows, super::super::map::ResampleInterval::Week);
|
|
||||||
assert_eq!(weekly.len(), 2);
|
|
||||||
assert_eq!(weekly[0].open, 100);
|
|
||||||
assert_eq!(weekly[0].close, 109);
|
|
||||||
assert_eq!(weekly[0].volume, 21);
|
|
||||||
assert_eq!(weekly[1].close, 114);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,41 +76,6 @@ pub(crate) struct IndustryMetric {
|
||||||
pub(crate) price_to_book_ratio: Option<f64>,
|
pub(crate) price_to_book_ratio: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
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 {
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) time_stamps: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) prices: Vec<f64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) open_prices: Vec<f64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) prices_high: Vec<f64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) prices_low: Vec<f64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(crate) volumes: Vec<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChartSeries {
|
|
||||||
pub(crate) fn has_real_ohlcv(&self) -> bool {
|
|
||||||
!self.time_stamps.is_empty()
|
|
||||||
&& self.open_prices.len() == self.time_stamps.len()
|
|
||||||
&& self.prices_high.len() == self.time_stamps.len()
|
|
||||||
&& self.prices_low.len() == self.time_stamps.len()
|
|
||||||
&& self.prices.len() == self.time_stamps.len()
|
|
||||||
&& self.volumes.len() == self.time_stamps.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(super) struct RawEquity {
|
pub(super) struct RawEquity {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use crate::api::types::{
|
||||||
};
|
};
|
||||||
use crate::api::{
|
use crate::api::{
|
||||||
EarningsProvider, FinancialsProvider, InsightsProvider, MarketDataProvider, NewsProvider,
|
EarningsProvider, FinancialsProvider, InsightsProvider, MarketDataProvider, NewsProvider,
|
||||||
ProfileProvider, SentimentProvider,
|
ProfileProvider, SentimentProvider, history_provider,
|
||||||
};
|
};
|
||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
use crate::config::IdxConfig;
|
use crate::config::IdxConfig;
|
||||||
|
|
@ -208,6 +208,13 @@ pub fn handle(
|
||||||
period,
|
period,
|
||||||
interval,
|
interval,
|
||||||
} => {
|
} => {
|
||||||
|
let hist_provider = history_provider(config.provider, false).ok_or_else(|| {
|
||||||
|
IdxError::Unsupported(
|
||||||
|
"MSN does not provide price history for IDX stocks. \
|
||||||
|
Use --provider yahoo for historical data."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let history_bucket = cache_bucket(config, "history");
|
let history_bucket = cache_bucket(config, "history");
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||||
let key = format!("{}-{}", period.as_str(), interval.as_str());
|
let key = format!("{}-{}", period.as_str(), interval.as_str());
|
||||||
|
|
@ -231,7 +238,7 @@ pub fn handle(
|
||||||
return render_history(&resolved, &stale, &config.output);
|
return render_history(&resolved, &stale, &config.output);
|
||||||
}
|
}
|
||||||
|
|
||||||
match provider.history(&resolved, period, interval) {
|
match hist_provider.history(&resolved, period, interval) {
|
||||||
Ok(history) => {
|
Ok(history) => {
|
||||||
if !no_cache {
|
if !no_cache {
|
||||||
cache.put(
|
cache.put(
|
||||||
|
|
@ -258,6 +265,13 @@ pub fn handle(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StocksSubcommand::Technical { symbol } => {
|
StocksSubcommand::Technical { symbol } => {
|
||||||
|
let hist_provider = history_provider(config.provider, false).ok_or_else(|| {
|
||||||
|
IdxError::Unsupported(
|
||||||
|
"MSN does not provide price history for IDX stocks. \
|
||||||
|
Use --provider yahoo for technical analysis."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let technical_bucket = cache_bucket(config, "technical");
|
let technical_bucket = cache_bucket(config, "technical");
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||||
if !no_cache
|
if !no_cache
|
||||||
|
|
@ -272,7 +286,7 @@ pub fn handle(
|
||||||
return render_technical(&stale, &config.output, config.no_color);
|
return render_technical(&stale, &config.output, config.no_color);
|
||||||
}
|
}
|
||||||
|
|
||||||
match provider.history(&resolved, &Period::OneYear, &Interval::Day) {
|
match hist_provider.history(&resolved, &Period::OneYear, &Interval::Day) {
|
||||||
Ok(history) => {
|
Ok(history) => {
|
||||||
let report = build_technical_report(&resolved, &history)?;
|
let report = build_technical_report(&resolved, &history)?;
|
||||||
if !no_cache {
|
if !no_cache {
|
||||||
|
|
|
||||||
25
tests/cli.rs
25
tests/cli.rs
|
|
@ -107,27 +107,32 @@ fn technical_with_mock_provider_json_contains_fields() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn msn_history_with_mock_returns_data() {
|
fn msn_history_returns_unsupported() {
|
||||||
// MSN chart parsing works with fixture data; real IDX stocks 404 on Finance/Charts
|
// MSN Finance/Charts returns 404 for IDX (XIDX) stocks — history is not supported.
|
||||||
// which surfaces as Unsupported (not the old blanket UNSUPPORTED message).
|
// history_provider() returns None for MSN, which surfaces as Unsupported error.
|
||||||
test_bin("msn-history-mock")
|
test_bin("msn-history-unsupported")
|
||||||
.env("IDX_PROVIDER", "msn")
|
.env("IDX_PROVIDER", "msn")
|
||||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
.args(["stocks", "history", "BBCA", "--period", "3mo"])
|
.args(["stocks", "history", "BBCA", "--period", "3mo"])
|
||||||
.assert()
|
.assert()
|
||||||
.success();
|
.failure()
|
||||||
|
.stderr(predicate::str::contains(
|
||||||
|
"MSN does not provide price history",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn msn_technical_with_mock_returns_data() {
|
fn msn_technical_returns_unsupported() {
|
||||||
// Technical analysis works via MSN chart fixture data (mock); real IDX stocks
|
// Technical analysis requires history — also unsupported for MSN/IDX.
|
||||||
// return Unsupported from Finance/Charts (404 on XIDX).
|
test_bin("msn-technical-unsupported")
|
||||||
test_bin("msn-technical-mock")
|
|
||||||
.env("IDX_PROVIDER", "msn")
|
.env("IDX_PROVIDER", "msn")
|
||||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
.args(["-o", "json", "stocks", "technical", "BBCA"])
|
.args(["-o", "json", "stocks", "technical", "BBCA"])
|
||||||
.assert()
|
.assert()
|
||||||
.success();
|
.failure()
|
||||||
|
.stderr(predicate::str::contains(
|
||||||
|
"MSN does not provide price history",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue