mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
refactor: split into idx-core library and idx-cli binary workspace
Phase 2 of docs/DESIGN.md, no behavior change: - crates/idx-core (library): api, analysis, cache, config file handling and provider selection, curl_impersonate, error, ownership, runtime, plus the shared test fixtures. clap derives are behind an optional `clap` feature so the library does not require clap. - crates/idx-cli (binary `idx`): cli/, output/, and IdxConfig (output format and color resolution). Package and binary names are unchanged. - config.rs is split: file read/write and validation move to core; a CLI test keeps core's OUTPUT_VALUES in sync with OutputSetting. - ownership::db::open_db/db_path drop their unused IdxConfig argument. - One workspace version; shared dependency versions; no third-party version changes in Cargo.lock. LICENSE is symlinked into each crate. - flake: workspace version, new source paths, and a per-crate package check (idx-cli is built from its tarball against packaged idx-core, since idx-core is not on crates.io yet). CI cache key, release path filter, and the golden whitespace-hook exclusion follow the new layout. Parity: all 143 golden outputs match byte for byte without regeneration; 157 core + 17 CLI unit tests (173 before plus the new OUTPUT_VALUES sync test), 62 CLI tests pass. Live A/B against main: 16/16 stocks cases identical; ownership discover got IDX 403 on both (exit 4 vs 1 is the phase 1 exit-code change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
22b6dabaeb
commit
15947de51a
238 changed files with 544 additions and 375 deletions
455
crates/idx-core/src/api/mod.rs
Normal file
455
crates/idx-core/src/api/mod.rs
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
pub mod msn;
|
||||
pub mod types;
|
||||
pub mod yahoo;
|
||||
|
||||
use chrono::FixedOffset;
|
||||
|
||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||
use crate::error::IdxError;
|
||||
use types::{
|
||||
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
|
||||
NewsItem, Period, Quote, SentimentData,
|
||||
};
|
||||
|
||||
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
|
||||
/// candles in UTC, so day boundaries must be taken in this zone.
|
||||
pub(crate) const IDX_UTC_OFFSET_SECS: i32 = 7 * 3600;
|
||||
|
||||
pub(crate) fn idx_offset() -> FixedOffset {
|
||||
FixedOffset::east_opt(IDX_UTC_OFFSET_SECS).expect("valid WIB offset")
|
||||
}
|
||||
|
||||
/// Parse a provider's RFC 3339 trade time and express it in WIB.
|
||||
pub(crate) fn parse_as_of(raw: Option<&str>) -> Option<chrono::DateTime<FixedOffset>> {
|
||||
chrono::DateTime::parse_from_rfc3339(raw?)
|
||||
.ok()
|
||||
.map(|t| t.with_timezone(&idx_offset()))
|
||||
}
|
||||
|
||||
pub trait QuoteProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
|
||||
}
|
||||
|
||||
pub trait HistoryProvider {
|
||||
fn history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> Result<Vec<Bar>, IdxError>;
|
||||
}
|
||||
|
||||
pub trait FundamentalsProvider {
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||
}
|
||||
|
||||
/// Core provider trait — quote + fundamentals only.
|
||||
/// History is a separate capability (`HistoryProvider`) not all providers support
|
||||
/// (e.g. MSN Finance/Charts returns 404 for IDX/XIDX stocks).
|
||||
pub trait MarketDataProvider: QuoteProvider + FundamentalsProvider {}
|
||||
impl<T> MarketDataProvider for T where T: QuoteProvider + FundamentalsProvider {}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub trait ProfileProvider {
|
||||
fn profile(&self, symbol: &str) -> Result<CompanyProfile, IdxError>;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub trait EarningsProvider {
|
||||
fn earnings(&self, symbol: &str) -> Result<EarningsReport, IdxError>;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub trait FinancialsProvider {
|
||||
fn financials(&self, symbol: &str) -> Result<FinancialStatements, IdxError>;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub trait SentimentProvider {
|
||||
fn sentiment(&self, symbol: &str) -> Result<SentimentData, IdxError>;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub trait InsightsProvider {
|
||||
fn insights(&self, symbol: &str) -> Result<InsightData, IdxError>;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub trait NewsProvider {
|
||||
fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError>;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub trait ScreenerProvider {
|
||||
/// `None` requests the complete candidate list for client-side ranking.
|
||||
fn screener(
|
||||
&self,
|
||||
filter: &str,
|
||||
region: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<Quote>, IdxError>;
|
||||
}
|
||||
|
||||
pub struct SelectedProvider {
|
||||
kind: ProviderKind,
|
||||
market: Box<dyn MarketDataProvider>,
|
||||
profile: Option<Box<dyn ProfileProvider>>,
|
||||
financials: Option<Box<dyn FinancialsProvider>>,
|
||||
earnings: Option<Box<dyn EarningsProvider>>,
|
||||
sentiment: Option<Box<dyn SentimentProvider>>,
|
||||
insights: Option<Box<dyn InsightsProvider>>,
|
||||
news: Option<Box<dyn NewsProvider>>,
|
||||
screener: Option<Box<dyn ScreenerProvider>>,
|
||||
}
|
||||
|
||||
impl SelectedProvider {
|
||||
pub fn kind(&self) -> ProviderKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn market(&self) -> &dyn MarketDataProvider {
|
||||
self.market.as_ref()
|
||||
}
|
||||
|
||||
pub fn profile_provider(&self, subject: &str) -> Result<&dyn ProfileProvider, IdxError> {
|
||||
self.profile
|
||||
.as_deref()
|
||||
.ok_or_else(|| msn_capability_error(subject))
|
||||
}
|
||||
|
||||
pub fn financials_provider(&self, subject: &str) -> Result<&dyn FinancialsProvider, IdxError> {
|
||||
self.financials
|
||||
.as_deref()
|
||||
.ok_or_else(|| msn_capability_error(subject))
|
||||
}
|
||||
|
||||
pub fn earnings_provider(&self, subject: &str) -> Result<&dyn EarningsProvider, IdxError> {
|
||||
self.earnings
|
||||
.as_deref()
|
||||
.ok_or_else(|| msn_capability_error(subject))
|
||||
}
|
||||
|
||||
pub fn sentiment_provider(&self, subject: &str) -> Result<&dyn SentimentProvider, IdxError> {
|
||||
self.sentiment
|
||||
.as_deref()
|
||||
.ok_or_else(|| msn_capability_error(subject))
|
||||
}
|
||||
|
||||
pub fn insights_provider(&self, subject: &str) -> Result<&dyn InsightsProvider, IdxError> {
|
||||
self.insights
|
||||
.as_deref()
|
||||
.ok_or_else(|| msn_capability_error(subject))
|
||||
}
|
||||
|
||||
pub fn news_provider(&self, subject: &str) -> Result<&dyn NewsProvider, IdxError> {
|
||||
self.news
|
||||
.as_deref()
|
||||
.ok_or_else(|| msn_capability_error(subject))
|
||||
}
|
||||
|
||||
pub fn screener_provider(&self, subject: &str) -> Result<&dyn ScreenerProvider, IdxError> {
|
||||
self.screener
|
||||
.as_deref()
|
||||
.ok_or_else(|| msn_capability_error(subject))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_symbol(symbol: &str, exchange: &str) -> Result<String, IdxError> {
|
||||
let trimmed = symbol.trim().to_uppercase();
|
||||
if trimmed.is_empty() {
|
||||
return Err(IdxError::InvalidInput(
|
||||
"ticker symbol cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if let Some((base, suffix)) = trimmed.rsplit_once('.')
|
||||
&& !base.is_empty()
|
||||
&& !suffix.is_empty()
|
||||
{
|
||||
return Ok(trimmed);
|
||||
}
|
||||
Ok(format!("{trimmed}.{}", exchange.trim().to_uppercase()))
|
||||
}
|
||||
|
||||
pub fn default_provider(provider: ProviderKind, verbose: bool) -> SelectedProvider {
|
||||
build_selected_provider(
|
||||
provider,
|
||||
verbose,
|
||||
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_selected_provider(
|
||||
provider: ProviderKind,
|
||||
verbose: bool,
|
||||
use_mock: bool,
|
||||
) -> SelectedProvider {
|
||||
match (provider, use_mock) {
|
||||
(ProviderKind::Yahoo, true) => SelectedProvider {
|
||||
kind: ProviderKind::Yahoo,
|
||||
market: Box::new(MockProvider::from_fixtures(ProviderKind::Yahoo)),
|
||||
profile: None,
|
||||
financials: None,
|
||||
earnings: None,
|
||||
sentiment: None,
|
||||
insights: None,
|
||||
news: None,
|
||||
screener: None,
|
||||
},
|
||||
(ProviderKind::Yahoo, false) => SelectedProvider {
|
||||
kind: ProviderKind::Yahoo,
|
||||
market: Box::new(yahoo::YahooProvider::new(verbose)),
|
||||
profile: None,
|
||||
financials: None,
|
||||
earnings: None,
|
||||
sentiment: None,
|
||||
insights: None,
|
||||
news: None,
|
||||
screener: None,
|
||||
},
|
||||
(ProviderKind::Msn, true) => SelectedProvider {
|
||||
kind: ProviderKind::Msn,
|
||||
market: Box::new(MockProvider::from_fixtures(ProviderKind::Msn)),
|
||||
profile: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
financials: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
earnings: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
sentiment: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
insights: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
news: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
screener: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
},
|
||||
(ProviderKind::Msn, false) => SelectedProvider {
|
||||
kind: ProviderKind::Msn,
|
||||
market: Box::new(msn::MsnProvider::new(verbose)),
|
||||
profile: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
financials: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
earnings: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
sentiment: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
insights: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
news: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
screener: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn msn_capability_error(subject: &str) -> IdxError {
|
||||
IdxError::Unsupported(format!("{subject}: command requires --provider msn"))
|
||||
}
|
||||
|
||||
/// Resolves a history provider based on the selected market data provider and
|
||||
/// history provider strategy.
|
||||
///
|
||||
/// `history_mode=auto` keeps using Yahoo for IDX history because Yahoo provides
|
||||
/// full OHLCV candles. Explicit `msn` opts into MSN's price-only chart feed.
|
||||
pub fn history_provider(
|
||||
provider: ProviderKind,
|
||||
history_mode: HistoryProviderKind,
|
||||
verbose: bool,
|
||||
) -> Result<(ProviderKind, Box<dyn HistoryProvider>), IdxError> {
|
||||
let resolved = match history_mode {
|
||||
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
|
||||
HistoryProviderKind::Msn => ProviderKind::Msn,
|
||||
HistoryProviderKind::Auto => match provider {
|
||||
ProviderKind::Yahoo => ProviderKind::Yahoo,
|
||||
ProviderKind::Msn => ProviderKind::Yahoo,
|
||||
},
|
||||
};
|
||||
|
||||
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
|
||||
return Ok((
|
||||
resolved,
|
||||
Box::new(MockProvider::from_fixtures_with_history_verbose(
|
||||
resolved, verbose,
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
match resolved {
|
||||
ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))),
|
||||
ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MockProvider {
|
||||
quote: Result<Quote, IdxError>,
|
||||
fundamentals: Result<Fundamentals, IdxError>,
|
||||
history: Result<Vec<Bar>, IdxError>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
pub fn from_fixtures(provider: ProviderKind) -> Self {
|
||||
Self::from_fixtures_with_history_verbose(provider, false)
|
||||
}
|
||||
|
||||
pub fn from_fixtures_with_history_verbose(
|
||||
provider: ProviderKind,
|
||||
history_verbose: bool,
|
||||
) -> Self {
|
||||
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
||||
return Self::with_error(IdxError::ProviderUnavailable);
|
||||
}
|
||||
|
||||
match provider {
|
||||
ProviderKind::Yahoo => Self::from_yahoo_fixtures(history_verbose),
|
||||
ProviderKind::Msn => Self::from_msn_fixtures(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_yahoo_fixtures(history_verbose: bool) -> Self {
|
||||
let quote_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json")
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let history_path = std::env::var("IDX_MOCK_YAHOO_HISTORY_FIXTURE")
|
||||
.unwrap_or_else(|_| "tests/fixtures/chart_bbca_3mo.json".to_string());
|
||||
let history_raw =
|
||||
std::fs::read_to_string(&history_path).unwrap_or_else(|_| "{}".to_string());
|
||||
let fundamentals_raw = std::fs::read_to_string("tests/fixtures/quotesummary_bbca.json")
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
let quote = yahoo::parse_quote_from_str("BBCA.JK", "e_raw)
|
||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||
let fundamentals = yahoo::parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw)
|
||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||
let history =
|
||||
yahoo::parse_history_from_str_with_verbose("BBCA.JK", &history_raw, history_verbose)
|
||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||
|
||||
Self {
|
||||
quote,
|
||||
fundamentals,
|
||||
history,
|
||||
}
|
||||
}
|
||||
|
||||
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 fundamentals_path = std::env::var("IDX_MOCK_MSN_KEYRATIOS_FIXTURE")
|
||||
.unwrap_or_else(|_| "tests/fixtures/msn_keyratios_bbca.json".to_string());
|
||||
let fundamentals_raw =
|
||||
std::fs::read_to_string(&fundamentals_path).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_raw = std::fs::read_to_string("tests/fixtures/msn_chart_bbca_3m.json")
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let history = msn::parse_history_from_str("BBCA.JK", &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()),
|
||||
fundamentals: Err(err.clone()),
|
||||
history: Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QuoteProvider for MockProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||
let mut q = self.quote.clone()?;
|
||||
q.symbol = symbol.to_string();
|
||||
Ok(q)
|
||||
}
|
||||
}
|
||||
|
||||
impl FundamentalsProvider for MockProvider {
|
||||
fn fundamentals(&self, _symbol: &str) -> Result<Fundamentals, IdxError> {
|
||||
self.fundamentals.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryProvider for MockProvider {
|
||||
fn history(
|
||||
&self,
|
||||
_symbol: &str,
|
||||
_period: &Period,
|
||||
_interval: &Interval,
|
||||
) -> Result<Vec<Bar>, IdxError> {
|
||||
self.history.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ProviderKind, build_selected_provider, resolve_symbol};
|
||||
use crate::error::IdxError;
|
||||
|
||||
#[test]
|
||||
fn resolves_symbol_variants() {
|
||||
assert_eq!(resolve_symbol("bbca", "JK").unwrap(), "BBCA.JK");
|
||||
assert_eq!(resolve_symbol("BBCA.JK", "JK").unwrap(), "BBCA.JK");
|
||||
assert_eq!(resolve_symbol("TLKM.us", "JK").unwrap(), "TLKM.US");
|
||||
assert_eq!(resolve_symbol("abcd.ef.gh", "JK").unwrap(), "ABCD.EF.GH");
|
||||
assert_eq!(resolve_symbol(" bbri ", "jk").unwrap(), "BBRI.JK");
|
||||
// Empty ticker should return error
|
||||
assert!(resolve_symbol("", "JK").is_err());
|
||||
// Whitespace-only ticker should also return error
|
||||
assert!(resolve_symbol(" ", "JK").is_err());
|
||||
// Valid ticker returns Ok
|
||||
assert_eq!(resolve_symbol("BBCA", "JK").unwrap(), "BBCA.JK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_provider_exposes_expected_capabilities_for_msn() {
|
||||
let provider = build_selected_provider(ProviderKind::Msn, false, false);
|
||||
|
||||
assert!(provider.profile_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.financials_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.earnings_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.sentiment_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.insights_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.news_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.screener_provider("screen").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_provider_exposes_expected_capabilities_for_mock_msn() {
|
||||
let provider = build_selected_provider(ProviderKind::Msn, false, true);
|
||||
|
||||
assert!(provider.profile_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.financials_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.earnings_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.sentiment_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.insights_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.news_provider("BBCA.JK").is_ok());
|
||||
assert!(provider.screener_provider("screen").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_provider_rejects_msn_only_capabilities_for_yahoo() {
|
||||
let provider = build_selected_provider(ProviderKind::Yahoo, false, false);
|
||||
let err = match provider.profile_provider("BBCA.JK") {
|
||||
Ok(_) => panic!("yahoo should not expose msn-only profile"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(matches!(err, IdxError::Unsupported(_)));
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"unsupported: BBCA.JK: command requires --provider msn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_provider_rejects_msn_only_capabilities_for_mock_yahoo() {
|
||||
let provider = build_selected_provider(ProviderKind::Yahoo, false, true);
|
||||
let err = match provider.screener_provider("screen") {
|
||||
Ok(_) => panic!("mock yahoo should not expose msn-only screener"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(matches!(err, IdxError::Unsupported(_)));
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"unsupported: screen: command requires --provider msn"
|
||||
);
|
||||
}
|
||||
}
|
||||
406
crates/idx-core/src/api/msn/bing.rs
Normal file
406
crates/idx-core/src/api/msn/bing.rs
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
//! Bing Finance institutional ownership HTTP client.
|
||||
//!
|
||||
//! Provides access to 5 ownership signal endpoints from the Bing hedge fund data
|
||||
//! provider API. Each endpoint returns a list of institutional holders grouped by
|
||||
//! signal type (holders, buyers, sellers, new positions, exits).
|
||||
//!
|
||||
//! # Usage
|
||||
//! ```rust,ignore
|
||||
//! use crate::api::msn::bing::{BingEndpoint, fetch_all_ownership};
|
||||
//!
|
||||
//! // Fetch all 5 signals for BBCA (instrument ID bn91jc)
|
||||
//! let results = fetch_all_ownership("bn91jc", false)?;
|
||||
//! for (signal, holders) in results {
|
||||
//! println!("{:?}: {} rows", signal, holders.len());
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::IdxError;
|
||||
use crate::ownership::types::{BingHolderRaw, FlowSignal};
|
||||
|
||||
/// Base URL for the Bing hedge fund data provider API.
|
||||
const BING_OWNERSHIP_BASE: &str =
|
||||
"https://services.bingapis.com/contentservices-finance.hedgefunddataprovider/api/v1";
|
||||
|
||||
/// Browser-like User-Agent matching existing MSN client pattern.
|
||||
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";
|
||||
|
||||
// ── Endpoint enum ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Bing ownership API endpoint variants.
|
||||
///
|
||||
/// Each variant maps to one Bing hedge-fund endpoint path and the
|
||||
/// [`FlowSignal`] it represents in the domain model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BingEndpoint {
|
||||
/// Existing top institutional holders.
|
||||
TopShareHolders,
|
||||
/// Net buyers over the most recent reporting period.
|
||||
TopBuyers,
|
||||
/// Net sellers over the most recent reporting period.
|
||||
TopSellers,
|
||||
/// Institutions that opened a new position this period.
|
||||
TopNewShareHolders,
|
||||
/// Institutions that fully exited their position this period.
|
||||
TopExitedShareHolders,
|
||||
}
|
||||
|
||||
impl BingEndpoint {
|
||||
/// URL path segment used in the Bing API request.
|
||||
///
|
||||
/// The full URL is constructed as:
|
||||
/// `{BING_OWNERSHIP_BASE}/{path}/{instrument_id}`
|
||||
pub fn path(&self) -> &'static str {
|
||||
match self {
|
||||
Self::TopShareHolders => "GetSecurityTopShareHolders",
|
||||
Self::TopBuyers => "GetSecurityTopBuyers",
|
||||
Self::TopSellers => "GetSecurityTopSellers",
|
||||
Self::TopNewShareHolders => "GetSecurityTopNewShareHolders",
|
||||
Self::TopExitedShareHolders => "GetSecurityTopExitedShareHolders",
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`FlowSignal`] this endpoint represents.
|
||||
pub fn signal(&self) -> FlowSignal {
|
||||
match self {
|
||||
Self::TopShareHolders => FlowSignal::Holder,
|
||||
Self::TopBuyers => FlowSignal::Buyer,
|
||||
Self::TopSellers => FlowSignal::Seller,
|
||||
Self::TopNewShareHolders => FlowSignal::NewPosition,
|
||||
Self::TopExitedShareHolders => FlowSignal::Exited,
|
||||
}
|
||||
}
|
||||
|
||||
/// All endpoint variants in a canonical order.
|
||||
pub fn all() -> &'static [BingEndpoint] {
|
||||
&[
|
||||
Self::TopShareHolders,
|
||||
Self::TopBuyers,
|
||||
Self::TopSellers,
|
||||
Self::TopNewShareHolders,
|
||||
Self::TopExitedShareHolders,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a `ureq::Agent` with timeouts matching existing MSN client conventions.
|
||||
fn build_agent() -> ureq::Agent {
|
||||
ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.timeout_recv_body(Some(Duration::from_secs(10)))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Wrapper type used to handle both bare-array and wrapped-object responses
|
||||
/// from the Bing API. Some endpoints may return `[...]` directly, others may
|
||||
/// wrap in `{ "value": [...] }` or similar. We first try bare Vec, then
|
||||
/// fall back to the wrapper.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum BingResponse {
|
||||
/// Direct JSON array of holder objects.
|
||||
Array(Vec<BingHolderRaw>),
|
||||
/// Object wrapper with a nested array under a common key.
|
||||
Wrapped(BingWrappedResponse),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct BingWrappedResponse {
|
||||
/// `value` key used by some Bing OData-style responses.
|
||||
#[serde(alias = "value", alias = "Value", alias = "data", alias = "Data")]
|
||||
items: Option<Vec<BingHolderRaw>>,
|
||||
}
|
||||
|
||||
impl BingResponse {
|
||||
fn into_holders(self) -> Vec<BingHolderRaw> {
|
||||
match self {
|
||||
BingResponse::Array(v) => v,
|
||||
BingResponse::Wrapped(w) => w.items.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fetch ownership data from a single Bing endpoint.
|
||||
///
|
||||
/// `instrument_id` is the MSN instrument identifier (for example `bn91jc` for BBCA).
|
||||
/// Use [`crate::api::msn::symbols::resolve_msn_id`] to obtain this from a ticker code.
|
||||
///
|
||||
/// Empty responses (valid HTTP 200 with empty array) are returned as `Ok(vec![])`.
|
||||
/// This is expected for some IDX stocks that Bing does not cover.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`IdxError::Http`] on network or non-retriable HTTP errors.
|
||||
/// Returns [`IdxError::RateLimited`] after exhausting retries on 429.
|
||||
/// Returns [`IdxError::ParseError`] if the response body cannot be deserialized.
|
||||
pub fn fetch_holders(
|
||||
instrument_id: &str,
|
||||
endpoint: &BingEndpoint,
|
||||
verbose: bool,
|
||||
) -> Result<Vec<BingHolderRaw>, IdxError> {
|
||||
let url = format!("{BING_OWNERSHIP_BASE}/{}/{instrument_id}", endpoint.path());
|
||||
|
||||
if verbose {
|
||||
eprintln!("[bing] GET {url}");
|
||||
}
|
||||
|
||||
let agent = build_agent();
|
||||
let endpoint_name = endpoint.path();
|
||||
let mut wait = Duration::from_millis(500);
|
||||
|
||||
for attempt in 0..3 {
|
||||
let response = agent
|
||||
.get(&url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "application/json")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Origin", "https://www.bing.com")
|
||||
.header("Referer", "https://www.bing.com/")
|
||||
.call();
|
||||
|
||||
match response {
|
||||
Ok(ok) => {
|
||||
let body = ok
|
||||
.into_body()
|
||||
.read_to_string()
|
||||
.map_err(|e| IdxError::Http(format!("bing {endpoint_name}: read body: {e}")))?;
|
||||
|
||||
if body.trim().is_empty() || body.trim() == "null" {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let parsed: BingResponse = serde_json::from_str(&body)
|
||||
.map_err(|e| IdxError::ParseError(format!("bing {endpoint_name}: {e}")))?;
|
||||
|
||||
return Ok(parsed.into_holders());
|
||||
}
|
||||
Err(ureq::Error::StatusCode(404)) => {
|
||||
// Instrument not found on Bing — return empty, not an error.
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Err(ureq::Error::StatusCode(429)) => {
|
||||
if attempt < 2 {
|
||||
std::thread::sleep(wait);
|
||||
wait *= 2;
|
||||
continue;
|
||||
}
|
||||
return Err(IdxError::RateLimited);
|
||||
}
|
||||
Err(ureq::Error::StatusCode(code)) if code >= 500 => {
|
||||
if attempt < 2 {
|
||||
std::thread::sleep(wait);
|
||||
wait *= 2;
|
||||
continue;
|
||||
}
|
||||
return Err(IdxError::Http(format!(
|
||||
"bing {endpoint_name}: status {code}"
|
||||
)));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(IdxError::Http(format!("bing {endpoint_name}: {err}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(IdxError::RateLimited)
|
||||
}
|
||||
|
||||
/// Fetch all 5 ownership endpoints for a given MSN instrument ID.
|
||||
///
|
||||
/// Returns results grouped by [`FlowSignal`] in canonical endpoint order:
|
||||
/// `Holder`, `Buyer`, `Seller`, `NewPosition`, `Exited`.
|
||||
///
|
||||
/// Endpoints that return empty data are still included as `(signal, vec![])`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates any non-empty error from [`fetch_holders`].
|
||||
pub fn fetch_all_ownership(
|
||||
instrument_id: &str,
|
||||
verbose: bool,
|
||||
) -> Result<Vec<(FlowSignal, Vec<BingHolderRaw>)>, IdxError> {
|
||||
let mut results = Vec::with_capacity(5);
|
||||
for endpoint in BingEndpoint::all() {
|
||||
let holders = fetch_holders(instrument_id, endpoint, verbose)?;
|
||||
results.push((endpoint.signal(), holders));
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── BingEndpoint::path() ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_path_top_share_holders() {
|
||||
assert_eq!(
|
||||
BingEndpoint::TopShareHolders.path(),
|
||||
"GetSecurityTopShareHolders"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_path_top_buyers() {
|
||||
assert_eq!(BingEndpoint::TopBuyers.path(), "GetSecurityTopBuyers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_path_top_sellers() {
|
||||
assert_eq!(BingEndpoint::TopSellers.path(), "GetSecurityTopSellers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_path_top_new_share_holders() {
|
||||
assert_eq!(
|
||||
BingEndpoint::TopNewShareHolders.path(),
|
||||
"GetSecurityTopNewShareHolders"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_path_top_exited_share_holders() {
|
||||
assert_eq!(
|
||||
BingEndpoint::TopExitedShareHolders.path(),
|
||||
"GetSecurityTopExitedShareHolders"
|
||||
);
|
||||
}
|
||||
|
||||
// ── BingEndpoint::signal() ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_signal_mapping() {
|
||||
assert_eq!(BingEndpoint::TopShareHolders.signal(), FlowSignal::Holder);
|
||||
assert_eq!(BingEndpoint::TopBuyers.signal(), FlowSignal::Buyer);
|
||||
assert_eq!(BingEndpoint::TopSellers.signal(), FlowSignal::Seller);
|
||||
assert_eq!(
|
||||
BingEndpoint::TopNewShareHolders.signal(),
|
||||
FlowSignal::NewPosition
|
||||
);
|
||||
assert_eq!(
|
||||
BingEndpoint::TopExitedShareHolders.signal(),
|
||||
FlowSignal::Exited
|
||||
);
|
||||
}
|
||||
|
||||
// ── BingEndpoint::all() ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_all_has_five_variants() {
|
||||
assert_eq!(BingEndpoint::all().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_all_covers_all_signals() {
|
||||
use std::collections::HashSet;
|
||||
let signals: HashSet<String> = BingEndpoint::all()
|
||||
.iter()
|
||||
.map(|e| format!("{:?}", e.signal()))
|
||||
.collect();
|
||||
assert!(signals.contains("Holder"));
|
||||
assert!(signals.contains("Buyer"));
|
||||
assert!(signals.contains("Seller"));
|
||||
assert!(signals.contains("NewPosition"));
|
||||
assert!(signals.contains("Exited"));
|
||||
}
|
||||
|
||||
// ── Fixture deserialization ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_holders_fixture() {
|
||||
let json = include_str!("../../../tests/fixtures/bing_holders_bbca.json");
|
||||
let holders: Vec<BingHolderRaw> = serde_json::from_str(json)
|
||||
.expect("bing_holders_bbca.json should deserialize into Vec<BingHolderRaw>");
|
||||
assert!(
|
||||
!holders.is_empty(),
|
||||
"fixture should have at least one holder"
|
||||
);
|
||||
// First holder should have a name
|
||||
assert!(
|
||||
holders[0].investor_name.is_some(),
|
||||
"first holder should have investor_name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_buyers_fixture() {
|
||||
let json = include_str!("../../../tests/fixtures/bing_buyers_bbca.json");
|
||||
let holders: Vec<BingHolderRaw> = serde_json::from_str(json)
|
||||
.expect("bing_buyers_bbca.json should deserialize into Vec<BingHolderRaw>");
|
||||
assert!(
|
||||
!holders.is_empty(),
|
||||
"fixture should have at least one buyer"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty response handling ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_empty_array_is_ok() {
|
||||
let json = "[]";
|
||||
let holders: Vec<BingHolderRaw> = serde_json::from_str(json).expect("empty array is valid");
|
||||
assert!(holders.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bing_response_bare_array() {
|
||||
let json = r#"[
|
||||
{
|
||||
"investorName": "Vanguard Group",
|
||||
"investorType": "Institutional",
|
||||
"sharesHeld": 1234567.0,
|
||||
"reportDate": "2024-12-31"
|
||||
}
|
||||
]"#;
|
||||
let resp: BingResponse = serde_json::from_str(json).unwrap();
|
||||
let holders = resp.into_holders();
|
||||
assert_eq!(holders.len(), 1);
|
||||
assert_eq!(holders[0].investor_name.as_deref(), Some("Vanguard Group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bing_response_wrapped_value_key() {
|
||||
let json = r#"{
|
||||
"value": [
|
||||
{
|
||||
"InvestorName": "BlackRock",
|
||||
"InvestorType": "Institutional",
|
||||
"SharesHeld": 9876543.0
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let resp: BingResponse = serde_json::from_str(json).unwrap();
|
||||
let holders = resp.into_holders();
|
||||
assert_eq!(holders.len(), 1);
|
||||
assert_eq!(holders[0].investor_name.as_deref(), Some("BlackRock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bing_response_wrapped_empty_value() {
|
||||
let json = r#"{ "value": [] }"#;
|
||||
let resp: BingResponse = serde_json::from_str(json).unwrap();
|
||||
let holders = resp.into_holders();
|
||||
assert!(holders.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_holder_optional_fields_are_none() {
|
||||
let json = r#"[{"investorName": "Some Fund"}]"#;
|
||||
let holders: Vec<BingHolderRaw> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(holders.len(), 1);
|
||||
assert!(holders[0].shares_held.is_none());
|
||||
assert!(holders[0].shares_changed.is_none());
|
||||
assert!(holders[0].pct_outstanding.is_none());
|
||||
assert!(holders[0].value.is_none());
|
||||
assert!(holders[0].report_date.is_none());
|
||||
}
|
||||
}
|
||||
466
crates/idx-core/src/api/msn/client.rs
Normal file
466
crates/idx-core/src/api/msn/client.rs
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::IdxError;
|
||||
|
||||
use super::raw_types::{
|
||||
KeyRatios, MsnQuote, RawChartResponse, RawEarningsResponse, RawEquity, RawFinancialStatement,
|
||||
RawInsight, RawNewsFeed, RawScreenerResponse, RawSentiment, ScreenerFilter, ScreenerOrder,
|
||||
ScreenerRequest,
|
||||
};
|
||||
use super::symbols::resolve_msn_id;
|
||||
use crate::api::types::{Interval, Period};
|
||||
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||
const MSN_ASSETS_BASE_URL: &str = "https://assets.msn.com/service/";
|
||||
const MSN_API_BASE_URL: &str = "https://api.msn.com/msn/v0/pages/finance/";
|
||||
// Public API key from MSN Money website (embedded in frontend JS)
|
||||
const MSN_API_KEY: &str = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM";
|
||||
|
||||
pub(super) struct MsnClient {
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
impl MsnClient {
|
||||
pub(super) fn new() -> Self {
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.timeout_recv_body(Some(Duration::from_secs(10)))
|
||||
.build()
|
||||
.into();
|
||||
|
||||
Self { agent }
|
||||
}
|
||||
|
||||
fn mock_error() -> Option<IdxError> {
|
||||
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() && std::env::var("IDX_MOCK_ERROR").is_ok()
|
||||
{
|
||||
Some(IdxError::ProviderUnavailable)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_body(endpoint: &str) -> Option<&'static str> {
|
||||
if std::env::var("IDX_USE_MOCK_PROVIDER").is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(match endpoint {
|
||||
"quote" => include_str!("../../../tests/fixtures/msn_quote_bbca.json"),
|
||||
"keyratios" => include_str!("../../../tests/fixtures/msn_keyratios_bbca.json"),
|
||||
"equities" => include_str!("../../../tests/fixtures/msn_profile_bbca.json"),
|
||||
"financialstatements" => {
|
||||
include_str!("../../../tests/fixtures/msn_financials_bbca.json")
|
||||
}
|
||||
"earnings" => include_str!("../../../tests/fixtures/msn_earnings_bbca.json"),
|
||||
"sentiment" => include_str!("../../../tests/fixtures/msn_sentiment_bbca.json"),
|
||||
"insights" => include_str!("../../../tests/fixtures/msn_insights_bbca.json"),
|
||||
"news" => include_str!("../../../tests/fixtures/msn_news_bbca.json"),
|
||||
"screener" => include_str!("../../../tests/fixtures/msn_screener_id_topperfs.json"),
|
||||
"chart" => include_str!("../../../tests/fixtures/msn_chart_bbca_3m.json"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn mock_json<T: DeserializeOwned>(endpoint: &str) -> Result<Option<T>, IdxError> {
|
||||
let Some(body) = Self::mock_body(endpoint) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
serde_json::from_str(body)
|
||||
.map(Some)
|
||||
.map_err(|e| IdxError::ParseError(format!("msn {endpoint}: {e}")))
|
||||
}
|
||||
|
||||
fn get_json<T: DeserializeOwned>(
|
||||
&self,
|
||||
url: &str,
|
||||
symbol: &str,
|
||||
endpoint: &str,
|
||||
) -> Result<T, IdxError> {
|
||||
if let Some(err) = Self::mock_error() {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Some(mocked) = Self::mock_json(endpoint)? {
|
||||
return Ok(mocked);
|
||||
}
|
||||
|
||||
let mut wait = Duration::from_millis(500);
|
||||
for attempt in 0..3 {
|
||||
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) => {
|
||||
return ok
|
||||
.into_body()
|
||||
.read_json::<T>()
|
||||
.map_err(|e| IdxError::ParseError(format!("msn {endpoint}: {e}")));
|
||||
}
|
||||
Err(ureq::Error::StatusCode(404)) => {
|
||||
return Err(IdxError::SymbolNotFound(symbol.to_string()));
|
||||
}
|
||||
Err(ureq::Error::StatusCode(429)) => {
|
||||
if attempt < 2 {
|
||||
std::thread::sleep(wait);
|
||||
wait *= 2;
|
||||
continue;
|
||||
}
|
||||
return Err(IdxError::RateLimited);
|
||||
}
|
||||
Err(ureq::Error::StatusCode(code)) if code >= 500 => {
|
||||
if attempt < 2 {
|
||||
std::thread::sleep(wait);
|
||||
wait *= 2;
|
||||
continue;
|
||||
}
|
||||
return Err(IdxError::Http(format!("msn {endpoint}: status {code}")));
|
||||
}
|
||||
Err(err) => return Err(IdxError::Http(format!("msn {endpoint}: {err}"))),
|
||||
}
|
||||
}
|
||||
Err(IdxError::RateLimited)
|
||||
}
|
||||
|
||||
fn post_json<B: Serialize, T: DeserializeOwned>(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &B,
|
||||
symbol: &str,
|
||||
endpoint: &str,
|
||||
) -> Result<T, IdxError> {
|
||||
if let Some(err) = Self::mock_error() {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Some(mocked) = Self::mock_json(endpoint)? {
|
||||
return Ok(mocked);
|
||||
}
|
||||
|
||||
let mut wait = Duration::from_millis(500);
|
||||
for attempt in 0..3 {
|
||||
let response = self
|
||||
.agent
|
||||
.post(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/")
|
||||
.header("Content-Type", "text/plain;charset=UTF-8")
|
||||
.send_json(body);
|
||||
|
||||
match response {
|
||||
Ok(ok) => {
|
||||
return ok
|
||||
.into_body()
|
||||
.read_json::<T>()
|
||||
.map_err(|e| IdxError::ParseError(format!("msn {endpoint}: {e}")));
|
||||
}
|
||||
Err(ureq::Error::StatusCode(404)) => {
|
||||
return Err(IdxError::SymbolNotFound(symbol.to_string()));
|
||||
}
|
||||
Err(ureq::Error::StatusCode(429)) => {
|
||||
if attempt < 2 {
|
||||
std::thread::sleep(wait);
|
||||
wait *= 2;
|
||||
continue;
|
||||
}
|
||||
return Err(IdxError::RateLimited);
|
||||
}
|
||||
Err(ureq::Error::StatusCode(code)) if code >= 500 => {
|
||||
if attempt < 2 {
|
||||
std::thread::sleep(wait);
|
||||
wait *= 2;
|
||||
continue;
|
||||
}
|
||||
return Err(IdxError::Http(format!("msn {endpoint}: status {code}")));
|
||||
}
|
||||
Err(err) => return Err(IdxError::Http(format!("msn {endpoint}: {err}"))),
|
||||
}
|
||||
}
|
||||
Err(IdxError::RateLimited)
|
||||
}
|
||||
|
||||
pub(super) fn fetch_quotes(&self, symbol: &str) -> Result<Vec<MsnQuote>, IdxError> {
|
||||
let id =
|
||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let url = format!(
|
||||
"{MSN_ASSETS_BASE_URL}Finance/Quotes?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
|
||||
);
|
||||
self.get_json(&url, symbol, "quote")
|
||||
}
|
||||
|
||||
pub(super) fn fetch_key_ratios(&self, symbol: &str) -> Result<Vec<KeyRatios>, IdxError> {
|
||||
let id =
|
||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let url =
|
||||
format!("{MSN_API_BASE_URL}keyratios?apikey={MSN_API_KEY}&ids={id}&wrapodata=false");
|
||||
self.get_json(&url, symbol, "keyratios")
|
||||
}
|
||||
|
||||
pub(super) fn fetch_equities(&self, symbol: &str) -> Result<Vec<RawEquity>, IdxError> {
|
||||
let id =
|
||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let url = format!(
|
||||
"{MSN_ASSETS_BASE_URL}Finance/Equities?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
|
||||
);
|
||||
self.get_json(&url, symbol, "equities")
|
||||
}
|
||||
|
||||
pub(super) fn fetch_financial_statements(
|
||||
&self,
|
||||
symbol: &str,
|
||||
) -> Result<Vec<RawFinancialStatement>, IdxError> {
|
||||
let id =
|
||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let url = format!(
|
||||
"{MSN_ASSETS_BASE_URL}Finance/Equities/financialstatements?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
|
||||
);
|
||||
self.get_json(&url, symbol, "financialstatements")
|
||||
}
|
||||
|
||||
pub(super) fn fetch_earnings(&self, symbol: &str) -> Result<RawEarningsResponse, IdxError> {
|
||||
let id =
|
||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let url = format!(
|
||||
"{MSN_ASSETS_BASE_URL}Finance/Events/Earnings?apikey={MSN_API_KEY}&ids={id}&wrapodata=false"
|
||||
);
|
||||
self.get_json(&url, symbol, "earnings")
|
||||
}
|
||||
|
||||
pub(super) fn fetch_sentiment(&self, symbol: &str) -> Result<Vec<RawSentiment>, IdxError> {
|
||||
let id =
|
||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let url = format!(
|
||||
"{MSN_ASSETS_BASE_URL}Finance/SentimentBrowser?apikey={MSN_API_KEY}&cm=id-id&it=web&scn=ANON&ids={id}&wrapodata=false&flightId=INeedDau"
|
||||
);
|
||||
self.get_json(&url, symbol, "sentiment")
|
||||
}
|
||||
|
||||
pub(super) fn fetch_insights(&self, symbol: &str) -> Result<Vec<RawInsight>, IdxError> {
|
||||
let id =
|
||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let url =
|
||||
format!("{MSN_API_BASE_URL}insights?apikey={MSN_API_KEY}&ids={id}&wrapodata=false");
|
||||
self.get_json(&url, symbol, "insights")
|
||||
}
|
||||
|
||||
pub(super) fn fetch_news(&self, symbol: &str, limit: usize) -> Result<RawNewsFeed, IdxError> {
|
||||
let id =
|
||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let url = format!(
|
||||
"{MSN_ASSETS_BASE_URL}MSN/Feed/me?$top={limit}&apikey={MSN_API_KEY}&cm=id-id&contentType=article,video,slideshow&it=web&query=ef_stock_{id}&queryType=entityfeed&responseSchema=cardview&scn=ANON&wrapodata=false"
|
||||
);
|
||||
self.get_json(&url, symbol, "news")
|
||||
}
|
||||
|
||||
fn post_screener(
|
||||
&self,
|
||||
url: &str,
|
||||
request: &ScreenerRequest,
|
||||
) -> Result<RawScreenerResponse, IdxError> {
|
||||
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
|
||||
if let Some(err) = Self::mock_error() {
|
||||
return Err(err);
|
||||
}
|
||||
if let Some(path) = std::env::var_os("IDX_MOCK_MSN_SCREENER_FIXTURE") {
|
||||
let body = std::fs::read_to_string(path)
|
||||
.map_err(|e| IdxError::ParseError(format!("msn screener fixture: {e}")))?;
|
||||
let mut response: RawScreenerResponse = serde_json::from_str(&body)
|
||||
.map_err(|e| IdxError::ParseError(format!("msn screener fixture: {e}")))?;
|
||||
// Model MSN's request-sized batches while preserving its reported
|
||||
// total, so complete-list fetching uses the same path as live data.
|
||||
if let Some(quotes) = response.quote.as_mut() {
|
||||
quotes.truncate(request.limit);
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
self.post_json(url, request, "SCREENER", "screener")
|
||||
}
|
||||
|
||||
pub(super) fn fetch_screener(
|
||||
&self,
|
||||
filter: &str,
|
||||
region: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<RawScreenerResponse, IdxError> {
|
||||
let url =
|
||||
format!("{MSN_ASSETS_BASE_URL}Finance/Screener?apikey={MSN_API_KEY}&wrapodata=false");
|
||||
let mut req = ScreenerRequest {
|
||||
filter: vec![
|
||||
ScreenerFilter {
|
||||
key: filter.to_string(),
|
||||
key_group: "st_list_".to_string(),
|
||||
is_range: false,
|
||||
},
|
||||
ScreenerFilter {
|
||||
key: region.to_string(),
|
||||
key_group: "st_reg_".to_string(),
|
||||
is_range: false,
|
||||
},
|
||||
],
|
||||
order: ScreenerOrder {
|
||||
key: "st_1yr_asc_order".to_string(),
|
||||
dir: "desc".to_string(),
|
||||
},
|
||||
return_value_type: vec!["quote".to_string(), "equity".to_string()],
|
||||
screener_type: "stock".to_string(),
|
||||
limit: limit.unwrap_or(500),
|
||||
page_index: 0,
|
||||
};
|
||||
|
||||
if limit.is_some() {
|
||||
return self.post_screener(&url, &req);
|
||||
}
|
||||
fetch_complete_screener(|limit| {
|
||||
req.limit = limit;
|
||||
self.post_screener(&url, &req)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn fetch_chart(
|
||||
&self,
|
||||
symbol: &str,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> Result<Vec<RawChartResponse>, IdxError> {
|
||||
let id =
|
||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let chart_type = msn_chart_type(period, interval)?;
|
||||
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 fetch_complete_screener(
|
||||
mut fetch: impl FnMut(usize) -> Result<RawScreenerResponse, IdxError>,
|
||||
) -> Result<RawScreenerResponse, IdxError> {
|
||||
let total_count = |response: &RawScreenerResponse| {
|
||||
response
|
||||
.count
|
||||
.and_then(|count| usize::try_from(count).ok())
|
||||
.ok_or_else(|| IdxError::ParseError("MSN screener omitted a valid total count".into()))
|
||||
};
|
||||
let mut response = fetch(500)?;
|
||||
let count = total_count(&response)?;
|
||||
if response.quote.as_ref().map_or(0, Vec::len) < count {
|
||||
// MSN ignores pageIndex for these lists. Re-request the reported total
|
||||
// instead of silently treating the initial batch as the whole market.
|
||||
response = fetch(count)?;
|
||||
}
|
||||
if response.quote.as_ref().map_or(0, Vec::len) < total_count(&response)? {
|
||||
return Err(IdxError::ParseError(
|
||||
"MSN screener returned an incomplete candidate list".into(),
|
||||
));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn msn_chart_type(period: &Period, interval: &Interval) -> Result<&'static str, IdxError> {
|
||||
if !matches!(interval, Interval::Day) {
|
||||
return Err(IdxError::Unsupported(
|
||||
"MSN charts currently support only --interval 1d for IDX history".into(),
|
||||
));
|
||||
}
|
||||
|
||||
match period {
|
||||
Period::OneMonth => Ok("1M"),
|
||||
Period::ThreeMonths => Ok("3M"),
|
||||
Period::OneYear => Ok("1Y"),
|
||||
_ => Err(IdxError::Unsupported(
|
||||
"MSN charts currently support --period 1mo, 3mo, or 1y with --interval 1d".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fetch_complete_screener, msn_chart_type};
|
||||
use crate::api::msn::raw_types::RawScreenerResponse;
|
||||
use crate::api::types::{Interval, Period};
|
||||
use crate::error::IdxError;
|
||||
|
||||
fn screener_response(count: Option<usize>, returned: usize) -> RawScreenerResponse {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"count": count,
|
||||
"quote": (0..returned).map(|index| serde_json::json!({
|
||||
"symbol": format!("STOCK{index}"),
|
||||
"price": 100
|
||||
})).collect::<Vec<_>>()
|
||||
}))
|
||||
.expect("valid screener response")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screener_includes_candidates_beyond_initial_batch() {
|
||||
let response =
|
||||
fetch_complete_screener(|limit| Ok(screener_response(Some(600), limit.min(600))))
|
||||
.expect("complete list");
|
||||
let symbols: Vec<_> = response
|
||||
.quote
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|quote| quote.symbol.unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
symbols,
|
||||
(0..600)
|
||||
.map(|index| format!("STOCK{index}"))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screener_rejects_a_truncated_total_response() {
|
||||
let err = fetch_complete_screener(|_| Ok(screener_response(Some(600), 500)))
|
||||
.expect_err("truncated list must not be ranked as complete");
|
||||
assert!(matches!(err, IdxError::ParseError(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screener_requires_a_total_count() {
|
||||
let err = fetch_complete_screener(|_| Ok(screener_response(None, 500)))
|
||||
.expect_err("unknown completeness must not be treated as a full list");
|
||||
assert!(matches!(err, IdxError::ParseError(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_supported_msn_chart_types() {
|
||||
assert_eq!(
|
||||
msn_chart_type(&Period::OneMonth, &Interval::Day).unwrap(),
|
||||
"1M"
|
||||
);
|
||||
assert_eq!(
|
||||
msn_chart_type(&Period::ThreeMonths, &Interval::Day).unwrap(),
|
||||
"3M"
|
||||
);
|
||||
assert_eq!(
|
||||
msn_chart_type(&Period::OneYear, &Interval::Day).unwrap(),
|
||||
"1Y"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_msn_chart_types() {
|
||||
let err = msn_chart_type(&Period::ThreeMonths, &Interval::Week).unwrap_err();
|
||||
assert!(matches!(err, IdxError::Unsupported(_)));
|
||||
|
||||
let err = msn_chart_type(&Period::SixMonths, &Interval::Day).unwrap_err();
|
||||
assert!(matches!(err, IdxError::Unsupported(_)));
|
||||
}
|
||||
}
|
||||
1008
crates/idx-core/src/api/msn/map.rs
Normal file
1008
crates/idx-core/src/api/msn/map.rs
Normal file
File diff suppressed because it is too large
Load diff
119
crates/idx-core/src/api/msn/mod.rs
Normal file
119
crates/idx-core/src/api/msn/mod.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#[allow(dead_code, clippy::enum_variant_names)]
|
||||
#[cfg(feature = "ownership")]
|
||||
pub mod bing;
|
||||
mod client;
|
||||
mod map;
|
||||
mod parse;
|
||||
mod raw_types;
|
||||
mod symbols;
|
||||
|
||||
use crate::api::types::{
|
||||
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
|
||||
NewsItem, Period, Quote, SentimentData,
|
||||
};
|
||||
use crate::api::{
|
||||
EarningsProvider, FinancialsProvider, FundamentalsProvider, HistoryProvider, InsightsProvider,
|
||||
NewsProvider, ProfileProvider, QuoteProvider, ScreenerProvider, SentimentProvider,
|
||||
};
|
||||
use crate::error::IdxError;
|
||||
|
||||
use client::MsnClient;
|
||||
use map::{
|
||||
parse_earnings, parse_financial_statements, parse_fundamentals, parse_history, parse_insights,
|
||||
parse_news, parse_profile, parse_quote, parse_screener_results, parse_sentiment,
|
||||
};
|
||||
|
||||
pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
|
||||
|
||||
pub struct MsnProvider {
|
||||
client: MsnClient,
|
||||
}
|
||||
|
||||
impl MsnProvider {
|
||||
pub fn new(_verbose: bool) -> Self {
|
||||
Self {
|
||||
client: MsnClient::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QuoteProvider for MsnProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||
let quotes = self.client.fetch_quotes(symbol)?;
|
||||
parse_quote(symbol, "es)
|
||||
}
|
||||
}
|
||||
|
||||
impl FundamentalsProvider for MsnProvider {
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
|
||||
let ratios = self.client.fetch_key_ratios(symbol)?;
|
||||
let quote = self.client.fetch_quotes(symbol)?;
|
||||
parse_fundamentals(&ratios, quote.first())
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryProvider for MsnProvider {
|
||||
fn history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> Result<Vec<Bar>, IdxError> {
|
||||
let raw = self.client.fetch_chart(symbol, period, interval)?;
|
||||
parse_history(symbol, &raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProfileProvider for MsnProvider {
|
||||
fn profile(&self, symbol: &str) -> Result<CompanyProfile, IdxError> {
|
||||
let raw = self.client.fetch_equities(symbol)?;
|
||||
parse_profile(symbol, &raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl EarningsProvider for MsnProvider {
|
||||
fn earnings(&self, symbol: &str) -> Result<EarningsReport, IdxError> {
|
||||
let raw = self.client.fetch_earnings(symbol)?;
|
||||
parse_earnings(symbol, &raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl FinancialsProvider for MsnProvider {
|
||||
fn financials(&self, symbol: &str) -> Result<FinancialStatements, IdxError> {
|
||||
let raw = self.client.fetch_financial_statements(symbol)?;
|
||||
parse_financial_statements(symbol, &raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl SentimentProvider for MsnProvider {
|
||||
fn sentiment(&self, symbol: &str) -> Result<SentimentData, IdxError> {
|
||||
let raw = self.client.fetch_sentiment(symbol)?;
|
||||
parse_sentiment(symbol, &raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl InsightsProvider for MsnProvider {
|
||||
fn insights(&self, symbol: &str) -> Result<InsightData, IdxError> {
|
||||
let raw = self.client.fetch_insights(symbol)?;
|
||||
parse_insights(symbol, &raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl NewsProvider for MsnProvider {
|
||||
fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError> {
|
||||
let raw = self.client.fetch_news(symbol, limit)?;
|
||||
parse_news(symbol, &raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenerProvider for MsnProvider {
|
||||
fn screener(
|
||||
&self,
|
||||
filter: &str,
|
||||
region: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<Quote>, IdxError> {
|
||||
let raw = self.client.fetch_screener(filter, region, limit)?;
|
||||
parse_screener_results(&raw)
|
||||
}
|
||||
}
|
||||
164
crates/idx-core/src/api/msn/parse.rs
Normal file
164
crates/idx-core/src/api/msn/parse.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
use super::map::{parse_fundamentals, parse_history, parse_quote};
|
||||
use super::raw_types::{KeyRatios, MsnQuote, RawChartResponse};
|
||||
use crate::api::types::{Fundamentals, Ohlc, Quote};
|
||||
use crate::error::IdxError;
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result<Quote, IdxError> {
|
||||
let quotes: Vec<MsnQuote> =
|
||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
parse_quote(symbol, "es)
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn parse_history_from_str(symbol: &str, raw: &str) -> Result<Vec<Ohlc>, IdxError> {
|
||||
let charts: Vec<RawChartResponse> =
|
||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
parse_history(symbol, &charts)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
#[allow(dead_code)]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
|
||||
|
||||
fn minimal_quote_raw() -> &'static str {
|
||||
r#"[{"symbol":"BBCA","marketCap":1215200000000000}]"#
|
||||
}
|
||||
|
||||
#[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, Some(117));
|
||||
assert_eq!(quote.volume, Some(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_3m.json")
|
||||
.expect("chart fixture exists");
|
||||
let history = parse_history_from_str("BBCA.JK", &raw).expect("chart fixture parsed");
|
||||
|
||||
assert_eq!(history.len(), 3);
|
||||
assert_eq!(history[0].date.to_string(), "2026-01-14");
|
||||
assert_eq!(history[0].close, 8000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fundamentals_with_infinity_string_as_missing_data() {
|
||||
let raw = r#"[
|
||||
{
|
||||
"companyMetrics": [
|
||||
{
|
||||
"year": "2025",
|
||||
"fiscalPeriodType": "TTM",
|
||||
"priceToEarningsRatio": "Infinity",
|
||||
"priceToBookRatio": 4.6,
|
||||
"roe": 19.8,
|
||||
"profitMargin": 44.2,
|
||||
"debtToEquityRatio": 0.75,
|
||||
"currentRatio": 1.4
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
let fundamentals = parse_fundamentals_from_str(raw, Some(minimal_quote_raw()))
|
||||
.expect("fundamentals parsed");
|
||||
|
||||
assert_eq!(fundamentals.trailing_pe, None);
|
||||
assert_eq!(fundamentals.price_to_book, Some(4.6));
|
||||
assert_eq!(fundamentals.return_on_equity, Some(0.198));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fundamentals_with_negative_infinity_string_as_missing_data() {
|
||||
let raw = r#"[
|
||||
{
|
||||
"companyMetrics": [
|
||||
{
|
||||
"year": "2025",
|
||||
"fiscalPeriodType": "TTM",
|
||||
"priceToEarningsRatio": 12.5,
|
||||
"debtToEquityRatio": "-Infinity",
|
||||
"roe": 19.8,
|
||||
"profitMargin": 44.2,
|
||||
"currentRatio": 1.4
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
let fundamentals = parse_fundamentals_from_str(raw, Some(minimal_quote_raw()))
|
||||
.expect("fundamentals parsed");
|
||||
|
||||
assert_eq!(fundamentals.trailing_pe, Some(12.5));
|
||||
assert_eq!(fundamentals.debt_to_equity, None);
|
||||
assert_eq!(fundamentals.return_on_equity, Some(0.198));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fundamentals_with_nan_string_as_missing_data() {
|
||||
let raw = r#"[
|
||||
{
|
||||
"companyMetrics": [
|
||||
{
|
||||
"year": "2025",
|
||||
"fiscalPeriodType": "TTM",
|
||||
"revenueGrowthRate": "NaN",
|
||||
"earningsGrowthRate": 12.1,
|
||||
"roe": 19.8,
|
||||
"profitMargin": 44.2,
|
||||
"debtToEquityRatio": 0.75,
|
||||
"currentRatio": 1.4
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
let fundamentals = parse_fundamentals_from_str(raw, Some(minimal_quote_raw()))
|
||||
.expect("fundamentals parsed");
|
||||
|
||||
assert_eq!(fundamentals.revenue_growth, None);
|
||||
assert_eq!(fundamentals.earnings_growth, Some(0.121));
|
||||
assert_eq!(fundamentals.return_on_equity, Some(0.198));
|
||||
}
|
||||
}
|
||||
393
crates/idx-core/src/api/msn/raw_types.rs
Normal file
393
crates/idx-core/src/api/msn/raw_types.rs
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MsnQuote {
|
||||
#[serde(default)]
|
||||
pub(crate) symbol: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) short_name: Option<String>,
|
||||
pub(crate) price: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) price_change: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) price_change_percent: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) price_previous_close: Option<f64>,
|
||||
#[serde(default, rename = "price52wHigh")]
|
||||
pub(crate) price_52w_high: Option<f64>,
|
||||
#[serde(default, rename = "price52wLow")]
|
||||
pub(crate) price_52w_low: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) accumulated_volume: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) average_volume: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) market_cap: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) return_ytd: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) time_last_traded: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct KeyRatios {
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
pub(crate) industry_metrics: Vec<IndustryMetric>,
|
||||
#[serde(default)]
|
||||
pub(crate) company_metrics: Vec<IndustryMetric>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct IndustryMetric {
|
||||
pub(crate) year: Option<String>,
|
||||
pub(crate) fiscal_period_type: Option<String>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) revenue_growth_rate: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) earnings_growth_rate: Option<f64>,
|
||||
#[serde(
|
||||
default,
|
||||
rename = "netIncomeYTDYTDGrowthRate",
|
||||
deserialize_with = "de_opt_f64_lenient"
|
||||
)]
|
||||
pub(crate) net_income_ytd_ytd_growth_rate: Option<f64>,
|
||||
#[serde(
|
||||
default,
|
||||
rename = "revenueYTDYTD",
|
||||
deserialize_with = "de_opt_f64_lenient"
|
||||
)]
|
||||
pub(crate) revenue_ytd_ytd: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) net_margin: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) profit_margin: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) roe: Option<f64>,
|
||||
#[serde(default, rename = "roaTTM", deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) roa_ttm: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) return_on_asset_current: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) debt_to_equity_ratio: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) current_ratio: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) price_to_earnings_ratio: Option<f64>,
|
||||
#[serde(
|
||||
default,
|
||||
rename = "forwardPriceToEPS",
|
||||
deserialize_with = "de_opt_f64_lenient"
|
||||
)]
|
||||
pub(crate) forward_price_to_eps: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) price_to_book_ratio: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawEquity {
|
||||
pub(super) id: Option<String>,
|
||||
pub(super) symbol: Option<String>,
|
||||
pub(super) short_name: Option<String>,
|
||||
pub(super) long_name: Option<String>,
|
||||
pub(super) display_name: Option<String>,
|
||||
pub(super) description: Option<String>,
|
||||
pub(super) sector: Option<String>,
|
||||
pub(super) industry: Option<String>,
|
||||
pub(super) website: Option<String>,
|
||||
pub(super) full_time_employees: Option<i64>,
|
||||
pub(super) address: Option<String>,
|
||||
pub(super) city: Option<String>,
|
||||
pub(super) country: Option<String>,
|
||||
pub(super) phone: Option<String>,
|
||||
pub(super) officers: Option<Vec<RawOfficer>>,
|
||||
pub(super) company: Option<RawCompany>,
|
||||
pub(super) localized_attributes: Option<HashMap<String, RawLocalizedAttribute>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawOfficer {
|
||||
pub(super) name: Option<String>,
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) age: Option<i32>,
|
||||
pub(super) year_born: Option<i32>,
|
||||
pub(super) total_pay: Option<i64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawCompany {
|
||||
pub(super) address: Option<RawCompanyAddress>,
|
||||
pub(super) description: Option<String>,
|
||||
pub(super) employees: Option<i64>,
|
||||
pub(super) industry: Option<String>,
|
||||
pub(super) sector: Option<String>,
|
||||
pub(super) website: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawCompanyAddress {
|
||||
pub(super) street: Option<String>,
|
||||
pub(super) city: Option<String>,
|
||||
pub(super) country: Option<String>,
|
||||
pub(super) phone: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawLocalizedAttribute {
|
||||
pub(super) display_name: Option<String>,
|
||||
pub(super) description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawFinancialStatement {
|
||||
pub(super) underlying_instrument: Option<RawInstrumentInfo>,
|
||||
pub(super) balance_sheets: Option<RawStatementSection>,
|
||||
pub(super) cash_flow: Option<RawStatementSection>,
|
||||
#[serde(rename = "incomeStatement")]
|
||||
pub(super) income_statements: Option<RawStatementSection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawInstrumentInfo {
|
||||
pub(super) instrument_id: Option<String>,
|
||||
pub(super) display_name: Option<String>,
|
||||
pub(super) short_name: Option<String>,
|
||||
pub(super) symbol: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct RawStatementSection {
|
||||
#[serde(flatten)]
|
||||
pub(super) data: HashMap<String, serde_json::Value>,
|
||||
pub(super) currency: Option<String>,
|
||||
pub(super) source: Option<String>,
|
||||
#[serde(rename = "sourceDate")]
|
||||
pub(super) source_date: Option<String>,
|
||||
#[serde(rename = "reportDate")]
|
||||
pub(super) report_date: Option<String>,
|
||||
#[serde(rename = "endDate")]
|
||||
pub(super) end_date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub(super) struct RawEarningsResponse {
|
||||
pub(super) eps_last_year: Option<f64>,
|
||||
pub(super) revenue_last_year: Option<f64>,
|
||||
pub(super) forecast: Option<RawEarningsBucket>,
|
||||
pub(super) history: Option<RawEarningsBucket>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct RawEarningsBucket {
|
||||
pub(super) annual: Option<HashMap<String, RawEarningsData>>,
|
||||
pub(super) quarterly: Option<HashMap<String, RawEarningsData>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub(super) struct RawEarningsData {
|
||||
pub(super) eps_actual: Option<f64>,
|
||||
pub(super) eps_surprise: Option<f64>,
|
||||
pub(super) eps_surprise_percent: Option<f64>,
|
||||
pub(super) eps_forecast: Option<f64>,
|
||||
pub(super) revenue_actual: Option<f64>,
|
||||
pub(super) revenue_surprise: Option<f64>,
|
||||
pub(super) revenue_forecast: Option<f64>,
|
||||
pub(super) earning_release_date: Option<String>,
|
||||
pub(super) ciq_fiscal_period_type: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawSentiment {
|
||||
pub(super) symbol: Option<String>,
|
||||
pub(super) display_name: Option<String>,
|
||||
pub(super) sentiment_statistics: Option<Vec<RawSentimentStat>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawSentimentStat {
|
||||
pub(super) time_range_name: Option<String>,
|
||||
pub(super) bullish: Option<i32>,
|
||||
pub(super) bearish: Option<i32>,
|
||||
pub(super) neutral: Option<i32>,
|
||||
}
|
||||
|
||||
// Actual MSN insights API response: array of insight containers, each holding
|
||||
// individual insight items grouped by category (Valuation, Risk, etc.)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawInsight {
|
||||
pub(super) instrument_id: Option<String>,
|
||||
pub(super) display_name: Option<String>,
|
||||
pub(super) insights: Option<Vec<RawInsightItem>>,
|
||||
pub(super) time_last_updated: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawInsightItem {
|
||||
pub(super) insight_name: Option<String>,
|
||||
pub(super) category: Option<String>,
|
||||
pub(super) insight_statement: Option<String>,
|
||||
pub(super) short_insight_statement: Option<String>,
|
||||
pub(super) details: Option<RawInsightDetails>,
|
||||
pub(super) time_last_updated: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawInsightDetails {
|
||||
pub(super) evaluation_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct RawNewsFeed {
|
||||
pub(super) value: Option<Vec<RawNewsItem>>,
|
||||
#[serde(rename = "subCards")]
|
||||
pub(super) sub_cards: Option<Vec<RawNewsItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawNewsItem {
|
||||
pub(super) id: Option<String>,
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) url: Option<String>,
|
||||
#[serde(rename = "abstract")]
|
||||
pub(super) description: Option<String>,
|
||||
pub(super) provider: Option<RawNewsProvider>,
|
||||
pub(super) published_date_time: Option<String>,
|
||||
pub(super) read_time_min: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct RawNewsProvider {
|
||||
pub(super) name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ScreenerRequest {
|
||||
pub(super) filter: Vec<ScreenerFilter>,
|
||||
pub(super) order: ScreenerOrder,
|
||||
pub(super) return_value_type: Vec<String>,
|
||||
pub(super) screener_type: String,
|
||||
pub(super) limit: usize,
|
||||
pub(super) page_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ScreenerFilter {
|
||||
pub(super) key: String,
|
||||
pub(super) key_group: String,
|
||||
pub(super) is_range: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ScreenerOrder {
|
||||
pub(super) key: String,
|
||||
pub(super) dir: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawScreenerResponse {
|
||||
pub(super) count: Option<i32>,
|
||||
pub(super) quote: Option<Vec<MsnQuote>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawChartResponse {
|
||||
#[serde(rename = "_p")]
|
||||
pub(super) id: Option<String>,
|
||||
pub(super) chart_type: Option<String>,
|
||||
pub(super) symbol: Option<String>,
|
||||
pub(super) series: Option<RawChartSeries>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RawChartSeries {
|
||||
#[serde(default)]
|
||||
pub(super) time_stamps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(super) prices: Vec<Option<f64>>,
|
||||
#[serde(default)]
|
||||
pub(super) open_prices: Vec<Option<f64>>,
|
||||
#[serde(default)]
|
||||
pub(super) prices_high: Vec<Option<f64>>,
|
||||
#[serde(default)]
|
||||
pub(super) prices_low: Vec<Option<f64>>,
|
||||
#[serde(default)]
|
||||
pub(super) volumes: Vec<Option<f64>>,
|
||||
pub(super) start_time: Option<String>,
|
||||
pub(super) end_time: Option<String>,
|
||||
}
|
||||
|
||||
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")
|
||||
|| trimmed.eq_ignore_ascii_case("infinity")
|
||||
|| trimmed.eq_ignore_ascii_case("-infinity")
|
||||
{
|
||||
Ok(None)
|
||||
} else {
|
||||
trimmed
|
||||
.parse::<f64>()
|
||||
.map_err(D::Error::custom)
|
||||
.map(|number| {
|
||||
if number.is_finite() {
|
||||
Some(number)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
896
crates/idx-core/src/api/msn/symbol_ids.tsv
Normal file
896
crates/idx-core/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
|
||||
|
56
crates/idx-core/src/api/msn/symbols.rs
Normal file
56
crates/idx-core/src/api/msn/symbols.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const SYMBOL_IDS_RAW: &str = include_str!("symbol_ids.tsv");
|
||||
|
||||
static SYMBOL_IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
|
||||
fn symbol_ids() -> &'static HashMap<&'static str, &'static str> {
|
||||
SYMBOL_IDS.get_or_init(|| {
|
||||
SYMBOL_IDS_RAW
|
||||
.lines()
|
||||
.filter_map(|line| line.split_once('\t'))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ticker_from_symbol(symbol: &str) -> Option<String> {
|
||||
let trimmed = symbol.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
trimmed
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(trimmed)
|
||||
.trim()
|
||||
.to_uppercase(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_msn_id(symbol: &str) -> Option<&'static str> {
|
||||
let ticker = ticker_from_symbol(symbol)?;
|
||||
symbol_ids().get(ticker.as_str()).copied()
|
||||
}
|
||||
|
||||
pub(crate) fn normalized_symbol(requested: &str, fallback_ticker: &str) -> String {
|
||||
let trimmed = requested.trim().to_uppercase();
|
||||
if trimmed.contains('.') || fallback_ticker.is_empty() {
|
||||
trimmed
|
||||
} else {
|
||||
format!("{}.JK", fallback_ticker.trim().to_uppercase())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_msn_id;
|
||||
|
||||
#[test]
|
||||
fn resolves_symbol_variants() {
|
||||
assert_eq!(resolve_msn_id("BBCA"), Some("bn91jc"));
|
||||
assert_eq!(resolve_msn_id("bbca.jk"), Some("bn91jc"));
|
||||
assert_eq!(resolve_msn_id("INVALID"), None);
|
||||
}
|
||||
}
|
||||
330
crates/idx-core/src/api/types.rs
Normal file
330
crates/idx-core/src/api/types.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
use chrono::{DateTime, FixedOffset, NaiveDate};
|
||||
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
|
||||
|
||||
/// Snapshot quote data normalized from Yahoo Finance `/v8/finance/chart` response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Quote {
|
||||
/// Trading symbol as returned by Yahoo `chart.result[0].meta.symbol`.
|
||||
pub symbol: String,
|
||||
/// Last traded regular market price in IDR (whole Rupiah), mapped from
|
||||
/// `chart.result[0].meta.regularMarketPrice` and rounded to nearest integer.
|
||||
#[serde(deserialize_with = "de_i64_from_number")]
|
||||
pub price: i64,
|
||||
/// Absolute day change in IDR (whole Rupiah), computed as
|
||||
/// `regularMarketPrice - previousClose` using rounded integer prices.
|
||||
/// `None` when the provider gives neither a previous close nor a change.
|
||||
#[serde(default, deserialize_with = "de_opt_i64_from_number")]
|
||||
pub change: Option<i64>,
|
||||
/// Percentage day change as decimal percent (`0-100` scale), computed from
|
||||
/// Yahoo `regularMarketPrice` and `previousClose` raw floats.
|
||||
/// `None` when the provider does not report it (never a fake `0.0`).
|
||||
#[serde(default)]
|
||||
pub change_pct: Option<f64>,
|
||||
/// Traded regular market volume (shares), from `regularMarketVolume`.
|
||||
/// `None` when the provider does not report it (never a fake `0`).
|
||||
#[serde(default, deserialize_with = "de_opt_u64_from_number")]
|
||||
pub volume: Option<u64>,
|
||||
/// Company market capitalization in IDR, from `marketCap`.
|
||||
#[serde(default, deserialize_with = "de_opt_u64_from_number")]
|
||||
pub market_cap: Option<u64>,
|
||||
/// 52-week high in IDR (whole Rupiah), from `fiftyTwoWeekHigh` rounded.
|
||||
#[serde(default, deserialize_with = "de_opt_i64_from_number")]
|
||||
pub week52_high: Option<i64>,
|
||||
/// 52-week low in IDR (whole Rupiah), from `fiftyTwoWeekLow` rounded.
|
||||
#[serde(default, deserialize_with = "de_opt_i64_from_number")]
|
||||
pub week52_low: Option<i64>,
|
||||
/// Relative position within 52-week range (`0.0..=1.0`), computed from raw
|
||||
/// Yahoo `fiftyTwoWeekLow` and `fiftyTwoWeekHigh`.
|
||||
pub week52_position: Option<f64>,
|
||||
/// Coarse 52-week range bucket derived from `week52_position`.
|
||||
pub range_signal: Option<String>,
|
||||
/// Previous close in IDR (whole Rupiah), from
|
||||
/// `previousClose` or `chartPreviousClose`, rounded.
|
||||
#[serde(default, deserialize_with = "de_opt_i64_from_number")]
|
||||
pub prev_close: Option<i64>,
|
||||
/// Average daily volume for the last 3 months (shares), from
|
||||
/// `averageDailyVolume3Month`.
|
||||
pub avg_volume: Option<u64>,
|
||||
/// Time of the last trade in WIB (UTC+7), from MSN `timeLastTraded` or
|
||||
/// Yahoo `regularMarketTime`. `None` when the provider does not say.
|
||||
/// Suspended stocks can carry a last trade months in the past.
|
||||
#[serde(default)]
|
||||
pub as_of: Option<DateTime<FixedOffset>>,
|
||||
}
|
||||
|
||||
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Ohlc {
|
||||
/// Candle date (exchange-local day boundary from Yahoo timestamp).
|
||||
pub date: NaiveDate,
|
||||
/// Opening price in IDR (whole Rupiah), from `indicators.quote[0].open` rounded.
|
||||
#[serde(deserialize_with = "de_i64_from_number")]
|
||||
pub open: i64,
|
||||
/// Highest traded price in IDR (whole Rupiah), from `indicators.quote[0].high` rounded.
|
||||
#[serde(deserialize_with = "de_i64_from_number")]
|
||||
pub high: i64,
|
||||
/// Lowest traded price in IDR (whole Rupiah), from `indicators.quote[0].low` rounded.
|
||||
#[serde(deserialize_with = "de_i64_from_number")]
|
||||
pub low: i64,
|
||||
/// Closing price in IDR (whole Rupiah), from `indicators.quote[0].close` rounded.
|
||||
#[serde(deserialize_with = "de_i64_from_number")]
|
||||
pub close: i64,
|
||||
/// Traded volume (shares), from `indicators.quote[0].volume`.
|
||||
pub volume: u64,
|
||||
}
|
||||
|
||||
/// Fundamental metrics normalized from Yahoo Finance `/v10/finance/quoteSummary`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Fundamentals {
|
||||
pub trailing_pe: Option<f64>,
|
||||
pub forward_pe: Option<f64>,
|
||||
pub price_to_book: Option<f64>,
|
||||
pub return_on_equity: Option<f64>,
|
||||
pub profit_margins: Option<f64>,
|
||||
pub return_on_assets: Option<f64>,
|
||||
pub revenue_growth: Option<f64>,
|
||||
pub earnings_growth: Option<f64>,
|
||||
pub debt_to_equity: Option<f64>,
|
||||
pub current_ratio: Option<f64>,
|
||||
pub enterprise_value: Option<i64>,
|
||||
pub ebitda: Option<i64>,
|
||||
pub market_cap: Option<u64>,
|
||||
}
|
||||
|
||||
pub type Bar = Ohlc;
|
||||
|
||||
// Forward-looking types for planned MSN endpoints — used once capability traits are wired to CLI.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompanyProfile {
|
||||
pub id: String,
|
||||
pub symbol: String,
|
||||
pub short_name: String,
|
||||
pub long_name: String,
|
||||
pub description: String,
|
||||
pub sector: String,
|
||||
pub industry: String,
|
||||
pub website: String,
|
||||
pub employees: i64,
|
||||
pub address: String,
|
||||
pub city: String,
|
||||
pub country: String,
|
||||
pub phone: String,
|
||||
pub officers: Vec<Officer>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Officer {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub age: Option<i32>,
|
||||
pub year_born: Option<i32>,
|
||||
pub total_pay: Option<i64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FinancialStatements {
|
||||
pub instrument: InstrumentInfo,
|
||||
pub balance_sheet: Option<StatementSection>,
|
||||
pub cash_flow: Option<StatementSection>,
|
||||
pub income_statement: Option<StatementSection>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstrumentInfo {
|
||||
pub id: String,
|
||||
pub symbol: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatementSection {
|
||||
pub values: std::collections::BTreeMap<String, f64>,
|
||||
pub currency: String,
|
||||
pub report_date: String,
|
||||
pub end_date: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EarningsReport {
|
||||
#[serde(default)]
|
||||
pub symbol: String,
|
||||
pub eps_last_year: f64,
|
||||
pub revenue_last_year: f64,
|
||||
pub forecast: Vec<EarningsData>,
|
||||
pub history: Vec<EarningsData>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EarningsData {
|
||||
pub eps_actual: Option<f64>,
|
||||
pub eps_forecast: Option<f64>,
|
||||
pub eps_surprise: Option<f64>,
|
||||
pub eps_surprise_pct: Option<f64>,
|
||||
pub revenue_actual: Option<f64>,
|
||||
pub revenue_forecast: Option<f64>,
|
||||
pub revenue_surprise: Option<f64>,
|
||||
pub earning_release_date: Option<String>,
|
||||
pub period_type: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SentimentData {
|
||||
pub symbol: String,
|
||||
pub statistics: Vec<SentimentPeriod>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SentimentPeriod {
|
||||
pub time_range: String,
|
||||
pub bullish: i32,
|
||||
pub bearish: i32,
|
||||
pub neutral: i32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InsightData {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub symbol: String,
|
||||
pub summary: String,
|
||||
pub highlights: Vec<String>,
|
||||
pub risks: Vec<String>,
|
||||
pub last_updated: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewsItem {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub symbol: String,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub description: String,
|
||||
pub provider: String,
|
||||
pub published_at: String,
|
||||
pub read_time_min: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum NumberLike {
|
||||
I64(i64),
|
||||
U64(u64),
|
||||
F64(f64),
|
||||
}
|
||||
|
||||
fn de_i64_from_number<'de, D>(deserializer: D) -> Result<i64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = NumberLike::deserialize(deserializer)?;
|
||||
Ok(match value {
|
||||
NumberLike::I64(v) => v,
|
||||
NumberLike::U64(v) => i64::try_from(v).map_err(D::Error::custom)?,
|
||||
NumberLike::F64(v) => v.round() as i64,
|
||||
})
|
||||
}
|
||||
|
||||
fn de_opt_i64_from_number<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Option::<NumberLike>::deserialize(deserializer).and_then(|v| {
|
||||
v.map(|n| match n {
|
||||
NumberLike::I64(x) => Ok(x),
|
||||
NumberLike::U64(x) => i64::try_from(x).map_err(D::Error::custom),
|
||||
NumberLike::F64(x) => Ok(x.round() as i64),
|
||||
})
|
||||
.transpose()
|
||||
})
|
||||
}
|
||||
|
||||
fn de_opt_u64_from_number<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Option::<NumberLike>::deserialize(deserializer).and_then(|v| {
|
||||
v.map(|n| match n {
|
||||
NumberLike::I64(x) => u64::try_from(x).map_err(D::Error::custom),
|
||||
NumberLike::U64(x) => Ok(x),
|
||||
NumberLike::F64(x) => {
|
||||
if x.is_sign_negative() {
|
||||
Err(D::Error::custom(
|
||||
"negative value cannot be converted to u64",
|
||||
))
|
||||
} else {
|
||||
Ok(x.round() as u64)
|
||||
}
|
||||
}
|
||||
})
|
||||
.transpose()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
pub enum Period {
|
||||
#[cfg_attr(feature = "clap", value(name = "1d"))]
|
||||
OneDay,
|
||||
#[cfg_attr(feature = "clap", value(name = "5d"))]
|
||||
FiveDays,
|
||||
#[cfg_attr(feature = "clap", value(name = "1mo"))]
|
||||
OneMonth,
|
||||
#[cfg_attr(feature = "clap", value(name = "3mo"))]
|
||||
ThreeMonths,
|
||||
#[cfg_attr(feature = "clap", value(name = "6mo"))]
|
||||
SixMonths,
|
||||
#[cfg_attr(feature = "clap", value(name = "1y"))]
|
||||
OneYear,
|
||||
#[cfg_attr(feature = "clap", value(name = "2y"))]
|
||||
TwoYears,
|
||||
#[cfg_attr(feature = "clap", value(name = "5y"))]
|
||||
FiveYears,
|
||||
}
|
||||
|
||||
impl Period {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OneDay => "1d",
|
||||
Self::FiveDays => "5d",
|
||||
Self::OneMonth => "1mo",
|
||||
Self::ThreeMonths => "3mo",
|
||||
Self::SixMonths => "6mo",
|
||||
Self::OneYear => "1y",
|
||||
Self::TwoYears => "2y",
|
||||
Self::FiveYears => "5y",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
pub enum Interval {
|
||||
#[cfg_attr(feature = "clap", value(name = "1d"))]
|
||||
Day,
|
||||
#[cfg_attr(feature = "clap", value(name = "1wk"))]
|
||||
Week,
|
||||
#[cfg_attr(feature = "clap", value(name = "1mo"))]
|
||||
Month,
|
||||
}
|
||||
|
||||
impl Interval {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Day => "1d",
|
||||
Self::Week => "1wk",
|
||||
Self::Month => "1mo",
|
||||
}
|
||||
}
|
||||
}
|
||||
359
crates/idx-core/src/api/yahoo/client.rs
Normal file
359
crates/idx-core/src/api/yahoo/client.rs
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::api::types::{Interval, Period};
|
||||
use crate::curl_impersonate;
|
||||
use crate::error::IdxError;
|
||||
use crate::runtime;
|
||||
|
||||
use super::raw_types::{ChartResponse, QuoteSummaryResponse};
|
||||
|
||||
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 BASE_URL: &str = "https://query2.finance.yahoo.com";
|
||||
const COOKIE_FETCH_URL: &str = "https://fc.yahoo.com";
|
||||
const CRUMB_FETCH_URL: &str = "https://query1.finance.yahoo.com/v1/test/getcrumb";
|
||||
|
||||
pub(super) struct YahooClient {
|
||||
agent: ureq::Agent,
|
||||
crumb: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl YahooClient {
|
||||
pub(super) fn new() -> Self {
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.timeout_recv_body(Some(Duration::from_secs(10)))
|
||||
.build()
|
||||
.into();
|
||||
|
||||
Self {
|
||||
agent,
|
||||
crumb: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn chart_url(symbol: &str, period: &Period, interval: &Interval) -> String {
|
||||
format!(
|
||||
"{BASE_URL}/v8/finance/chart/{symbol}?range={}&interval={}",
|
||||
period.as_str(),
|
||||
interval.as_str()
|
||||
)
|
||||
}
|
||||
|
||||
fn quote_summary_url(symbol: &str, crumb: &str) -> String {
|
||||
format!(
|
||||
"{BASE_URL}/v10/finance/quoteSummary/{symbol}?modules=summaryDetail,defaultKeyStatistics,financialData,assetProfile,incomeStatementHistory&crumb={crumb}"
|
||||
)
|
||||
}
|
||||
|
||||
fn cookie_jar_path() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("idx_yf_{}.txt", std::process::id()))
|
||||
}
|
||||
|
||||
pub(super) fn parse_crumb_body(raw: &str) -> Result<String, IdxError> {
|
||||
let crumb = raw.trim();
|
||||
if crumb.is_empty() {
|
||||
return Err(IdxError::Http("received empty Yahoo crumb".to_string()));
|
||||
}
|
||||
|
||||
let normalized = crumb.to_ascii_lowercase();
|
||||
if normalized.contains("<html>") || normalized.contains("<!doctype html") {
|
||||
return Err(IdxError::Http(
|
||||
"received HTML instead of a Yahoo crumb".to_string(),
|
||||
));
|
||||
}
|
||||
if normalized.contains("too many requests") {
|
||||
return Err(IdxError::Http(
|
||||
"Yahoo crumb request was rate limited".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(crumb.to_string())
|
||||
}
|
||||
|
||||
fn cookie_header_from_jar(path: &Path) -> Result<String, IdxError> {
|
||||
let jar = std::fs::read_to_string(path).map_err(|e| {
|
||||
IdxError::Http(format!(
|
||||
"failed to read Yahoo cookie jar {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let cookies: Vec<String> = jar
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidate = trimmed.strip_prefix("#HttpOnly_").unwrap_or(trimmed);
|
||||
if candidate.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let fields: Vec<_> = candidate.split('\t').collect();
|
||||
if fields.len() < 7 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = fields[5].trim();
|
||||
let value = fields[6].trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!("{name}={value}"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if cookies.is_empty() {
|
||||
return Err(IdxError::Http(format!(
|
||||
"Yahoo cookie jar {} did not contain any cookies",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(cookies.join("; "))
|
||||
}
|
||||
|
||||
fn fetch_crumb_via_curl(&self) -> Result<String, IdxError> {
|
||||
let binary = curl_impersonate::chrome_curl_binary()?;
|
||||
|
||||
let cookie_jar = Self::cookie_jar_path();
|
||||
let cookie_jar_str = cookie_jar.to_str().ok_or_else(|| {
|
||||
IdxError::Io(format!(
|
||||
"failed to encode Yahoo cookie jar path {}",
|
||||
cookie_jar.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
// Step 1: fetch fc.yahoo.com to set A3 cookie (returns 404 but writes cookie jar).
|
||||
// We allow non-zero exit here since 404 still writes the cookie.
|
||||
let _ = Command::new(binary)
|
||||
.args(["--silent", "--cookie-jar", cookie_jar_str, COOKIE_FETCH_URL])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output();
|
||||
|
||||
// Step 2: fetch crumb with cookie jar (Chrome TLS fingerprint + A3 cookie).
|
||||
let output = curl_impersonate::run(
|
||||
"Yahoo crumb fetch",
|
||||
&["--silent", "--cookie", cookie_jar_str, CRUMB_FETCH_URL],
|
||||
)?;
|
||||
|
||||
let body = String::from_utf8_lossy(&output.stdout);
|
||||
Self::parse_crumb_body(&body)
|
||||
}
|
||||
|
||||
fn get_or_init_crumb(&self) -> Result<String, IdxError> {
|
||||
let mut guard = self
|
||||
.crumb
|
||||
.lock()
|
||||
.map_err(|e| IdxError::Io(format!("crumb lock poisoned: {e}")))?;
|
||||
|
||||
if let Some(crumb) = guard.as_ref() {
|
||||
return Ok(crumb.clone());
|
||||
}
|
||||
|
||||
let crumb = self.fetch_crumb_via_curl()?;
|
||||
*guard = Some(crumb.clone());
|
||||
Ok(crumb)
|
||||
}
|
||||
|
||||
fn clear_crumb(&self) -> Result<(), IdxError> {
|
||||
let mut guard = self
|
||||
.crumb
|
||||
.lock()
|
||||
.map_err(|e| IdxError::Io(format!("crumb lock poisoned: {e}")))?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retry_rate_limited<T, F>(&self, request: F) -> Result<Result<T, ureq::Error>, IdxError>
|
||||
where
|
||||
F: FnMut() -> Result<T, ureq::Error>,
|
||||
{
|
||||
Self::retry_rate_limited_with(request, std::thread::sleep, jitter)
|
||||
}
|
||||
|
||||
fn retry_rate_limited_with<T, F, S, J>(
|
||||
mut request: F,
|
||||
mut sleeper: S,
|
||||
mut jitter_fn: J,
|
||||
) -> Result<Result<T, ureq::Error>, IdxError>
|
||||
where
|
||||
F: FnMut() -> Result<T, ureq::Error>,
|
||||
S: FnMut(Duration),
|
||||
J: FnMut() -> Duration,
|
||||
{
|
||||
let mut wait = Duration::from_millis(250);
|
||||
|
||||
for attempt in 0..3 {
|
||||
match request() {
|
||||
Err(ureq::Error::StatusCode(429)) => {
|
||||
if attempt < 2 {
|
||||
sleeper(wait + jitter_fn());
|
||||
wait *= 2;
|
||||
continue;
|
||||
}
|
||||
return Err(IdxError::RateLimited);
|
||||
}
|
||||
other => return Ok(other),
|
||||
}
|
||||
}
|
||||
|
||||
Err(IdxError::RateLimited)
|
||||
}
|
||||
|
||||
pub(super) fn fetch_chart(
|
||||
&self,
|
||||
symbol: &str,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> Result<ChartResponse, IdxError> {
|
||||
let url = Self::chart_url(symbol, period, interval);
|
||||
let response = self
|
||||
.retry_rate_limited(|| self.agent.get(&url).header("User-Agent", USER_AGENT).call())?;
|
||||
|
||||
match response {
|
||||
Ok(ok) => ok
|
||||
.into_body()
|
||||
.read_json::<ChartResponse>()
|
||||
.map_err(|e| IdxError::ParseError(e.to_string())),
|
||||
Err(ureq::Error::StatusCode(404)) => Err(IdxError::SymbolNotFound(symbol.to_string())),
|
||||
Err(e) => Err(IdxError::Http(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn fetch_quote_summary(
|
||||
&self,
|
||||
symbol: &str,
|
||||
) -> Result<QuoteSummaryResponse, IdxError> {
|
||||
for auth_attempt in 0..2 {
|
||||
let crumb = self.get_or_init_crumb()?;
|
||||
let cookie_header = match Self::cookie_header_from_jar(&Self::cookie_jar_path()) {
|
||||
Ok(header) => header,
|
||||
Err(err) => {
|
||||
runtime::warn(format!("failed to parse Yahoo cookie jar: {err}"));
|
||||
return Err(IdxError::AuthError(format!(
|
||||
"failed to parse Yahoo cookies: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let url = Self::quote_summary_url(symbol, &crumb);
|
||||
let response = self.retry_rate_limited(|| {
|
||||
let mut req = self.agent.get(&url).header("User-Agent", USER_AGENT);
|
||||
if !cookie_header.is_empty() {
|
||||
req = req.header("Cookie", &cookie_header);
|
||||
}
|
||||
req.call()
|
||||
})?;
|
||||
|
||||
match response {
|
||||
Ok(ok) => {
|
||||
return ok
|
||||
.into_body()
|
||||
.read_json::<QuoteSummaryResponse>()
|
||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||
}
|
||||
Err(ureq::Error::StatusCode(401)) => {
|
||||
if auth_attempt == 0 {
|
||||
self.clear_crumb()?;
|
||||
continue;
|
||||
}
|
||||
return Err(IdxError::Http(
|
||||
"yahoo quoteSummary returned unauthorized (401)".to_string(),
|
||||
));
|
||||
}
|
||||
Err(ureq::Error::StatusCode(404)) => {
|
||||
return Err(IdxError::SymbolNotFound(symbol.to_string()));
|
||||
}
|
||||
Err(e) => return Err(IdxError::Http(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
Err(IdxError::RateLimited)
|
||||
}
|
||||
}
|
||||
|
||||
fn jitter() -> Duration {
|
||||
Duration::from_millis(fastrand::u64(0..100))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::YahooClient;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn parses_crumb_body_trimmed() {
|
||||
let crumb = YahooClient::parse_crumb_body(" abc123xyz\n").expect("crumb should parse");
|
||||
assert_eq!(crumb, "abc123xyz");
|
||||
|
||||
let empty = YahooClient::parse_crumb_body(" \n").expect_err("empty crumb must fail");
|
||||
assert!(matches!(empty, crate::error::IdxError::Http(_)));
|
||||
|
||||
let html = YahooClient::parse_crumb_body("<html>blocked</html>")
|
||||
.expect_err("html crumb must fail");
|
||||
assert!(matches!(html, crate::error::IdxError::Http(_)));
|
||||
|
||||
let rate_limited = YahooClient::parse_crumb_body("Too Many Requests")
|
||||
.expect_err("rate limited crumb must fail");
|
||||
assert!(matches!(rate_limited, crate::error::IdxError::Http(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_rate_limited_succeeds_after_retry() {
|
||||
let mut attempts = 0;
|
||||
let mut sleeps = Vec::new();
|
||||
|
||||
let result = YahooClient::retry_rate_limited_with(
|
||||
|| {
|
||||
attempts += 1;
|
||||
if attempts < 3 {
|
||||
Err(ureq::Error::StatusCode(429))
|
||||
} else {
|
||||
Ok("ok")
|
||||
}
|
||||
},
|
||||
|duration| sleeps.push(duration),
|
||||
|| Duration::ZERO,
|
||||
)
|
||||
.expect("retry helper should not fail")
|
||||
.expect("third attempt should succeed");
|
||||
|
||||
assert_eq!(result, "ok");
|
||||
assert_eq!(attempts, 3);
|
||||
assert_eq!(
|
||||
sleeps,
|
||||
vec![Duration::from_millis(250), Duration::from_millis(500)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_rate_limited_returns_rate_limited_after_exhaustion() {
|
||||
let mut attempts = 0;
|
||||
let mut sleeps = Vec::new();
|
||||
|
||||
let err = YahooClient::retry_rate_limited_with(
|
||||
|| {
|
||||
attempts += 1;
|
||||
Err::<(), ureq::Error>(ureq::Error::StatusCode(429))
|
||||
},
|
||||
|duration| sleeps.push(duration),
|
||||
|| Duration::ZERO,
|
||||
)
|
||||
.expect_err("rate-limited helper should fail after three attempts");
|
||||
|
||||
assert!(matches!(err, crate::error::IdxError::RateLimited));
|
||||
assert_eq!(attempts, 3);
|
||||
assert_eq!(
|
||||
sleeps,
|
||||
vec![Duration::from_millis(250), Duration::from_millis(500)]
|
||||
);
|
||||
}
|
||||
}
|
||||
217
crates/idx-core/src/api/yahoo/map.rs
Normal file
217
crates/idx-core/src/api/yahoo/map.rs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
use crate::api::types::{Fundamentals, Ohlc, Quote};
|
||||
use crate::error::IdxError;
|
||||
|
||||
use super::raw_types::{ChartError, ChartResponse, QuoteSummaryResponse};
|
||||
|
||||
pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||
if let Some(err) = chart.chart.error.as_ref() {
|
||||
return Err(map_yahoo_error(symbol, "chart", err));
|
||||
}
|
||||
|
||||
let result = chart
|
||||
.chart
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|r| r.first())
|
||||
.ok_or(IdxError::ProviderUnavailable)?;
|
||||
let meta = result.meta.as_ref().ok_or(IdxError::ProviderUnavailable)?;
|
||||
let raw_price = meta
|
||||
.regular_market_price
|
||||
.ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let raw_prev_close = meta.previous_close.or(meta.chart_previous_close);
|
||||
|
||||
let price = round_price(raw_price);
|
||||
let prev_close = raw_prev_close.map(round_price);
|
||||
let change = prev_close.map(|p| price - p);
|
||||
let change_pct = raw_prev_close
|
||||
.filter(|p| *p != 0.0)
|
||||
.map(|p| ((raw_price - p) / p) * 100.0);
|
||||
|
||||
let (week52_position, range_signal) = match (meta.fifty_two_week_low, meta.fifty_two_week_high)
|
||||
{
|
||||
(Some(low), Some(high)) if high > low => {
|
||||
let pos = (raw_price - low) / (high - low);
|
||||
let signal = if pos > 0.66 {
|
||||
"upper"
|
||||
} else if pos < 0.33 {
|
||||
"lower"
|
||||
} else {
|
||||
"middle"
|
||||
};
|
||||
(Some(pos), Some(signal.to_string()))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
Ok(Quote {
|
||||
symbol: meta.symbol.clone().unwrap_or_else(|| symbol.to_string()),
|
||||
price,
|
||||
change,
|
||||
change_pct,
|
||||
volume: meta.regular_market_volume,
|
||||
market_cap: meta.market_cap,
|
||||
week52_high: meta.fifty_two_week_high.map(round_price),
|
||||
week52_low: meta.fifty_two_week_low.map(round_price),
|
||||
week52_position,
|
||||
range_signal,
|
||||
prev_close,
|
||||
avg_volume: meta.average_daily_volume_3month,
|
||||
as_of: meta
|
||||
.regular_market_time
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.map(|t| t.with_timezone(&crate::api::idx_offset())),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parse_history(
|
||||
symbol: &str,
|
||||
chart: &ChartResponse,
|
||||
) -> Result<(Vec<Ohlc>, usize), IdxError> {
|
||||
if let Some(err) = chart.chart.error.as_ref() {
|
||||
return Err(map_yahoo_error(symbol, "chart", err));
|
||||
}
|
||||
|
||||
let result = chart
|
||||
.chart
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|r| r.first())
|
||||
.ok_or(IdxError::ProviderUnavailable)?;
|
||||
let timestamps = result
|
||||
.timestamp
|
||||
.as_ref()
|
||||
.ok_or(IdxError::ProviderUnavailable)?;
|
||||
let quote = result
|
||||
.indicators
|
||||
.as_ref()
|
||||
.and_then(|i| i.quote.as_ref())
|
||||
.and_then(|q| q.first())
|
||||
.ok_or(IdxError::ProviderUnavailable)?;
|
||||
// Yahoo stamps weekly/monthly bars at local midnight (17:00Z the day before),
|
||||
// so take the date in the exchange's own offset, not UTC.
|
||||
let exchange_tz = result
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.gmtoffset)
|
||||
.and_then(chrono::FixedOffset::east_opt)
|
||||
.unwrap_or_else(crate::api::idx_offset);
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut dropped = 0usize;
|
||||
for (i, ts) in timestamps.iter().enumerate() {
|
||||
let open = quote
|
||||
.open
|
||||
.as_ref()
|
||||
.and_then(|v| v.get(i).copied().flatten())
|
||||
.map(round_price);
|
||||
let high = quote
|
||||
.high
|
||||
.as_ref()
|
||||
.and_then(|v| v.get(i).copied().flatten())
|
||||
.map(round_price);
|
||||
let low = quote
|
||||
.low
|
||||
.as_ref()
|
||||
.and_then(|v| v.get(i).copied().flatten())
|
||||
.map(round_price);
|
||||
let close = quote
|
||||
.close
|
||||
.as_ref()
|
||||
.and_then(|v| v.get(i).copied().flatten())
|
||||
.map(round_price);
|
||||
let volume = quote
|
||||
.volume
|
||||
.as_ref()
|
||||
.and_then(|v| v.get(i).copied().flatten());
|
||||
|
||||
if let (Some(open), Some(high), Some(low), Some(close), Some(volume)) =
|
||||
(open, high, low, close, volume)
|
||||
&& let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0)
|
||||
{
|
||||
out.push(Ohlc {
|
||||
date: dt.with_timezone(&exchange_tz).date_naive(),
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume,
|
||||
});
|
||||
} else {
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((out, dropped))
|
||||
}
|
||||
|
||||
pub(super) fn parse_fundamentals(
|
||||
symbol: &str,
|
||||
quote_summary: &QuoteSummaryResponse,
|
||||
) -> Result<Fundamentals, IdxError> {
|
||||
if let Some(err) = quote_summary.quote_summary.error.as_ref() {
|
||||
return Err(map_yahoo_error(symbol, "quoteSummary", err));
|
||||
}
|
||||
|
||||
let result = quote_summary
|
||||
.quote_summary
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|results| results.first())
|
||||
.ok_or(IdxError::ProviderUnavailable)?;
|
||||
|
||||
let stats = result.default_key_statistics.as_ref();
|
||||
let fin = result.financial_data.as_ref();
|
||||
let summary = result.summary_detail.as_ref();
|
||||
|
||||
Ok(Fundamentals {
|
||||
trailing_pe: stats
|
||||
.and_then(|s| s.trailing_pe.as_ref().and_then(|v| v.raw))
|
||||
.or_else(|| fin.and_then(|f| f.trailing_pe.as_ref().and_then(|v| v.raw)))
|
||||
.or_else(|| summary.and_then(|s| s.trailing_pe.as_ref().and_then(|v| v.raw))),
|
||||
forward_pe: stats
|
||||
.and_then(|s| s.forward_pe.as_ref().and_then(|v| v.raw))
|
||||
.or_else(|| fin.and_then(|f| f.forward_pe.as_ref().and_then(|v| v.raw)))
|
||||
.or_else(|| summary.and_then(|s| s.forward_pe.as_ref().and_then(|v| v.raw))),
|
||||
price_to_book: stats
|
||||
.and_then(|s| s.price_to_book.as_ref().and_then(|v| v.raw))
|
||||
.or_else(|| fin.and_then(|f| f.price_to_book.as_ref().and_then(|v| v.raw)))
|
||||
.or_else(|| summary.and_then(|s| s.price_to_book.as_ref().and_then(|v| v.raw))),
|
||||
return_on_equity: fin.and_then(|f| f.return_on_equity.as_ref().and_then(|v| v.raw)),
|
||||
profit_margins: fin.and_then(|f| f.profit_margins.as_ref().and_then(|v| v.raw)),
|
||||
return_on_assets: fin.and_then(|f| f.return_on_assets.as_ref().and_then(|v| v.raw)),
|
||||
revenue_growth: fin.and_then(|f| f.revenue_growth.as_ref().and_then(|v| v.raw)),
|
||||
earnings_growth: stats
|
||||
.and_then(|s| s.earnings_growth.as_ref().and_then(|v| v.raw))
|
||||
.or_else(|| fin.and_then(|f| f.earnings_growth.as_ref().and_then(|v| v.raw))),
|
||||
debt_to_equity: fin.and_then(|f| f.debt_to_equity.as_ref().and_then(|v| v.raw)),
|
||||
current_ratio: fin.and_then(|f| f.current_ratio.as_ref().and_then(|v| v.raw)),
|
||||
enterprise_value: stats
|
||||
.and_then(|s| s.enterprise_value.as_ref().and_then(|v| v.raw))
|
||||
.or_else(|| fin.and_then(|f| f.enterprise_value.as_ref().and_then(|v| v.raw))),
|
||||
ebitda: fin
|
||||
.and_then(|f| f.ebitda.as_ref().and_then(|v| v.raw))
|
||||
.or_else(|| stats.and_then(|s| s.ebitda.as_ref().and_then(|v| v.raw))),
|
||||
market_cap: fin
|
||||
.and_then(|f| f.market_cap.as_ref().and_then(|v| v.raw))
|
||||
.or_else(|| stats.and_then(|s| s.market_cap.as_ref().and_then(|v| v.raw)))
|
||||
.or_else(|| {
|
||||
summary
|
||||
.and_then(|s| s.market_cap.as_ref().and_then(|v| v.raw))
|
||||
.map(|n| n.round() as u64)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn round_price(value: f64) -> i64 {
|
||||
value.round() as i64
|
||||
}
|
||||
|
||||
pub(super) fn map_yahoo_error(symbol: &str, endpoint: &str, err: &ChartError) -> IdxError {
|
||||
if err.code.eq_ignore_ascii_case("Not Found") {
|
||||
return IdxError::SymbolNotFound(symbol.to_string());
|
||||
}
|
||||
IdxError::Http(format!(
|
||||
"yahoo {endpoint} error {}: {}",
|
||||
err.code, err.description
|
||||
))
|
||||
}
|
||||
58
crates/idx-core/src/api/yahoo/mod.rs
Normal file
58
crates/idx-core/src/api/yahoo/mod.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
mod client;
|
||||
mod map;
|
||||
mod parse;
|
||||
mod raw_types;
|
||||
|
||||
use crate::api::types::{Bar, Fundamentals, Interval, Period, Quote};
|
||||
use crate::api::{FundamentalsProvider, HistoryProvider, QuoteProvider};
|
||||
use crate::error::IdxError;
|
||||
|
||||
use client::YahooClient;
|
||||
use map::{parse_fundamentals, parse_quote};
|
||||
use parse::parse_history_with_verbose;
|
||||
|
||||
pub(crate) use parse::{
|
||||
parse_fundamentals_from_str, parse_history_from_str_with_verbose, parse_quote_from_str,
|
||||
};
|
||||
|
||||
pub struct YahooProvider {
|
||||
client: YahooClient,
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
impl YahooProvider {
|
||||
pub fn new(verbose: bool) -> Self {
|
||||
Self {
|
||||
client: YahooClient::new(),
|
||||
verbose,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QuoteProvider for YahooProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||
let chart = self
|
||||
.client
|
||||
.fetch_chart(symbol, &Period::OneDay, &Interval::Day)?;
|
||||
parse_quote(symbol, &chart)
|
||||
}
|
||||
}
|
||||
|
||||
impl FundamentalsProvider for YahooProvider {
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
|
||||
let quote_summary = self.client.fetch_quote_summary(symbol)?;
|
||||
parse_fundamentals(symbol, "e_summary)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryProvider for YahooProvider {
|
||||
fn history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> Result<Vec<Bar>, IdxError> {
|
||||
let chart = self.client.fetch_chart(symbol, period, interval)?;
|
||||
parse_history_with_verbose(symbol, &chart, self.verbose)
|
||||
}
|
||||
}
|
||||
151
crates/idx-core/src/api/yahoo/parse.rs
Normal file
151
crates/idx-core/src/api/yahoo/parse.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
use crate::api::types::{Fundamentals, Ohlc, Quote};
|
||||
use crate::error::IdxError;
|
||||
|
||||
use super::map::{parse_fundamentals, parse_history, parse_quote};
|
||||
use super::raw_types::{ChartResponse, QuoteSummaryResponse};
|
||||
|
||||
pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result<Quote, IdxError> {
|
||||
let chart: ChartResponse =
|
||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
parse_quote(symbol, &chart)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_history_from_str(symbol: &str, raw: &str) -> Result<Vec<Ohlc>, IdxError> {
|
||||
parse_history_from_str_with_verbose(symbol, raw, false)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_history_from_str_with_verbose(
|
||||
symbol: &str,
|
||||
raw: &str,
|
||||
verbose: bool,
|
||||
) -> Result<Vec<Ohlc>, IdxError> {
|
||||
let chart: ChartResponse =
|
||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
parse_history_with_verbose(symbol, &chart, verbose)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_fundamentals_from_str(
|
||||
symbol: &str,
|
||||
raw: &str,
|
||||
) -> Result<Fundamentals, IdxError> {
|
||||
let quote_summary: QuoteSummaryResponse =
|
||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
parse_fundamentals(symbol, "e_summary)
|
||||
}
|
||||
|
||||
pub(super) fn parse_history_with_verbose(
|
||||
symbol: &str,
|
||||
chart: &ChartResponse,
|
||||
verbose: bool,
|
||||
) -> Result<Vec<Ohlc>, IdxError> {
|
||||
let (history, dropped) = parse_history(symbol, chart)?;
|
||||
if dropped > 0 && verbose {
|
||||
crate::runtime::warn(format!(
|
||||
"dropped {dropped} OHLC row(s) from Yahoo response due to missing fields"
|
||||
));
|
||||
}
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ChartResponse, parse_fundamentals_from_str, parse_history_from_str,
|
||||
parse_history_from_str_with_verbose, parse_history_with_verbose, parse_quote,
|
||||
parse_quote_from_str,
|
||||
};
|
||||
|
||||
const SAMPLE: &str = r#"{
|
||||
"chart": {
|
||||
"result": [{
|
||||
"meta": {
|
||||
"symbol": "BBCA.JK",
|
||||
"regularMarketPrice": 9875.0,
|
||||
"previousClose": 9758.0,
|
||||
"regularMarketVolume": 12300000,
|
||||
"marketCap": 1215200000000000,
|
||||
"fiftyTwoWeekHigh": 10250.0,
|
||||
"fiftyTwoWeekLow": 7800.0,
|
||||
"averageDailyVolume3Month": 10000000
|
||||
},
|
||||
"timestamp": [1709251200,1709337600],
|
||||
"indicators": {"quote":[{
|
||||
"open":[9800.0,9850.0],
|
||||
"high":[9900.0,9900.0],
|
||||
"low":[9750.0,9800.0],
|
||||
"close":[9875.0,9880.0],
|
||||
"volume":[12300000,11000000]
|
||||
}]}
|
||||
}]
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_quote_and_history() {
|
||||
let chart: ChartResponse = serde_json::from_str(SAMPLE).expect("valid chart fixture");
|
||||
let quote = parse_quote("BBCA.JK", &chart).expect("quote parsed");
|
||||
assert_eq!(quote.symbol, "BBCA.JK");
|
||||
assert_eq!(quote.price, 9875);
|
||||
let history = parse_history_with_verbose("BBCA.JK", &chart, false).expect("history parsed");
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history[0].close, 9875);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_dates_use_exchange_offset_not_utc() {
|
||||
// Weekly bars are stamped at Monday 00:00 WIB, i.e. Sunday 17:00Z.
|
||||
let raw = r#"{"chart":{"error":null,"result":[{
|
||||
"meta":{"gmtoffset":25200},
|
||||
"timestamp":[1790528400],
|
||||
"indicators":{"quote":[{"open":[6000],"high":[6150],"low":[5975],"close":[6100],"volume":[1000]}]}
|
||||
}]}}"#;
|
||||
let history = parse_history_from_str("BBCA.JK", raw).expect("history parsed");
|
||||
assert_eq!(history[0].date.to_string(), "2026-09-28");
|
||||
|
||||
let without_offset = raw.replace(r#""gmtoffset":25200"#, r#""symbol":"BBCA.JK""#);
|
||||
let history = parse_history_from_str("BBCA.JK", &without_offset).expect("history parsed");
|
||||
assert_eq!(history[0].date.to_string(), "2026-09-28");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_realistic_fixture_json() {
|
||||
let quote_raw =
|
||||
std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json").expect("fixture exists");
|
||||
let history_raw =
|
||||
std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.json").expect("fixture exists");
|
||||
let fundamentals_raw = std::fs::read_to_string("tests/fixtures/quotesummary_bbca.json")
|
||||
.expect("fixture exists");
|
||||
|
||||
let quote = parse_quote_from_str("BBCA.JK", "e_raw).expect("fixture quote parsed");
|
||||
assert_eq!(quote.symbol, "BBCA.JK");
|
||||
assert_eq!(quote.market_cap, Some(1_215_200_000_000_000));
|
||||
assert_eq!(quote.avg_volume, Some(10_000_000));
|
||||
|
||||
let history =
|
||||
parse_history_from_str("BBCA.JK", &history_raw).expect("fixture history parsed");
|
||||
assert!(!history.is_empty());
|
||||
|
||||
let verbose_history = parse_history_from_str_with_verbose("BBCA.JK", &history_raw, true)
|
||||
.expect("fixture history parsed in verbose mode");
|
||||
assert_eq!(verbose_history.len(), history.len());
|
||||
assert_eq!(verbose_history[0].close, history[0].close);
|
||||
|
||||
let fundamentals = parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw)
|
||||
.expect("fixture fundamentals parsed");
|
||||
assert_eq!(fundamentals.trailing_pe, Some(25.4));
|
||||
assert_eq!(fundamentals.forward_pe, Some(23.1));
|
||||
assert_eq!(fundamentals.price_to_book, Some(4.6));
|
||||
assert_eq!(fundamentals.earnings_growth, Some(0.121));
|
||||
assert_eq!(fundamentals.enterprise_value, Some(1_245_000_000_000_000));
|
||||
assert_eq!(fundamentals.ebitda, Some(58_500_000_000_000));
|
||||
assert_eq!(fundamentals.market_cap, Some(1_215_200_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_not_found_chart_error_to_symbol_not_found() {
|
||||
let raw = r#"{"chart":{"result":null,"error":{"code":"Not Found","description":"No data found"}}}"#;
|
||||
let err = parse_quote_from_str("INVALID.JK", raw).expect_err("expected symbol error");
|
||||
assert!(matches!(err, crate::error::IdxError::SymbolNotFound(_)));
|
||||
}
|
||||
}
|
||||
158
crates/idx-core/src/api/yahoo/raw_types.rs
Normal file
158
crates/idx-core/src/api/yahoo/raw_types.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// Raw serde structs for Yahoo API responses. Fields not yet consumed by map.rs are
|
||||
// retained for future fundamentals expansion; suppress dead_code for forward-compat.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ChartResponse {
|
||||
pub(super) chart: ChartRoot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct QuoteSummaryResponse {
|
||||
pub(super) quote_summary: QuoteSummaryRoot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct QuoteSummaryRoot {
|
||||
pub(super) result: Option<Vec<QuoteSummaryResult>>,
|
||||
pub(super) error: Option<ChartError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct QuoteSummaryResult {
|
||||
#[serde(default)]
|
||||
pub(super) summary_detail: Option<SummaryDetail>,
|
||||
#[serde(default)]
|
||||
pub(super) default_key_statistics: Option<DefaultKeyStatistics>,
|
||||
#[serde(default)]
|
||||
pub(super) financial_data: Option<FinancialData>,
|
||||
#[serde(default)]
|
||||
pub(super) asset_profile: Option<AssetProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ChartRoot {
|
||||
pub(super) result: Option<Vec<ChartResult>>,
|
||||
pub(super) error: Option<ChartError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ChartError {
|
||||
pub(super) code: String,
|
||||
pub(super) description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ChartResult {
|
||||
pub(super) meta: Option<ChartMeta>,
|
||||
pub(super) timestamp: Option<Vec<i64>>,
|
||||
pub(super) indicators: Option<Indicators>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(dead_code)]
|
||||
pub(super) struct ChartMeta {
|
||||
pub(super) symbol: Option<String>,
|
||||
pub(super) regular_market_price: Option<f64>,
|
||||
pub(super) previous_close: Option<f64>,
|
||||
pub(super) chart_previous_close: Option<f64>,
|
||||
pub(super) regular_market_volume: Option<u64>,
|
||||
pub(super) regular_market_day_high: Option<f64>,
|
||||
pub(super) regular_market_day_low: Option<f64>,
|
||||
pub(super) market_cap: Option<u64>,
|
||||
pub(super) fifty_two_week_high: Option<f64>,
|
||||
pub(super) fifty_two_week_low: Option<f64>,
|
||||
#[serde(rename = "averageDailyVolume3Month")]
|
||||
pub(super) average_daily_volume_3month: Option<u64>,
|
||||
pub(super) gmtoffset: Option<i32>,
|
||||
pub(super) regular_market_time: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct Indicators {
|
||||
pub(super) quote: Option<Vec<IndicatorQuote>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct IndicatorQuote {
|
||||
pub(super) open: Option<Vec<Option<f64>>>,
|
||||
pub(super) high: Option<Vec<Option<f64>>>,
|
||||
pub(super) low: Option<Vec<Option<f64>>>,
|
||||
pub(super) close: Option<Vec<Option<f64>>>,
|
||||
pub(super) volume: Option<Vec<Option<u64>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SummaryDetail {
|
||||
#[serde(rename = "trailingPE")]
|
||||
pub trailing_pe: Option<FloatValue>,
|
||||
#[serde(rename = "forwardPE")]
|
||||
pub forward_pe: Option<FloatValue>,
|
||||
pub price_to_book: Option<FloatValue>,
|
||||
pub dividend_yield: Option<FloatValue>,
|
||||
pub market_cap: Option<FloatValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DefaultKeyStatistics {
|
||||
#[serde(rename = "trailingPE")]
|
||||
pub trailing_pe: Option<FloatValue>,
|
||||
#[serde(rename = "forwardPE")]
|
||||
pub forward_pe: Option<FloatValue>,
|
||||
pub price_to_book: Option<FloatValue>,
|
||||
pub earnings_growth: Option<FloatValue>,
|
||||
pub enterprise_value: Option<IntValue>,
|
||||
pub ebitda: Option<IntValue>,
|
||||
pub market_cap: Option<UIntValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FinancialData {
|
||||
#[serde(rename = "trailingPE")]
|
||||
pub trailing_pe: Option<FloatValue>,
|
||||
#[serde(rename = "forwardPE")]
|
||||
pub forward_pe: Option<FloatValue>,
|
||||
pub price_to_book: Option<FloatValue>,
|
||||
pub return_on_equity: Option<FloatValue>,
|
||||
pub profit_margins: Option<FloatValue>,
|
||||
pub return_on_assets: Option<FloatValue>,
|
||||
pub revenue_growth: Option<FloatValue>,
|
||||
pub earnings_growth: Option<FloatValue>,
|
||||
pub debt_to_equity: Option<FloatValue>,
|
||||
pub current_ratio: Option<FloatValue>,
|
||||
pub enterprise_value: Option<IntValue>,
|
||||
pub ebitda: Option<IntValue>,
|
||||
pub market_cap: Option<UIntValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssetProfile {
|
||||
pub sector: Option<String>,
|
||||
pub industry: Option<String>,
|
||||
pub long_business_summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FloatValue {
|
||||
pub raw: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IntValue {
|
||||
pub raw: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UIntValue {
|
||||
pub raw: Option<u64>,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue