mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
3383fdd983
commit
2cdefeb7a6
11 changed files with 2099 additions and 36 deletions
|
|
@ -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,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()),
|
||||
|
|
|
|||
921
src/api/msn.rs
Normal file
921
src/api/msn.rs
Normal file
|
|
@ -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<HashMap<&'static str, &'static str>> = 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<String> {
|
||||
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<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}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_quotes(&self, symbol: &str) -> Result<Vec<MsnQuote>, 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<Vec<KeyRatios>, 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<Vec<MsnChart>, 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<Quote, IdxError> {
|
||||
let quotes = self.fetch_quotes(symbol)?;
|
||||
parse_quote(symbol, "es)
|
||||
}
|
||||
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
|
||||
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<Vec<Ohlc>, 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<Quote, IdxError> {
|
||||
let quotes: Vec<MsnQuote> =
|
||||
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<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(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<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())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ResampleInterval {
|
||||
Week,
|
||||
Month,
|
||||
}
|
||||
|
||||
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")]
|
||||
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")]
|
||||
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)]
|
||||
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::{
|
||||
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);
|
||||
}
|
||||
}
|
||||
896
src/api/msn_symbol_ids.tsv
Normal file
896
src/api/msn_symbol_ids.tsv
Normal 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
|
||||
|
|
|
@ -677,6 +677,7 @@ impl QuoteSummarySectionExt for QuoteSummarySection {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
#[allow(dead_code)]
|
||||
enum QuoteSummaryValue {
|
||||
Wrapped { raw: Option<YahooNumber> },
|
||||
Direct(YahooNumber),
|
||||
|
|
|
|||
|
|
@ -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::<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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
src/main.rs
10
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue