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 types;
|
||||||
pub mod yahoo;
|
pub mod yahoo;
|
||||||
|
|
||||||
|
use crate::config::ProviderKind;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use types::{Fundamentals, Interval, Ohlc, Period, Quote};
|
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())
|
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() {
|
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
|
||||||
Box::new(MockProvider::from_fixtures())
|
Box::new(MockProvider::from_fixtures(provider))
|
||||||
} else {
|
} 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 {
|
impl MockProvider {
|
||||||
pub fn from_fixtures() -> Self {
|
pub fn from_fixtures(provider: ProviderKind) -> Self {
|
||||||
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
||||||
return Self::with_error(IdxError::ProviderUnavailable);
|
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")
|
let quote_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json")
|
||||||
.unwrap_or_else(|_| "{}".to_string());
|
.unwrap_or_else(|_| "{}".to_string());
|
||||||
let history_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.json")
|
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 {
|
pub fn with_error(err: IdxError) -> Self {
|
||||||
Self {
|
Self {
|
||||||
quote: Err(err.clone()),
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
|
#[allow(dead_code)]
|
||||||
enum QuoteSummaryValue {
|
enum QuoteSummaryValue {
|
||||||
Wrapped { raw: Option<YahooNumber> },
|
Wrapped { raw: Option<YahooNumber> },
|
||||||
Direct(YahooNumber),
|
Direct(YahooNumber),
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,15 @@ use crate::output::{
|
||||||
render_growth, render_history, render_quotes, render_risk, render_technical, render_valuation,
|
render_growth, render_history, render_quotes, render_risk, render_technical, render_valuation,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct FundamentalCacheSpec<'a> {
|
struct FundamentalCacheSpec {
|
||||||
key: &'a str,
|
bucket: String,
|
||||||
ttl_secs: u64,
|
ttl_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cache_bucket(config: &IdxConfig, key: &str) -> String {
|
||||||
|
format!("{}-{key}", config.provider.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
#[command(about = "Stock data and analysis")]
|
#[command(about = "Stock data and analysis")]
|
||||||
pub struct StocksCmd {
|
pub struct StocksCmd {
|
||||||
|
|
@ -112,17 +116,18 @@ pub fn handle(
|
||||||
|
|
||||||
match &cmd.command {
|
match &cmd.command {
|
||||||
StocksSubcommand::Quote { symbols } => {
|
StocksSubcommand::Quote { symbols } => {
|
||||||
|
let quote_bucket = cache_bucket(config, "quote");
|
||||||
let mut quotes = Vec::new();
|
let mut quotes = Vec::new();
|
||||||
for sym in symbols.iter().flat_map(|s| s.split(',')) {
|
for sym in symbols.iter().flat_map(|s| s.split(',')) {
|
||||||
let resolved = crate::api::resolve_symbol(sym, &config.exchange);
|
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);
|
quotes.push(q);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if offline {
|
if offline {
|
||||||
let stale = cache
|
let stale = cache
|
||||||
.get_stale("quote", &resolved)?
|
.get_stale("e_bucket, &resolved)?
|
||||||
.ok_or_else(|| IdxError::CacheMiss(format!("quote/{resolved}")))?;
|
.ok_or_else(|| IdxError::CacheMiss(format!("{quote_bucket}/{resolved}")))?;
|
||||||
quotes.push(stale);
|
quotes.push(stale);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -130,12 +135,14 @@ pub fn handle(
|
||||||
match provider.quote(&resolved) {
|
match provider.quote(&resolved) {
|
||||||
Ok(q) => {
|
Ok(q) => {
|
||||||
if !no_cache {
|
if !no_cache {
|
||||||
cache.put("quote", &resolved, &q, config.quote_ttl)?;
|
cache.put("e_bucket, &resolved, &q, config.quote_ttl)?;
|
||||||
}
|
}
|
||||||
quotes.push(q);
|
quotes.push(q);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
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!(
|
eprintln!(
|
||||||
"warning: network failed, serving stale cache for {resolved}"
|
"warning: network failed, serving stale cache for {resolved}"
|
||||||
);
|
);
|
||||||
|
|
@ -153,21 +160,26 @@ pub fn handle(
|
||||||
period,
|
period,
|
||||||
interval,
|
interval,
|
||||||
} => {
|
} => {
|
||||||
|
let history_bucket = cache_bucket(config, "history");
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||||
let key = format!("{}-{}", period.as_str(), interval.as_str());
|
let key = format!("{}-{}", period.as_str(), interval.as_str());
|
||||||
if !no_cache
|
if !no_cache
|
||||||
&& let Some(history) = cache
|
&& let Some(history) = cache.get::<Vec<crate::api::types::Ohlc>>(
|
||||||
.get::<Vec<crate::api::types::Ohlc>>("history", &format!("{resolved}-{key}"))?
|
&history_bucket,
|
||||||
|
&format!("{resolved}-{key}"),
|
||||||
|
)?
|
||||||
{
|
{
|
||||||
return render_history(&resolved, &history, &config.output);
|
return render_history(&resolved, &history, &config.output);
|
||||||
}
|
}
|
||||||
if offline {
|
if offline {
|
||||||
let stale = cache
|
let stale = cache
|
||||||
.get_stale::<Vec<crate::api::types::Ohlc>>(
|
.get_stale::<Vec<crate::api::types::Ohlc>>(
|
||||||
"history",
|
&history_bucket,
|
||||||
&format!("{resolved}-{key}"),
|
&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);
|
return render_history(&resolved, &stale, &config.output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +187,7 @@ pub fn handle(
|
||||||
Ok(history) => {
|
Ok(history) => {
|
||||||
if !no_cache {
|
if !no_cache {
|
||||||
cache.put(
|
cache.put(
|
||||||
"history",
|
&history_bucket,
|
||||||
&format!("{resolved}-{key}"),
|
&format!("{resolved}-{key}"),
|
||||||
&history,
|
&history,
|
||||||
config.quote_ttl,
|
config.quote_ttl,
|
||||||
|
|
@ -186,7 +198,7 @@ pub fn handle(
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if !no_cache
|
if !no_cache
|
||||||
&& let Some(stale) = cache.get_stale::<Vec<crate::api::types::Ohlc>>(
|
&& let Some(stale) = cache.get_stale::<Vec<crate::api::types::Ohlc>>(
|
||||||
"history",
|
&history_bucket,
|
||||||
&format!("{resolved}-{key}"),
|
&format!("{resolved}-{key}"),
|
||||||
)?
|
)?
|
||||||
{
|
{
|
||||||
|
|
@ -198,16 +210,17 @@ pub fn handle(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StocksSubcommand::Technical { symbol } => {
|
StocksSubcommand::Technical { symbol } => {
|
||||||
|
let technical_bucket = cache_bucket(config, "technical");
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||||
if !no_cache
|
if !no_cache
|
||||||
&& 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);
|
return render_technical(&report, &config.output, config.no_color);
|
||||||
}
|
}
|
||||||
if offline {
|
if offline {
|
||||||
let stale = cache
|
let stale = cache
|
||||||
.get_stale::<TechnicalReport>("technical", &resolved)?
|
.get_stale::<TechnicalReport>(&technical_bucket, &resolved)?
|
||||||
.ok_or_else(|| IdxError::CacheMiss(format!("technical/{resolved}")))?;
|
.ok_or_else(|| IdxError::CacheMiss(format!("{technical_bucket}/{resolved}")))?;
|
||||||
return render_technical(&stale, &config.output, config.no_color);
|
return render_technical(&stale, &config.output, config.no_color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,14 +228,14 @@ pub fn handle(
|
||||||
Ok(history) => {
|
Ok(history) => {
|
||||||
let report = build_technical_report(&resolved, &history)?;
|
let report = build_technical_report(&resolved, &history)?;
|
||||||
if !no_cache {
|
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)
|
render_technical(&report, &config.output, config.no_color)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if !no_cache
|
if !no_cache
|
||||||
&& let Some(stale) =
|
&& 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}");
|
eprintln!("warning: network failed, serving stale cache for {resolved}");
|
||||||
return render_technical(&stale, &config.output, config.no_color);
|
return render_technical(&stale, &config.output, config.no_color);
|
||||||
|
|
@ -238,7 +251,7 @@ pub fn handle(
|
||||||
provider,
|
provider,
|
||||||
&resolved,
|
&resolved,
|
||||||
FundamentalCacheSpec {
|
FundamentalCacheSpec {
|
||||||
key: "growth",
|
bucket: cache_bucket(config, "growth"),
|
||||||
ttl_secs: config.fundamental_ttl,
|
ttl_secs: config.fundamental_ttl,
|
||||||
},
|
},
|
||||||
offline,
|
offline,
|
||||||
|
|
@ -254,7 +267,7 @@ pub fn handle(
|
||||||
provider,
|
provider,
|
||||||
&resolved,
|
&resolved,
|
||||||
FundamentalCacheSpec {
|
FundamentalCacheSpec {
|
||||||
key: "valuation",
|
bucket: cache_bucket(config, "valuation"),
|
||||||
ttl_secs: config.fundamental_ttl,
|
ttl_secs: config.fundamental_ttl,
|
||||||
},
|
},
|
||||||
offline,
|
offline,
|
||||||
|
|
@ -270,7 +283,7 @@ pub fn handle(
|
||||||
provider,
|
provider,
|
||||||
&resolved,
|
&resolved,
|
||||||
FundamentalCacheSpec {
|
FundamentalCacheSpec {
|
||||||
key: "risk",
|
bucket: cache_bucket(config, "risk"),
|
||||||
ttl_secs: config.fundamental_ttl,
|
ttl_secs: config.fundamental_ttl,
|
||||||
},
|
},
|
||||||
offline,
|
offline,
|
||||||
|
|
@ -286,7 +299,7 @@ pub fn handle(
|
||||||
provider,
|
provider,
|
||||||
&resolved,
|
&resolved,
|
||||||
FundamentalCacheSpec {
|
FundamentalCacheSpec {
|
||||||
key: "fundamental",
|
bucket: cache_bucket(config, "fundamental"),
|
||||||
ttl_secs: config.fundamental_ttl,
|
ttl_secs: config.fundamental_ttl,
|
||||||
},
|
},
|
||||||
offline,
|
offline,
|
||||||
|
|
@ -306,7 +319,7 @@ pub fn handle(
|
||||||
provider,
|
provider,
|
||||||
&resolved,
|
&resolved,
|
||||||
FundamentalCacheSpec {
|
FundamentalCacheSpec {
|
||||||
key: "fundamental",
|
bucket: cache_bucket(config, "fundamental"),
|
||||||
ttl_secs: config.fundamental_ttl,
|
ttl_secs: config.fundamental_ttl,
|
||||||
},
|
},
|
||||||
offline,
|
offline,
|
||||||
|
|
@ -336,7 +349,7 @@ fn fetch_fundamental_analysis_report<T, F>(
|
||||||
cache: &Cache,
|
cache: &Cache,
|
||||||
provider: &dyn MarketDataProvider,
|
provider: &dyn MarketDataProvider,
|
||||||
resolved: &str,
|
resolved: &str,
|
||||||
cache_spec: FundamentalCacheSpec<'_>,
|
cache_spec: FundamentalCacheSpec,
|
||||||
offline: bool,
|
offline: bool,
|
||||||
no_cache: bool,
|
no_cache: bool,
|
||||||
analyzer: F,
|
analyzer: F,
|
||||||
|
|
@ -345,26 +358,26 @@ where
|
||||||
T: Serialize + DeserializeOwned,
|
T: Serialize + DeserializeOwned,
|
||||||
F: FnOnce(&str, &Fundamentals) -> T,
|
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);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
if offline {
|
if offline {
|
||||||
return cache
|
return cache
|
||||||
.get_stale::<T>(cache_spec.key, resolved)?
|
.get_stale::<T>(&cache_spec.bucket, resolved)?
|
||||||
.ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.key)));
|
.ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.bucket)));
|
||||||
}
|
}
|
||||||
|
|
||||||
match provider.fundamentals(resolved) {
|
match provider.fundamentals(resolved) {
|
||||||
Ok(fundamentals) => {
|
Ok(fundamentals) => {
|
||||||
let report = analyzer(resolved, &fundamentals);
|
let report = analyzer(resolved, &fundamentals);
|
||||||
if !no_cache {
|
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)
|
Ok(report)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
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}");
|
eprintln!("warning: network failed, serving stale cache for {resolved}");
|
||||||
return Ok(stale);
|
return Ok(stale);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,37 @@ use crate::cli::Cli;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use crate::output::OutputFormat;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct IdxConfig {
|
pub struct IdxConfig {
|
||||||
|
pub provider: ProviderKind,
|
||||||
pub exchange: String,
|
pub exchange: String,
|
||||||
pub output: OutputFormat,
|
pub output: OutputFormat,
|
||||||
pub no_color: bool,
|
pub no_color: bool,
|
||||||
|
|
@ -25,6 +54,7 @@ struct FileConfig {
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
#[derive(Debug, Deserialize, Default)]
|
||||||
struct FileGeneral {
|
struct FileGeneral {
|
||||||
|
provider: Option<ProviderKind>,
|
||||||
exchange: Option<String>,
|
exchange: Option<String>,
|
||||||
output: Option<OutputFormat>,
|
output: Option<OutputFormat>,
|
||||||
color: Option<bool>,
|
color: Option<bool>,
|
||||||
|
|
@ -39,6 +69,7 @@ struct FileCache {
|
||||||
impl Default for IdxConfig {
|
impl Default for IdxConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
provider: ProviderKind::Yahoo,
|
||||||
exchange: "JK".to_string(),
|
exchange: "JK".to_string(),
|
||||||
output: OutputFormat::Table,
|
output: OutputFormat::Table,
|
||||||
no_color: false,
|
no_color: false,
|
||||||
|
|
@ -52,6 +83,9 @@ impl IdxConfig {
|
||||||
pub fn load_with_cli(cli: &Cli) -> Result<Self, IdxError> {
|
pub fn load_with_cli(cli: &Cli) -> Result<Self, IdxError> {
|
||||||
let mut cfg = Self::load()?;
|
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") {
|
if let Ok(exchange) = std::env::var("IDX_EXCHANGE") {
|
||||||
cfg.exchange = exchange;
|
cfg.exchange = exchange;
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +126,9 @@ impl IdxConfig {
|
||||||
let parsed: FileConfig =
|
let parsed: FileConfig =
|
||||||
toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
|
toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
|
||||||
if let Some(general) = parsed.general {
|
if let Some(general) = parsed.general {
|
||||||
|
if let Some(provider) = general.provider {
|
||||||
|
cfg.provider = provider;
|
||||||
|
}
|
||||||
if let Some(exchange) = general.exchange {
|
if let Some(exchange) = general.exchange {
|
||||||
cfg.exchange = exchange;
|
cfg.exchange = exchange;
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +153,7 @@ impl IdxConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_config_toml() -> String {
|
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> {
|
pub fn config_path() -> Result<PathBuf, IdxError> {
|
||||||
|
|
@ -210,7 +247,21 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn default_values_are_sane() {
|
fn default_values_are_sane() {
|
||||||
let cfg = IdxConfig::default();
|
let cfg = IdxConfig::default();
|
||||||
|
assert_eq!(cfg.provider, super::ProviderKind::Yahoo);
|
||||||
assert_eq!(cfg.exchange, "JK");
|
assert_eq!(cfg.exchange, "JK");
|
||||||
assert_eq!(cfg.quote_ttl, 300);
|
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> {
|
fn run() -> Result<(), IdxError> {
|
||||||
let cli = Cli::parse();
|
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 {
|
match &cli.command {
|
||||||
Commands::Version => {
|
Commands::Version => {
|
||||||
|
|
@ -40,7 +46,7 @@ fn run() -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Commands::Stocks(stocks) => {
|
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(
|
if let Err(err) = cli::stocks::handle(
|
||||||
stocks,
|
stocks,
|
||||||
&config,
|
&config,
|
||||||
|
|
|
||||||
65
tests/cli.rs
65
tests/cli.rs
|
|
@ -195,6 +195,8 @@ fn config_init_creates_file() {
|
||||||
.success();
|
.success();
|
||||||
|
|
||||||
assert!(config_home.join("idx/config.toml").exists());
|
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]
|
#[test]
|
||||||
|
|
@ -239,6 +241,22 @@ fn serves_stale_cache_on_provider_failure_with_warning() {
|
||||||
.stderr(predicate::str::contains("warning: network failed"));
|
.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]
|
#[test]
|
||||||
fn technical_serves_stale_cache_on_provider_failure_with_warning() {
|
fn technical_serves_stale_cache_on_provider_failure_with_warning() {
|
||||||
let root = test_env_dir("technical-stale");
|
let root = test_env_dir("technical-stale");
|
||||||
|
|
@ -263,6 +281,43 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() {
|
||||||
.stderr(predicate::str::contains("warning: network failed"));
|
.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]
|
#[test]
|
||||||
fn invalid_symbol_returns_non_zero() {
|
fn invalid_symbol_returns_non_zero() {
|
||||||
test_bin("invalid-symbol")
|
test_bin("invalid-symbol")
|
||||||
|
|
@ -273,3 +328,13 @@ fn invalid_symbol_returns_non_zero() {
|
||||||
.failure()
|
.failure()
|
||||||
.stderr(predicate::str::contains("Error:"));
|
.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
22
tests/fixtures/msn_chart_bbca_3mo.json
vendored
Normal 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
36
tests/fixtures/msn_keyratios_bbca.json
vendored
Normal 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
18
tests/fixtures/msn_quote_bbca.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue