mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
refactor: schema-driven architecture, capability traits, hardened error paths
- Split parse.rs into raw_types.rs (serde structs) + map.rs (pure transforms) for MSN and Yahoo - Replace Yahoo fundamentals dynamic HashMap with typed structs (SummaryDetail, DefaultKeyStatistics, etc.) - Introduce capability-based provider traits: QuoteProvider, FundamentalsProvider, HistoryProvider - Add future MSN capability traits: ProfileProvider, EarningsProvider, FinancialsProvider, SentimentProvider, InsightsProvider, NewsProvider (all dead_code until wired to CLI) - Add shared domain types in src/api/types.rs (CompanyProfile, EarningsReport, FinancialStatements, SentimentData, InsightData, NewsItem) - Harden error propagation: Yahoo cookie auth, MSN partial fundamentals, history symbol context, cache clear failures - Strict config parsing: invalid IDX_OUTPUT returns ConfigError instead of silent fallback - Cache schema version enforcement: version mismatch treated as cache miss - Extract fetch_with_cache() helper in cli/stocks.rs - Add MSN retry/backoff parity with Yahoo client - Standardize Option<T> policy through parse/map layers - All 56 tests passing, clippy clean
This commit is contained in:
parent
a45ee3620f
commit
3998e38ffc
17 changed files with 1185 additions and 969 deletions
|
|
@ -4,17 +4,59 @@ pub mod yahoo;
|
|||
|
||||
use crate::config::ProviderKind;
|
||||
use crate::error::IdxError;
|
||||
use types::{Fundamentals, Interval, Ohlc, Period, Quote};
|
||||
use types::{
|
||||
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
|
||||
NewsItem, Period, Quote, SentimentData,
|
||||
};
|
||||
|
||||
pub trait MarketDataProvider {
|
||||
pub trait QuoteProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||
}
|
||||
|
||||
pub trait HistoryProvider {
|
||||
fn history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> Result<Vec<Ohlc>, IdxError>;
|
||||
) -> Result<Vec<Bar>, IdxError>;
|
||||
}
|
||||
|
||||
pub trait FundamentalsProvider {
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||
}
|
||||
|
||||
pub trait MarketDataProvider: QuoteProvider + FundamentalsProvider + HistoryProvider {}
|
||||
impl<T> MarketDataProvider for T where T: QuoteProvider + FundamentalsProvider + HistoryProvider {}
|
||||
|
||||
#[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>;
|
||||
}
|
||||
|
||||
pub fn resolve_symbol(symbol: &str, exchange: &str) -> String {
|
||||
|
|
@ -42,7 +84,7 @@ pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box<dyn Market
|
|||
pub struct MockProvider {
|
||||
quote: Result<Quote, IdxError>,
|
||||
fundamentals: Result<Fundamentals, IdxError>,
|
||||
history: Result<Vec<Ohlc>, IdxError>,
|
||||
history: Result<Vec<Bar>, IdxError>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
|
|
@ -69,7 +111,7 @@ impl MockProvider {
|
|||
.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(&history_raw)
|
||||
let history = yahoo::parse_history_from_str("BBCA.JK", &history_raw)
|
||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||
|
||||
Self {
|
||||
|
|
@ -111,23 +153,27 @@ impl MockProvider {
|
|||
}
|
||||
}
|
||||
|
||||
impl MarketDataProvider for MockProvider {
|
||||
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<Ohlc>, IdxError> {
|
||||
) -> Result<Vec<Bar>, IdxError> {
|
||||
self.history.clone()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use serde::de::DeserializeOwned;
|
|||
|
||||
use crate::error::IdxError;
|
||||
|
||||
use super::parse::{KeyRatios, MsnQuote};
|
||||
use super::raw_types::{KeyRatios, MsnQuote};
|
||||
use super::symbols::resolve_msn_id;
|
||||
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||
|
|
@ -34,6 +34,8 @@ impl MsnClient {
|
|||
symbol: &str,
|
||||
endpoint: &str,
|
||||
) -> Result<T, IdxError> {
|
||||
let mut wait = Duration::from_millis(500);
|
||||
for attempt in 0..3 {
|
||||
let response = self
|
||||
.agent
|
||||
.get(url)
|
||||
|
|
@ -45,14 +47,35 @@ impl MsnClient {
|
|||
.call();
|
||||
|
||||
match response {
|
||||
Ok(ok) => ok
|
||||
Ok(ok) => {
|
||||
return ok
|
||||
.into_body()
|
||||
.read_json::<T>()
|
||||
.map_err(|e| IdxError::ParseError(format!("msn {endpoint}: {e}"))),
|
||||
Err(ureq::Error::StatusCode(404)) => Err(IdxError::SymbolNotFound(symbol.to_string())),
|
||||
Err(ureq::Error::StatusCode(429)) => Err(IdxError::RateLimited),
|
||||
Err(err) => Err(IdxError::Http(format!("msn {endpoint}: {err}"))),
|
||||
.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> {
|
||||
|
|
|
|||
316
src/api/msn/map.rs
Normal file
316
src/api/msn/map.rs
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
|
||||
use super::raw_types::{IndustryMetric, KeyRatios, MsnChart, MsnQuote};
|
||||
use super::symbols::{normalized_symbol, ticker_from_symbol};
|
||||
use crate::api::types::{Fundamentals, Ohlc, Period, Quote};
|
||||
use crate::error::IdxError;
|
||||
|
||||
pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, IdxError> {
|
||||
let quote = quotes.first().ok_or(IdxError::ProviderUnavailable)?;
|
||||
let raw_price = quote
|
||||
.price
|
||||
.ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let prev_close = quote.price_previous_close.map(round_price);
|
||||
let price = round_price(raw_price);
|
||||
let change = prev_close
|
||||
.map(|previous| price - previous)
|
||||
.or_else(|| quote.price_change.map(round_price))
|
||||
.unwrap_or(0);
|
||||
|
||||
let ticker = quote
|
||||
.symbol
|
||||
.as_deref()
|
||||
.and_then(ticker_from_symbol)
|
||||
.unwrap_or_else(|| ticker_from_symbol(symbol).unwrap_or_default());
|
||||
|
||||
let (week52_position, range_signal) = match (quote.price_52w_low, quote.price_52w_high) {
|
||||
(Some(low), Some(high)) if high > low => {
|
||||
let position = (raw_price - low) / (high - low);
|
||||
let signal = if position > 0.66 {
|
||||
Some("upper".to_string())
|
||||
} else if position < 0.33 {
|
||||
Some("lower".to_string())
|
||||
} else {
|
||||
Some("middle".to_string())
|
||||
};
|
||||
(Some(position), signal)
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
Ok(Quote {
|
||||
symbol: normalized_symbol(symbol, &ticker),
|
||||
price,
|
||||
change,
|
||||
change_pct: quote.price_change_percent.unwrap_or(0.0),
|
||||
volume: round_u64(quote.accumulated_volume).unwrap_or(0),
|
||||
market_cap: round_u64(quote.market_cap),
|
||||
week52_high: quote.price_52w_high.map(round_price),
|
||||
week52_low: quote.price_52w_low.map(round_price),
|
||||
week52_position,
|
||||
range_signal,
|
||||
prev_close,
|
||||
avg_volume: round_u64(quote.average_volume),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parse_fundamentals(
|
||||
ratios: &[KeyRatios],
|
||||
quote: Option<&MsnQuote>,
|
||||
) -> Result<Fundamentals, IdxError> {
|
||||
let ratios = ratios.first().ok_or(IdxError::ProviderUnavailable)?;
|
||||
let metrics = if ratios.company_metrics.is_empty() {
|
||||
&ratios.industry_metrics
|
||||
} else {
|
||||
&ratios.company_metrics
|
||||
};
|
||||
if preferred_metric(metrics).is_none() {
|
||||
return Err(IdxError::ProviderUnavailable);
|
||||
}
|
||||
|
||||
Ok(Fundamentals {
|
||||
trailing_pe: best_metric_value(metrics, |metric| metric.price_to_earnings_ratio),
|
||||
forward_pe: best_metric_value(metrics, |metric| metric.forward_price_to_eps),
|
||||
price_to_book: best_metric_value(metrics, |metric| metric.price_to_book_ratio),
|
||||
return_on_equity: best_metric_value(metrics, |metric| normalize_percentish(metric.roe)),
|
||||
profit_margins: best_metric_value(metrics, |metric| {
|
||||
normalize_percentish(metric.profit_margin.or(metric.net_margin))
|
||||
}),
|
||||
return_on_assets: best_metric_value(metrics, |metric| {
|
||||
normalize_percentish(metric.roa_ttm.or(metric.return_on_asset_current))
|
||||
}),
|
||||
revenue_growth: best_metric_value(metrics, |metric| {
|
||||
normalize_percentish(metric.revenue_ytd_ytd.or(metric.revenue_growth_rate))
|
||||
}),
|
||||
earnings_growth: best_metric_value(metrics, |metric| {
|
||||
normalize_percentish(
|
||||
metric
|
||||
.net_income_ytd_ytd_growth_rate
|
||||
.or(metric.earnings_growth_rate),
|
||||
)
|
||||
}),
|
||||
debt_to_equity: best_metric_value(metrics, |metric| metric.debt_to_equity_ratio),
|
||||
current_ratio: best_metric_value(metrics, |metric| {
|
||||
sanitize_current_ratio(metric.current_ratio)
|
||||
}),
|
||||
enterprise_value: None,
|
||||
ebitda: None,
|
||||
market_cap: quote.and_then(|item| round_u64(item.market_cap)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parse_history(period: &Period, charts: &[MsnChart]) -> Result<Vec<Ohlc>, IdxError> {
|
||||
parse_history_with_drop_count(period, charts).map(|v| v.0)
|
||||
}
|
||||
|
||||
pub(super) fn parse_history_with_drop_count(
|
||||
period: &Period,
|
||||
charts: &[MsnChart],
|
||||
) -> Result<(Vec<Ohlc>, usize), IdxError> {
|
||||
let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?;
|
||||
|
||||
if !chart.series.has_real_ohlcv() {
|
||||
return Err(IdxError::ParseError(
|
||||
"msn does not expose real OHLC/volume for this history range".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let timestamps = &chart.series.time_stamps;
|
||||
|
||||
let mut grouped: BTreeMap<NaiveDate, Ohlc> = BTreeMap::new();
|
||||
let mut dropped = 0usize;
|
||||
|
||||
for (idx, raw_ts) in timestamps.iter().enumerate() {
|
||||
let Some(date) = parse_chart_date(raw_ts) else {
|
||||
dropped += 1;
|
||||
continue;
|
||||
};
|
||||
let point = (
|
||||
chart.series.open_prices.get(idx).copied(),
|
||||
chart.series.prices_high.get(idx).copied(),
|
||||
chart.series.prices_low.get(idx).copied(),
|
||||
chart.series.prices.get(idx).copied(),
|
||||
chart.series.volumes.get(idx).copied(),
|
||||
);
|
||||
|
||||
let (Some(open), Some(high), Some(low), Some(close), Some(volume)) = point else {
|
||||
dropped += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let candle = Ohlc {
|
||||
date,
|
||||
open: round_price(open),
|
||||
high: round_price(high),
|
||||
low: round_price(low),
|
||||
close: round_price(close),
|
||||
volume: round_u64(Some(volume)).unwrap_or(0),
|
||||
};
|
||||
|
||||
grouped
|
||||
.entry(date)
|
||||
.and_modify(|existing| {
|
||||
existing.high = existing.high.max(candle.high);
|
||||
existing.low = existing.low.min(candle.low);
|
||||
existing.close = candle.close;
|
||||
existing.volume = existing.volume.saturating_add(candle.volume);
|
||||
})
|
||||
.or_insert(candle);
|
||||
}
|
||||
|
||||
let mut out: Vec<Ohlc> = grouped.into_values().collect();
|
||||
trim_history_to_period(period, &mut out);
|
||||
|
||||
if out.is_empty() {
|
||||
return Err(IdxError::ProviderUnavailable);
|
||||
}
|
||||
|
||||
Ok((out, dropped))
|
||||
}
|
||||
|
||||
fn trim_history_to_period(period: &Period, rows: &mut Vec<Ohlc>) {
|
||||
let days: i64 = match period {
|
||||
Period::OneDay => return,
|
||||
Period::FiveDays => 5,
|
||||
Period::OneMonth => 31,
|
||||
Period::ThreeMonths => 92,
|
||||
Period::SixMonths => 183,
|
||||
Period::OneYear => 366,
|
||||
Period::TwoYears => 731,
|
||||
Period::FiveYears => 1826,
|
||||
};
|
||||
|
||||
let Some(last_date) = rows.last().map(|item| item.date) else {
|
||||
return;
|
||||
};
|
||||
let cutoff = last_date - chrono::Duration::days(days.saturating_sub(1));
|
||||
rows.retain(|item| item.date >= cutoff);
|
||||
}
|
||||
|
||||
fn preferred_metric(metrics: &[IndustryMetric]) -> Option<&IndustryMetric> {
|
||||
metrics.iter().max_by_key(|metric| metric_rank(metric))
|
||||
}
|
||||
|
||||
fn best_metric_value<T: Copy>(
|
||||
metrics: &[IndustryMetric],
|
||||
extractor: impl Fn(&IndustryMetric) -> Option<T>,
|
||||
) -> Option<T> {
|
||||
metrics
|
||||
.iter()
|
||||
.filter_map(|metric| extractor(metric).map(|value| (metric_rank(metric), value)))
|
||||
.max_by_key(|(rank, _)| *rank)
|
||||
.map(|(_, value)| value)
|
||||
}
|
||||
|
||||
fn metric_rank(metric: &IndustryMetric) -> (i32, i32) {
|
||||
(
|
||||
metric
|
||||
.year
|
||||
.as_deref()
|
||||
.and_then(|year| year.parse::<i32>().ok())
|
||||
.unwrap_or(i32::MIN),
|
||||
metric_period_priority(metric.fiscal_period_type.as_deref()),
|
||||
)
|
||||
}
|
||||
|
||||
fn metric_period_priority(period: Option<&str>) -> i32 {
|
||||
match period.map(|value| value.trim()) {
|
||||
Some(value) if value.eq_ignore_ascii_case("TTM") => 7,
|
||||
Some(value)
|
||||
if value.eq_ignore_ascii_case("ANNUAL")
|
||||
|| value.eq_ignore_ascii_case("FY")
|
||||
|| value.eq_ignore_ascii_case("YEAR") =>
|
||||
{
|
||||
6
|
||||
}
|
||||
Some(value) if value.eq_ignore_ascii_case("Q4") => 5,
|
||||
Some(value) if value.eq_ignore_ascii_case("Q3") => 4,
|
||||
Some(value) if value.eq_ignore_ascii_case("Q2") => 3,
|
||||
Some(value) if value.eq_ignore_ascii_case("Q1") => 2,
|
||||
Some(value) if value.eq_ignore_ascii_case("NTM") => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_percentish(value: Option<f64>) -> Option<f64> {
|
||||
value.and_then(|number| {
|
||||
if !number.is_finite() {
|
||||
None
|
||||
} else if number.abs() > 1.0 {
|
||||
Some(number / 100.0)
|
||||
} else {
|
||||
Some(number)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_current_ratio(value: Option<f64>) -> Option<f64> {
|
||||
value.and_then(|number| {
|
||||
if !number.is_finite() || number < 0.01 {
|
||||
None
|
||||
} else {
|
||||
Some(number)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_chart_date(raw: &str) -> Option<NaiveDate> {
|
||||
if let Ok(date) = chrono::DateTime::parse_from_rfc3339(raw) {
|
||||
return Some(date.date_naive());
|
||||
}
|
||||
if let Ok(timestamp) = raw.parse::<i64>() {
|
||||
return chrono::DateTime::from_timestamp(timestamp, 0).map(|dt| dt.date_naive());
|
||||
}
|
||||
NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()
|
||||
}
|
||||
|
||||
fn round_price(value: f64) -> i64 {
|
||||
value.round() as i64
|
||||
}
|
||||
|
||||
fn round_u64(value: Option<f64>) -> Option<u64> {
|
||||
value.and_then(|number| {
|
||||
if !number.is_finite() || number.is_sign_negative() {
|
||||
None
|
||||
} else {
|
||||
Some(number.round() as u64)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum ResampleInterval {
|
||||
Week,
|
||||
Month,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn resample_history(rows: &[Ohlc], interval: ResampleInterval) -> Vec<Ohlc> {
|
||||
let mut grouped: BTreeMap<(i32, u32), Ohlc> = BTreeMap::new();
|
||||
|
||||
for row in rows {
|
||||
let key = match interval {
|
||||
ResampleInterval::Week => {
|
||||
let iso = row.date.iso_week();
|
||||
(iso.year(), iso.week())
|
||||
}
|
||||
ResampleInterval::Month => (row.date.year(), row.date.month()),
|
||||
};
|
||||
|
||||
grouped
|
||||
.entry(key)
|
||||
.and_modify(|existing| {
|
||||
existing.high = existing.high.max(row.high);
|
||||
existing.low = existing.low.min(row.low);
|
||||
existing.close = row.close;
|
||||
existing.volume = existing.volume.saturating_add(row.volume);
|
||||
existing.date = row.date;
|
||||
})
|
||||
.or_insert_with(|| row.clone());
|
||||
}
|
||||
|
||||
grouped.into_values().collect()
|
||||
}
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
mod client;
|
||||
mod map;
|
||||
mod parse;
|
||||
mod raw_types;
|
||||
mod symbols;
|
||||
|
||||
use crate::api::MarketDataProvider;
|
||||
use crate::api::types::{Fundamentals, Interval, Ohlc, Period, Quote};
|
||||
use crate::api::types::{Bar, Fundamentals, Interval, Period, Quote};
|
||||
use crate::api::{FundamentalsProvider, HistoryProvider, QuoteProvider};
|
||||
use crate::error::IdxError;
|
||||
|
||||
use client::MsnClient;
|
||||
use parse::{parse_fundamentals, parse_quote};
|
||||
use map::{parse_fundamentals, parse_quote};
|
||||
|
||||
pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
|
||||
|
||||
|
|
@ -15,46 +17,38 @@ const HISTORY_UNSUPPORTED_REASON: &str = "MSN provider does not currently suppor
|
|||
|
||||
pub struct MsnProvider {
|
||||
client: MsnClient,
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
impl MsnProvider {
|
||||
pub fn new(verbose: bool) -> Self {
|
||||
pub fn new(_verbose: bool) -> Self {
|
||||
Self {
|
||||
client: MsnClient::new(),
|
||||
verbose,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarketDataProvider for MsnProvider {
|
||||
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)
|
||||
.map_err(|e| {
|
||||
if self.verbose {
|
||||
eprintln!("warning: quote fetch for fundamentals failed: {e}");
|
||||
let quote = self.client.fetch_quotes(symbol)?;
|
||||
parse_fundamentals(&ratios, quote.first())
|
||||
}
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
.and_then(|quotes| quotes.into_iter().next());
|
||||
parse_fundamentals(&ratios, quote.as_ref())
|
||||
}
|
||||
|
||||
impl HistoryProvider for MsnProvider {
|
||||
fn history(
|
||||
&self,
|
||||
_symbol: &str,
|
||||
_period: &Period,
|
||||
_interval: &Interval,
|
||||
) -> Result<Vec<Ohlc>, IdxError> {
|
||||
) -> Result<Vec<Bar>, IdxError> {
|
||||
Err(IdxError::Unsupported(
|
||||
HISTORY_UNSUPPORTED_REASON.to_string(),
|
||||
))
|
||||
|
|
@ -64,7 +58,7 @@ impl MarketDataProvider for MsnProvider {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MsnProvider;
|
||||
use crate::api::MarketDataProvider;
|
||||
use crate::api::HistoryProvider;
|
||||
use crate::api::types::{Interval, Period};
|
||||
use crate::error::IdxError;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
use std::collections::BTreeMap;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
use super::symbols::{normalized_symbol, ticker_from_symbol};
|
||||
use super::map::{parse_fundamentals, parse_history, parse_history_with_drop_count, parse_quote};
|
||||
use super::raw_types::{KeyRatios, MsnChart, MsnQuote};
|
||||
use crate::api::types::{Fundamentals, Ohlc, Period, Quote};
|
||||
use crate::error::IdxError;
|
||||
|
||||
|
|
@ -15,55 +12,6 @@ pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result<Quote, Idx
|
|||
parse_quote(symbol, "es)
|
||||
}
|
||||
|
||||
pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, IdxError> {
|
||||
let quote = quotes.first().ok_or(IdxError::ProviderUnavailable)?;
|
||||
let raw_price = quote
|
||||
.price
|
||||
.ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let prev_close = quote.price_previous_close.map(round_price);
|
||||
let price = round_price(raw_price);
|
||||
let change = prev_close
|
||||
.map(|previous| price - previous)
|
||||
.or_else(|| quote.price_change.map(round_price))
|
||||
.unwrap_or(0);
|
||||
|
||||
let ticker = quote
|
||||
.symbol
|
||||
.as_deref()
|
||||
.and_then(ticker_from_symbol)
|
||||
.unwrap_or_else(|| ticker_from_symbol(symbol).unwrap_or_default());
|
||||
|
||||
let (week52_position, range_signal) = match (quote.price_52w_low, quote.price_52w_high) {
|
||||
(Some(low), Some(high)) if high > low => {
|
||||
let position = (raw_price - low) / (high - low);
|
||||
let signal = if position > 0.66 {
|
||||
Some("upper".to_string())
|
||||
} else if position < 0.33 {
|
||||
Some("lower".to_string())
|
||||
} else {
|
||||
Some("middle".to_string())
|
||||
};
|
||||
(Some(position), signal)
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
Ok(Quote {
|
||||
symbol: normalized_symbol(symbol, &ticker),
|
||||
price,
|
||||
change,
|
||||
change_pct: quote.price_change_percent.unwrap_or(0.0),
|
||||
volume: round_u64(quote.accumulated_volume).unwrap_or(0),
|
||||
market_cap: round_u64(quote.market_cap),
|
||||
week52_high: quote.price_52w_high.map(round_price),
|
||||
week52_low: quote.price_52w_low.map(round_price),
|
||||
week52_position,
|
||||
range_signal,
|
||||
prev_close,
|
||||
avg_volume: round_u64(quote.average_volume),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn parse_fundamentals_from_str(
|
||||
raw: &str,
|
||||
|
|
@ -79,56 +27,24 @@ pub(crate) fn parse_fundamentals_from_str(
|
|||
parse_fundamentals(&ratios, quote.as_ref())
|
||||
}
|
||||
|
||||
pub(super) fn parse_fundamentals(
|
||||
ratios: &[KeyRatios],
|
||||
quote: Option<&MsnQuote>,
|
||||
) -> Result<Fundamentals, IdxError> {
|
||||
let ratios = ratios.first().ok_or(IdxError::ProviderUnavailable)?;
|
||||
let metrics = if ratios.company_metrics.is_empty() {
|
||||
&ratios.industry_metrics
|
||||
} else {
|
||||
&ratios.company_metrics
|
||||
};
|
||||
if preferred_metric(metrics).is_none() {
|
||||
return Err(IdxError::ProviderUnavailable);
|
||||
}
|
||||
|
||||
Ok(Fundamentals {
|
||||
trailing_pe: best_metric_value(metrics, |metric| metric.price_to_earnings_ratio),
|
||||
forward_pe: best_metric_value(metrics, |metric| metric.forward_price_to_eps),
|
||||
price_to_book: best_metric_value(metrics, |metric| metric.price_to_book_ratio),
|
||||
return_on_equity: best_metric_value(metrics, |metric| normalize_percentish(metric.roe)),
|
||||
profit_margins: best_metric_value(metrics, |metric| {
|
||||
normalize_percentish(metric.profit_margin.or(metric.net_margin))
|
||||
}),
|
||||
return_on_assets: best_metric_value(metrics, |metric| {
|
||||
normalize_percentish(metric.roa_ttm.or(metric.return_on_asset_current))
|
||||
}),
|
||||
revenue_growth: best_metric_value(metrics, |metric| {
|
||||
normalize_percentish(metric.revenue_ytd_ytd.or(metric.revenue_growth_rate))
|
||||
}),
|
||||
earnings_growth: best_metric_value(metrics, |metric| {
|
||||
normalize_percentish(
|
||||
metric
|
||||
.net_income_ytd_ytd_growth_rate
|
||||
.or(metric.earnings_growth_rate),
|
||||
)
|
||||
}),
|
||||
debt_to_equity: best_metric_value(metrics, |metric| metric.debt_to_equity_ratio),
|
||||
current_ratio: best_metric_value(metrics, |metric| {
|
||||
sanitize_current_ratio(metric.current_ratio)
|
||||
}),
|
||||
enterprise_value: None,
|
||||
ebitda: None,
|
||||
market_cap: quote.and_then(|item| round_u64(item.market_cap)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn parse_history_from_str(period: &Period, raw: &str) -> Result<Vec<Ohlc>, IdxError> {
|
||||
let charts: Vec<MsnChart> =
|
||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
parse_history_with_verbose(period, &charts, false)
|
||||
parse_history(period, &charts)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // retained for verbose history path, wired once MSN charts are exposed
|
||||
pub(super) fn parse_history_with_verbose(
|
||||
period: &Period,
|
||||
charts: &[MsnChart],
|
||||
verbose: bool,
|
||||
) -> Result<Vec<Ohlc>, IdxError> {
|
||||
let (history, dropped) = parse_history_with_drop_count(period, charts)?;
|
||||
if dropped > 0 && verbose {
|
||||
eprintln!("warning: dropped {dropped} OHLC row(s) from MSN response due to missing fields");
|
||||
}
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
|
@ -141,86 +57,27 @@ fn parse_close_only_history_from_str(
|
|||
parse_close_only_history(period, &charts)
|
||||
}
|
||||
|
||||
pub(super) fn parse_history_with_verbose(
|
||||
period: &Period,
|
||||
charts: &[MsnChart],
|
||||
verbose: bool,
|
||||
) -> Result<Vec<Ohlc>, IdxError> {
|
||||
let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?;
|
||||
|
||||
if !chart.series.has_real_ohlcv() {
|
||||
return Err(IdxError::ParseError(
|
||||
"msn does not expose real OHLC/volume for this history range".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let timestamps = &chart.series.time_stamps;
|
||||
|
||||
let mut grouped: BTreeMap<NaiveDate, Ohlc> = BTreeMap::new();
|
||||
let mut dropped = 0usize;
|
||||
|
||||
for (idx, raw_ts) in timestamps.iter().enumerate() {
|
||||
let Some(date) = parse_chart_date(raw_ts) else {
|
||||
dropped += 1;
|
||||
continue;
|
||||
};
|
||||
let point = (
|
||||
chart.series.open_prices.get(idx).copied(),
|
||||
chart.series.prices_high.get(idx).copied(),
|
||||
chart.series.prices_low.get(idx).copied(),
|
||||
chart.series.prices.get(idx).copied(),
|
||||
chart.series.volumes.get(idx).copied(),
|
||||
);
|
||||
|
||||
let (Some(open), Some(high), Some(low), Some(close), Some(volume)) = point else {
|
||||
dropped += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let candle = Ohlc {
|
||||
date,
|
||||
open: round_price(open),
|
||||
high: round_price(high),
|
||||
low: round_price(low),
|
||||
close: round_price(close),
|
||||
volume: round_u64(Some(volume)).unwrap_or(0),
|
||||
};
|
||||
|
||||
grouped
|
||||
.entry(date)
|
||||
.and_modify(|existing| {
|
||||
existing.high = existing.high.max(candle.high);
|
||||
existing.low = existing.low.min(candle.low);
|
||||
existing.close = candle.close;
|
||||
existing.volume = existing.volume.saturating_add(candle.volume);
|
||||
})
|
||||
.or_insert(candle);
|
||||
}
|
||||
|
||||
let mut out: Vec<Ohlc> = grouped.into_values().collect();
|
||||
trim_history_to_period(period, &mut out);
|
||||
|
||||
if dropped > 0 && verbose {
|
||||
eprintln!("warning: dropped {dropped} OHLC row(s) from MSN response due to missing fields");
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
return Err(IdxError::ProviderUnavailable);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_close_only_history(
|
||||
period: &Period,
|
||||
charts: &[MsnChart],
|
||||
) -> Result<Vec<ClosePoint>, IdxError> {
|
||||
let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?;
|
||||
let timestamps = &chart.series.time_stamps;
|
||||
let mut grouped: BTreeMap<NaiveDate, ClosePoint> = BTreeMap::new();
|
||||
let mut grouped: std::collections::BTreeMap<NaiveDate, ClosePoint> =
|
||||
std::collections::BTreeMap::new();
|
||||
|
||||
for (idx, raw_ts) in timestamps.iter().enumerate() {
|
||||
let Some(date) = parse_chart_date(raw_ts) else {
|
||||
let Some(date) = chrono::DateTime::parse_from_rfc3339(raw_ts)
|
||||
.map(|d| d.date_naive())
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
raw_ts
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0).map(|d| d.date_naive()))
|
||||
})
|
||||
.or_else(|| NaiveDate::parse_from_str(raw_ts, "%Y-%m-%d").ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(close) = chart.series.prices.get(idx).copied() else {
|
||||
|
|
@ -231,7 +88,7 @@ fn parse_close_only_history(
|
|||
date,
|
||||
ClosePoint {
|
||||
date,
|
||||
close: round_price(close),
|
||||
close: close.round() as i64,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -246,25 +103,6 @@ fn parse_close_only_history(
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
fn trim_history_to_period(period: &Period, rows: &mut Vec<Ohlc>) {
|
||||
let days: i64 = match period {
|
||||
Period::OneDay => return,
|
||||
Period::FiveDays => 5,
|
||||
Period::OneMonth => 31,
|
||||
Period::ThreeMonths => 92,
|
||||
Period::SixMonths => 183,
|
||||
Period::OneYear => 366,
|
||||
Period::TwoYears => 731,
|
||||
Period::FiveYears => 1826,
|
||||
};
|
||||
|
||||
let Some(last_date) = rows.last().map(|item| item.date) else {
|
||||
return;
|
||||
};
|
||||
let cutoff = last_date - chrono::Duration::days(days.saturating_sub(1));
|
||||
rows.retain(|item| item.date >= cutoff);
|
||||
}
|
||||
|
||||
fn trim_close_history_to_period(period: &Period, rows: &mut Vec<ClosePoint>) {
|
||||
let days: i64 = match period {
|
||||
Period::OneDay => return,
|
||||
|
|
@ -284,260 +122,6 @@ fn trim_close_history_to_period(period: &Period, rows: &mut Vec<ClosePoint>) {
|
|||
rows.retain(|item| item.date >= cutoff);
|
||||
}
|
||||
|
||||
fn preferred_metric(metrics: &[IndustryMetric]) -> Option<&IndustryMetric> {
|
||||
metrics.iter().max_by_key(|metric| metric_rank(metric))
|
||||
}
|
||||
|
||||
fn best_metric_value<T: Copy>(
|
||||
metrics: &[IndustryMetric],
|
||||
extractor: impl Fn(&IndustryMetric) -> Option<T>,
|
||||
) -> Option<T> {
|
||||
metrics
|
||||
.iter()
|
||||
.filter_map(|metric| extractor(metric).map(|value| (metric_rank(metric), value)))
|
||||
.max_by_key(|(rank, _)| *rank)
|
||||
.map(|(_, value)| value)
|
||||
}
|
||||
|
||||
fn metric_rank(metric: &IndustryMetric) -> (i32, i32) {
|
||||
(
|
||||
metric
|
||||
.year
|
||||
.as_deref()
|
||||
.and_then(|year| year.parse::<i32>().ok())
|
||||
.unwrap_or(i32::MIN),
|
||||
metric_period_priority(metric.fiscal_period_type.as_deref()),
|
||||
)
|
||||
}
|
||||
|
||||
fn metric_period_priority(period: Option<&str>) -> i32 {
|
||||
match period.map(|value| value.trim()) {
|
||||
Some(value) if value.eq_ignore_ascii_case("TTM") => 7,
|
||||
Some(value)
|
||||
if value.eq_ignore_ascii_case("ANNUAL")
|
||||
|| value.eq_ignore_ascii_case("FY")
|
||||
|| value.eq_ignore_ascii_case("YEAR") =>
|
||||
{
|
||||
6
|
||||
}
|
||||
Some(value) if value.eq_ignore_ascii_case("Q4") => 5,
|
||||
Some(value) if value.eq_ignore_ascii_case("Q3") => 4,
|
||||
Some(value) if value.eq_ignore_ascii_case("Q2") => 3,
|
||||
Some(value) if value.eq_ignore_ascii_case("Q1") => 2,
|
||||
Some(value) if value.eq_ignore_ascii_case("NTM") => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_percentish(value: Option<f64>) -> Option<f64> {
|
||||
value.and_then(|number| {
|
||||
if !number.is_finite() {
|
||||
None
|
||||
} else if number.abs() > 1.0 {
|
||||
Some(number / 100.0)
|
||||
} else {
|
||||
Some(number)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_current_ratio(value: Option<f64>) -> Option<f64> {
|
||||
value.and_then(|number| {
|
||||
if !number.is_finite() || number < 0.01 {
|
||||
None
|
||||
} else {
|
||||
Some(number)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum ResampleInterval {
|
||||
Week,
|
||||
Month,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn resample_history(rows: &[Ohlc], interval: ResampleInterval) -> Vec<Ohlc> {
|
||||
let mut grouped: BTreeMap<(i32, u32), Ohlc> = BTreeMap::new();
|
||||
|
||||
for row in rows {
|
||||
let key = match interval {
|
||||
ResampleInterval::Week => {
|
||||
let iso = row.date.iso_week();
|
||||
(iso.year(), iso.week())
|
||||
}
|
||||
ResampleInterval::Month => (row.date.year(), row.date.month()),
|
||||
};
|
||||
|
||||
grouped
|
||||
.entry(key)
|
||||
.and_modify(|existing| {
|
||||
existing.high = existing.high.max(row.high);
|
||||
existing.low = existing.low.min(row.low);
|
||||
existing.close = row.close;
|
||||
existing.volume = existing.volume.saturating_add(row.volume);
|
||||
existing.date = row.date;
|
||||
})
|
||||
.or_insert_with(|| row.clone());
|
||||
}
|
||||
|
||||
grouped.into_values().collect()
|
||||
}
|
||||
|
||||
fn parse_chart_date(raw: &str) -> Option<NaiveDate> {
|
||||
if let Ok(date) = chrono::DateTime::parse_from_rfc3339(raw) {
|
||||
return Some(date.date_naive());
|
||||
}
|
||||
if let Ok(timestamp) = raw.parse::<i64>() {
|
||||
return chrono::DateTime::from_timestamp(timestamp, 0).map(|dt| dt.date_naive());
|
||||
}
|
||||
NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()
|
||||
}
|
||||
|
||||
fn round_price(value: f64) -> i64 {
|
||||
value.round() as i64
|
||||
}
|
||||
|
||||
fn round_u64(value: Option<f64>) -> Option<u64> {
|
||||
value.and_then(|number| {
|
||||
if !number.is_finite() || number.is_sign_negative() {
|
||||
None
|
||||
} else {
|
||||
Some(number.round() as u64)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn de_opt_f64_lenient<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum NumberLike {
|
||||
F64(f64),
|
||||
String(String),
|
||||
}
|
||||
|
||||
let value = Option::<NumberLike>::deserialize(deserializer)?;
|
||||
match value {
|
||||
Some(NumberLike::F64(number)) if number.is_finite() => Ok(Some(number)),
|
||||
Some(NumberLike::F64(_)) => Ok(None),
|
||||
Some(NumberLike::String(raw)) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("nan") {
|
||||
Ok(None)
|
||||
} else {
|
||||
trimmed.parse::<f64>().map(Some).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MsnQuote {
|
||||
#[serde(default)]
|
||||
symbol: Option<String>,
|
||||
price: Option<f64>,
|
||||
#[serde(default)]
|
||||
price_change: Option<f64>,
|
||||
#[serde(default)]
|
||||
price_change_percent: Option<f64>,
|
||||
#[serde(default)]
|
||||
price_previous_close: Option<f64>,
|
||||
#[serde(default, rename = "price52wHigh")]
|
||||
price_52w_high: Option<f64>,
|
||||
#[serde(default, rename = "price52wLow")]
|
||||
price_52w_low: Option<f64>,
|
||||
#[serde(default)]
|
||||
accumulated_volume: Option<f64>,
|
||||
#[serde(default)]
|
||||
average_volume: Option<f64>,
|
||||
#[serde(default)]
|
||||
market_cap: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct KeyRatios {
|
||||
#[serde(default)]
|
||||
industry_metrics: Vec<IndustryMetric>,
|
||||
#[serde(default)]
|
||||
company_metrics: Vec<IndustryMetric>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct IndustryMetric {
|
||||
year: Option<String>,
|
||||
fiscal_period_type: Option<String>,
|
||||
#[serde(default)]
|
||||
revenue_growth_rate: Option<f64>,
|
||||
#[serde(default)]
|
||||
earnings_growth_rate: Option<f64>,
|
||||
#[serde(default, rename = "netIncomeYTDYTDGrowthRate")]
|
||||
net_income_ytd_ytd_growth_rate: Option<f64>,
|
||||
#[serde(default, rename = "revenueYTDYTD")]
|
||||
revenue_ytd_ytd: Option<f64>,
|
||||
#[serde(default)]
|
||||
net_margin: Option<f64>,
|
||||
#[serde(default)]
|
||||
profit_margin: Option<f64>,
|
||||
#[serde(default)]
|
||||
roe: Option<f64>,
|
||||
#[serde(default, rename = "roaTTM")]
|
||||
roa_ttm: Option<f64>,
|
||||
#[serde(default)]
|
||||
return_on_asset_current: Option<f64>,
|
||||
#[serde(default)]
|
||||
debt_to_equity_ratio: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
current_ratio: Option<f64>,
|
||||
#[serde(default)]
|
||||
price_to_earnings_ratio: Option<f64>,
|
||||
#[serde(default, rename = "forwardPriceToEPS")]
|
||||
forward_price_to_eps: Option<f64>,
|
||||
#[serde(default)]
|
||||
price_to_book_ratio: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MsnChart {
|
||||
series: ChartSeries,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChartSeries {
|
||||
#[serde(default)]
|
||||
time_stamps: Vec<String>,
|
||||
#[serde(default)]
|
||||
prices: Vec<f64>,
|
||||
#[serde(default)]
|
||||
open_prices: Vec<f64>,
|
||||
#[serde(default)]
|
||||
prices_high: Vec<f64>,
|
||||
#[serde(default)]
|
||||
prices_low: Vec<f64>,
|
||||
#[serde(default)]
|
||||
volumes: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ChartSeries {
|
||||
fn has_real_ohlcv(&self) -> bool {
|
||||
!self.time_stamps.is_empty()
|
||||
&& self.open_prices.len() == self.time_stamps.len()
|
||||
&& self.prices_high.len() == self.time_stamps.len()
|
||||
&& self.prices_low.len() == self.time_stamps.len()
|
||||
&& self.prices.len() == self.time_stamps.len()
|
||||
&& self.volumes.len() == self.time_stamps.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct ClosePoint {
|
||||
date: NaiveDate,
|
||||
|
|
@ -547,8 +131,8 @@ struct ClosePoint {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ResampleInterval, parse_close_only_history_from_str, parse_fundamentals_from_str,
|
||||
parse_history_from_str, parse_quote_from_str, resample_history,
|
||||
parse_close_only_history_from_str, parse_fundamentals_from_str, parse_history_from_str,
|
||||
parse_quote_from_str,
|
||||
};
|
||||
use crate::api::types::{Ohlc, Period};
|
||||
|
||||
|
|
@ -657,83 +241,12 @@ mod tests {
|
|||
},
|
||||
];
|
||||
|
||||
let weekly = resample_history(&rows, ResampleInterval::Week);
|
||||
let weekly =
|
||||
super::super::map::resample_history(&rows, super::super::map::ResampleInterval::Week);
|
||||
assert_eq!(weekly.len(), 2);
|
||||
assert_eq!(weekly[0].open, 100);
|
||||
assert_eq!(weekly[0].close, 109);
|
||||
assert_eq!(weekly[0].volume, 21);
|
||||
assert_eq!(weekly[1].close, 114);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_live_style_percent_metrics() {
|
||||
let raw = r#"[
|
||||
{
|
||||
"industryMetrics": [
|
||||
{
|
||||
"year": "2025",
|
||||
"fiscalPeriodType": "Q1",
|
||||
"revenueGrowthRate": 9.584679119559473,
|
||||
"earningsGrowthRate": 28.793562408178182,
|
||||
"netMargin": 35.05868669243578,
|
||||
"roe": 16.27117054525313,
|
||||
"returnOnAssetCurrent": 2.5707368150889867,
|
||||
"debtToEquityRatio": 32.80253090283387,
|
||||
"currentRatio": 9.38775908812586E-06,
|
||||
"priceToEarningsRatio": 21.331183408517173,
|
||||
"priceToBookRatio": 3.0625539678152234
|
||||
},
|
||||
{
|
||||
"year": "2025",
|
||||
"fiscalPeriodType": "TTM",
|
||||
"revenueYTDYTD": 0.0481563350951302,
|
||||
"netIncomeYTDYTDGrowthRate": 0.0492553610240516,
|
||||
"profitMargin": 0.504190105842766,
|
||||
"roe": 0.211493,
|
||||
"roaTTM": 3.7919,
|
||||
"priceToEarningsRatio": 17.296683642049683,
|
||||
"priceToSalesRatio": 7.6652108104296985,
|
||||
"priceToBookRatio": 3.107795874896335
|
||||
},
|
||||
{
|
||||
"year": "2025",
|
||||
"fiscalPeriodType": "NTM",
|
||||
"forwardPriceToEPS": 14.723
|
||||
}
|
||||
],
|
||||
"companyMetrics": [
|
||||
{
|
||||
"year": "2025",
|
||||
"fiscalPeriodType": "TTM",
|
||||
"revenueYTDYTD": 0.0481563350951302,
|
||||
"netIncomeYTDYTDGrowthRate": 0.0492553610240516,
|
||||
"profitMargin": 0.504190105842766,
|
||||
"roe": 0.211493,
|
||||
"roaTTM": 3.7919,
|
||||
"priceToEarningsRatio": 17.296683642049683,
|
||||
"priceToBookRatio": 3.107795874896335
|
||||
},
|
||||
{
|
||||
"year": "2025",
|
||||
"fiscalPeriodType": "NTM",
|
||||
"forwardPriceToEPS": 14.723
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
let quote_raw = r#"[{"symbol":"BBCA","marketCap":866500400000000.0}]"#;
|
||||
|
||||
let fundamentals =
|
||||
parse_fundamentals_from_str(raw, Some(quote_raw)).expect("fundamentals parsed");
|
||||
assert_eq!(fundamentals.trailing_pe, Some(17.296683642049683));
|
||||
assert_eq!(fundamentals.forward_pe, Some(14.723));
|
||||
assert_eq!(fundamentals.price_to_book, Some(3.107795874896335));
|
||||
assert_eq!(fundamentals.return_on_equity, Some(0.211493));
|
||||
assert_eq!(fundamentals.profit_margins, Some(0.504190105842766));
|
||||
assert_eq!(fundamentals.return_on_assets, Some(0.037919));
|
||||
assert_eq!(fundamentals.revenue_growth, Some(0.0481563350951302));
|
||||
assert_eq!(fundamentals.earnings_growth, Some(0.0492553610240516));
|
||||
assert_eq!(fundamentals.debt_to_equity, None);
|
||||
assert_eq!(fundamentals.current_ratio, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
src/api/msn/raw_types.rs
Normal file
130
src/api/msn/raw_types.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MsnQuote {
|
||||
#[serde(default)]
|
||||
pub(crate) symbol: 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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct KeyRatios {
|
||||
#[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)]
|
||||
pub(crate) revenue_growth_rate: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) earnings_growth_rate: Option<f64>,
|
||||
#[serde(default, rename = "netIncomeYTDYTDGrowthRate")]
|
||||
pub(crate) net_income_ytd_ytd_growth_rate: Option<f64>,
|
||||
#[serde(default, rename = "revenueYTDYTD")]
|
||||
pub(crate) revenue_ytd_ytd: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) net_margin: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) profit_margin: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) roe: Option<f64>,
|
||||
#[serde(default, rename = "roaTTM")]
|
||||
pub(crate) roa_ttm: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) return_on_asset_current: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) debt_to_equity_ratio: Option<f64>,
|
||||
#[serde(default, deserialize_with = "de_opt_f64_lenient")]
|
||||
pub(crate) current_ratio: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) price_to_earnings_ratio: Option<f64>,
|
||||
#[serde(default, rename = "forwardPriceToEPS")]
|
||||
pub(crate) forward_price_to_eps: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) price_to_book_ratio: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MsnChart {
|
||||
pub(crate) series: ChartSeries,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ChartSeries {
|
||||
#[serde(default)]
|
||||
pub(crate) time_stamps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) prices: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) open_prices: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) prices_high: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) prices_low: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) volumes: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ChartSeries {
|
||||
pub(crate) fn has_real_ohlcv(&self) -> bool {
|
||||
!self.time_stamps.is_empty()
|
||||
&& self.open_prices.len() == self.time_stamps.len()
|
||||
&& self.prices_high.len() == self.time_stamps.len()
|
||||
&& self.prices_low.len() == self.time_stamps.len()
|
||||
&& self.prices.len() == self.time_stamps.len()
|
||||
&& self.volumes.len() == self.time_stamps.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn de_opt_f64_lenient<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum NumberLike {
|
||||
F64(f64),
|
||||
String(String),
|
||||
}
|
||||
|
||||
let value = Option::<NumberLike>::deserialize(deserializer)?;
|
||||
match value {
|
||||
Some(NumberLike::F64(number)) if number.is_finite() => Ok(Some(number)),
|
||||
Some(NumberLike::F64(_)) => Ok(None),
|
||||
Some(NumberLike::String(raw)) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("nan") {
|
||||
Ok(None)
|
||||
} else {
|
||||
trimmed.parse::<f64>().map(Some).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
119
src/api/types.rs
119
src/api/types.rs
|
|
@ -82,6 +82,125 @@ pub struct Fundamentals {
|
|||
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::HashMap<String, f64>,
|
||||
pub currency: String,
|
||||
pub report_date: String,
|
||||
pub end_date: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EarningsReport {
|
||||
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,
|
||||
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,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::time::Duration;
|
|||
use crate::api::types::{Interval, Period};
|
||||
use crate::error::IdxError;
|
||||
|
||||
use super::parse::{ChartResponse, QuoteSummaryResponse};
|
||||
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";
|
||||
|
|
@ -42,7 +42,7 @@ impl YahooClient {
|
|||
|
||||
fn quote_summary_url(symbol: &str, crumb: &str) -> String {
|
||||
format!(
|
||||
"{BASE_URL}/v10/finance/quoteSummary/{symbol}?modules=defaultKeyStatistics,financialData,incomeStatementHistory&crumb={crumb}"
|
||||
"{BASE_URL}/v10/finance/quoteSummary/{symbol}?modules=summaryDetail,defaultKeyStatistics,financialData,assetProfile,incomeStatementHistory&crumb={crumb}"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -262,8 +262,15 @@ impl YahooClient {
|
|||
) -> Result<QuoteSummaryResponse, IdxError> {
|
||||
for auth_attempt in 0..2 {
|
||||
let crumb = self.get_or_init_crumb()?;
|
||||
let cookie_header =
|
||||
Self::cookie_header_from_jar(&Self::cookie_jar_path()).unwrap_or_default();
|
||||
let cookie_header = match Self::cookie_header_from_jar(&Self::cookie_jar_path()) {
|
||||
Ok(header) => header,
|
||||
Err(err) => {
|
||||
eprintln!("warning: 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 mut wait = Duration::from_millis(250);
|
||||
|
||||
|
|
|
|||
209
src/api/yahoo/map.rs
Normal file
209
src/api/yahoo/map.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
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_or(0, |p| price - p);
|
||||
let change_pct = raw_prev_close.map_or(0.0, |p| {
|
||||
if p != 0.0 {
|
||||
((raw_price - p) / p) * 100.0
|
||||
} else {
|
||||
0.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.unwrap_or(0),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
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.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
|
||||
))
|
||||
}
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
mod client;
|
||||
mod map;
|
||||
mod parse;
|
||||
mod raw_types;
|
||||
|
||||
use crate::api::MarketDataProvider;
|
||||
use crate::api::types::{Fundamentals, Interval, Ohlc, Period, Quote};
|
||||
use crate::api::types::{Bar, Fundamentals, Interval, Period, Quote};
|
||||
use crate::api::{FundamentalsProvider, HistoryProvider, QuoteProvider};
|
||||
use crate::error::IdxError;
|
||||
|
||||
use client::YahooClient;
|
||||
use parse::{parse_fundamentals, parse_history_with_verbose, parse_quote};
|
||||
use map::{parse_fundamentals, parse_quote};
|
||||
use parse::parse_history_with_verbose;
|
||||
|
||||
pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
|
||||
|
||||
|
|
@ -24,26 +27,30 @@ impl YahooProvider {
|
|||
}
|
||||
}
|
||||
|
||||
impl MarketDataProvider for YahooProvider {
|
||||
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<Ohlc>, IdxError> {
|
||||
) -> Result<Vec<Bar>, IdxError> {
|
||||
let chart = self.client.fetch_chart(symbol, period, interval)?;
|
||||
parse_history_with_verbose(&chart, self.verbose)
|
||||
parse_history_with_verbose(symbol, &chart, self.verbose)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,83 +1,19 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
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()))?;
|
||||
if let Some(err) = chart.chart.error.as_ref() {
|
||||
return Err(map_yahoo_error(symbol, "chart", err));
|
||||
}
|
||||
parse_quote(symbol, &chart)
|
||||
}
|
||||
|
||||
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_or(0, |p| price - p);
|
||||
let change_pct = raw_prev_close.map_or(0.0, |p| {
|
||||
if p != 0.0 {
|
||||
((raw_price - p) / p) * 100.0
|
||||
} else {
|
||||
0.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.unwrap_or(0),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_history_from_str(raw: &str) -> Result<Vec<Ohlc>, IdxError> {
|
||||
pub(crate) fn parse_history_from_str(symbol: &str, raw: &str) -> Result<Vec<Ohlc>, IdxError> {
|
||||
let chart: ChartResponse =
|
||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
parse_history_with_verbose(&chart, false)
|
||||
parse_history_with_verbose(symbol, &chart, false)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_fundamentals_from_str(
|
||||
|
|
@ -86,327 +22,21 @@ pub(crate) fn parse_fundamentals_from_str(
|
|||
) -> Result<Fundamentals, IdxError> {
|
||||
let quote_summary: QuoteSummaryResponse =
|
||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
if let Some(err) = quote_summary.quote_summary.error.as_ref() {
|
||||
return Err(map_yahoo_error(symbol, "quoteSummary", err));
|
||||
}
|
||||
parse_fundamentals(symbol, "e_summary)
|
||||
}
|
||||
|
||||
pub(super) fn parse_history_with_verbose(
|
||||
symbol: &str,
|
||||
chart: &ChartResponse,
|
||||
verbose: bool,
|
||||
) -> Result<Vec<Ohlc>, IdxError> {
|
||||
if let Some(err) = chart.chart.error.as_ref() {
|
||||
return Err(map_yahoo_error("unknown", "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)?;
|
||||
|
||||
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.date_naive(),
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume,
|
||||
});
|
||||
} else {
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let (history, dropped) = parse_history(symbol, chart)?;
|
||||
if dropped > 0 && verbose {
|
||||
eprintln!(
|
||||
"warning: dropped {dropped} OHLC row(s) from Yahoo response due to missing fields"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
Ok(Fundamentals {
|
||||
trailing_pe: result
|
||||
.default_key_statistics
|
||||
.get_f64("trailingPE")
|
||||
.or_else(|| result.financial_data.get_f64("trailingPE")),
|
||||
forward_pe: result
|
||||
.default_key_statistics
|
||||
.get_f64("forwardPE")
|
||||
.or_else(|| result.financial_data.get_f64("forwardPE")),
|
||||
price_to_book: result
|
||||
.default_key_statistics
|
||||
.get_f64("priceToBook")
|
||||
.or_else(|| result.financial_data.get_f64("priceToBook")),
|
||||
return_on_equity: result.financial_data.get_f64("returnOnEquity"),
|
||||
profit_margins: result.financial_data.get_f64("profitMargins"),
|
||||
return_on_assets: result.financial_data.get_f64("returnOnAssets"),
|
||||
revenue_growth: result.financial_data.get_f64("revenueGrowth"),
|
||||
earnings_growth: result
|
||||
.default_key_statistics
|
||||
.get_f64("earningsGrowth")
|
||||
.or_else(|| result.financial_data.get_f64("earningsGrowth")),
|
||||
debt_to_equity: result.financial_data.get_f64("debtToEquity"),
|
||||
current_ratio: result.financial_data.get_f64("currentRatio"),
|
||||
enterprise_value: result
|
||||
.default_key_statistics
|
||||
.get_i64("enterpriseValue")
|
||||
.or_else(|| result.financial_data.get_i64("enterpriseValue")),
|
||||
ebitda: result
|
||||
.financial_data
|
||||
.get_i64("ebitda")
|
||||
.or_else(|| result.default_key_statistics.get_i64("ebitda")),
|
||||
market_cap: result
|
||||
.financial_data
|
||||
.get_u64("marketCap")
|
||||
.or_else(|| result.default_key_statistics.get_u64("marketCap")),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ChartResponse {
|
||||
chart: ChartRoot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct QuoteSummaryResponse {
|
||||
quote_summary: QuoteSummaryRoot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct QuoteSummaryRoot {
|
||||
result: Option<Vec<QuoteSummaryResult>>,
|
||||
error: Option<ChartError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct QuoteSummaryResult {
|
||||
#[serde(default)]
|
||||
default_key_statistics: QuoteSummarySection,
|
||||
#[serde(default)]
|
||||
financial_data: QuoteSummarySection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ChartRoot {
|
||||
result: Option<Vec<ChartResult>>,
|
||||
error: Option<ChartError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ChartError {
|
||||
code: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ChartResult {
|
||||
meta: Option<ChartMeta>,
|
||||
timestamp: Option<Vec<i64>>,
|
||||
indicators: Option<Indicators>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(dead_code)]
|
||||
pub(super) struct ChartMeta {
|
||||
symbol: Option<String>,
|
||||
regular_market_price: Option<f64>,
|
||||
previous_close: Option<f64>,
|
||||
chart_previous_close: Option<f64>,
|
||||
regular_market_volume: Option<u64>,
|
||||
regular_market_day_high: Option<f64>,
|
||||
regular_market_day_low: Option<f64>,
|
||||
market_cap: Option<u64>,
|
||||
fifty_two_week_high: Option<f64>,
|
||||
fifty_two_week_low: Option<f64>,
|
||||
#[serde(rename = "averageDailyVolume3Month")]
|
||||
average_daily_volume_3month: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct Indicators {
|
||||
quote: Option<Vec<IndicatorQuote>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct IndicatorQuote {
|
||||
open: Option<Vec<Option<f64>>>,
|
||||
high: Option<Vec<Option<f64>>>,
|
||||
low: Option<Vec<Option<f64>>>,
|
||||
close: Option<Vec<Option<f64>>>,
|
||||
volume: Option<Vec<Option<u64>>>,
|
||||
}
|
||||
|
||||
type QuoteSummarySection = HashMap<String, QuoteSummaryValue>;
|
||||
|
||||
trait QuoteSummarySectionExt {
|
||||
fn get_f64(&self, key: &str) -> Option<f64>;
|
||||
fn get_i64(&self, key: &str) -> Option<i64>;
|
||||
fn get_u64(&self, key: &str) -> Option<u64>;
|
||||
}
|
||||
|
||||
impl QuoteSummarySectionExt for QuoteSummarySection {
|
||||
fn get_f64(&self, key: &str) -> Option<f64> {
|
||||
self.get(key).and_then(QuoteSummaryValue::as_f64)
|
||||
}
|
||||
|
||||
fn get_i64(&self, key: &str) -> Option<i64> {
|
||||
self.get(key).and_then(QuoteSummaryValue::as_i64)
|
||||
}
|
||||
|
||||
fn get_u64(&self, key: &str) -> Option<u64> {
|
||||
self.get(key).and_then(QuoteSummaryValue::as_u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
#[allow(dead_code)]
|
||||
enum QuoteSummaryValue {
|
||||
Wrapped { raw: Option<YahooNumber> },
|
||||
Direct(YahooNumber),
|
||||
// Catch-all for empty objects {}, null, strings, booleans; return None for numeric extractions.
|
||||
Unknown(serde_json::Value),
|
||||
}
|
||||
|
||||
impl QuoteSummaryValue {
|
||||
fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
Self::Wrapped { raw } => raw.as_ref().map(YahooNumber::as_f64),
|
||||
Self::Direct(value) => Some(value.as_f64()),
|
||||
Self::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_i64(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::Wrapped { raw } => raw.as_ref().and_then(YahooNumber::as_i64),
|
||||
Self::Direct(value) => value.as_i64(),
|
||||
Self::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_u64(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Wrapped { raw } => raw.as_ref().and_then(YahooNumber::as_u64),
|
||||
Self::Direct(value) => value.as_u64(),
|
||||
Self::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum YahooNumber {
|
||||
I64(i64),
|
||||
U64(u64),
|
||||
F64(f64),
|
||||
}
|
||||
|
||||
impl YahooNumber {
|
||||
fn as_f64(&self) -> f64 {
|
||||
match self {
|
||||
Self::I64(value) => *value as f64,
|
||||
Self::U64(value) => *value as f64,
|
||||
Self::F64(value) => *value,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_i64(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::I64(value) => Some(*value),
|
||||
Self::U64(value) => i64::try_from(*value).ok(),
|
||||
Self::F64(value) => Some(value.round() as i64),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_u64(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::I64(value) => u64::try_from(*value).ok(),
|
||||
Self::U64(value) => Some(*value),
|
||||
Self::F64(value) if value.is_sign_negative() => None,
|
||||
Self::F64(value) => Some(value.round() as u64),
|
||||
}
|
||||
}
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -447,7 +77,7 @@ mod tests {
|
|||
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(&chart, false).expect("history parsed");
|
||||
let history = parse_history_with_verbose("BBCA.JK", &chart, false).expect("history parsed");
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history[0].close, 9875);
|
||||
}
|
||||
|
|
@ -466,7 +96,8 @@ mod tests {
|
|||
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(&history_raw).expect("fixture history parsed");
|
||||
let history =
|
||||
parse_history_from_str("BBCA.JK", &history_raw).expect("fixture history parsed");
|
||||
assert!(!history.is_empty());
|
||||
|
||||
let fundamentals = parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw)
|
||||
|
|
|
|||
156
src/api/yahoo/raw_types.rs
Normal file
156
src/api/yahoo/raw_types.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// 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>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
33
src/cache.rs
33
src/cache.rs
|
|
@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::error::IdxError;
|
||||
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
const CURRENT_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cache {
|
||||
|
|
@ -83,7 +83,7 @@ impl Cache {
|
|||
let entry = CacheEntry {
|
||||
fetched_at: Utc::now(),
|
||||
ttl_secs,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
schema_version: CURRENT_SCHEMA_VERSION,
|
||||
data,
|
||||
};
|
||||
let raw = serde_json::to_string_pretty(&entry)
|
||||
|
|
@ -124,17 +124,21 @@ impl Cache {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn clear(&self) -> Result<usize, IdxError> {
|
||||
pub fn clear(&self) -> Result<(usize, Vec<PathBuf>), IdxError> {
|
||||
if !self.root.exists() {
|
||||
return Ok(0);
|
||||
return Ok((0, Vec::new()));
|
||||
}
|
||||
let mut removed = 0usize;
|
||||
let mut failed = Vec::new();
|
||||
self.walk(&self.root, &mut |p| {
|
||||
if p.is_file() && fs::remove_file(p).is_ok() {
|
||||
removed += 1;
|
||||
if p.is_file() {
|
||||
match fs::remove_file(p) {
|
||||
Ok(_) => removed += 1,
|
||||
Err(_) => failed.push(p.to_path_buf()),
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(removed)
|
||||
Ok((removed, failed))
|
||||
}
|
||||
|
||||
fn walk<F: FnMut(&Path)>(&self, dir: &Path, f: &mut F) -> Result<(), IdxError> {
|
||||
|
|
@ -159,8 +163,19 @@ impl Cache {
|
|||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read_to_string(path).map_err(|e| IdxError::Io(e.to_string()))?;
|
||||
let entry = serde_json::from_str(&raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
|
||||
let entry: CacheEntry<T> =
|
||||
serde_json::from_str(&raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
if entry.schema_version != CURRENT_SCHEMA_VERSION {
|
||||
eprintln!(
|
||||
"debug: cache schema mismatch for {} (got {}, expected {})",
|
||||
path.display(),
|
||||
entry.schema_version,
|
||||
CURRENT_SCHEMA_VERSION
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,8 +40,11 @@ pub fn handle(cmd: &CacheCmd) -> Result<(), IdxError> {
|
|||
);
|
||||
}
|
||||
CacheSubcommand::Clear => {
|
||||
let removed = cache.clear()?;
|
||||
let (removed, failed) = cache.clear()?;
|
||||
println!("cleared {removed} files");
|
||||
if !failed.is_empty() {
|
||||
eprintln!("warning: failed to remove {} file(s)", failed.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -345,6 +345,40 @@ pub fn handle(
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // wired up once per-subcommand handlers are fully split
|
||||
pub(crate) fn fetch_with_cache<T, F>(
|
||||
cache: &Cache,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
ttl_secs: u64,
|
||||
offline: bool,
|
||||
no_cache: bool,
|
||||
fetch_fn: F,
|
||||
) -> Result<T, IdxError>
|
||||
where
|
||||
T: Serialize + DeserializeOwned,
|
||||
F: FnOnce() -> Result<T, IdxError>,
|
||||
{
|
||||
if !no_cache
|
||||
&& !offline
|
||||
&& let Some(cached) = cache.get::<T>(bucket, key)?
|
||||
{
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
if offline {
|
||||
return cache
|
||||
.get_stale::<T>(bucket, key)?
|
||||
.ok_or_else(|| IdxError::Offline("no cached data available".to_string()));
|
||||
}
|
||||
|
||||
let data = fetch_fn()?;
|
||||
if !no_cache {
|
||||
let _ = cache.put(bucket, key, &data, ttl_secs);
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn fetch_fundamental_analysis_report<T, F>(
|
||||
cache: &Cache,
|
||||
provider: &dyn MarketDataProvider,
|
||||
|
|
|
|||
|
|
@ -92,8 +92,13 @@ impl IdxConfig {
|
|||
if let Ok(output) = std::env::var("IDX_OUTPUT") {
|
||||
cfg.output = if output.eq_ignore_ascii_case("json") {
|
||||
OutputFormat::Json
|
||||
} else {
|
||||
} else if output.eq_ignore_ascii_case("table") {
|
||||
OutputFormat::Table
|
||||
} else {
|
||||
return Err(IdxError::ConfigError(format!(
|
||||
"invalid IDX_OUTPUT value: '{}', expected 'json' or 'table'",
|
||||
output
|
||||
)));
|
||||
};
|
||||
}
|
||||
if let Ok(no_color) = std::env::var("IDX_NO_COLOR") {
|
||||
|
|
|
|||
|
|
@ -16,12 +16,16 @@ pub enum IdxError {
|
|||
ParseError(String),
|
||||
#[error("cache miss: {0}")]
|
||||
CacheMiss(String),
|
||||
#[error("offline: {0}")]
|
||||
Offline(String),
|
||||
#[error("config error: {0}")]
|
||||
ConfigError(String),
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
#[error("http error: {0}")]
|
||||
Http(String),
|
||||
#[error("auth error: {0}")]
|
||||
AuthError(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
|
|
@ -32,9 +36,11 @@ pub enum ErrorCode {
|
|||
Unsupported,
|
||||
ParseError,
|
||||
CacheMiss,
|
||||
Offline,
|
||||
ConfigError,
|
||||
Io,
|
||||
Http,
|
||||
AuthError,
|
||||
}
|
||||
|
||||
impl IdxError {
|
||||
|
|
@ -46,9 +52,11 @@ impl IdxError {
|
|||
Self::Unsupported(_) => ErrorCode::Unsupported,
|
||||
Self::ParseError(_) => ErrorCode::ParseError,
|
||||
Self::CacheMiss(_) => ErrorCode::CacheMiss,
|
||||
Self::Offline(_) => ErrorCode::Offline,
|
||||
Self::ConfigError(_) => ErrorCode::ConfigError,
|
||||
Self::Io(_) => ErrorCode::Io,
|
||||
Self::Http(_) => ErrorCode::Http,
|
||||
Self::AuthError(_) => ErrorCode::AuthError,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue