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