From 9b2e32e0c659ec1fbae780cce7dd4d8f7bb09288 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Fri, 6 Mar 2026 10:51:20 +0000 Subject: [PATCH] refactor(api): split MSN provider module --- src/api/msn/client.rs | 87 +++++++ src/api/msn/mod.rs | 93 ++++++++ src/api/{msn.rs => msn/parse.rs} | 216 ++---------------- .../symbol_ids.tsv} | 0 src/api/msn/symbols.rs | 56 +++++ 5 files changed, 252 insertions(+), 200 deletions(-) create mode 100644 src/api/msn/client.rs create mode 100644 src/api/msn/mod.rs rename src/api/{msn.rs => msn/parse.rs} (75%) rename src/api/{msn_symbol_ids.tsv => msn/symbol_ids.tsv} (100%) create mode 100644 src/api/msn/symbols.rs diff --git a/src/api/msn/client.rs b/src/api/msn/client.rs new file mode 100644 index 0000000..4921749 --- /dev/null +++ b/src/api/msn/client.rs @@ -0,0 +1,87 @@ +use std::time::Duration; + +use serde::de::DeserializeOwned; + +use crate::error::IdxError; + +use super::parse::{KeyRatios, MsnChart, MsnQuote}; +use super::symbols::resolve_msn_id; + +const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; +const MSN_ASSETS_BASE_URL: &str = "https://assets.msn.com/service/"; +const MSN_API_BASE_URL: &str = "https://api.msn.com/msn/v0/pages/finance/"; +// Public API key from MSN Money website (embedded in frontend JS) +const MSN_API_KEY: &str = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM"; + +pub(super) struct MsnClient { + agent: ureq::Agent, +} + +impl MsnClient { + pub(super) fn new() -> Self { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(5))) + .timeout_recv_body(Some(Duration::from_secs(10))) + .build() + .into(); + + Self { agent } + } + + fn get_json( + &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}"))), + } + } + + pub(super) fn fetch_quotes(&self, symbol: &str) -> Result, IdxError> { + let id = + resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; + let url = format!( + "{MSN_ASSETS_BASE_URL}Finance/Quotes?apikey={MSN_API_KEY}&ids={id}&wrapodata=false" + ); + self.get_json(&url, symbol, "quote") + } + + pub(super) fn fetch_key_ratios(&self, symbol: &str) -> Result, IdxError> { + let id = + resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; + let url = + format!("{MSN_API_BASE_URL}keyratios?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"); + self.get_json(&url, symbol, "keyratios") + } + + pub(super) fn fetch_charts( + &self, + symbol: &str, + chart_type: &str, + ) -> Result, 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}&cm=id-id&ids={id}&type={chart_type}&wrapodata=false" + ); + self.get_json(&url, symbol, "chart") + } +} diff --git a/src/api/msn/mod.rs b/src/api/msn/mod.rs new file mode 100644 index 0000000..6a6fbbf --- /dev/null +++ b/src/api/msn/mod.rs @@ -0,0 +1,93 @@ +mod client; +mod parse; +mod symbols; + +use crate::api::MarketDataProvider; +use crate::api::types::{Fundamentals, Interval, Ohlc, Period, Quote}; +use crate::error::IdxError; + +use client::MsnClient; +use parse::{ + ResampleInterval, parse_fundamentals, parse_history_with_verbose, parse_quote, resample_history, +}; + +pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str}; + +pub struct MsnProvider { + client: MsnClient, + verbose: bool, +} + +impl MsnProvider { + pub fn new(verbose: bool) -> Self { + Self { + client: MsnClient::new(), + verbose, + } + } + + 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.client.fetch_quotes(symbol)?; + parse_quote(symbol, "es) + } + + fn fundamentals(&self, symbol: &str) -> Result { + let ratios = self.client.fetch_key_ratios(symbol)?; + let quote = self + .client + .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 + .client + .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(test)] +mod tests { + use super::MsnProvider; + use crate::api::types::Period; + + #[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"); + } +} diff --git a/src/api/msn.rs b/src/api/msn/parse.rs similarity index 75% rename from src/api/msn.rs rename to src/api/msn/parse.rs index 62d9405..01e1ed6 100644 --- a/src/api/msn.rs +++ b/src/api/msn/parse.rs @@ -1,181 +1,13 @@ -use std::collections::{BTreeMap, HashMap}; -use std::sync::OnceLock; -use std::time::Duration; +use std::collections::BTreeMap; 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 super::symbols::{normalized_symbol, ticker_from_symbol}; +use crate::api::types::{Fundamentals, 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 = @@ -183,7 +15,7 @@ pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result Result { +pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result { let quote = quotes.first().ok_or(IdxError::ProviderUnavailable)?; let raw_price = quote .price @@ -198,8 +30,8 @@ fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result { let ticker = quote .symbol .as_deref() - .and_then(MsnProvider::ticker_from_symbol) - .unwrap_or_else(|| MsnProvider::ticker_from_symbol(symbol).unwrap_or_default()); + .and_then(ticker_from_symbol) + .unwrap_or_else(|| 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 => { @@ -217,7 +49,7 @@ fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result { }; Ok(Quote { - symbol: MsnProvider::normalized_symbol(symbol, &ticker), + symbol: normalized_symbol(symbol, &ticker), price, change, change_pct: quote.price_change_percent.unwrap_or(0.0), @@ -247,7 +79,7 @@ pub(crate) fn parse_fundamentals_from_str( parse_fundamentals(&ratios, quote.as_ref()) } -fn parse_fundamentals( +pub(super) fn parse_fundamentals( ratios: &[KeyRatios], quote: Option<&MsnQuote>, ) -> Result { @@ -309,7 +141,7 @@ fn parse_close_only_history_from_str( parse_close_only_history(period, &charts) } -fn parse_history_with_verbose( +pub(super) fn parse_history_with_verbose( period: &Period, charts: &[MsnChart], verbose: bool, @@ -520,12 +352,12 @@ fn sanitize_current_ratio(value: Option) -> Option { } #[derive(Clone, Copy)] -enum ResampleInterval { +pub(super) enum ResampleInterval { Week, Month, } -fn resample_history(rows: &[Ohlc], interval: ResampleInterval) -> Vec { +pub(super) fn resample_history(rows: &[Ohlc], interval: ResampleInterval) -> Vec { let mut grouped: BTreeMap<(i32, u32), Ohlc> = BTreeMap::new(); for row in rows { @@ -605,7 +437,7 @@ where #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct MsnQuote { +pub(crate) struct MsnQuote { #[serde(default)] symbol: Option, price: Option, @@ -629,7 +461,7 @@ struct MsnQuote { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct KeyRatios { +pub(crate) struct KeyRatios { #[serde(default)] industry_metrics: Vec, #[serde(default)] @@ -672,7 +504,7 @@ struct IndustryMetric { } #[derive(Debug, Deserialize)] -struct MsnChart { +pub(crate) struct MsnChart { series: ChartSeries, } @@ -713,19 +545,11 @@ struct ClosePoint { #[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, + 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") @@ -839,14 +663,6 @@ mod tests { 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#"[ diff --git a/src/api/msn_symbol_ids.tsv b/src/api/msn/symbol_ids.tsv similarity index 100% rename from src/api/msn_symbol_ids.tsv rename to src/api/msn/symbol_ids.tsv diff --git a/src/api/msn/symbols.rs b/src/api/msn/symbols.rs new file mode 100644 index 0000000..a105c6b --- /dev/null +++ b/src/api/msn/symbols.rs @@ -0,0 +1,56 @@ +use std::collections::HashMap; +use std::sync::OnceLock; + +const SYMBOL_IDS_RAW: &str = include_str!("symbol_ids.tsv"); + +static SYMBOL_IDS: OnceLock> = OnceLock::new(); + +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() + }) +} + +pub(crate) 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(), + ) +} + +pub(crate) fn resolve_msn_id(symbol: &str) -> Option<&'static str> { + let ticker = ticker_from_symbol(symbol)?; + symbol_ids().get(ticker.as_str()).copied() +} + +pub(crate) 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()) + } +} + +#[cfg(test)] +mod tests { + use super::resolve_msn_id; + + #[test] + fn resolves_symbol_variants() { + assert_eq!(resolve_msn_id("BBCA"), Some("bn91jc")); + assert_eq!(resolve_msn_id("bbca.jk"), Some("bn91jc")); + assert_eq!(resolve_msn_id("INVALID"), None); + } +}