mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
Fix Yahoo schema handling, integer IDR prices, and resilience
This commit is contained in:
parent
dc1de5b344
commit
f3cefaaaf4
7 changed files with 237 additions and 54 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -367,6 +367,12 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
|
|
@ -576,6 +582,7 @@ dependencies = [
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"comfy-table",
|
"comfy-table",
|
||||||
"directories",
|
"directories",
|
||||||
|
"fastrand",
|
||||||
"owo-colors",
|
"owo-colors",
|
||||||
"predicates",
|
"predicates",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ toml = "0.8"
|
||||||
directories = "5"
|
directories = "5"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
fastrand = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,11 @@ pub fn resolve_symbol(symbol: &str, exchange: &str) -> String {
|
||||||
format!("{trimmed}.{}", exchange.trim().to_uppercase())
|
format!("{trimmed}.{}", exchange.trim().to_uppercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_provider() -> Box<dyn MarketDataProvider> {
|
pub fn default_provider(verbose: bool) -> Box<dyn MarketDataProvider> {
|
||||||
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
|
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
|
||||||
Box::new(MockProvider::from_fixtures())
|
Box::new(MockProvider::from_fixtures())
|
||||||
} else {
|
} else {
|
||||||
Box::new(yahoo::YahooProvider::new())
|
Box::new(yahoo::YahooProvider::new(verbose))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
114
src/api/types.rs
114
src/api/types.rs
|
|
@ -1,33 +1,125 @@
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
|
||||||
|
|
||||||
|
/// Snapshot quote data normalized from Yahoo Finance `/v8/finance/chart` response.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Quote {
|
pub struct Quote {
|
||||||
|
/// Trading symbol as returned by Yahoo `chart.result[0].meta.symbol`.
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub price: f64,
|
/// Last traded regular market price in IDR (whole Rupiah), mapped from
|
||||||
pub change: f64,
|
/// `chart.result[0].meta.regularMarketPrice` and rounded to nearest integer.
|
||||||
|
#[serde(deserialize_with = "de_i64_from_number")]
|
||||||
|
pub price: i64,
|
||||||
|
/// Absolute day change in IDR (whole Rupiah), computed as
|
||||||
|
/// `regularMarketPrice - previousClose` using rounded integer prices.
|
||||||
|
#[serde(deserialize_with = "de_i64_from_number")]
|
||||||
|
pub change: i64,
|
||||||
|
/// Percentage day change as decimal percent (`0-100` scale), computed from
|
||||||
|
/// Yahoo `regularMarketPrice` and `previousClose` raw floats.
|
||||||
pub change_pct: f64,
|
pub change_pct: f64,
|
||||||
|
/// Traded regular market volume (shares), from `regularMarketVolume`.
|
||||||
pub volume: u64,
|
pub volume: u64,
|
||||||
pub market_cap: Option<f64>,
|
/// Company market capitalization in IDR, from `marketCap`.
|
||||||
pub week52_high: Option<f64>,
|
#[serde(default, deserialize_with = "de_opt_u64_from_number")]
|
||||||
pub week52_low: Option<f64>,
|
pub market_cap: Option<u64>,
|
||||||
|
/// 52-week high in IDR (whole Rupiah), from `fiftyTwoWeekHigh` rounded.
|
||||||
|
#[serde(default, deserialize_with = "de_opt_i64_from_number")]
|
||||||
|
pub week52_high: Option<i64>,
|
||||||
|
/// 52-week low in IDR (whole Rupiah), from `fiftyTwoWeekLow` rounded.
|
||||||
|
#[serde(default, deserialize_with = "de_opt_i64_from_number")]
|
||||||
|
pub week52_low: Option<i64>,
|
||||||
|
/// Relative position within 52-week range (`0.0..=1.0`), computed from raw
|
||||||
|
/// Yahoo `fiftyTwoWeekLow` and `fiftyTwoWeekHigh`.
|
||||||
pub week52_position: Option<f64>,
|
pub week52_position: Option<f64>,
|
||||||
|
/// Coarse 52-week range bucket derived from `week52_position`.
|
||||||
pub range_signal: Option<String>,
|
pub range_signal: Option<String>,
|
||||||
pub prev_close: Option<f64>,
|
/// Previous close in IDR (whole Rupiah), from
|
||||||
|
/// `previousClose` or `chartPreviousClose`, rounded.
|
||||||
|
#[serde(default, deserialize_with = "de_opt_i64_from_number")]
|
||||||
|
pub prev_close: Option<i64>,
|
||||||
|
/// Average daily volume for the last 3 months (shares), from
|
||||||
|
/// `averageDailyVolume3Month`.
|
||||||
pub avg_volume: Option<u64>,
|
pub avg_volume: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Ohlc {
|
pub struct Ohlc {
|
||||||
|
/// Candle date (exchange-local day boundary from Yahoo timestamp).
|
||||||
pub date: NaiveDate,
|
pub date: NaiveDate,
|
||||||
pub open: f64,
|
/// Opening price in IDR (whole Rupiah), from `indicators.quote[0].open` rounded.
|
||||||
pub high: f64,
|
#[serde(deserialize_with = "de_i64_from_number")]
|
||||||
pub low: f64,
|
pub open: i64,
|
||||||
pub close: f64,
|
/// Highest traded price in IDR (whole Rupiah), from `indicators.quote[0].high` rounded.
|
||||||
|
#[serde(deserialize_with = "de_i64_from_number")]
|
||||||
|
pub high: i64,
|
||||||
|
/// Lowest traded price in IDR (whole Rupiah), from `indicators.quote[0].low` rounded.
|
||||||
|
#[serde(deserialize_with = "de_i64_from_number")]
|
||||||
|
pub low: i64,
|
||||||
|
/// Closing price in IDR (whole Rupiah), from `indicators.quote[0].close` rounded.
|
||||||
|
#[serde(deserialize_with = "de_i64_from_number")]
|
||||||
|
pub close: i64,
|
||||||
|
/// Traded volume (shares), from `indicators.quote[0].volume`.
|
||||||
pub volume: u64,
|
pub volume: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum NumberLike {
|
||||||
|
I64(i64),
|
||||||
|
U64(u64),
|
||||||
|
F64(f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn de_i64_from_number<'de, D>(deserializer: D) -> Result<i64, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = NumberLike::deserialize(deserializer)?;
|
||||||
|
Ok(match value {
|
||||||
|
NumberLike::I64(v) => v,
|
||||||
|
NumberLike::U64(v) => i64::try_from(v).map_err(D::Error::custom)?,
|
||||||
|
NumberLike::F64(v) => v.round() as i64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn de_opt_i64_from_number<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Option::<NumberLike>::deserialize(deserializer).and_then(|v| {
|
||||||
|
v.map(|n| match n {
|
||||||
|
NumberLike::I64(x) => Ok(x),
|
||||||
|
NumberLike::U64(x) => i64::try_from(x).map_err(D::Error::custom),
|
||||||
|
NumberLike::F64(x) => Ok(x.round() as i64),
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn de_opt_u64_from_number<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Option::<NumberLike>::deserialize(deserializer).and_then(|v| {
|
||||||
|
v.map(|n| match n {
|
||||||
|
NumberLike::I64(x) => u64::try_from(x).map_err(D::Error::custom),
|
||||||
|
NumberLike::U64(x) => Ok(x),
|
||||||
|
NumberLike::F64(x) => {
|
||||||
|
if x.is_sign_negative() {
|
||||||
|
Err(D::Error::custom(
|
||||||
|
"negative value cannot be converted to u64",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(x.round() as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ValueEnum)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ValueEnum)]
|
||||||
pub enum Period {
|
pub enum Period {
|
||||||
#[value(name = "1d")]
|
#[value(name = "1d")]
|
||||||
|
|
|
||||||
140
src/api/yahoo.rs
140
src/api/yahoo.rs
|
|
@ -12,13 +12,18 @@ const BASE_URL: &str = "https://query2.finance.yahoo.com";
|
||||||
|
|
||||||
pub struct YahooProvider {
|
pub struct YahooProvider {
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
|
verbose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl YahooProvider {
|
impl YahooProvider {
|
||||||
pub fn new() -> Self {
|
pub fn new(verbose: bool) -> Self {
|
||||||
Self {
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||||
agent: ureq::Agent::new_with_defaults(),
|
.timeout_connect(Some(Duration::from_secs(5)))
|
||||||
}
|
.timeout_recv_body(Some(Duration::from_secs(10)))
|
||||||
|
.build()
|
||||||
|
.into();
|
||||||
|
|
||||||
|
Self { agent, verbose }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chart_url(symbol: &str, period: &Period, interval: &Interval) -> String {
|
fn chart_url(symbol: &str, period: &Period, interval: &Interval) -> String {
|
||||||
|
|
@ -41,10 +46,14 @@ impl YahooProvider {
|
||||||
let response = self.agent.get(&url).header("User-Agent", USER_AGENT).call();
|
let response = self.agent.get(&url).header("User-Agent", USER_AGENT).call();
|
||||||
match response {
|
match response {
|
||||||
Ok(ok) => {
|
Ok(ok) => {
|
||||||
return ok
|
let chart = ok
|
||||||
.into_body()
|
.into_body()
|
||||||
.read_json::<ChartResponse>()
|
.read_json::<ChartResponse>()
|
||||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
.map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||||
|
if let Some(err) = chart.chart.error.as_ref() {
|
||||||
|
return Err(map_chart_error(symbol, err));
|
||||||
|
}
|
||||||
|
return Ok(chart);
|
||||||
}
|
}
|
||||||
Err(ureq::Error::StatusCode(429)) => {
|
Err(ureq::Error::StatusCode(429)) => {
|
||||||
if attempt < 2 {
|
if attempt < 2 {
|
||||||
|
|
@ -52,6 +61,9 @@ impl YahooProvider {
|
||||||
wait *= 2;
|
wait *= 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(ureq::Error::StatusCode(404)) => {
|
||||||
|
return Err(IdxError::SymbolNotFound(symbol.to_string()));
|
||||||
|
}
|
||||||
Err(e) => return Err(IdxError::Http(e.to_string())),
|
Err(e) => return Err(IdxError::Http(e.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,11 +72,23 @@ impl YahooProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jitter() -> Duration {
|
fn jitter() -> Duration {
|
||||||
let millis = (std::time::SystemTime::now()
|
Duration::from_millis(fastrand::u64(0..100))
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
}
|
||||||
.map(|d| d.subsec_millis() % 100)
|
|
||||||
.unwrap_or(42)) as u64;
|
fn round_price(value: f64) -> i64 {
|
||||||
Duration::from_millis(millis)
|
value.round() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
// verbose behavior is configured on YahooProvider and threaded into history parsing.
|
||||||
|
|
||||||
|
fn map_chart_error(symbol: &str, err: &ChartError) -> IdxError {
|
||||||
|
if err.code.eq_ignore_ascii_case("Not Found") {
|
||||||
|
return IdxError::SymbolNotFound(symbol.to_string());
|
||||||
|
}
|
||||||
|
IdxError::Http(format!(
|
||||||
|
"yahoo chart error {}: {}",
|
||||||
|
err.code, err.description
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MarketDataProvider for YahooProvider {
|
impl MarketDataProvider for YahooProvider {
|
||||||
|
|
@ -80,17 +104,24 @@ impl MarketDataProvider for YahooProvider {
|
||||||
interval: &Interval,
|
interval: &Interval,
|
||||||
) -> Result<Vec<Ohlc>, IdxError> {
|
) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
let chart = self.fetch_chart(symbol, period, interval)?;
|
let chart = self.fetch_chart(symbol, period, interval)?;
|
||||||
parse_history(&chart)
|
parse_history_with_verbose(&chart, self.verbose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result<Quote, IdxError> {
|
pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result<Quote, IdxError> {
|
||||||
let chart: ChartResponse =
|
let chart: ChartResponse =
|
||||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
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_chart_error(symbol, err));
|
||||||
|
}
|
||||||
parse_quote(symbol, &chart)
|
parse_quote(symbol, &chart)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||||
|
if let Some(err) = chart.chart.error.as_ref() {
|
||||||
|
return Err(map_chart_error(symbol, err));
|
||||||
|
}
|
||||||
|
|
||||||
let result = chart
|
let result = chart
|
||||||
.chart
|
.chart
|
||||||
.result
|
.result
|
||||||
|
|
@ -98,17 +129,26 @@ fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||||
.and_then(|r| r.first())
|
.and_then(|r| r.first())
|
||||||
.ok_or(IdxError::ProviderUnavailable)?;
|
.ok_or(IdxError::ProviderUnavailable)?;
|
||||||
let meta = result.meta.as_ref().ok_or(IdxError::ProviderUnavailable)?;
|
let meta = result.meta.as_ref().ok_or(IdxError::ProviderUnavailable)?;
|
||||||
let price = meta
|
let raw_price = meta
|
||||||
.regular_market_price
|
.regular_market_price
|
||||||
.ok_or(IdxError::SymbolNotFound(symbol.to_string()))?;
|
.ok_or(IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||||
let prev_close = meta.previous_close.or(meta.chart_previous_close);
|
let raw_prev_close = meta.previous_close.or(meta.chart_previous_close);
|
||||||
let change = prev_close.map_or(0.0, |p| price - p);
|
|
||||||
let change_pct = prev_close.map_or(0.0, |p| if p != 0.0 { (change / p) * 100.0 } else { 0.0 });
|
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)
|
let (week52_position, range_signal) = match (meta.fifty_two_week_low, meta.fifty_two_week_high)
|
||||||
{
|
{
|
||||||
(Some(low), Some(high)) if high > low => {
|
(Some(low), Some(high)) if high > low => {
|
||||||
let pos = (price - low) / (high - low);
|
let pos = (raw_price - low) / (high - low);
|
||||||
let signal = if pos > 0.66 {
|
let signal = if pos > 0.66 {
|
||||||
"upper"
|
"upper"
|
||||||
} else if pos < 0.33 {
|
} else if pos < 0.33 {
|
||||||
|
|
@ -128,8 +168,8 @@ fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||||
change_pct,
|
change_pct,
|
||||||
volume: meta.regular_market_volume.unwrap_or(0),
|
volume: meta.regular_market_volume.unwrap_or(0),
|
||||||
market_cap: meta.market_cap,
|
market_cap: meta.market_cap,
|
||||||
week52_high: meta.fifty_two_week_high,
|
week52_high: meta.fifty_two_week_high.map(round_price),
|
||||||
week52_low: meta.fifty_two_week_low,
|
week52_low: meta.fifty_two_week_low.map(round_price),
|
||||||
week52_position,
|
week52_position,
|
||||||
range_signal,
|
range_signal,
|
||||||
prev_close,
|
prev_close,
|
||||||
|
|
@ -140,10 +180,14 @@ fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||||
pub(crate) fn parse_history_from_str(raw: &str) -> Result<Vec<Ohlc>, IdxError> {
|
pub(crate) fn parse_history_from_str(raw: &str) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
let chart: ChartResponse =
|
let chart: ChartResponse =
|
||||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||||
parse_history(&chart)
|
parse_history_with_verbose(&chart, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_history(chart: &ChartResponse) -> Result<Vec<Ohlc>, IdxError> {
|
fn parse_history_with_verbose(chart: &ChartResponse, verbose: bool) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
if let Some(err) = chart.chart.error.as_ref() {
|
||||||
|
return Err(map_chart_error("unknown", err));
|
||||||
|
}
|
||||||
|
|
||||||
let result = chart
|
let result = chart
|
||||||
.chart
|
.chart
|
||||||
.result
|
.result
|
||||||
|
|
@ -162,20 +206,28 @@ fn parse_history(chart: &ChartResponse) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
.ok_or(IdxError::ProviderUnavailable)?;
|
.ok_or(IdxError::ProviderUnavailable)?;
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
let mut dropped = 0usize;
|
||||||
for (i, ts) in timestamps.iter().enumerate() {
|
for (i, ts) in timestamps.iter().enumerate() {
|
||||||
let open = quote
|
let open = quote
|
||||||
.open
|
.open
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|v| v.get(i).copied().flatten());
|
.and_then(|v| v.get(i).copied().flatten())
|
||||||
|
.map(round_price);
|
||||||
let high = quote
|
let high = quote
|
||||||
.high
|
.high
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|v| v.get(i).copied().flatten());
|
.and_then(|v| v.get(i).copied().flatten())
|
||||||
let low = quote.low.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
|
let close = quote
|
||||||
.close
|
.close
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|v| v.get(i).copied().flatten());
|
.and_then(|v| v.get(i).copied().flatten())
|
||||||
|
.map(round_price);
|
||||||
let volume = quote
|
let volume = quote
|
||||||
.volume
|
.volume
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -193,8 +245,17 @@ fn parse_history(chart: &ChartResponse) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
close,
|
close,
|
||||||
volume,
|
volume,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
dropped += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dropped > 0 && verbose {
|
||||||
|
eprintln!(
|
||||||
|
"warning: dropped {dropped} OHLC row(s) from Yahoo response due to missing fields"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,6 +267,14 @@ struct ChartResponse {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ChartRoot {
|
struct ChartRoot {
|
||||||
result: Option<Vec<ChartResult>>,
|
result: Option<Vec<ChartResult>>,
|
||||||
|
error: Option<ChartError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ChartError {
|
||||||
|
code: String,
|
||||||
|
description: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -226,9 +295,10 @@ struct ChartMeta {
|
||||||
regular_market_volume: Option<u64>,
|
regular_market_volume: Option<u64>,
|
||||||
regular_market_day_high: Option<f64>,
|
regular_market_day_high: Option<f64>,
|
||||||
regular_market_day_low: Option<f64>,
|
regular_market_day_low: Option<f64>,
|
||||||
market_cap: Option<f64>,
|
market_cap: Option<u64>,
|
||||||
fifty_two_week_high: Option<f64>,
|
fifty_two_week_high: Option<f64>,
|
||||||
fifty_two_week_low: Option<f64>,
|
fifty_two_week_low: Option<f64>,
|
||||||
|
#[serde(rename = "averageDailyVolume3Month")]
|
||||||
average_daily_volume_3month: Option<u64>,
|
average_daily_volume_3month: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,7 +319,8 @@ struct IndicatorQuote {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ChartResponse, parse_history, parse_history_from_str, parse_quote, parse_quote_from_str,
|
ChartResponse, parse_history_from_str, parse_history_with_verbose, parse_quote,
|
||||||
|
parse_quote_from_str,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SAMPLE: &str = r#"{
|
const SAMPLE: &str = r#"{
|
||||||
|
|
@ -282,10 +353,10 @@ mod tests {
|
||||||
let chart: ChartResponse = serde_json::from_str(SAMPLE).expect("valid chart fixture");
|
let chart: ChartResponse = serde_json::from_str(SAMPLE).expect("valid chart fixture");
|
||||||
let quote = parse_quote("BBCA.JK", &chart).expect("quote parsed");
|
let quote = parse_quote("BBCA.JK", &chart).expect("quote parsed");
|
||||||
assert_eq!(quote.symbol, "BBCA.JK");
|
assert_eq!(quote.symbol, "BBCA.JK");
|
||||||
assert_eq!(quote.price, 9875.0);
|
assert_eq!(quote.price, 9875);
|
||||||
let history = parse_history(&chart).expect("history parsed");
|
let history = parse_history_with_verbose(&chart, false).expect("history parsed");
|
||||||
assert_eq!(history.len(), 2);
|
assert_eq!(history.len(), 2);
|
||||||
assert_eq!(history[0].close, 9875.0);
|
assert_eq!(history[0].close, 9875);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -297,8 +368,17 @@ mod tests {
|
||||||
|
|
||||||
let quote = parse_quote_from_str("BBCA.JK", "e_raw).expect("fixture quote parsed");
|
let quote = parse_quote_from_str("BBCA.JK", "e_raw).expect("fixture quote parsed");
|
||||||
assert_eq!(quote.symbol, "BBCA.JK");
|
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(&history_raw).expect("fixture history parsed");
|
let history = parse_history_from_str(&history_raw).expect("fixture history parsed");
|
||||||
assert!(!history.is_empty());
|
assert!(!history.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(_)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ fn run() -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Commands::Stocks(stocks) => {
|
Commands::Stocks(stocks) => {
|
||||||
let provider = default_provider();
|
let provider = default_provider(cli.verbose > 0);
|
||||||
if let Err(err) = cli::stocks::handle(
|
if let Err(err) = cli::stocks::handle(
|
||||||
stocks,
|
stocks,
|
||||||
&config,
|
&config,
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@ use owo_colors::OwoColorize;
|
||||||
use crate::api::types::{Ohlc, Quote};
|
use crate::api::types::{Ohlc, Quote};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
pub fn format_idr(value: f64) -> String {
|
pub fn format_idr(value: i64) -> String {
|
||||||
let rounded = value.round() as i64;
|
let chars: Vec<char> = value.to_string().chars().rev().collect();
|
||||||
let chars: Vec<char> = rounded.to_string().chars().rev().collect();
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for (i, ch) in chars.iter().enumerate() {
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
if i > 0 && i % 3 == 0 {
|
if i > 0 && i % 3 == 0 {
|
||||||
|
|
@ -17,6 +16,10 @@ pub fn format_idr(value: f64) -> String {
|
||||||
out.chars().rev().collect()
|
out.chars().rev().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn format_u64(value: u64) -> String {
|
||||||
|
format_idr(value as i64)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table
|
table
|
||||||
|
|
@ -36,12 +39,12 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
||||||
table.add_row(vec![
|
table.add_row(vec![
|
||||||
Cell::new(&q.symbol),
|
Cell::new(&q.symbol),
|
||||||
Cell::new(format_idr(q.price)),
|
Cell::new(format_idr(q.price)),
|
||||||
Cell::new(format!("{:+.2}", q.change)),
|
Cell::new(format!("{:+}", q.change)),
|
||||||
pct_cell,
|
pct_cell,
|
||||||
Cell::new(format_idr(q.volume as f64)),
|
Cell::new(format_u64(q.volume)),
|
||||||
Cell::new(
|
Cell::new(
|
||||||
q.market_cap
|
q.market_cap
|
||||||
.map(format_idr)
|
.map(format_u64)
|
||||||
.unwrap_or_else(|| "-".to_string()),
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
@ -65,7 +68,7 @@ pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> {
|
||||||
Cell::new(format_idr(item.high)),
|
Cell::new(format_idr(item.high)),
|
||||||
Cell::new(format_idr(item.low)),
|
Cell::new(format_idr(item.low)),
|
||||||
Cell::new(format_idr(item.close)),
|
Cell::new(format_idr(item.close)),
|
||||||
Cell::new(format_idr(item.volume as f64)),
|
Cell::new(format_u64(item.volume)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
println!("{table}");
|
println!("{table}");
|
||||||
|
|
@ -74,11 +77,11 @@ pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::format_idr;
|
use super::{format_idr, format_u64};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formats_idr_numbers() {
|
fn formats_idr_numbers() {
|
||||||
assert_eq!(format_idr(9875.0), "9,875");
|
assert_eq!(format_idr(9875), "9,875");
|
||||||
assert_eq!(format_idr(1_215_200_000_000_000.0), "1,215,200,000,000,000");
|
assert_eq!(format_u64(1_215_200_000_000_000), "1,215,200,000,000,000");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue