From 2cdefeb7a6cc8f3306ebff23028fa353702d73c1 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Fri, 6 Mar 2026 10:19:10 +0000 Subject: [PATCH] feat(api): add MSN Finance as alternative data provider Add MsnProvider implementing MarketDataProvider trait with quote, fundamentals, and history support. Includes provider-aware config (file/env/CLI), cache namespace isolation per provider, symbol ID mapping via embedded TSV, OHLCV resampling, and comprehensive unit + integration tests with MSN fixture data. Key changes: - MsnProvider with quote, key-ratios, and chart endpoints - ProviderKind enum (yahoo/msn) with config hierarchy support - Provider-namespaced cache buckets to prevent cross-provider poisoning - Provider-aware MockProvider loading correct fixtures per provider - Integration tests for config round-trip and cache isolation Co-Authored-By: Claude Opus 4.6 --- src/api/mod.rs | 42 +- src/api/msn.rs | 921 +++++++++++++++++++++++++ src/api/msn_symbol_ids.tsv | 896 ++++++++++++++++++++++++ src/api/yahoo.rs | 1 + src/cli/stocks.rs | 71 +- src/config.rs | 53 +- src/main.rs | 10 +- tests/cli.rs | 65 ++ tests/fixtures/msn_chart_bbca_3mo.json | 22 + tests/fixtures/msn_keyratios_bbca.json | 36 + tests/fixtures/msn_quote_bbca.json | 18 + 11 files changed, 2099 insertions(+), 36 deletions(-) create mode 100644 src/api/msn.rs create mode 100644 src/api/msn_symbol_ids.tsv create mode 100644 tests/fixtures/msn_chart_bbca_3mo.json create mode 100644 tests/fixtures/msn_keyratios_bbca.json create mode 100644 tests/fixtures/msn_quote_bbca.json diff --git a/src/api/mod.rs b/src/api/mod.rs index d786f60..c127642 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,8 @@ +pub mod msn; pub mod types; pub mod yahoo; +use crate::config::ProviderKind; use crate::error::IdxError; use types::{Fundamentals, Interval, Ohlc, Period, Quote}; @@ -26,11 +28,14 @@ pub fn resolve_symbol(symbol: &str, exchange: &str) -> String { format!("{trimmed}.{}", exchange.trim().to_uppercase()) } -pub fn default_provider(verbose: bool) -> Box { +pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box { if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() { - Box::new(MockProvider::from_fixtures()) + Box::new(MockProvider::from_fixtures(provider)) } else { - Box::new(yahoo::YahooProvider::new(verbose)) + match provider { + ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)), + ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)), + } } } @@ -41,11 +46,18 @@ pub struct MockProvider { } impl MockProvider { - pub fn from_fixtures() -> Self { + pub fn from_fixtures(provider: ProviderKind) -> Self { if std::env::var("IDX_MOCK_ERROR").is_ok() { return Self::with_error(IdxError::ProviderUnavailable); } + match provider { + ProviderKind::Yahoo => Self::from_yahoo_fixtures(), + ProviderKind::Msn => Self::from_msn_fixtures(), + } + } + + fn from_yahoo_fixtures() -> Self { let quote_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json") .unwrap_or_else(|_| "{}".to_string()); let history_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.json") @@ -67,6 +79,28 @@ impl MockProvider { } } + fn from_msn_fixtures() -> Self { + let quote_raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json") + .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") + .unwrap_or_else(|_| "[]".to_string()); + + let quote = msn::parse_quote_from_str("BBCA.JK", "e_raw) + .map_err(|e| IdxError::ParseError(e.to_string())); + let fundamentals = msn::parse_fundamentals_from_str(&fundamentals_raw, Some("e_raw)) + .map_err(|e| IdxError::ParseError(e.to_string())); + let history = msn::parse_history_from_str(&crate::api::types::Period::ThreeMonths, &history_raw) + .map_err(|e| IdxError::ParseError(e.to_string())); + + Self { + quote, + fundamentals, + history, + } + } + pub fn with_error(err: IdxError) -> Self { Self { quote: Err(err.clone()), diff --git a/src/api/msn.rs b/src/api/msn.rs new file mode 100644 index 0000000..62d9405 --- /dev/null +++ b/src/api/msn.rs @@ -0,0 +1,921 @@ +use std::collections::{BTreeMap, HashMap}; +use std::sync::OnceLock; +use std::time::Duration; + +use chrono::{Datelike, NaiveDate}; +use serde::de::DeserializeOwned; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer}; + +use crate::api::MarketDataProvider; +use crate::api::types::{Fundamentals, Interval, Ohlc, Period, Quote}; +use crate::error::IdxError; + +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"; +const SYMBOL_IDS_RAW: &str = include_str!("msn_symbol_ids.tsv"); + +static SYMBOL_IDS: OnceLock> = OnceLock::new(); + +pub struct MsnProvider { + agent: ureq::Agent, + verbose: bool, +} + +impl MsnProvider { + pub fn new(verbose: bool) -> 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, verbose } + } + + fn symbol_ids() -> &'static HashMap<&'static str, &'static str> { + SYMBOL_IDS.get_or_init(|| { + SYMBOL_IDS_RAW + .lines() + .filter_map(|line| line.split_once('\t')) + .collect() + }) + } + + fn ticker_from_symbol(symbol: &str) -> Option { + let trimmed = symbol.trim(); + if trimmed.is_empty() { + return None; + } + Some( + trimmed + .split('.') + .next() + .unwrap_or(trimmed) + .trim() + .to_uppercase(), + ) + } + + fn resolve_msn_id(symbol: &str) -> Option<&'static str> { + let ticker = Self::ticker_from_symbol(symbol)?; + Self::symbol_ids().get(ticker.as_str()).copied() + } + + fn normalized_symbol(requested: &str, fallback_ticker: &str) -> String { + let trimmed = requested.trim().to_uppercase(); + if trimmed.contains('.') || fallback_ticker.is_empty() { + trimmed + } else { + format!("{}.JK", fallback_ticker.trim().to_uppercase()) + } + } + + fn get_json( + &self, + url: &str, + symbol: &str, + endpoint: &str, + ) -> Result { + 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) => ok + .into_body() + .read_json::() + .map_err(|e| IdxError::ParseError(format!("msn {endpoint}: {e}"))), + Err(ureq::Error::StatusCode(404)) => Err(IdxError::SymbolNotFound(symbol.to_string())), + Err(ureq::Error::StatusCode(429)) => Err(IdxError::RateLimited), + Err(err) => Err(IdxError::Http(format!("msn {endpoint}: {err}"))), + } + } + + fn fetch_quotes(&self, symbol: &str) -> Result, IdxError> { + let id = Self::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") + } + + fn fetch_key_ratios(&self, symbol: &str) -> Result, IdxError> { + let id = Self::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") + } + + fn fetch_charts(&self, symbol: &str, chart_type: &str) -> Result, IdxError> { + let id = Self::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}&cm=id-id&ids={id}&type={chart_type}&wrapodata=false" + ); + self.get_json(&url, symbol, "chart") + } + + fn chart_type_for_period(period: &Period) -> &'static str { + match period { + Period::OneDay => "1D1M", + Period::FiveDays | Period::OneMonth => "1M", + Period::ThreeMonths => "3M", + Period::SixMonths | Period::OneYear => "1Y", + Period::TwoYears => "3Y", + Period::FiveYears => "5Y", + } + } +} + +impl MarketDataProvider for MsnProvider { + fn quote(&self, symbol: &str) -> Result { + let quotes = self.fetch_quotes(symbol)?; + parse_quote(symbol, "es) + } + + fn fundamentals(&self, symbol: &str) -> Result { + let ratios = self.fetch_key_ratios(symbol)?; + let quote = self + .fetch_quotes(symbol) + .map_err(|e| { + if self.verbose { + eprintln!("warning: quote fetch for fundamentals failed: {e}"); + } + e + }) + .ok() + .and_then(|quotes| quotes.into_iter().next()); + parse_fundamentals(&ratios, quote.as_ref()) + } + + fn history( + &self, + symbol: &str, + period: &Period, + interval: &Interval, + ) -> Result, IdxError> { + let charts = self.fetch_charts(symbol, Self::chart_type_for_period(period))?; + let rows = parse_history_with_verbose(period, &charts, self.verbose)?; + Ok(match interval { + Interval::Day => rows, + Interval::Week => resample_history(&rows, ResampleInterval::Week), + Interval::Month => resample_history(&rows, ResampleInterval::Month), + }) + } +} + +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result { + let quotes: Vec = + serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; + parse_quote(symbol, "es) +} + +fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result { + let quote = quotes.first().ok_or(IdxError::ProviderUnavailable)?; + let raw_price = quote + .price + .ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; + let prev_close = quote.price_previous_close.map(round_price); + let price = round_price(raw_price); + let change = prev_close + .map(|previous| price - previous) + .or_else(|| quote.price_change.map(round_price)) + .unwrap_or(0); + + let ticker = quote + .symbol + .as_deref() + .and_then(MsnProvider::ticker_from_symbol) + .unwrap_or_else(|| MsnProvider::ticker_from_symbol(symbol).unwrap_or_default()); + + let (week52_position, range_signal) = match (quote.price_52w_low, quote.price_52w_high) { + (Some(low), Some(high)) if high > low => { + let position = (raw_price - low) / (high - low); + let signal = if position > 0.66 { + Some("upper".to_string()) + } else if position < 0.33 { + Some("lower".to_string()) + } else { + Some("middle".to_string()) + }; + (Some(position), signal) + } + _ => (None, None), + }; + + Ok(Quote { + symbol: MsnProvider::normalized_symbol(symbol, &ticker), + price, + change, + change_pct: quote.price_change_percent.unwrap_or(0.0), + volume: round_u64(quote.accumulated_volume).unwrap_or(0), + market_cap: round_u64(quote.market_cap), + week52_high: quote.price_52w_high.map(round_price), + week52_low: quote.price_52w_low.map(round_price), + week52_position, + range_signal, + prev_close, + avg_volume: round_u64(quote.average_volume), + }) +} + +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn parse_fundamentals_from_str( + raw: &str, + quote_raw: Option<&str>, +) -> Result { + let ratios: Vec = + serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; + let quote = quote_raw + .map(serde_json::from_str::>) + .transpose() + .map_err(|e| IdxError::ParseError(e.to_string()))? + .and_then(|quotes| quotes.into_iter().next()); + parse_fundamentals(&ratios, quote.as_ref()) +} + +fn parse_fundamentals( + ratios: &[KeyRatios], + quote: Option<&MsnQuote>, +) -> Result { + let ratios = ratios.first().ok_or(IdxError::ProviderUnavailable)?; + let metrics = if ratios.company_metrics.is_empty() { + &ratios.industry_metrics + } else { + &ratios.company_metrics + }; + if preferred_metric(metrics).is_none() { + return Err(IdxError::ProviderUnavailable); + } + + Ok(Fundamentals { + trailing_pe: best_metric_value(metrics, |metric| metric.price_to_earnings_ratio), + forward_pe: best_metric_value(metrics, |metric| metric.forward_price_to_eps), + price_to_book: best_metric_value(metrics, |metric| metric.price_to_book_ratio), + return_on_equity: best_metric_value(metrics, |metric| normalize_percentish(metric.roe)), + profit_margins: best_metric_value(metrics, |metric| { + normalize_percentish(metric.profit_margin.or(metric.net_margin)) + }), + return_on_assets: best_metric_value(metrics, |metric| { + normalize_percentish(metric.roa_ttm.or(metric.return_on_asset_current)) + }), + revenue_growth: best_metric_value(metrics, |metric| { + normalize_percentish(metric.revenue_ytd_ytd.or(metric.revenue_growth_rate)) + }), + earnings_growth: best_metric_value(metrics, |metric| { + normalize_percentish( + metric + .net_income_ytd_ytd_growth_rate + .or(metric.earnings_growth_rate), + ) + }), + debt_to_equity: best_metric_value(metrics, |metric| metric.debt_to_equity_ratio), + current_ratio: best_metric_value(metrics, |metric| { + sanitize_current_ratio(metric.current_ratio) + }), + enterprise_value: None, + ebitda: None, + market_cap: quote.and_then(|item| round_u64(item.market_cap)), + }) +} + +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn parse_history_from_str(period: &Period, raw: &str) -> Result, IdxError> { + let charts: Vec = + serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; + parse_history_with_verbose(period, &charts, false) +} + +#[allow(dead_code)] +fn parse_close_only_history_from_str( + period: &Period, + raw: &str, +) -> Result, IdxError> { + let charts: Vec = + serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; + parse_close_only_history(period, &charts) +} + +fn parse_history_with_verbose( + period: &Period, + charts: &[MsnChart], + verbose: bool, +) -> Result, 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 = 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 = grouped.into_values().collect(); + trim_history_to_period(period, &mut out); + + if dropped > 0 && verbose { + eprintln!("warning: dropped {dropped} OHLC row(s) from MSN response due to missing fields"); + } + + if out.is_empty() { + return Err(IdxError::ProviderUnavailable); + } + + Ok(out) +} + +fn parse_close_only_history( + period: &Period, + charts: &[MsnChart], +) -> Result, IdxError> { + let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?; + let timestamps = &chart.series.time_stamps; + let mut grouped: BTreeMap = BTreeMap::new(); + + for (idx, raw_ts) in timestamps.iter().enumerate() { + let Some(date) = parse_chart_date(raw_ts) else { + continue; + }; + let Some(close) = chart.series.prices.get(idx).copied() else { + continue; + }; + + grouped.insert( + date, + ClosePoint { + date, + close: round_price(close), + }, + ); + } + + let mut out: Vec = grouped.into_values().collect(); + trim_close_history_to_period(period, &mut out); + + if out.is_empty() { + return Err(IdxError::ProviderUnavailable); + } + + Ok(out) +} + +fn trim_history_to_period(period: &Period, rows: &mut Vec) { + 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 trim_close_history_to_period(period: &Period, rows: &mut Vec) { + 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> { + metrics.iter().max_by_key(|metric| metric_rank(metric)) +} + +fn best_metric_value( + metrics: &[IndustryMetric], + extractor: impl Fn(&IndustryMetric) -> Option, +) -> Option { + metrics + .iter() + .filter_map(|metric| extractor(metric).map(|value| (metric_rank(metric), value))) + .max_by_key(|(rank, _)| *rank) + .map(|(_, value)| value) +} + +fn metric_rank(metric: &IndustryMetric) -> (i32, i32) { + ( + metric + .year + .as_deref() + .and_then(|year| year.parse::().ok()) + .unwrap_or(i32::MIN), + metric_period_priority(metric.fiscal_period_type.as_deref()), + ) +} + +fn metric_period_priority(period: Option<&str>) -> i32 { + match period.map(|value| value.trim()) { + Some(value) if value.eq_ignore_ascii_case("TTM") => 7, + Some(value) + if value.eq_ignore_ascii_case("ANNUAL") + || value.eq_ignore_ascii_case("FY") + || value.eq_ignore_ascii_case("YEAR") => + { + 6 + } + Some(value) if value.eq_ignore_ascii_case("Q4") => 5, + Some(value) if value.eq_ignore_ascii_case("Q3") => 4, + Some(value) if value.eq_ignore_ascii_case("Q2") => 3, + Some(value) if value.eq_ignore_ascii_case("Q1") => 2, + Some(value) if value.eq_ignore_ascii_case("NTM") => 1, + _ => 0, + } +} + +fn normalize_percentish(value: Option) -> Option { + value.and_then(|number| { + if !number.is_finite() { + None + } else if number.abs() > 1.0 { + Some(number / 100.0) + } else { + Some(number) + } + }) +} + +fn sanitize_current_ratio(value: Option) -> Option { + value.and_then(|number| { + if !number.is_finite() || number < 0.01 { + None + } else { + Some(number) + } + }) +} + +#[derive(Clone, Copy)] +enum ResampleInterval { + Week, + Month, +} + +fn resample_history(rows: &[Ohlc], interval: ResampleInterval) -> Vec { + 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() +} + +fn parse_chart_date(raw: &str) -> Option { + if let Ok(date) = chrono::DateTime::parse_from_rfc3339(raw) { + return Some(date.date_naive()); + } + if let Ok(timestamp) = raw.parse::() { + 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 { + value.round() as i64 +} + +fn round_u64(value: Option) -> Option { + value.and_then(|number| { + if !number.is_finite() || number.is_sign_negative() { + None + } else { + Some(number.round() as u64) + } + }) +} + +fn de_opt_f64_lenient<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum NumberLike { + F64(f64), + String(String), + } + + let value = Option::::deserialize(deserializer)?; + match value { + Some(NumberLike::F64(number)) if number.is_finite() => Ok(Some(number)), + Some(NumberLike::F64(_)) => Ok(None), + Some(NumberLike::String(raw)) => { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("nan") { + Ok(None) + } else { + trimmed.parse::().map(Some).map_err(D::Error::custom) + } + } + None => Ok(None), + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MsnQuote { + #[serde(default)] + symbol: Option, + price: Option, + #[serde(default)] + price_change: Option, + #[serde(default)] + price_change_percent: Option, + #[serde(default)] + price_previous_close: Option, + #[serde(default, rename = "price52wHigh")] + price_52w_high: Option, + #[serde(default, rename = "price52wLow")] + price_52w_low: Option, + #[serde(default)] + accumulated_volume: Option, + #[serde(default)] + average_volume: Option, + #[serde(default)] + market_cap: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KeyRatios { + #[serde(default)] + industry_metrics: Vec, + #[serde(default)] + company_metrics: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IndustryMetric { + year: Option, + fiscal_period_type: Option, + #[serde(default)] + revenue_growth_rate: Option, + #[serde(default)] + earnings_growth_rate: Option, + #[serde(default, rename = "netIncomeYTDYTDGrowthRate")] + net_income_ytd_ytd_growth_rate: Option, + #[serde(default, rename = "revenueYTDYTD")] + revenue_ytd_ytd: Option, + #[serde(default)] + net_margin: Option, + #[serde(default)] + profit_margin: Option, + #[serde(default)] + roe: Option, + #[serde(default, rename = "roaTTM")] + roa_ttm: Option, + #[serde(default)] + return_on_asset_current: Option, + #[serde(default)] + debt_to_equity_ratio: Option, + #[serde(default, deserialize_with = "de_opt_f64_lenient")] + current_ratio: Option, + #[serde(default)] + price_to_earnings_ratio: Option, + #[serde(default, rename = "forwardPriceToEPS")] + forward_price_to_eps: Option, + #[serde(default)] + price_to_book_ratio: Option, +} + +#[derive(Debug, Deserialize)] +struct MsnChart { + series: ChartSeries, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChartSeries { + #[serde(default)] + time_stamps: Vec, + #[serde(default)] + prices: Vec, + #[serde(default)] + open_prices: Vec, + #[serde(default)] + prices_high: Vec, + #[serde(default)] + prices_low: Vec, + #[serde(default)] + volumes: Vec, +} + +impl ChartSeries { + 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, Clone, Copy, PartialEq, Eq)] +struct ClosePoint { + date: NaiveDate, + close: i64, +} + +#[cfg(test)] +mod tests { + use super::{ + MsnProvider, ResampleInterval, parse_close_only_history_from_str, + parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str, + resample_history, + }; + use crate::api::types::{Ohlc, Period}; + + #[test] + fn resolves_symbol_variants() { + assert_eq!(MsnProvider::resolve_msn_id("BBCA"), Some("bn91jc")); + assert_eq!(MsnProvider::resolve_msn_id("bbca.jk"), Some("bn91jc")); + assert_eq!(MsnProvider::resolve_msn_id("INVALID"), None); + } + + #[test] + fn parses_quote_fixture_json() { + let raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json") + .expect("quote fixture exists"); + let quote = parse_quote_from_str("BBCA.JK", &raw).expect("quote parsed"); + assert_eq!(quote.symbol, "BBCA.JK"); + assert_eq!(quote.price, 9875); + assert_eq!(quote.change, 117); + assert_eq!(quote.volume, 12_300_000); + assert_eq!(quote.market_cap, Some(1_215_200_000_000_000)); + assert_eq!(quote.avg_volume, Some(10_000_000)); + } + + #[test] + fn parses_fundamentals_fixture_json() { + let raw = std::fs::read_to_string("tests/fixtures/msn_keyratios_bbca.json") + .expect("fundamentals fixture exists"); + let quote_raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json") + .expect("quote fixture exists"); + let fundamentals = + parse_fundamentals_from_str(&raw, Some("e_raw)).expect("fundamentals parsed"); + assert_eq!(fundamentals.trailing_pe, Some(25.4)); + assert_eq!(fundamentals.price_to_book, Some(4.6)); + assert_eq!(fundamentals.return_on_equity, Some(0.198)); + assert_eq!(fundamentals.revenue_growth, Some(0.081)); + assert_eq!(fundamentals.earnings_growth, Some(0.121)); + 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 = resample_history(&rows, 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); + } + + #[test] + fn maps_periods_to_supported_chart_types() { + assert_eq!(MsnProvider::chart_type_for_period(&Period::OneDay), "1D1M"); + assert_eq!(MsnProvider::chart_type_for_period(&Period::SixMonths), "1Y"); + assert_eq!(MsnProvider::chart_type_for_period(&Period::TwoYears), "3Y"); + assert_eq!(MsnProvider::chart_type_for_period(&Period::FiveYears), "5Y"); + } + + #[test] + fn normalizes_live_style_percent_metrics() { + let raw = r#"[ + { + "industryMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "Q1", + "revenueGrowthRate": 9.584679119559473, + "earningsGrowthRate": 28.793562408178182, + "netMargin": 35.05868669243578, + "roe": 16.27117054525313, + "returnOnAssetCurrent": 2.5707368150889867, + "debtToEquityRatio": 32.80253090283387, + "currentRatio": 9.38775908812586E-06, + "priceToEarningsRatio": 21.331183408517173, + "priceToBookRatio": 3.0625539678152234 + }, + { + "year": "2025", + "fiscalPeriodType": "TTM", + "revenueYTDYTD": 0.0481563350951302, + "netIncomeYTDYTDGrowthRate": 0.0492553610240516, + "profitMargin": 0.504190105842766, + "roe": 0.211493, + "roaTTM": 3.7919, + "priceToEarningsRatio": 17.296683642049683, + "priceToSalesRatio": 7.6652108104296985, + "priceToBookRatio": 3.107795874896335 + }, + { + "year": "2025", + "fiscalPeriodType": "NTM", + "forwardPriceToEPS": 14.723 + } + ], + "companyMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "TTM", + "revenueYTDYTD": 0.0481563350951302, + "netIncomeYTDYTDGrowthRate": 0.0492553610240516, + "profitMargin": 0.504190105842766, + "roe": 0.211493, + "roaTTM": 3.7919, + "priceToEarningsRatio": 17.296683642049683, + "priceToBookRatio": 3.107795874896335 + }, + { + "year": "2025", + "fiscalPeriodType": "NTM", + "forwardPriceToEPS": 14.723 + } + ] + } + ]"#; + let quote_raw = r#"[{"symbol":"BBCA","marketCap":866500400000000.0}]"#; + + let fundamentals = + parse_fundamentals_from_str(raw, Some(quote_raw)).expect("fundamentals parsed"); + assert_eq!(fundamentals.trailing_pe, Some(17.296683642049683)); + assert_eq!(fundamentals.forward_pe, Some(14.723)); + assert_eq!(fundamentals.price_to_book, Some(3.107795874896335)); + assert_eq!(fundamentals.return_on_equity, Some(0.211493)); + assert_eq!(fundamentals.profit_margins, Some(0.504190105842766)); + assert_eq!(fundamentals.return_on_assets, Some(0.037919)); + assert_eq!(fundamentals.revenue_growth, Some(0.0481563350951302)); + assert_eq!(fundamentals.earnings_growth, Some(0.0492553610240516)); + assert_eq!(fundamentals.debt_to_equity, None); + assert_eq!(fundamentals.current_ratio, None); + } +} diff --git a/src/api/msn_symbol_ids.tsv b/src/api/msn_symbol_ids.tsv new file mode 100644 index 0000000..b6cfe49 --- /dev/null +++ b/src/api/msn_symbol_ids.tsv @@ -0,0 +1,896 @@ +AADI cfatz2 +AALI bn8zk2 +ABBA bkf9ec +ABDA bkf9h7 +ABMM bkf9k2 +ACES bn8zyc +ACRO cc48gh +ACST bkf9pr +ADCP c59mf2 +ADES bkf9sm +ADHI bn9127 +ADMF bkf9yc +ADMG bn9152 +ADMR c4pl52 +ADRO bn917w +AEGS carobh +AGAR bsu9xm +AGII bkfa7w +AGRO bkfaar +AGRS bkfadm +AHAP bkfagh +AIMS bkfajc +AISA bkfam7 +AKKU bkfctc +AKPI bkfcw7 +AKRA bkfcz2 +AKSI bkfd2w +ALDO bkfd5r +ALII ccanzr +ALKA bkfd8m +ALMI bkfdbh +ALTO bkfdec +AMAG bkfchw +AMAN btysar +AMAR btf6a2 +AMFG bkfckr +AMIN bkfcnm +AMMN ca993m +AMMS c6yaw7 +AMOR btfbw7 +AMRT bkfcqh +ANDI bgrwf2 +ANJT bkfem7 +ANTM bn8zmw +APEX bkferw +APIC bkfeur +APII bkfexm +APLI bkff1h +APLN bkff4c +ARCI c2il1h +AREA ccsom7 +ARGO bkff77 +ARII bkffa2 +ARKA bqtwtc +ARKO c6qaqh +ARNA bkfffr +ARTA bkffim +ARTO bkffoc +ASBI bkffzr +ASDM bkfg3m +ASGR bkfg6h +ASHA c6a3yc +ASII bn91gh +ASJT bkfgc7 +ASLC c4xl52 +ASLI cc41ww +ASMI bkfgf2 +ASPI btr8pr +ASPR chbyjc +ASRI bkfghw +ASRM bkfgkr +ASSA bkfgnm +ATAP bwzup2 +ATIC bkffr7 +ATLA ccwjvh +AUTO bkffu2 +AVIA c4bepr +AWAN c9f9yc +AXIO c6v4pr +AYAM cbkfkr +AYLS btox8m +BABP bkfgqh +BABY carnqh +BACA bkfgtc +BAIK ccbzar +BAJA bkfgw7 +BALI bkfgz2 +BANK bxjrc7 +BAPA bkfh2w +BAPI breeur +BATA bkflyc +BATR cddzvh +BAUT c4xpm7 +BAYU bkfm27 +BBCA bn91jc +BBHI bkfm7w +BBKP bkfmar +BBLD bkfmdm +BBMD bkfmgh +BBNI bn91m7 +BBRI bn91p2 +BBRM bkfmrw +BBSI bvpgvh +BBSS bu9cqh +BBTN bkfmur +BBYB bkfmxm +BCAP bkfn1h +BCIC bgt4rw +BCIP bkfn4c +BDKR c8zmyc +BDMN bn91rw +BEBS bzv7ar +BEEF bokavh +BEER c8if77 +BEKS bkfna2 +BELI c7svnm +BELL bkfncw +BESS btyakr +BEST bkfh5r +BFIN bkfh8m +BGTG bkfhbh +BHAT bua6k2 +BHIT bkfhec +BIKE c5iucw +BIMA bkfkz2 +BINA bn92u2 +BINO c45tp2 +BIPI bn92ww +BIPP bkfl8m +BIRD bkflbh +BISI bn92zr +BJBR bkflh7 +BJTM bn936h +BKDP bkflmw +BKSL bkflpr +BKSW bkflsm +BLES cdlvh7 +BLOG chc7m7 +BLTA bgt5u2 +BLTZ bkflvh +BLUE bqrgar +BMAS bkfoqh +BMBL c8isqh +BMHS c2md4c +BMRI bn939c +BMSR bkfow7 +BMTR bkfoz2 +BNBA bkfp2w +BNBR bn93c7 +BNGA bkfp8m +BNII bn93f2 +BNLI bn93hw +BOAT cf4yu2 +BOBA c3wioc +BOGA bkfph7 +BOLA bqe3p2 +BOLT bkfpk2 +BPFI bkfppr +BPII bkfpsm +BPTR bgth1h +BRAM bkfpvh +BREN cb1ra2 +BRIS bgtha2 +BRMS bn924c +BRNA bkfq27 +BRPT bn9277 +BRRC cflfa2 +BSBK c7svhw +BSDE bkfq7w +BSIM bn92a2 +BSML c4is3m +BSSR bkfqdm +BSWD bkfqgh +BTEK bkfqjc +BTON bkfqp2 +BTPN bkfqrw +BTPS bgtwjc +BUAH c71dyc +BUDI bkfqur +BUKA c2wgkr +BUKK bkfqxm +BULL bkfnu2 +BUMI bn92fr +BUVA bkfnzr +BVIC bkfo3m +BWPT bn92im +BYAN bn92lh +CAKK bne4c7 +CAMP bkfoc7 +CANI bkfof2 +CARE btysdm +CARS bkfohw +CASA bkfokr +CASH bufc7w +CASS bkfonm +CBDK cflejc +CBPE c8ipmw +CBRE c8ifww +CBUT c7sxgh +CCSI bqh6ar +CDIA chdaw7 +CEKA bkfkqh +CENT bkfktc +CFIN bkfkw7 +CGAS cc45zr +CHEK chddzr +CHEM c6qahw +CHIP c8qlmw +CINT bkfnfr +CITA bkfnim +CITY bjs6w7 +CLAY bona52 +CLEO bkfnoc +CLPI bkfnr7 +CMNP bn93kr +CMNT c3bxcw +CMPP bkgku2 +CMRY c4b81h +CNKO bkgkzr +CNMA cagkkr +COAL c78lp2 +COCO bpfy1h +COIN chc7p2 +CPIN bn93nm +CPRO bkgl9c +CRAB c71ep2 +CRSN ca9dnm +CSAP bn93z2 +CSIS bkglf2 +CSMI bua4a2 +CSRA btf6cw +CTBN bkglhw +CTRA bn942w +CTTH bkglnm +CUAN c8ztf2 +CYBR caj9jc +DAAZ cf3dr7 +DADA btov1h +DART bkglqh +DATA cd3e27 +DAYA bkgltc +DCII bxbr3m +DEFI bkglw7 +DEPO c461k2 +DEWA bn93qh +DEWI c6t4oc +DFAM bkgm2w +DGIK bkgm5r +DGNS bxbs2w +DGWG cfl46h +DIGI bguqr7 +DILD bn93tc +DIVA bny8f2 +DKFT bkgmbh +DKHH cgo8rw +DLTA bkgn52 +DMAS bkgn7w +DMMX bsh6z2 +DMND bti2qh +DNAR bkgnar +DNET bkgndm +DOID bkgngh +DOOH c9pg52 +DOSS cecd8m +DPNS bkgnjc +DPUM bkgnm7 +DRMA c4is9c +DSFI bkgnp2 +DSNG bkgnrw +DSSA bn94ec +DUTI bn94h7 +DVLA bkgmsm +DWGL bkgmvh +DYAN bkgmyc +EAST bqrjmw +ECII bkgn27 +EDGE bxmmkr +EKAD bkgmec +ELIT c8ifcw +ELPI c71dh7 +ELSA bkgmh7 +ELTY bn94k2 +EMAS ci4x5r +EMDE bkgo77 +EMTK bkgocw +ENAK c53nww +ENRG bkgofr +ENZO bvqna2 +EPAC buzekr +EPMT bkgoim +ERAA bkgolh +ERAL cakb4c +ERTX bkgooc +ESIP bsq1dm +ESSA bkgnxm +ESTA btxltc +ESTI bkgo1h +EURO c6zylh +EXCL bn94pr +FAPA bx7mww +FAST bkgor7 +FASW bkgou2 +FILM bgva7w +FIMP c1paqh +FIRE bkgozr +FISH bkgp3m +FITT bqcw2w +FLMC c2p22w +FMII bkgp9c +FOLK caj9ar +FOOD bohgur +FORE cghp7w +FORU bkgpc7 +FPNI bkgphw +FUJI bqrhww +FUTR c8xc2w +FWCT c8no4c +GDST bkgpnm +GDYR bkgpqh +GEMA bkgptc +GEMS bkgpw7 +GGRM bn94vh +GGRP brivww +GHON bkgq2w +GIAA bkgq5r +GJTL bn9527 +GLOB bgveur +GLVA bt94fr +GMFI bkgqbh +GMTD bkgqk2 +GOLD bkgqmw +GOLF cdml52 +GOOD bkgqsm +GOTO c5tv8m +GPRA bkgqvh +GPSO c39qp2 +GRIA cakam7 +GRPH cc65vh +GRPM ca9bar +GSMF bkgqec +GTBO bkgqh7 +GTRA c9a7c7 +GTSI c3atkr +GULA c6ybbh +GUNA cdm6oc +GWSA bkgqyc +GZCO bkgr27 +HADE bkgr52 +HAIS c3839c +HAJJ c9c9z2 +HALO c8snbh +HATM c6vxxm +HBAT caj5ur +HDFA bkgr7w +HDIT bqsr9c +HEAL bgvigh +HELI bkgrdm +HERO bkgrgh +HEXA bn95ar +HGII cfl3r7 +HILL c8xfhw +HITS bkgrm7 +HMSP bn95jc +HOKI bkgrur +HOMI bvpl7w +HOPE c24y52 +HRME bpplhw +HRTA bkgs4c +HRUM bn957w +HUMI caj9p2 +HYGN ccbzjc +IATA bkgsa2 +IBFN bkgscw +IBOS c5z1vh +IBST bkgsfr +ICBP bkgsim +ICON bkgslh +IDEA c3at9c +IDPR bkgsoc +IFII bt26w7 +IFSH bsua4c +IGAR bkgsr7 +IKAI bkgsww +IKAN btov4c +IKBI bkgszr +IKPM cbetnm +IMAS bkguk2 +IMJS bkgumw +IMPC bkgupr +INAI bkguvh +INCF bkguyc +INCI bkgv27 +INCO bn96cw +INDF bn96fr +INDO btf6ww +INDR bkgvar +INDS bkgvdm +INDX bkgvgh +INDY bkgvjc +INET caehu2 +INKP bn96im +INOV bqtw6h +INPC bkgttc +INPP bkgtw7 +INPS bkgtz2 +INRU bkgu2w +INTA bkgu5r +INTD bkgu8m +INTP bn95m7 +IOTF cb1mar +IPAC c2ip4c +IPCC bgvlmw +IPCM bkguec +IPOL bkguh7 +IPTV bqrdim +IRRA bs6n9c +IRSX c8ptdm +ISAP c87m5r +ISAT bn95p2 +ISEA cdmkyc +ISSP bn95rw +ITIC bqptyc +ITMA bkgt9c +ITMG bn95ur +JARR c6yb2w +JAST bpztur +JATI c9pikr +JAWA bkgtf2 +JAYA bp3fkr +JECC bkgthw +JGLE bkgtkr +JIHD bkgtnm +JKON bn95xm +JMAS bkgwcw +JPFA bkgwfr +JRPT bkgwim +JSMR bn964c +JSPT bkgwr7 +JTPE bkgwu2 +KAEF bkgvp2 +KAQI cg3ah7 +KARW bkgvrw +KBAG bu8obh +KBLI bkgvur +KBLM bkgvxm +KBLV bkgw1h +KDSI bkgwww +KDTN c7sxar +KEEN brdma2 +KEJU bss8im +KETR c7x3cw +KIAS bgw8lh +KICI bkgwzr +KIJA bkgx3m +KING c8v3jc +KINO bkgx6h +KIOS bkgx9c +KJEN bqoa1h +KKES c71dsm +KKGI bkgxqh +KLAS c9zg9c +KLBF bn96u2 +KLIN c71ear +KMDS bvpjz2 +KMTR bkgxw7 +KOBX bkgxz2 +KOCI cazma2 +KOIN bkgy2w +KOKA cb2m5r +KONI bkgy5r +KOPI bkgy8m +KOTA bqspm7 +KPIG bkgxf2 +KRAS bn96zr +KREN bkgxnm +KRYA c6vopr +KSIX cfl4f2 +KUAS c3tgqh +LABA c2ekur +LABS cdml7w +LAJU c8ni2w +LAND bgwfxm +LAPD bkgybh +LCKM bkgyh7 +LEAD bkgyk2 +LFLO c1g5f2 +LIFE bqsrz2 +LINK bkgymw +LION bkgypr +LIVE ccbz52 +LMAX caj9gh +LMPI bkgyyc +LMSH bkgz27 +LOPI cb2a2w +LPCK bkgz52 +LPGI bkgz7w +LPIN bkgzar +LPKR bn99ar +LPLI bn99dm +LPPF bkgzjc +LPPS bn99gh +LRNA bkgzp2 +LSIP bn99jc +LTLS bkgzur +LUCK bny8z2 +LUCY c1zlh7 +MAHA caem3m +MAIN bkh177 +MANG cc463m +MAPA bgwzm7 +MAPB bkh1cw +MAPI bn98pr +MARI bkh1im +MARK bkh1lh +MASB c2jw8m +MAXI c9zd5r +MAYA bkh1r7 +MBAP bkh1u2 +MBMA c9fe52 +MBSS bkh1ww +MBTO bkh1zr +MCAS bkh23m +MCOL c3apnm +MCOR bkh26h +MDIA bkh2c7 +MDIY cff1lh +MDKA bkh2f2 +MDKI bkh2hw +MDLA cgbphw +MDLN bkh2kr +MDRN bkh2nm +MEDC bn9a77 +MEDS c71idm +MEGA bkh2tc +MEJA ccbqh7 +MENN c9fbu2 +MERI che6cw +MERK bkh2w7 +MFMI bkh35r +MGLV c2cba2 +MGNA bkh38m +MGRO bgxdcw +MHKI ccwk27 +MICE bn9afr +MIDI bn9aim +MIKA bkh3h7 +MINA bkh427 +MINE cg3epr +MIRA bkh452 +MITI bkh47w +MKAP ccbrar +MKPI bkh4dm +MKTR c7sz6h +MLBI bkh4gh +MLIA bkh4jc +MLPL bn9alh +MLPT bkh4p2 +MMIX c87lz2 +MMLP bkh4rw +MNCN bkh4ur +MOLI b9fksm +MORA c6zya2 +MPIX ccbqmw +MPMX bkh3mw +MPOW bkh3pr +MPPA bn9azr +MPRO bkh3vh +MPXL c9ql7w +MRAT bkh3yc +MREI bkhcur +MSIE cakqgh +MSIN bgy7nm +MSJA cc466h +MSKY bkhcxm +MSTI cbe9qh +MTDL bkhd1h +MTEL c45acw +MTFN bgy82w +MTLA bkhd4c +MTMH c5w4lh +MTPS bpmq9c +MTSM bkhda2 +MTWI bkhdfr +MUTU cajgyc +MYOH bkhcgh +MYOR bn9bkr +MYTX bkhcp2 +NAIK cf6abh +NANO c5es5r +NASA bkhc52 +NASI c4earw +NATO bor3r7 +NAYZ c8pvw7 +NCKL c9dmnm +NELY bkhcar +NEST cecdh7 +NETV c4zpnm +NFCX bh2cm7 +NICE cc46f2 +NICK bkhdoc +NICL c2og3m +NIKL bkhdr7 +NINE c87ih7 +NIRO bkhdww +NISP bkhdzr +NOBU bkhe3m +NPGF c1mpp2 +NRCA bkhe9c +NSSS c92ssm +NTBK c53nim +NZIA brkv8m +OASA bkhdim +OBAT cflf77 +OBMD c4dnkr +OILS c3a84c +OKAS bkhdlh +OLIV c64i27 +OMED c7t2jc +OMRE bkhekr +OPMS brkez2 +PACK c8sn2w +PADA c87qyc +PADI bkheqh +PALM bkhetc +PAMG bqrcxm +PANI bh3952 +PANR bkhez2 +PANS bkhf2w +PART cdlta2 +PBID bkhf5r +PBRX bkhf8m +PBSA bkhfbh +PCAR bkhfec +PDES bkhfh7 +PDPP c7wzkr +PEGE bkhfk2 +PEHA boeyr7 +PEVE c8lqu2 +PGAS bn9ch7 +PGEO c8wmc7 +PGJO btf6im +PGLI bkhfpr +PGUN bv2tpr +PICO bkhfvh +PIPA c9cy5r +PJAA bkhfyc +PKPK bkhh1h +PLAN bvsmnm +PLIN bkhh77 +PMJS bt81mw +PMMP bx3rtc +PMUI che6fr +PNBN bn9d27 +PNBS bn9d52 +PNGO bvilbh +PNIN bkhhfr +PNLF bn9d7w +PNSE bkhhlh +POLA bnw452 +POLI bokeh7 +POLU bqiq5r +POLY bkhg27 +PORT bkhg7w +POWR bn9dar +PPGL bv7ka2 +PPRE bkhgdm +PPRI caj5xm +PPRO bkhggh +PRAY c7svf2 +PRDA bkhgm7 +PRIM bh3bnm +PSAB bkhgp2 +PSDN bkhgrw +PSGO bssb7w +PSKT bkhgur +PSSI bkhgxm +PTBA bn9i4c +PTDU bwzbim +PTIS bkhif2 +PTMP c8zh3m +PTMR cese7w +PTPP bkhihw +PTPS cb26pr +PTPW btowc7 +PTRO bkhinm +PTSN bkhiqh +PTSP bkhitc +PUDP bkhiw7 +PURA btjqcw +PURI bvmvlh +PWON bkhiz2 +PYFA bkhj2w +PZZA bh3dar +RAAM c9pg7w +RAFI c6zy1h +RAJA bkhhu2 +RALS bn9iim +RANC bkhhzr +RATU cfl3oc +RBMS bkhi3m +RCCC c6y4ur +RDTX bkhi6h +REAL bsxdzr +RELF ca43u2 +RELI bkhi9c +RGAS cbe25r +RICY bkhj5r +RIGS bkhj8m +RISE bh3par +RMKE c4bf52 +RMKO cafyoc +ROCK bvpnqh +RODA bkhkur +RONY bua477 +ROTI bkhkxm +RSCH caoza2 +RSGK c3atf2 +RUIS bkhjh7 +RUNS c3athw +SAFE bh4khw +SAGE c8ztkr +SAME bkhl1h +SAMF bu77h7 +SAPX bkhl4c +SATU bnmpyc +SBMA c3apw7 +SCCO bkhl77 +SCMA bkhla2 +SCNP bvpljc +SDMU bkhlcw +SDPC bkhlfr +SDRA bkhlim +SEMA c4s94c +SFAN bqigoc +SGER bvdqa2 +SGRO bn9j9c +SHID bkhlww +SHIP bkhlzr +SICO c5tatc +SIDO bkhm6h +SILO bn9jnm +SIMP bkhmf2 +SINI bsn27w +SIPD bkhpzr +SKBM bkhq3m +SKLT bkhq6h +SKRN bkhqc7 +SLIS bs6kh7 +SMAR bkhrmw +SMBR bn9lar +SMCB bn9ldm +SMDM bkhryc +SMDR bkhs27 +SMGA cc93tc +SMGR bn9lgh +SMIL c9r7oc +SMKL bqrfh7 +SMKM c5gqk2 +SMLE cc45oc +SMMA bkhs7w +SMMT bkhsar +SMRA bn9ljc +SMSM bkhsjc +SNLK c1c3ur +SOCI bkhmkr +SOFA bv1prw +SOHO bvpia2 +SOLA cd3j77 +SONA bkhmqh +SOSS borblh +SOTS bo5fk2 +SOUL c8hmu2 +SPMA bkhqhw +SPRE cdllww +SPTO bh4tpr +SQMI bkhqkr +SRAJ bkhqnm +SRSN bkhqw7 +SRTG bkhqz2 +SSIA bkhr2w +SSMS bkhr5r +SSTM bkhr8m +STAA c5grjc +STAR bkhrbh +STRK cb1rcw +STTP bkhrec +SULI bn9l52 +SUNI c8irzr +SUPR bkhsp2 +SURE bkhsrw +SURI cbriz2 +SWAT bh4xyc +SWID c6q9ww +TALF bkhsxm +TAMA btnwec +TAMU bkht1h +TAPG c1pha2 +TARA bkht4c +TAXI bkhta2 +TAYS c4bfdm +TBIG bkhtcw +TBLA bn9m77 +TBMS bkhtim +TCID bkhtlh +TCPI bh55nm +TEBE bsqufr +TELE bkhtr7 +TFAS brgc52 +TFCO bkhtzr +TGKA bkhu6h +TGRA bkhu9c +TGUK ca99f2 +TIFA bkhuc7 +TINS bn9o2w +TIRA bkhunm +TIRT bkhuqh +TKIM bn9o5r +TLDN c5tiqh +TLKM bn9o8m +TMAS bn9oec +TMPO bkhv8m +TNCA bh5cim +TOBA bkhvk2 +TOOL c71edm +TOSK ccbqk2 +TOTL bn9ok2 +TOTO bkhvsm +TOWR bn9omw +TPIA bkhvyc +TPMA bkhw27 +TRGU c6qatc +TRIM bki2lh +TRIN btfcbh +TRIS bki2oc +TRJA bviktc +TRON c8zthw +TRST bki2u2 +TRUE c2cfa2 +TRUK bh7r6h +TRUS bki2zr +TSPC bki33m +TUGU bh72cw +TYRE c9pfpr +UANG bv1qlh +UCID bt7nnm +UDNG cbazxm +UFOE bxjur7 +ULTJ bki36h +UNIC bki39c +UNIQ bzv6mw +UNSP bn9pjc +UNTD ccanww +UNTR bn9pm7 +UNVR bn9pp2 +URBN bo5fsm +UVCR c2ruww +VAST c8sn8m +VERN cesblh +VICI bx3pp2 +VICO bki3nm +VINS bki3qh +VISI cchqzr +VIVA bki45r +VKTR ca2e9c +VOKS bki4bh +VRNA bki4k2 +VTNY c89txm +WAPO bki4mw +WEGE bki4pr +WEHA bki4sm +WGSH c4bevh +WICO bki76h +WIDI ca9dkr +WIFI bx86dm +WIIM bki79c +WIKA bn9q77 +WINE c8isf2 +WINR c5yu77 +WINS bn9qa2 +WIRG c5o9z2 +WMUU bxjkec +WOMF bki7hw +WOOD bki7kr +WOWS bsnwf2 +WSBP bki7z2 +WTON bn9r9c +YELO bnehec +YOII cfl49c +YPAS bki8bh +YULE bki7nm +YUPI cgaarw +ZATA c7x3im +ZBRA bhacdm +ZINC bki7qh +ZONE bo5fyc +ZYRX c1c1kr diff --git a/src/api/yahoo.rs b/src/api/yahoo.rs index bd87066..e21860d 100644 --- a/src/api/yahoo.rs +++ b/src/api/yahoo.rs @@ -677,6 +677,7 @@ impl QuoteSummarySectionExt for QuoteSummarySection { #[derive(Debug, Deserialize)] #[serde(untagged)] +#[allow(dead_code)] enum QuoteSummaryValue { Wrapped { raw: Option }, Direct(YahooNumber), diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 3e27d57..9f1af5c 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -17,11 +17,15 @@ use crate::output::{ render_growth, render_history, render_quotes, render_risk, render_technical, render_valuation, }; -struct FundamentalCacheSpec<'a> { - key: &'a str, +struct FundamentalCacheSpec { + bucket: String, ttl_secs: u64, } +fn cache_bucket(config: &IdxConfig, key: &str) -> String { + format!("{}-{key}", config.provider.as_str()) +} + #[derive(Debug, Args)] #[command(about = "Stock data and analysis")] pub struct StocksCmd { @@ -112,17 +116,18 @@ pub fn handle( match &cmd.command { StocksSubcommand::Quote { symbols } => { + let quote_bucket = cache_bucket(config, "quote"); let mut quotes = Vec::new(); for sym in symbols.iter().flat_map(|s| s.split(',')) { let resolved = crate::api::resolve_symbol(sym, &config.exchange); - if !no_cache && let Some(q) = cache.get("quote", &resolved)? { + if !no_cache && let Some(q) = cache.get("e_bucket, &resolved)? { quotes.push(q); continue; } if offline { let stale = cache - .get_stale("quote", &resolved)? - .ok_or_else(|| IdxError::CacheMiss(format!("quote/{resolved}")))?; + .get_stale("e_bucket, &resolved)? + .ok_or_else(|| IdxError::CacheMiss(format!("{quote_bucket}/{resolved}")))?; quotes.push(stale); continue; } @@ -130,12 +135,14 @@ pub fn handle( match provider.quote(&resolved) { Ok(q) => { if !no_cache { - cache.put("quote", &resolved, &q, config.quote_ttl)?; + cache.put("e_bucket, &resolved, &q, config.quote_ttl)?; } quotes.push(q); } Err(err) => { - if !no_cache && let Some(stale) = cache.get_stale("quote", &resolved)? { + if !no_cache + && let Some(stale) = cache.get_stale("e_bucket, &resolved)? + { eprintln!( "warning: network failed, serving stale cache for {resolved}" ); @@ -153,21 +160,26 @@ pub fn handle( period, interval, } => { + let history_bucket = cache_bucket(config, "history"); let resolved = crate::api::resolve_symbol(symbol, &config.exchange); let key = format!("{}-{}", period.as_str(), interval.as_str()); if !no_cache - && let Some(history) = cache - .get::>("history", &format!("{resolved}-{key}"))? + && let Some(history) = cache.get::>( + &history_bucket, + &format!("{resolved}-{key}"), + )? { return render_history(&resolved, &history, &config.output); } if offline { let stale = cache .get_stale::>( - "history", + &history_bucket, &format!("{resolved}-{key}"), )? - .ok_or_else(|| IdxError::CacheMiss(format!("history/{resolved}-{key}")))?; + .ok_or_else(|| { + IdxError::CacheMiss(format!("{history_bucket}/{resolved}-{key}")) + })?; return render_history(&resolved, &stale, &config.output); } @@ -175,7 +187,7 @@ pub fn handle( Ok(history) => { if !no_cache { cache.put( - "history", + &history_bucket, &format!("{resolved}-{key}"), &history, config.quote_ttl, @@ -186,7 +198,7 @@ pub fn handle( Err(err) => { if !no_cache && let Some(stale) = cache.get_stale::>( - "history", + &history_bucket, &format!("{resolved}-{key}"), )? { @@ -198,16 +210,17 @@ pub fn handle( } } StocksSubcommand::Technical { symbol } => { + let technical_bucket = cache_bucket(config, "technical"); let resolved = crate::api::resolve_symbol(symbol, &config.exchange); if !no_cache - && let Some(report) = cache.get::("technical", &resolved)? + && let Some(report) = cache.get::(&technical_bucket, &resolved)? { return render_technical(&report, &config.output, config.no_color); } if offline { let stale = cache - .get_stale::("technical", &resolved)? - .ok_or_else(|| IdxError::CacheMiss(format!("technical/{resolved}")))?; + .get_stale::(&technical_bucket, &resolved)? + .ok_or_else(|| IdxError::CacheMiss(format!("{technical_bucket}/{resolved}")))?; return render_technical(&stale, &config.output, config.no_color); } @@ -215,14 +228,14 @@ pub fn handle( Ok(history) => { let report = build_technical_report(&resolved, &history)?; if !no_cache { - cache.put("technical", &resolved, &report, config.quote_ttl)?; + cache.put(&technical_bucket, &resolved, &report, config.quote_ttl)?; } render_technical(&report, &config.output, config.no_color) } Err(err) => { if !no_cache && let Some(stale) = - cache.get_stale::("technical", &resolved)? + cache.get_stale::(&technical_bucket, &resolved)? { eprintln!("warning: network failed, serving stale cache for {resolved}"); return render_technical(&stale, &config.output, config.no_color); @@ -238,7 +251,7 @@ pub fn handle( provider, &resolved, FundamentalCacheSpec { - key: "growth", + bucket: cache_bucket(config, "growth"), ttl_secs: config.fundamental_ttl, }, offline, @@ -254,7 +267,7 @@ pub fn handle( provider, &resolved, FundamentalCacheSpec { - key: "valuation", + bucket: cache_bucket(config, "valuation"), ttl_secs: config.fundamental_ttl, }, offline, @@ -270,7 +283,7 @@ pub fn handle( provider, &resolved, FundamentalCacheSpec { - key: "risk", + bucket: cache_bucket(config, "risk"), ttl_secs: config.fundamental_ttl, }, offline, @@ -286,7 +299,7 @@ pub fn handle( provider, &resolved, FundamentalCacheSpec { - key: "fundamental", + bucket: cache_bucket(config, "fundamental"), ttl_secs: config.fundamental_ttl, }, offline, @@ -306,7 +319,7 @@ pub fn handle( provider, &resolved, FundamentalCacheSpec { - key: "fundamental", + bucket: cache_bucket(config, "fundamental"), ttl_secs: config.fundamental_ttl, }, offline, @@ -336,7 +349,7 @@ fn fetch_fundamental_analysis_report( cache: &Cache, provider: &dyn MarketDataProvider, resolved: &str, - cache_spec: FundamentalCacheSpec<'_>, + cache_spec: FundamentalCacheSpec, offline: bool, no_cache: bool, analyzer: F, @@ -345,26 +358,26 @@ where T: Serialize + DeserializeOwned, F: FnOnce(&str, &Fundamentals) -> T, { - if !no_cache && let Some(report) = cache.get::(cache_spec.key, resolved)? { + if !no_cache && let Some(report) = cache.get::(&cache_spec.bucket, resolved)? { return Ok(report); } if offline { return cache - .get_stale::(cache_spec.key, resolved)? - .ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.key))); + .get_stale::(&cache_spec.bucket, resolved)? + .ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.bucket))); } match provider.fundamentals(resolved) { Ok(fundamentals) => { let report = analyzer(resolved, &fundamentals); if !no_cache { - cache.put(cache_spec.key, resolved, &report, cache_spec.ttl_secs)?; + cache.put(&cache_spec.bucket, resolved, &report, cache_spec.ttl_secs)?; } Ok(report) } Err(err) => { - if !no_cache && let Some(stale) = cache.get_stale::(cache_spec.key, resolved)? { + if !no_cache && let Some(stale) = cache.get_stale::(&cache_spec.bucket, resolved)? { eprintln!("warning: network failed, serving stale cache for {resolved}"); return Ok(stale); } diff --git a/src/config.rs b/src/config.rs index 329507a..ef1f63d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,8 +8,37 @@ use crate::cli::Cli; use crate::error::IdxError; use crate::output::OutputFormat; +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProviderKind { + Yahoo, + Msn, +} + +impl ProviderKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Yahoo => "yahoo", + Self::Msn => "msn", + } + } + + fn parse(value: &str) -> Result { + if value.eq_ignore_ascii_case("yahoo") { + Ok(Self::Yahoo) + } else if value.eq_ignore_ascii_case("msn") { + Ok(Self::Msn) + } else { + Err(IdxError::ConfigError(format!( + "invalid provider '{value}' (expected yahoo or msn)" + ))) + } + } +} + #[derive(Debug, Clone)] pub struct IdxConfig { + pub provider: ProviderKind, pub exchange: String, pub output: OutputFormat, pub no_color: bool, @@ -25,6 +54,7 @@ struct FileConfig { #[derive(Debug, Deserialize, Default)] struct FileGeneral { + provider: Option, exchange: Option, output: Option, color: Option, @@ -39,6 +69,7 @@ struct FileCache { impl Default for IdxConfig { fn default() -> Self { Self { + provider: ProviderKind::Yahoo, exchange: "JK".to_string(), output: OutputFormat::Table, no_color: false, @@ -52,6 +83,9 @@ impl IdxConfig { pub fn load_with_cli(cli: &Cli) -> Result { let mut cfg = Self::load()?; + if let Ok(provider) = std::env::var("IDX_PROVIDER") { + cfg.provider = ProviderKind::parse(&provider)?; + } if let Ok(exchange) = std::env::var("IDX_EXCHANGE") { cfg.exchange = exchange; } @@ -92,6 +126,9 @@ impl IdxConfig { let parsed: FileConfig = toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?; if let Some(general) = parsed.general { + if let Some(provider) = general.provider { + cfg.provider = provider; + } if let Some(exchange) = general.exchange { cfg.exchange = exchange; } @@ -116,7 +153,7 @@ impl IdxConfig { } pub fn default_config_toml() -> String { - "[general]\nexchange = \"JK\"\noutput = \"table\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string() + "[general]\nprovider = \"yahoo\"\nexchange = \"JK\"\noutput = \"table\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string() } pub fn config_path() -> Result { @@ -210,7 +247,21 @@ mod tests { #[test] fn default_values_are_sane() { let cfg = IdxConfig::default(); + assert_eq!(cfg.provider, super::ProviderKind::Yahoo); assert_eq!(cfg.exchange, "JK"); assert_eq!(cfg.quote_ttl, 300); } + + #[test] + fn parses_provider_values() { + assert_eq!( + super::ProviderKind::parse("yahoo").expect("yahoo provider"), + super::ProviderKind::Yahoo + ); + assert_eq!( + super::ProviderKind::parse("MSN").expect("msn provider"), + super::ProviderKind::Msn + ); + assert!(super::ProviderKind::parse("unknown").is_err()); + } } diff --git a/src/main.rs b/src/main.rs index 243bcbc..fed1d65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,13 @@ fn main() { fn run() -> Result<(), IdxError> { let cli = Cli::parse(); - let config = IdxConfig::load_with_cli(&cli)?; + let config = match IdxConfig::load_with_cli(&cli) { + Ok(config) => config, + Err(err) => { + eprintln!("Error: {err}"); + return Err(err); + } + }; match &cli.command { Commands::Version => { @@ -40,7 +46,7 @@ fn run() -> Result<(), IdxError> { } } Commands::Stocks(stocks) => { - let provider = default_provider(cli.verbose > 0); + let provider = default_provider(config.provider, cli.verbose > 0); if let Err(err) = cli::stocks::handle( stocks, &config, diff --git a/tests/cli.rs b/tests/cli.rs index 72b7686..4c605b3 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -195,6 +195,8 @@ fn config_init_creates_file() { .success(); assert!(config_home.join("idx/config.toml").exists()); + let raw = fs::read_to_string(config_home.join("idx/config.toml")).expect("read config"); + assert!(raw.contains("provider = \"yahoo\"")); } #[test] @@ -239,6 +241,22 @@ fn serves_stale_cache_on_provider_failure_with_warning() { .stderr(predicate::str::contains("warning: network failed")); } +#[test] +fn config_set_and_get_provider_round_trip() { + let root = test_env_dir("config-provider"); + + bin_with_root(&root) + .args(["config", "set", "general.provider", "msn"]) + .assert() + .success(); + + bin_with_root(&root) + .args(["config", "get", "general.provider"]) + .assert() + .success() + .stdout(predicate::str::contains("msn")); +} + #[test] fn technical_serves_stale_cache_on_provider_failure_with_warning() { let root = test_env_dir("technical-stale"); @@ -263,6 +281,43 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() { .stderr(predicate::str::contains("warning: network failed")); } +#[test] +fn cache_namespace_isolated_by_provider() { + let root = test_env_dir("provider-cache"); + let cache_home = root.join("cache"); + + // Populate Yahoo cache + bin_with_root(&root) + .env("XDG_CACHE_HOME", &cache_home) + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_CACHE_QUOTE_TTL", "0") + .args(["stocks", "quote", "BBCA"]) + .assert() + .success(); + + // MSN mock succeeds independently (uses MSN fixtures, not Yahoo's cache) + bin_with_root(&root) + .env("XDG_CACHE_HOME", &cache_home) + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_CACHE_QUOTE_TTL", "0") + .args(["stocks", "quote", "BBCA"]) + .assert() + .success(); + + // MSN with mock error fails — Yahoo's cached data is NOT reused for MSN + bin_with_root(&root) + .env("XDG_CACHE_HOME", &cache_home) + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_CACHE_QUOTE_TTL", "0") + .env("IDX_MOCK_ERROR", "1") + .args(["stocks", "quote", "BBCA"]) + .assert() + .success() + .stderr(predicate::str::contains("warning: network failed")); +} + #[test] fn invalid_symbol_returns_non_zero() { test_bin("invalid-symbol") @@ -273,3 +328,13 @@ fn invalid_symbol_returns_non_zero() { .failure() .stderr(predicate::str::contains("Error:")); } + +#[test] +fn invalid_provider_env_returns_non_zero() { + test_bin("invalid-provider") + .env("IDX_PROVIDER", "bogus") + .args(["version"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid provider")); +} diff --git a/tests/fixtures/msn_chart_bbca_3mo.json b/tests/fixtures/msn_chart_bbca_3mo.json new file mode 100644 index 0000000..1852d79 --- /dev/null +++ b/tests/fixtures/msn_chart_bbca_3mo.json @@ -0,0 +1,22 @@ +[ + { + "_p": "bn91jc", + "chartType": "3M", + "symbol": "BBCA", + "series": { + "timeStamps": [ + "2025-01-06T00:00:00Z", + "2025-01-07T00:00:00Z", + "2025-01-08T00:00:00Z", + "2025-01-13T00:00:00Z", + "2025-01-14T00:00:00Z", + "2025-01-15T00:00:00Z" + ], + "prices": [9875.0, 9880.0, 9895.0, 9910.0, 9925.0, 9940.0], + "openPrices": [9800.0, 9850.0, 9870.0, 9890.0, 9905.0, 9920.0], + "pricesHigh": [9900.0, 9905.0, 9910.0, 9930.0, 9940.0, 9950.0], + "pricesLow": [9750.0, 9825.0, 9855.0, 9880.0, 9890.0, 9910.0], + "volumes": [12300000.0, 11000000.0, 11300000.0, 12500000.0, 11800000.0, 12000000.0] + } + } +] diff --git a/tests/fixtures/msn_keyratios_bbca.json b/tests/fixtures/msn_keyratios_bbca.json new file mode 100644 index 0000000..84ce690 --- /dev/null +++ b/tests/fixtures/msn_keyratios_bbca.json @@ -0,0 +1,36 @@ +[ + { + "stockId": "bn91jc", + "symbol": "BBCA", + "displayName": "Bank Central Asia Tbk", + "shortName": "Bank Central Asia", + "industryMetrics": [ + { + "year": "2025", + "fiscalPeriodType": "FY", + "revenueGrowthRate": 0.081, + "earningsGrowthRate": 0.121, + "netMargin": 0.324, + "roe": 0.198, + "returnOnAssetCurrent": 0.031, + "debtToEquityRatio": 0.42, + "currentRatio": 1.18, + "priceToEarningsRatio": 25.4, + "priceToBookRatio": 4.6 + }, + { + "year": "2024", + "fiscalPeriodType": "FY", + "revenueGrowthRate": 0.073, + "earningsGrowthRate": 0.102, + "netMargin": 0.311, + "roe": 0.187, + "returnOnAssetCurrent": 0.029, + "debtToEquityRatio": 0.45, + "currentRatio": 1.14, + "priceToEarningsRatio": 24.1, + "priceToBookRatio": 4.2 + } + ] + } +] diff --git a/tests/fixtures/msn_quote_bbca.json b/tests/fixtures/msn_quote_bbca.json new file mode 100644 index 0000000..95e8b4e --- /dev/null +++ b/tests/fixtures/msn_quote_bbca.json @@ -0,0 +1,18 @@ +[ + { + "id": "bn91jc", + "instrumentId": "bn91jc", + "symbol": "BBCA", + "shortName": "Bank Central Asia", + "displayName": "Bank Central Asia Tbk", + "price": 9875.0, + "priceChange": 117.0, + "priceChangePercent": 1.2, + "pricePreviousClose": 9758.0, + "price52wHigh": 10250.0, + "price52wLow": 7800.0, + "accumulatedVolume": 12300000.0, + "averageVolume": 10000000.0, + "marketCap": 1215200000000000.0 + } +]