Merge pull request #1 from 0xrsydn/feature/msn-provider-mvp

feat(api): add MSN Finance as alternative data provider
This commit is contained in:
Rasyidan A F 2026-03-06 18:41:29 +07:00 committed by GitHub
commit 343dac92ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2163 additions and 36 deletions

View file

@ -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<dyn MarketDataProvider> {
pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box<dyn MarketDataProvider> {
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,29 @@ 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", &quote_raw)
.map_err(|e| IdxError::ParseError(e.to_string()));
let fundamentals = msn::parse_fundamentals_from_str(&fundamentals_raw, Some(&quote_raw))
.map_err(|e| IdxError::ParseError(e.to_string()));
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()),

74
src/api/msn/client.rs Normal file
View file

@ -0,0 +1,74 @@
use std::time::Duration;
use serde::de::DeserializeOwned;
use crate::error::IdxError;
use super::parse::{KeyRatios, 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<T: DeserializeOwned>(
&self,
url: &str,
symbol: &str,
endpoint: &str,
) -> Result<T, IdxError> {
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::<T>()
.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<Vec<MsnQuote>, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let url = format!(
"{MSN_ASSETS_BASE_URL}Finance/Quotes?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
);
self.get_json(&url, symbol, "quote")
}
pub(super) fn fetch_key_ratios(&self, symbol: &str) -> Result<Vec<KeyRatios>, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let url =
format!("{MSN_API_BASE_URL}keyratios?apikey={MSN_API_KEY}&ids={id}&wrapodata=false");
self.get_json(&url, symbol, "keyratios")
}
}

83
src/api/msn/mod.rs Normal file
View file

@ -0,0 +1,83 @@
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::{parse_fundamentals, parse_quote};
pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
const HISTORY_UNSUPPORTED_REASON: &str = "MSN provider does not currently support history or technical analysis because MSN charts do not consistently expose real OHLCV data";
pub struct MsnProvider {
client: MsnClient,
verbose: bool,
}
impl MsnProvider {
pub fn new(verbose: bool) -> Self {
Self {
client: MsnClient::new(),
verbose,
}
}
}
impl MarketDataProvider for MsnProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
let quotes = self.client.fetch_quotes(symbol)?;
parse_quote(symbol, &quotes)
}
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
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<Vec<Ohlc>, IdxError> {
Err(IdxError::Unsupported(
HISTORY_UNSUPPORTED_REASON.to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::MsnProvider;
use crate::api::MarketDataProvider;
use crate::api::types::{Interval, Period};
use crate::error::IdxError;
#[test]
fn history_is_explicitly_unsupported() {
let provider = MsnProvider::new(false);
let err = provider
.history("BBCA.JK", &Period::OneMonth, &Interval::Day)
.expect_err("history should be unsupported");
assert!(matches!(err, IdxError::Unsupported(_)));
assert!(
err.to_string()
.contains("MSN provider does not currently support history or technical analysis")
);
}
}

739
src/api/msn/parse.rs Normal file
View file

@ -0,0 +1,739 @@
use std::collections::BTreeMap;
use chrono::{Datelike, NaiveDate};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use super::symbols::{normalized_symbol, ticker_from_symbol};
use crate::api::types::{Fundamentals, Ohlc, Period, Quote};
use crate::error::IdxError;
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result<Quote, IdxError> {
let quotes: Vec<MsnQuote> =
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
parse_quote(symbol, &quotes)
}
pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, IdxError> {
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(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 => {
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: 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<Fundamentals, IdxError> {
let ratios: Vec<KeyRatios> =
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
let quote = quote_raw
.map(serde_json::from_str::<Vec<MsnQuote>>)
.transpose()
.map_err(|e| IdxError::ParseError(e.to_string()))?
.and_then(|quotes| quotes.into_iter().next());
parse_fundamentals(&ratios, quote.as_ref())
}
pub(super) fn parse_fundamentals(
ratios: &[KeyRatios],
quote: Option<&MsnQuote>,
) -> Result<Fundamentals, IdxError> {
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<Vec<Ohlc>, IdxError> {
let charts: Vec<MsnChart> =
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<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)
}
pub(super) fn parse_history_with_verbose(
period: &Period,
charts: &[MsnChart],
verbose: bool,
) -> Result<Vec<Ohlc>, 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 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<Vec<ClosePoint>, IdxError> {
let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?;
let timestamps = &chart.series.time_stamps;
let mut grouped: BTreeMap<NaiveDate, ClosePoint> = 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<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_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 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);
}
fn preferred_metric(metrics: &[IndustryMetric]) -> Option<&IndustryMetric> {
metrics.iter().max_by_key(|metric| metric_rank(metric))
}
fn best_metric_value<T: Copy>(
metrics: &[IndustryMetric],
extractor: impl Fn(&IndustryMetric) -> Option<T>,
) -> Option<T> {
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::<i32>().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<f64>) -> Option<f64> {
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<f64>) -> Option<f64> {
value.and_then(|number| {
if !number.is_finite() || number < 0.01 {
None
} else {
Some(number)
}
})
}
#[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()
}
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 {
value.round() as i64
}
fn round_u64(value: Option<f64>) -> Option<u64> {
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<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum NumberLike {
F64(f64),
String(String),
}
let value = Option::<NumberLike>::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::<f64>().map(Some).map_err(D::Error::custom)
}
}
None => Ok(None),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MsnQuote {
#[serde(default)]
symbol: Option<String>,
price: Option<f64>,
#[serde(default)]
price_change: Option<f64>,
#[serde(default)]
price_change_percent: Option<f64>,
#[serde(default)]
price_previous_close: Option<f64>,
#[serde(default, rename = "price52wHigh")]
price_52w_high: Option<f64>,
#[serde(default, rename = "price52wLow")]
price_52w_low: Option<f64>,
#[serde(default)]
accumulated_volume: Option<f64>,
#[serde(default)]
average_volume: Option<f64>,
#[serde(default)]
market_cap: Option<f64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct KeyRatios {
#[serde(default)]
industry_metrics: Vec<IndustryMetric>,
#[serde(default)]
company_metrics: Vec<IndustryMetric>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndustryMetric {
year: Option<String>,
fiscal_period_type: Option<String>,
#[serde(default)]
revenue_growth_rate: Option<f64>,
#[serde(default)]
earnings_growth_rate: Option<f64>,
#[serde(default, rename = "netIncomeYTDYTDGrowthRate")]
net_income_ytd_ytd_growth_rate: Option<f64>,
#[serde(default, rename = "revenueYTDYTD")]
revenue_ytd_ytd: Option<f64>,
#[serde(default)]
net_margin: Option<f64>,
#[serde(default)]
profit_margin: Option<f64>,
#[serde(default)]
roe: Option<f64>,
#[serde(default, rename = "roaTTM")]
roa_ttm: Option<f64>,
#[serde(default)]
return_on_asset_current: Option<f64>,
#[serde(default)]
debt_to_equity_ratio: Option<f64>,
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
current_ratio: Option<f64>,
#[serde(default)]
price_to_earnings_ratio: Option<f64>,
#[serde(default, rename = "forwardPriceToEPS")]
forward_price_to_eps: Option<f64>,
#[serde(default)]
price_to_book_ratio: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct MsnChart {
series: ChartSeries,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChartSeries {
#[serde(default)]
time_stamps: Vec<String>,
#[serde(default)]
prices: Vec<f64>,
#[serde(default)]
open_prices: Vec<f64>,
#[serde(default)]
prices_high: Vec<f64>,
#[serde(default)]
prices_low: Vec<f64>,
#[serde(default)]
volumes: Vec<f64>,
}
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::{
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 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(&quote_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 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);
}
}

896
src/api/msn/symbol_ids.tsv Normal file
View file

@ -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
1 AADI cfatz2
2 AALI bn8zk2
3 ABBA bkf9ec
4 ABDA bkf9h7
5 ABMM bkf9k2
6 ACES bn8zyc
7 ACRO cc48gh
8 ACST bkf9pr
9 ADCP c59mf2
10 ADES bkf9sm
11 ADHI bn9127
12 ADMF bkf9yc
13 ADMG bn9152
14 ADMR c4pl52
15 ADRO bn917w
16 AEGS carobh
17 AGAR bsu9xm
18 AGII bkfa7w
19 AGRO bkfaar
20 AGRS bkfadm
21 AHAP bkfagh
22 AIMS bkfajc
23 AISA bkfam7
24 AKKU bkfctc
25 AKPI bkfcw7
26 AKRA bkfcz2
27 AKSI bkfd2w
28 ALDO bkfd5r
29 ALII ccanzr
30 ALKA bkfd8m
31 ALMI bkfdbh
32 ALTO bkfdec
33 AMAG bkfchw
34 AMAN btysar
35 AMAR btf6a2
36 AMFG bkfckr
37 AMIN bkfcnm
38 AMMN ca993m
39 AMMS c6yaw7
40 AMOR btfbw7
41 AMRT bkfcqh
42 ANDI bgrwf2
43 ANJT bkfem7
44 ANTM bn8zmw
45 APEX bkferw
46 APIC bkfeur
47 APII bkfexm
48 APLI bkff1h
49 APLN bkff4c
50 ARCI c2il1h
51 AREA ccsom7
52 ARGO bkff77
53 ARII bkffa2
54 ARKA bqtwtc
55 ARKO c6qaqh
56 ARNA bkfffr
57 ARTA bkffim
58 ARTO bkffoc
59 ASBI bkffzr
60 ASDM bkfg3m
61 ASGR bkfg6h
62 ASHA c6a3yc
63 ASII bn91gh
64 ASJT bkfgc7
65 ASLC c4xl52
66 ASLI cc41ww
67 ASMI bkfgf2
68 ASPI btr8pr
69 ASPR chbyjc
70 ASRI bkfghw
71 ASRM bkfgkr
72 ASSA bkfgnm
73 ATAP bwzup2
74 ATIC bkffr7
75 ATLA ccwjvh
76 AUTO bkffu2
77 AVIA c4bepr
78 AWAN c9f9yc
79 AXIO c6v4pr
80 AYAM cbkfkr
81 AYLS btox8m
82 BABP bkfgqh
83 BABY carnqh
84 BACA bkfgtc
85 BAIK ccbzar
86 BAJA bkfgw7
87 BALI bkfgz2
88 BANK bxjrc7
89 BAPA bkfh2w
90 BAPI breeur
91 BATA bkflyc
92 BATR cddzvh
93 BAUT c4xpm7
94 BAYU bkfm27
95 BBCA bn91jc
96 BBHI bkfm7w
97 BBKP bkfmar
98 BBLD bkfmdm
99 BBMD bkfmgh
100 BBNI bn91m7
101 BBRI bn91p2
102 BBRM bkfmrw
103 BBSI bvpgvh
104 BBSS bu9cqh
105 BBTN bkfmur
106 BBYB bkfmxm
107 BCAP bkfn1h
108 BCIC bgt4rw
109 BCIP bkfn4c
110 BDKR c8zmyc
111 BDMN bn91rw
112 BEBS bzv7ar
113 BEEF bokavh
114 BEER c8if77
115 BEKS bkfna2
116 BELI c7svnm
117 BELL bkfncw
118 BESS btyakr
119 BEST bkfh5r
120 BFIN bkfh8m
121 BGTG bkfhbh
122 BHAT bua6k2
123 BHIT bkfhec
124 BIKE c5iucw
125 BIMA bkfkz2
126 BINA bn92u2
127 BINO c45tp2
128 BIPI bn92ww
129 BIPP bkfl8m
130 BIRD bkflbh
131 BISI bn92zr
132 BJBR bkflh7
133 BJTM bn936h
134 BKDP bkflmw
135 BKSL bkflpr
136 BKSW bkflsm
137 BLES cdlvh7
138 BLOG chc7m7
139 BLTA bgt5u2
140 BLTZ bkflvh
141 BLUE bqrgar
142 BMAS bkfoqh
143 BMBL c8isqh
144 BMHS c2md4c
145 BMRI bn939c
146 BMSR bkfow7
147 BMTR bkfoz2
148 BNBA bkfp2w
149 BNBR bn93c7
150 BNGA bkfp8m
151 BNII bn93f2
152 BNLI bn93hw
153 BOAT cf4yu2
154 BOBA c3wioc
155 BOGA bkfph7
156 BOLA bqe3p2
157 BOLT bkfpk2
158 BPFI bkfppr
159 BPII bkfpsm
160 BPTR bgth1h
161 BRAM bkfpvh
162 BREN cb1ra2
163 BRIS bgtha2
164 BRMS bn924c
165 BRNA bkfq27
166 BRPT bn9277
167 BRRC cflfa2
168 BSBK c7svhw
169 BSDE bkfq7w
170 BSIM bn92a2
171 BSML c4is3m
172 BSSR bkfqdm
173 BSWD bkfqgh
174 BTEK bkfqjc
175 BTON bkfqp2
176 BTPN bkfqrw
177 BTPS bgtwjc
178 BUAH c71dyc
179 BUDI bkfqur
180 BUKA c2wgkr
181 BUKK bkfqxm
182 BULL bkfnu2
183 BUMI bn92fr
184 BUVA bkfnzr
185 BVIC bkfo3m
186 BWPT bn92im
187 BYAN bn92lh
188 CAKK bne4c7
189 CAMP bkfoc7
190 CANI bkfof2
191 CARE btysdm
192 CARS bkfohw
193 CASA bkfokr
194 CASH bufc7w
195 CASS bkfonm
196 CBDK cflejc
197 CBPE c8ipmw
198 CBRE c8ifww
199 CBUT c7sxgh
200 CCSI bqh6ar
201 CDIA chdaw7
202 CEKA bkfkqh
203 CENT bkfktc
204 CFIN bkfkw7
205 CGAS cc45zr
206 CHEK chddzr
207 CHEM c6qahw
208 CHIP c8qlmw
209 CINT bkfnfr
210 CITA bkfnim
211 CITY bjs6w7
212 CLAY bona52
213 CLEO bkfnoc
214 CLPI bkfnr7
215 CMNP bn93kr
216 CMNT c3bxcw
217 CMPP bkgku2
218 CMRY c4b81h
219 CNKO bkgkzr
220 CNMA cagkkr
221 COAL c78lp2
222 COCO bpfy1h
223 COIN chc7p2
224 CPIN bn93nm
225 CPRO bkgl9c
226 CRAB c71ep2
227 CRSN ca9dnm
228 CSAP bn93z2
229 CSIS bkglf2
230 CSMI bua4a2
231 CSRA btf6cw
232 CTBN bkglhw
233 CTRA bn942w
234 CTTH bkglnm
235 CUAN c8ztf2
236 CYBR caj9jc
237 DAAZ cf3dr7
238 DADA btov1h
239 DART bkglqh
240 DATA cd3e27
241 DAYA bkgltc
242 DCII bxbr3m
243 DEFI bkglw7
244 DEPO c461k2
245 DEWA bn93qh
246 DEWI c6t4oc
247 DFAM bkgm2w
248 DGIK bkgm5r
249 DGNS bxbs2w
250 DGWG cfl46h
251 DIGI bguqr7
252 DILD bn93tc
253 DIVA bny8f2
254 DKFT bkgmbh
255 DKHH cgo8rw
256 DLTA bkgn52
257 DMAS bkgn7w
258 DMMX bsh6z2
259 DMND bti2qh
260 DNAR bkgnar
261 DNET bkgndm
262 DOID bkgngh
263 DOOH c9pg52
264 DOSS cecd8m
265 DPNS bkgnjc
266 DPUM bkgnm7
267 DRMA c4is9c
268 DSFI bkgnp2
269 DSNG bkgnrw
270 DSSA bn94ec
271 DUTI bn94h7
272 DVLA bkgmsm
273 DWGL bkgmvh
274 DYAN bkgmyc
275 EAST bqrjmw
276 ECII bkgn27
277 EDGE bxmmkr
278 EKAD bkgmec
279 ELIT c8ifcw
280 ELPI c71dh7
281 ELSA bkgmh7
282 ELTY bn94k2
283 EMAS ci4x5r
284 EMDE bkgo77
285 EMTK bkgocw
286 ENAK c53nww
287 ENRG bkgofr
288 ENZO bvqna2
289 EPAC buzekr
290 EPMT bkgoim
291 ERAA bkgolh
292 ERAL cakb4c
293 ERTX bkgooc
294 ESIP bsq1dm
295 ESSA bkgnxm
296 ESTA btxltc
297 ESTI bkgo1h
298 EURO c6zylh
299 EXCL bn94pr
300 FAPA bx7mww
301 FAST bkgor7
302 FASW bkgou2
303 FILM bgva7w
304 FIMP c1paqh
305 FIRE bkgozr
306 FISH bkgp3m
307 FITT bqcw2w
308 FLMC c2p22w
309 FMII bkgp9c
310 FOLK caj9ar
311 FOOD bohgur
312 FORE cghp7w
313 FORU bkgpc7
314 FPNI bkgphw
315 FUJI bqrhww
316 FUTR c8xc2w
317 FWCT c8no4c
318 GDST bkgpnm
319 GDYR bkgpqh
320 GEMA bkgptc
321 GEMS bkgpw7
322 GGRM bn94vh
323 GGRP brivww
324 GHON bkgq2w
325 GIAA bkgq5r
326 GJTL bn9527
327 GLOB bgveur
328 GLVA bt94fr
329 GMFI bkgqbh
330 GMTD bkgqk2
331 GOLD bkgqmw
332 GOLF cdml52
333 GOOD bkgqsm
334 GOTO c5tv8m
335 GPRA bkgqvh
336 GPSO c39qp2
337 GRIA cakam7
338 GRPH cc65vh
339 GRPM ca9bar
340 GSMF bkgqec
341 GTBO bkgqh7
342 GTRA c9a7c7
343 GTSI c3atkr
344 GULA c6ybbh
345 GUNA cdm6oc
346 GWSA bkgqyc
347 GZCO bkgr27
348 HADE bkgr52
349 HAIS c3839c
350 HAJJ c9c9z2
351 HALO c8snbh
352 HATM c6vxxm
353 HBAT caj5ur
354 HDFA bkgr7w
355 HDIT bqsr9c
356 HEAL bgvigh
357 HELI bkgrdm
358 HERO bkgrgh
359 HEXA bn95ar
360 HGII cfl3r7
361 HILL c8xfhw
362 HITS bkgrm7
363 HMSP bn95jc
364 HOKI bkgrur
365 HOMI bvpl7w
366 HOPE c24y52
367 HRME bpplhw
368 HRTA bkgs4c
369 HRUM bn957w
370 HUMI caj9p2
371 HYGN ccbzjc
372 IATA bkgsa2
373 IBFN bkgscw
374 IBOS c5z1vh
375 IBST bkgsfr
376 ICBP bkgsim
377 ICON bkgslh
378 IDEA c3at9c
379 IDPR bkgsoc
380 IFII bt26w7
381 IFSH bsua4c
382 IGAR bkgsr7
383 IKAI bkgsww
384 IKAN btov4c
385 IKBI bkgszr
386 IKPM cbetnm
387 IMAS bkguk2
388 IMJS bkgumw
389 IMPC bkgupr
390 INAI bkguvh
391 INCF bkguyc
392 INCI bkgv27
393 INCO bn96cw
394 INDF bn96fr
395 INDO btf6ww
396 INDR bkgvar
397 INDS bkgvdm
398 INDX bkgvgh
399 INDY bkgvjc
400 INET caehu2
401 INKP bn96im
402 INOV bqtw6h
403 INPC bkgttc
404 INPP bkgtw7
405 INPS bkgtz2
406 INRU bkgu2w
407 INTA bkgu5r
408 INTD bkgu8m
409 INTP bn95m7
410 IOTF cb1mar
411 IPAC c2ip4c
412 IPCC bgvlmw
413 IPCM bkguec
414 IPOL bkguh7
415 IPTV bqrdim
416 IRRA bs6n9c
417 IRSX c8ptdm
418 ISAP c87m5r
419 ISAT bn95p2
420 ISEA cdmkyc
421 ISSP bn95rw
422 ITIC bqptyc
423 ITMA bkgt9c
424 ITMG bn95ur
425 JARR c6yb2w
426 JAST bpztur
427 JATI c9pikr
428 JAWA bkgtf2
429 JAYA bp3fkr
430 JECC bkgthw
431 JGLE bkgtkr
432 JIHD bkgtnm
433 JKON bn95xm
434 JMAS bkgwcw
435 JPFA bkgwfr
436 JRPT bkgwim
437 JSMR bn964c
438 JSPT bkgwr7
439 JTPE bkgwu2
440 KAEF bkgvp2
441 KAQI cg3ah7
442 KARW bkgvrw
443 KBAG bu8obh
444 KBLI bkgvur
445 KBLM bkgvxm
446 KBLV bkgw1h
447 KDSI bkgwww
448 KDTN c7sxar
449 KEEN brdma2
450 KEJU bss8im
451 KETR c7x3cw
452 KIAS bgw8lh
453 KICI bkgwzr
454 KIJA bkgx3m
455 KING c8v3jc
456 KINO bkgx6h
457 KIOS bkgx9c
458 KJEN bqoa1h
459 KKES c71dsm
460 KKGI bkgxqh
461 KLAS c9zg9c
462 KLBF bn96u2
463 KLIN c71ear
464 KMDS bvpjz2
465 KMTR bkgxw7
466 KOBX bkgxz2
467 KOCI cazma2
468 KOIN bkgy2w
469 KOKA cb2m5r
470 KONI bkgy5r
471 KOPI bkgy8m
472 KOTA bqspm7
473 KPIG bkgxf2
474 KRAS bn96zr
475 KREN bkgxnm
476 KRYA c6vopr
477 KSIX cfl4f2
478 KUAS c3tgqh
479 LABA c2ekur
480 LABS cdml7w
481 LAJU c8ni2w
482 LAND bgwfxm
483 LAPD bkgybh
484 LCKM bkgyh7
485 LEAD bkgyk2
486 LFLO c1g5f2
487 LIFE bqsrz2
488 LINK bkgymw
489 LION bkgypr
490 LIVE ccbz52
491 LMAX caj9gh
492 LMPI bkgyyc
493 LMSH bkgz27
494 LOPI cb2a2w
495 LPCK bkgz52
496 LPGI bkgz7w
497 LPIN bkgzar
498 LPKR bn99ar
499 LPLI bn99dm
500 LPPF bkgzjc
501 LPPS bn99gh
502 LRNA bkgzp2
503 LSIP bn99jc
504 LTLS bkgzur
505 LUCK bny8z2
506 LUCY c1zlh7
507 MAHA caem3m
508 MAIN bkh177
509 MANG cc463m
510 MAPA bgwzm7
511 MAPB bkh1cw
512 MAPI bn98pr
513 MARI bkh1im
514 MARK bkh1lh
515 MASB c2jw8m
516 MAXI c9zd5r
517 MAYA bkh1r7
518 MBAP bkh1u2
519 MBMA c9fe52
520 MBSS bkh1ww
521 MBTO bkh1zr
522 MCAS bkh23m
523 MCOL c3apnm
524 MCOR bkh26h
525 MDIA bkh2c7
526 MDIY cff1lh
527 MDKA bkh2f2
528 MDKI bkh2hw
529 MDLA cgbphw
530 MDLN bkh2kr
531 MDRN bkh2nm
532 MEDC bn9a77
533 MEDS c71idm
534 MEGA bkh2tc
535 MEJA ccbqh7
536 MENN c9fbu2
537 MERI che6cw
538 MERK bkh2w7
539 MFMI bkh35r
540 MGLV c2cba2
541 MGNA bkh38m
542 MGRO bgxdcw
543 MHKI ccwk27
544 MICE bn9afr
545 MIDI bn9aim
546 MIKA bkh3h7
547 MINA bkh427
548 MINE cg3epr
549 MIRA bkh452
550 MITI bkh47w
551 MKAP ccbrar
552 MKPI bkh4dm
553 MKTR c7sz6h
554 MLBI bkh4gh
555 MLIA bkh4jc
556 MLPL bn9alh
557 MLPT bkh4p2
558 MMIX c87lz2
559 MMLP bkh4rw
560 MNCN bkh4ur
561 MOLI b9fksm
562 MORA c6zya2
563 MPIX ccbqmw
564 MPMX bkh3mw
565 MPOW bkh3pr
566 MPPA bn9azr
567 MPRO bkh3vh
568 MPXL c9ql7w
569 MRAT bkh3yc
570 MREI bkhcur
571 MSIE cakqgh
572 MSIN bgy7nm
573 MSJA cc466h
574 MSKY bkhcxm
575 MSTI cbe9qh
576 MTDL bkhd1h
577 MTEL c45acw
578 MTFN bgy82w
579 MTLA bkhd4c
580 MTMH c5w4lh
581 MTPS bpmq9c
582 MTSM bkhda2
583 MTWI bkhdfr
584 MUTU cajgyc
585 MYOH bkhcgh
586 MYOR bn9bkr
587 MYTX bkhcp2
588 NAIK cf6abh
589 NANO c5es5r
590 NASA bkhc52
591 NASI c4earw
592 NATO bor3r7
593 NAYZ c8pvw7
594 NCKL c9dmnm
595 NELY bkhcar
596 NEST cecdh7
597 NETV c4zpnm
598 NFCX bh2cm7
599 NICE cc46f2
600 NICK bkhdoc
601 NICL c2og3m
602 NIKL bkhdr7
603 NINE c87ih7
604 NIRO bkhdww
605 NISP bkhdzr
606 NOBU bkhe3m
607 NPGF c1mpp2
608 NRCA bkhe9c
609 NSSS c92ssm
610 NTBK c53nim
611 NZIA brkv8m
612 OASA bkhdim
613 OBAT cflf77
614 OBMD c4dnkr
615 OILS c3a84c
616 OKAS bkhdlh
617 OLIV c64i27
618 OMED c7t2jc
619 OMRE bkhekr
620 OPMS brkez2
621 PACK c8sn2w
622 PADA c87qyc
623 PADI bkheqh
624 PALM bkhetc
625 PAMG bqrcxm
626 PANI bh3952
627 PANR bkhez2
628 PANS bkhf2w
629 PART cdlta2
630 PBID bkhf5r
631 PBRX bkhf8m
632 PBSA bkhfbh
633 PCAR bkhfec
634 PDES bkhfh7
635 PDPP c7wzkr
636 PEGE bkhfk2
637 PEHA boeyr7
638 PEVE c8lqu2
639 PGAS bn9ch7
640 PGEO c8wmc7
641 PGJO btf6im
642 PGLI bkhfpr
643 PGUN bv2tpr
644 PICO bkhfvh
645 PIPA c9cy5r
646 PJAA bkhfyc
647 PKPK bkhh1h
648 PLAN bvsmnm
649 PLIN bkhh77
650 PMJS bt81mw
651 PMMP bx3rtc
652 PMUI che6fr
653 PNBN bn9d27
654 PNBS bn9d52
655 PNGO bvilbh
656 PNIN bkhhfr
657 PNLF bn9d7w
658 PNSE bkhhlh
659 POLA bnw452
660 POLI bokeh7
661 POLU bqiq5r
662 POLY bkhg27
663 PORT bkhg7w
664 POWR bn9dar
665 PPGL bv7ka2
666 PPRE bkhgdm
667 PPRI caj5xm
668 PPRO bkhggh
669 PRAY c7svf2
670 PRDA bkhgm7
671 PRIM bh3bnm
672 PSAB bkhgp2
673 PSDN bkhgrw
674 PSGO bssb7w
675 PSKT bkhgur
676 PSSI bkhgxm
677 PTBA bn9i4c
678 PTDU bwzbim
679 PTIS bkhif2
680 PTMP c8zh3m
681 PTMR cese7w
682 PTPP bkhihw
683 PTPS cb26pr
684 PTPW btowc7
685 PTRO bkhinm
686 PTSN bkhiqh
687 PTSP bkhitc
688 PUDP bkhiw7
689 PURA btjqcw
690 PURI bvmvlh
691 PWON bkhiz2
692 PYFA bkhj2w
693 PZZA bh3dar
694 RAAM c9pg7w
695 RAFI c6zy1h
696 RAJA bkhhu2
697 RALS bn9iim
698 RANC bkhhzr
699 RATU cfl3oc
700 RBMS bkhi3m
701 RCCC c6y4ur
702 RDTX bkhi6h
703 REAL bsxdzr
704 RELF ca43u2
705 RELI bkhi9c
706 RGAS cbe25r
707 RICY bkhj5r
708 RIGS bkhj8m
709 RISE bh3par
710 RMKE c4bf52
711 RMKO cafyoc
712 ROCK bvpnqh
713 RODA bkhkur
714 RONY bua477
715 ROTI bkhkxm
716 RSCH caoza2
717 RSGK c3atf2
718 RUIS bkhjh7
719 RUNS c3athw
720 SAFE bh4khw
721 SAGE c8ztkr
722 SAME bkhl1h
723 SAMF bu77h7
724 SAPX bkhl4c
725 SATU bnmpyc
726 SBMA c3apw7
727 SCCO bkhl77
728 SCMA bkhla2
729 SCNP bvpljc
730 SDMU bkhlcw
731 SDPC bkhlfr
732 SDRA bkhlim
733 SEMA c4s94c
734 SFAN bqigoc
735 SGER bvdqa2
736 SGRO bn9j9c
737 SHID bkhlww
738 SHIP bkhlzr
739 SICO c5tatc
740 SIDO bkhm6h
741 SILO bn9jnm
742 SIMP bkhmf2
743 SINI bsn27w
744 SIPD bkhpzr
745 SKBM bkhq3m
746 SKLT bkhq6h
747 SKRN bkhqc7
748 SLIS bs6kh7
749 SMAR bkhrmw
750 SMBR bn9lar
751 SMCB bn9ldm
752 SMDM bkhryc
753 SMDR bkhs27
754 SMGA cc93tc
755 SMGR bn9lgh
756 SMIL c9r7oc
757 SMKL bqrfh7
758 SMKM c5gqk2
759 SMLE cc45oc
760 SMMA bkhs7w
761 SMMT bkhsar
762 SMRA bn9ljc
763 SMSM bkhsjc
764 SNLK c1c3ur
765 SOCI bkhmkr
766 SOFA bv1prw
767 SOHO bvpia2
768 SOLA cd3j77
769 SONA bkhmqh
770 SOSS borblh
771 SOTS bo5fk2
772 SOUL c8hmu2
773 SPMA bkhqhw
774 SPRE cdllww
775 SPTO bh4tpr
776 SQMI bkhqkr
777 SRAJ bkhqnm
778 SRSN bkhqw7
779 SRTG bkhqz2
780 SSIA bkhr2w
781 SSMS bkhr5r
782 SSTM bkhr8m
783 STAA c5grjc
784 STAR bkhrbh
785 STRK cb1rcw
786 STTP bkhrec
787 SULI bn9l52
788 SUNI c8irzr
789 SUPR bkhsp2
790 SURE bkhsrw
791 SURI cbriz2
792 SWAT bh4xyc
793 SWID c6q9ww
794 TALF bkhsxm
795 TAMA btnwec
796 TAMU bkht1h
797 TAPG c1pha2
798 TARA bkht4c
799 TAXI bkhta2
800 TAYS c4bfdm
801 TBIG bkhtcw
802 TBLA bn9m77
803 TBMS bkhtim
804 TCID bkhtlh
805 TCPI bh55nm
806 TEBE bsqufr
807 TELE bkhtr7
808 TFAS brgc52
809 TFCO bkhtzr
810 TGKA bkhu6h
811 TGRA bkhu9c
812 TGUK ca99f2
813 TIFA bkhuc7
814 TINS bn9o2w
815 TIRA bkhunm
816 TIRT bkhuqh
817 TKIM bn9o5r
818 TLDN c5tiqh
819 TLKM bn9o8m
820 TMAS bn9oec
821 TMPO bkhv8m
822 TNCA bh5cim
823 TOBA bkhvk2
824 TOOL c71edm
825 TOSK ccbqk2
826 TOTL bn9ok2
827 TOTO bkhvsm
828 TOWR bn9omw
829 TPIA bkhvyc
830 TPMA bkhw27
831 TRGU c6qatc
832 TRIM bki2lh
833 TRIN btfcbh
834 TRIS bki2oc
835 TRJA bviktc
836 TRON c8zthw
837 TRST bki2u2
838 TRUE c2cfa2
839 TRUK bh7r6h
840 TRUS bki2zr
841 TSPC bki33m
842 TUGU bh72cw
843 TYRE c9pfpr
844 UANG bv1qlh
845 UCID bt7nnm
846 UDNG cbazxm
847 UFOE bxjur7
848 ULTJ bki36h
849 UNIC bki39c
850 UNIQ bzv6mw
851 UNSP bn9pjc
852 UNTD ccanww
853 UNTR bn9pm7
854 UNVR bn9pp2
855 URBN bo5fsm
856 UVCR c2ruww
857 VAST c8sn8m
858 VERN cesblh
859 VICI bx3pp2
860 VICO bki3nm
861 VINS bki3qh
862 VISI cchqzr
863 VIVA bki45r
864 VKTR ca2e9c
865 VOKS bki4bh
866 VRNA bki4k2
867 VTNY c89txm
868 WAPO bki4mw
869 WEGE bki4pr
870 WEHA bki4sm
871 WGSH c4bevh
872 WICO bki76h
873 WIDI ca9dkr
874 WIFI bx86dm
875 WIIM bki79c
876 WIKA bn9q77
877 WINE c8isf2
878 WINR c5yu77
879 WINS bn9qa2
880 WIRG c5o9z2
881 WMUU bxjkec
882 WOMF bki7hw
883 WOOD bki7kr
884 WOWS bsnwf2
885 WSBP bki7z2
886 WTON bn9r9c
887 YELO bnehec
888 YOII cfl49c
889 YPAS bki8bh
890 YULE bki7nm
891 YUPI cgaarw
892 ZATA c7x3im
893 ZBRA bhacdm
894 ZINC bki7qh
895 ZONE bo5fyc
896 ZYRX c1c1kr

56
src/api/msn/symbols.rs Normal file
View file

@ -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<HashMap<&'static str, &'static str>> = 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<String> {
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);
}
}

View file

@ -677,6 +677,7 @@ impl QuoteSummarySectionExt for QuoteSummarySection {
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[allow(dead_code)]
enum QuoteSummaryValue {
Wrapped { raw: Option<YahooNumber> },
Direct(YahooNumber),

View file

@ -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(&quote_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(&quote_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(&quote_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(&quote_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::<Vec<crate::api::types::Ohlc>>("history", &format!("{resolved}-{key}"))?
&& let Some(history) = cache.get::<Vec<crate::api::types::Ohlc>>(
&history_bucket,
&format!("{resolved}-{key}"),
)?
{
return render_history(&resolved, &history, &config.output);
}
if offline {
let stale = cache
.get_stale::<Vec<crate::api::types::Ohlc>>(
"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::<Vec<crate::api::types::Ohlc>>(
"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::<TechnicalReport>("technical", &resolved)?
&& let Some(report) = cache.get::<TechnicalReport>(&technical_bucket, &resolved)?
{
return render_technical(&report, &config.output, config.no_color);
}
if offline {
let stale = cache
.get_stale::<TechnicalReport>("technical", &resolved)?
.ok_or_else(|| IdxError::CacheMiss(format!("technical/{resolved}")))?;
.get_stale::<TechnicalReport>(&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::<TechnicalReport>("technical", &resolved)?
cache.get_stale::<TechnicalReport>(&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<T, F>(
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::<T>(cache_spec.key, resolved)? {
if !no_cache && let Some(report) = cache.get::<T>(&cache_spec.bucket, resolved)? {
return Ok(report);
}
if offline {
return cache
.get_stale::<T>(cache_spec.key, resolved)?
.ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.key)));
.get_stale::<T>(&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::<T>(cache_spec.key, resolved)? {
if !no_cache && let Some(stale) = cache.get_stale::<T>(&cache_spec.bucket, resolved)? {
eprintln!("warning: network failed, serving stale cache for {resolved}");
return Ok(stale);
}

View file

@ -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<Self, IdxError> {
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<ProviderKind>,
exchange: Option<String>,
output: Option<OutputFormat>,
color: Option<bool>,
@ -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<Self, IdxError> {
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<PathBuf, IdxError> {
@ -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());
}
}

View file

@ -10,6 +10,8 @@ pub enum IdxError {
RateLimited,
#[error("provider unavailable")]
ProviderUnavailable,
#[error("unsupported: {0}")]
Unsupported(String),
#[error("parse error: {0}")]
ParseError(String),
#[error("cache miss: {0}")]
@ -27,6 +29,7 @@ pub enum ErrorCode {
SymbolNotFound,
RateLimited,
ProviderUnavailable,
Unsupported,
ParseError,
CacheMiss,
ConfigError,
@ -40,6 +43,7 @@ impl IdxError {
Self::SymbolNotFound(_) => ErrorCode::SymbolNotFound,
Self::RateLimited => ErrorCode::RateLimited,
Self::ProviderUnavailable => ErrorCode::ProviderUnavailable,
Self::Unsupported(_) => ErrorCode::Unsupported,
Self::ParseError(_) => ErrorCode::ParseError,
Self::CacheMiss(_) => ErrorCode::CacheMiss,
Self::ConfigError(_) => ErrorCode::ConfigError,
@ -65,5 +69,8 @@ mod tests {
let parse = IdxError::ParseError("bad json".to_string());
assert_eq!(parse.code(), ErrorCode::ParseError);
let unsupported = IdxError::Unsupported("history unavailable".to_string());
assert_eq!(unsupported.code(), ErrorCode::Unsupported);
}
}

View file

@ -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,

View file

@ -106,6 +106,31 @@ fn technical_with_mock_provider_json_contains_fields() {
.stdout(predicate::str::contains("\"signals\""));
}
#[test]
fn msn_history_reports_unsupported() {
test_bin("msn-history-unsupported")
.env("IDX_PROVIDER", "msn")
.args(["stocks", "history", "BBCA", "--period", "1mo"])
.assert()
.failure()
.stderr(predicate::str::contains(
"MSN provider does not currently support history or technical analysis",
));
}
#[test]
fn msn_technical_json_reports_unsupported() {
test_bin("msn-technical-unsupported")
.env("IDX_PROVIDER", "msn")
.args(["-o", "json", "stocks", "technical", "BBCA"])
.assert()
.failure()
.stderr(predicate::str::contains("\"code\": \"UNSUPPORTED\""))
.stderr(predicate::str::contains(
"MSN provider does not currently support history or technical analysis",
));
}
#[test]
fn growth_with_mock_provider_table_contains_expected_rows() {
test_bin("growth-table")
@ -195,6 +220,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 +266,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 +306,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 +353,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"));
}

22
tests/fixtures/msn_chart_bbca_3mo.json vendored Normal file
View file

@ -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]
}
}
]

36
tests/fixtures/msn_keyratios_bbca.json vendored Normal file
View file

@ -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
}
]
}
]

18
tests/fixtures/msn_quote_bbca.json vendored Normal file
View file

@ -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
}
]