mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
Merge pull request #36: market data fixes
This commit is contained in:
commit
36c121e74d
11 changed files with 302 additions and 77 deletions
|
|
@ -2,6 +2,8 @@ pub mod msn;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod yahoo;
|
pub mod yahoo;
|
||||||
|
|
||||||
|
use chrono::FixedOffset;
|
||||||
|
|
||||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use types::{
|
use types::{
|
||||||
|
|
@ -9,6 +11,21 @@ use types::{
|
||||||
NewsItem, Period, Quote, SentimentData,
|
NewsItem, Period, Quote, SentimentData,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
|
||||||
|
/// candles in UTC, so day boundaries must be taken in this zone.
|
||||||
|
pub(crate) const IDX_UTC_OFFSET_SECS: i32 = 7 * 3600;
|
||||||
|
|
||||||
|
pub(crate) fn idx_offset() -> FixedOffset {
|
||||||
|
FixedOffset::east_opt(IDX_UTC_OFFSET_SECS).expect("valid WIB offset")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a provider's RFC 3339 trade time and express it in WIB.
|
||||||
|
pub(crate) fn parse_as_of(raw: Option<&str>) -> Option<chrono::DateTime<FixedOffset>> {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(raw?)
|
||||||
|
.ok()
|
||||||
|
.map(|t| t.with_timezone(&idx_offset()))
|
||||||
|
}
|
||||||
|
|
||||||
pub trait QuoteProvider {
|
pub trait QuoteProvider {
|
||||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,21 @@ use std::collections::HashMap;
|
||||||
|
|
||||||
pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, IdxError> {
|
pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, IdxError> {
|
||||||
let quote = quotes.first().ok_or(IdxError::ProviderUnavailable)?;
|
let quote = quotes.first().ok_or(IdxError::ProviderUnavailable)?;
|
||||||
let raw_price = quote
|
let Some(raw_price) = quote.price else {
|
||||||
.price
|
// MSN still lists suspended/untraded names (symbol, name, market cap)
|
||||||
.ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
// but sends no price. That is a real instrument with no market data.
|
||||||
|
if quote.symbol.is_some() {
|
||||||
|
return Err(IdxError::NoMarketData(format!(
|
||||||
|
"{symbol} is listed but has no price from MSN (it may be suspended)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Err(IdxError::SymbolNotFound(symbol.to_string()));
|
||||||
|
};
|
||||||
let prev_close = quote.price_previous_close.map(round_price);
|
let prev_close = quote.price_previous_close.map(round_price);
|
||||||
let price = round_price(raw_price);
|
let price = round_price(raw_price);
|
||||||
let change = prev_close
|
let change = prev_close
|
||||||
.map(|previous| price - previous)
|
.map(|previous| price - previous)
|
||||||
.or_else(|| quote.price_change.map(round_price))
|
.or_else(|| quote.price_change.map(round_price));
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let ticker = quote
|
let ticker = quote
|
||||||
.symbol
|
.symbol
|
||||||
|
|
@ -50,8 +56,8 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
|
||||||
symbol: normalized_symbol(symbol, &ticker),
|
symbol: normalized_symbol(symbol, &ticker),
|
||||||
price,
|
price,
|
||||||
change,
|
change,
|
||||||
change_pct: quote.price_change_percent.unwrap_or(0.0),
|
change_pct: quote.price_change_percent,
|
||||||
volume: round_u64(quote.accumulated_volume).unwrap_or(0),
|
volume: round_u64(quote.accumulated_volume),
|
||||||
market_cap: round_u64(quote.market_cap),
|
market_cap: round_u64(quote.market_cap),
|
||||||
week52_high: quote.price_52w_high.map(round_price),
|
week52_high: quote.price_52w_high.map(round_price),
|
||||||
week52_low: quote.price_52w_low.map(round_price),
|
week52_low: quote.price_52w_low.map(round_price),
|
||||||
|
|
@ -59,6 +65,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
|
||||||
range_signal,
|
range_signal,
|
||||||
prev_close,
|
prev_close,
|
||||||
avg_volume: round_u64(quote.average_volume),
|
avg_volume: round_u64(quote.average_volume),
|
||||||
|
as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -460,9 +467,15 @@ pub(super) fn parse_sentiment(
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
raw: &[RawSentiment],
|
raw: &[RawSentiment],
|
||||||
) -> Result<SentimentData, IdxError> {
|
) -> Result<SentimentData, IdxError> {
|
||||||
let item = raw
|
// MSN answers `[]` for tickers nobody has voted on (all IDX names as of
|
||||||
.first()
|
// 2026-10). That is "no votes", not a malformed response.
|
||||||
.ok_or_else(|| IdxError::ParseError("no sentiment data".into()))?;
|
let Some(item) = raw.first() else {
|
||||||
|
let ticker = ticker_from_symbol(symbol).unwrap_or_default();
|
||||||
|
return Ok(SentimentData {
|
||||||
|
symbol: normalized_symbol(symbol, &ticker),
|
||||||
|
statistics: Vec::new(),
|
||||||
|
});
|
||||||
|
};
|
||||||
let stats = item
|
let stats = item
|
||||||
.sentiment_statistics
|
.sentiment_statistics
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -578,8 +591,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
|
||||||
let prev_close = q.price_previous_close.map(round_price);
|
let prev_close = q.price_previous_close.map(round_price);
|
||||||
let change = prev_close
|
let change = prev_close
|
||||||
.map(|pc| price - pc)
|
.map(|pc| price - pc)
|
||||||
.or_else(|| q.price_change.map(round_price))
|
.or_else(|| q.price_change.map(round_price));
|
||||||
.unwrap_or(0);
|
|
||||||
let ticker = q
|
let ticker = q
|
||||||
.symbol
|
.symbol
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|
@ -603,8 +615,8 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
|
||||||
symbol: normalized_symbol(&ticker, &ticker),
|
symbol: normalized_symbol(&ticker, &ticker),
|
||||||
price,
|
price,
|
||||||
change,
|
change,
|
||||||
change_pct: q.price_change_percent.unwrap_or(0.0),
|
change_pct: q.price_change_percent,
|
||||||
volume: round_u64(q.accumulated_volume).unwrap_or(0),
|
volume: round_u64(q.accumulated_volume),
|
||||||
market_cap: round_u64(q.market_cap),
|
market_cap: round_u64(q.market_cap),
|
||||||
week52_high: q.price_52w_high.map(round_price),
|
week52_high: q.price_52w_high.map(round_price),
|
||||||
week52_low: q.price_52w_low.map(round_price),
|
week52_low: q.price_52w_low.map(round_price),
|
||||||
|
|
@ -612,6 +624,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
|
||||||
range_signal,
|
range_signal,
|
||||||
prev_close,
|
prev_close,
|
||||||
avg_volume: round_u64(q.average_volume),
|
avg_volume: round_u64(q.average_volume),
|
||||||
|
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -648,7 +661,10 @@ pub(super) fn parse_history(
|
||||||
let volume = series_volume_at(series, idx);
|
let volume = series_volume_at(series, idx);
|
||||||
|
|
||||||
out.push(Ohlc {
|
out.push(Ohlc {
|
||||||
date: timestamp.date_naive(),
|
// MSN stamps each IDX day at local midnight (17:00Z the day before).
|
||||||
|
date: timestamp
|
||||||
|
.with_timezone(&crate::api::idx_offset())
|
||||||
|
.date_naive(),
|
||||||
open,
|
open,
|
||||||
high,
|
high,
|
||||||
low,
|
low,
|
||||||
|
|
@ -769,9 +785,9 @@ fn collect_earnings(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
KeyRatios, RawChartResponse, RawFinancialStatement, RawNewsFeed, RawScreenerResponse,
|
KeyRatios, MsnQuote, RawChartResponse, RawFinancialStatement, RawNewsFeed,
|
||||||
RawSentiment, parse_financial_statements, parse_fundamentals, parse_history, parse_news,
|
RawScreenerResponse, RawSentiment, parse_financial_statements, parse_fundamentals,
|
||||||
parse_screener_results, parse_sentiment,
|
parse_history, parse_news, parse_quote, parse_screener_results, parse_sentiment,
|
||||||
};
|
};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
|
@ -850,7 +866,7 @@ mod tests {
|
||||||
assert_eq!(quotes.len(), 2);
|
assert_eq!(quotes.len(), 2);
|
||||||
assert_eq!(quotes[0].symbol, "BBCA.JK");
|
assert_eq!(quotes[0].symbol, "BBCA.JK");
|
||||||
assert_eq!(quotes[0].price, 9_875);
|
assert_eq!(quotes[0].price, 9_875);
|
||||||
assert_eq!(quotes[0].change, 117);
|
assert_eq!(quotes[0].change, Some(117));
|
||||||
assert_eq!(quotes[0].market_cap, Some(1_215_200_000_000_000));
|
assert_eq!(quotes[0].market_cap, Some(1_215_200_000_000_000));
|
||||||
assert_eq!(quotes[0].range_signal.as_deref(), Some("upper"));
|
assert_eq!(quotes[0].range_signal.as_deref(), Some("upper"));
|
||||||
assert_eq!(quotes[0].avg_volume, Some(10_000_000));
|
assert_eq!(quotes[0].avg_volume, Some(10_000_000));
|
||||||
|
|
@ -926,6 +942,52 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quote_as_of_is_last_trade_in_wib() {
|
||||||
|
let raw: Vec<MsnQuote> = serde_json::from_str(
|
||||||
|
r#"[{"symbol":"BBCA","price":6100.0,"timeLastTraded":"2026-10-02T09:15:00Z"}]"#,
|
||||||
|
)
|
||||||
|
.expect("quote should deserialize");
|
||||||
|
let quote = parse_quote("BBCA.JK", &raw).expect("quote parsed");
|
||||||
|
assert_eq!(
|
||||||
|
quote.as_of.unwrap().to_rfc3339(),
|
||||||
|
"2026-10-02T16:15:00+07:00"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_change_and_volume_stay_missing() {
|
||||||
|
let raw: Vec<MsnQuote> = serde_json::from_str(r#"[{"symbol":"BBCA","price":6100.0}]"#)
|
||||||
|
.expect("quote should deserialize");
|
||||||
|
let quote = parse_quote("BBCA.JK", &raw).expect("quote parsed");
|
||||||
|
assert_eq!(quote.change, None);
|
||||||
|
assert_eq!(quote.change_pct, None);
|
||||||
|
assert_eq!(quote.volume, None);
|
||||||
|
let json = serde_json::to_value("e).unwrap();
|
||||||
|
assert!(json["change_pct"].is_null() && json["volume"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listed_quote_without_price_is_no_market_data() {
|
||||||
|
let raw: Vec<MsnQuote> = serde_json::from_str(
|
||||||
|
r#"[{"symbol":"WIKA","shortName":"Wijaya Karya","marketCap":8080746000000.0}]"#,
|
||||||
|
)
|
||||||
|
.expect("quote should deserialize");
|
||||||
|
let err = parse_quote("WIKA.JK", &raw).unwrap_err();
|
||||||
|
assert!(matches!(err, IdxError::NoMarketData(_)), "{err:?}");
|
||||||
|
|
||||||
|
let raw: Vec<MsnQuote> = serde_json::from_str("[{}]").expect("quote should deserialize");
|
||||||
|
let err = parse_quote("ZZZZ.JK", &raw).unwrap_err();
|
||||||
|
assert!(matches!(err, IdxError::SymbolNotFound(_)), "{err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_sentiment_response_is_empty_data_not_an_error() {
|
||||||
|
let sentiment = parse_sentiment("BBCA", &[]).expect("empty sentiment is valid");
|
||||||
|
assert_eq!(sentiment.symbol, "BBCA.JK");
|
||||||
|
assert!(sentiment.statistics.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_msn_chart_price_only_fixture_as_synthetic_ohlc() {
|
fn parses_msn_chart_price_only_fixture_as_synthetic_ohlc() {
|
||||||
let raw: Vec<RawChartResponse> = serde_json::from_str(include_str!(
|
let raw: Vec<RawChartResponse> = serde_json::from_str(include_str!(
|
||||||
|
|
@ -935,7 +997,8 @@ mod tests {
|
||||||
let history = parse_history("BBCA.JK", &raw).expect("chart history should parse");
|
let history = parse_history("BBCA.JK", &raw).expect("chart history should parse");
|
||||||
|
|
||||||
assert_eq!(history.len(), 3);
|
assert_eq!(history.len(), 3);
|
||||||
assert_eq!(history[0].date.to_string(), "2026-01-13");
|
// `2026-01-13T17:00:00Z` is midnight WIB on the 14th, the trading day it belongs to.
|
||||||
|
assert_eq!(history[0].date.to_string(), "2026-01-14");
|
||||||
assert_eq!(history[0].open, 8000);
|
assert_eq!(history[0].open, 8000);
|
||||||
assert_eq!(history[0].high, 8000);
|
assert_eq!(history[0].high, 8000);
|
||||||
assert_eq!(history[0].low, 8000);
|
assert_eq!(history[0].low, 8000);
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,8 @@ mod tests {
|
||||||
let quote = parse_quote_from_str("BBCA.JK", &raw).expect("quote parsed");
|
let quote = parse_quote_from_str("BBCA.JK", &raw).expect("quote parsed");
|
||||||
assert_eq!(quote.symbol, "BBCA.JK");
|
assert_eq!(quote.symbol, "BBCA.JK");
|
||||||
assert_eq!(quote.price, 9875);
|
assert_eq!(quote.price, 9875);
|
||||||
assert_eq!(quote.change, 117);
|
assert_eq!(quote.change, Some(117));
|
||||||
assert_eq!(quote.volume, 12_300_000);
|
assert_eq!(quote.volume, Some(12_300_000));
|
||||||
assert_eq!(quote.market_cap, Some(1_215_200_000_000_000));
|
assert_eq!(quote.market_cap, Some(1_215_200_000_000_000));
|
||||||
assert_eq!(quote.avg_volume, Some(10_000_000));
|
assert_eq!(quote.avg_volume, Some(10_000_000));
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +78,7 @@ mod tests {
|
||||||
let history = parse_history_from_str("BBCA.JK", &raw).expect("chart fixture parsed");
|
let history = parse_history_from_str("BBCA.JK", &raw).expect("chart fixture parsed");
|
||||||
|
|
||||||
assert_eq!(history.len(), 3);
|
assert_eq!(history.len(), 3);
|
||||||
assert_eq!(history[0].date.to_string(), "2026-01-13");
|
assert_eq!(history[0].date.to_string(), "2026-01-14");
|
||||||
assert_eq!(history[0].close, 8000);
|
assert_eq!(history[0].close, 8000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ pub(crate) struct MsnQuote {
|
||||||
pub(crate) market_cap: Option<f64>,
|
pub(crate) market_cap: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) return_ytd: Option<f64>,
|
pub(crate) return_ytd: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) time_last_traded: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use chrono::NaiveDate;
|
use chrono::{DateTime, FixedOffset, NaiveDate};
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
|
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
|
||||||
|
|
||||||
|
|
@ -13,13 +13,18 @@ pub struct Quote {
|
||||||
pub price: i64,
|
pub price: i64,
|
||||||
/// Absolute day change in IDR (whole Rupiah), computed as
|
/// Absolute day change in IDR (whole Rupiah), computed as
|
||||||
/// `regularMarketPrice - previousClose` using rounded integer prices.
|
/// `regularMarketPrice - previousClose` using rounded integer prices.
|
||||||
#[serde(deserialize_with = "de_i64_from_number")]
|
/// `None` when the provider gives neither a previous close nor a change.
|
||||||
pub change: i64,
|
#[serde(default, deserialize_with = "de_opt_i64_from_number")]
|
||||||
|
pub change: Option<i64>,
|
||||||
/// Percentage day change as decimal percent (`0-100` scale), computed from
|
/// Percentage day change as decimal percent (`0-100` scale), computed from
|
||||||
/// Yahoo `regularMarketPrice` and `previousClose` raw floats.
|
/// Yahoo `regularMarketPrice` and `previousClose` raw floats.
|
||||||
pub change_pct: f64,
|
/// `None` when the provider does not report it (never a fake `0.0`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub change_pct: Option<f64>,
|
||||||
/// Traded regular market volume (shares), from `regularMarketVolume`.
|
/// Traded regular market volume (shares), from `regularMarketVolume`.
|
||||||
pub volume: u64,
|
/// `None` when the provider does not report it (never a fake `0`).
|
||||||
|
#[serde(default, deserialize_with = "de_opt_u64_from_number")]
|
||||||
|
pub volume: Option<u64>,
|
||||||
/// Company market capitalization in IDR, from `marketCap`.
|
/// Company market capitalization in IDR, from `marketCap`.
|
||||||
#[serde(default, deserialize_with = "de_opt_u64_from_number")]
|
#[serde(default, deserialize_with = "de_opt_u64_from_number")]
|
||||||
pub market_cap: Option<u64>,
|
pub market_cap: Option<u64>,
|
||||||
|
|
@ -41,6 +46,11 @@ pub struct Quote {
|
||||||
/// Average daily volume for the last 3 months (shares), from
|
/// Average daily volume for the last 3 months (shares), from
|
||||||
/// `averageDailyVolume3Month`.
|
/// `averageDailyVolume3Month`.
|
||||||
pub avg_volume: Option<u64>,
|
pub avg_volume: Option<u64>,
|
||||||
|
/// Time of the last trade in WIB (UTC+7), from MSN `timeLastTraded` or
|
||||||
|
/// Yahoo `regularMarketTime`. `None` when the provider does not say.
|
||||||
|
/// Suspended stocks can carry a last trade months in the past.
|
||||||
|
#[serde(default)]
|
||||||
|
pub as_of: Option<DateTime<FixedOffset>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
|
||||||
|
|
||||||
let price = round_price(raw_price);
|
let price = round_price(raw_price);
|
||||||
let prev_close = raw_prev_close.map(round_price);
|
let prev_close = raw_prev_close.map(round_price);
|
||||||
let change = prev_close.map_or(0, |p| price - p);
|
let change = prev_close.map(|p| price - p);
|
||||||
let change_pct = raw_prev_close.map_or(0.0, |p| {
|
let change_pct = raw_prev_close
|
||||||
if p != 0.0 {
|
.filter(|p| *p != 0.0)
|
||||||
((raw_price - p) / p) * 100.0
|
.map(|p| ((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)
|
||||||
{
|
{
|
||||||
|
|
@ -52,7 +48,7 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
|
||||||
price,
|
price,
|
||||||
change,
|
change,
|
||||||
change_pct,
|
change_pct,
|
||||||
volume: meta.regular_market_volume.unwrap_or(0),
|
volume: meta.regular_market_volume,
|
||||||
market_cap: meta.market_cap,
|
market_cap: meta.market_cap,
|
||||||
week52_high: meta.fifty_two_week_high.map(round_price),
|
week52_high: meta.fifty_two_week_high.map(round_price),
|
||||||
week52_low: meta.fifty_two_week_low.map(round_price),
|
week52_low: meta.fifty_two_week_low.map(round_price),
|
||||||
|
|
@ -60,6 +56,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
|
||||||
range_signal,
|
range_signal,
|
||||||
prev_close,
|
prev_close,
|
||||||
avg_volume: meta.average_daily_volume_3month,
|
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())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +87,14 @@ pub(super) fn parse_history(
|
||||||
.and_then(|i| i.quote.as_ref())
|
.and_then(|i| i.quote.as_ref())
|
||||||
.and_then(|q| q.first())
|
.and_then(|q| q.first())
|
||||||
.ok_or(IdxError::ProviderUnavailable)?;
|
.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 out = Vec::new();
|
||||||
let mut dropped = 0usize;
|
let mut dropped = 0usize;
|
||||||
|
|
@ -121,7 +129,7 @@ pub(super) fn parse_history(
|
||||||
&& let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0)
|
&& let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0)
|
||||||
{
|
{
|
||||||
out.push(Ohlc {
|
out.push(Ohlc {
|
||||||
date: dt.date_naive(),
|
date: dt.with_timezone(&exchange_tz).date_naive(),
|
||||||
open,
|
open,
|
||||||
high,
|
high,
|
||||||
low,
|
low,
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,22 @@ mod tests {
|
||||||
assert_eq!(history[0].close, 9875);
|
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]
|
#[test]
|
||||||
fn parses_realistic_fixture_json() {
|
fn parses_realistic_fixture_json() {
|
||||||
let quote_raw =
|
let quote_raw =
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ pub(super) struct ChartMeta {
|
||||||
pub(super) fifty_two_week_low: Option<f64>,
|
pub(super) fifty_two_week_low: Option<f64>,
|
||||||
#[serde(rename = "averageDailyVolume3Month")]
|
#[serde(rename = "averageDailyVolume3Month")]
|
||||||
pub(super) average_daily_volume_3month: Option<u64>,
|
pub(super) average_daily_volume_3month: Option<u64>,
|
||||||
|
pub(super) gmtoffset: Option<i32>,
|
||||||
|
pub(super) regular_market_time: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -615,9 +615,13 @@ pub fn handle(
|
||||||
} => {
|
} => {
|
||||||
let filter_key = screener_filter_key(filter)?;
|
let filter_key = screener_filter_key(filter)?;
|
||||||
let region_key = screener_region_key(region)?;
|
let region_key = screener_region_key(region)?;
|
||||||
// For filters that fall back to topperfs, fetch all stocks so
|
// Filters re-sorted client-side need the whole MSN list: MSN orders
|
||||||
// client-side sorting picks the correct top N.
|
// by 1-year return, so truncating first and then sorting by day
|
||||||
let needs_full_fetch = matches!(filter.as_str(), "high-volume" | "large-cap");
|
// change made `--limit 3` return different stocks than `--limit 25`.
|
||||||
|
let needs_full_fetch = matches!(
|
||||||
|
filter.as_str(),
|
||||||
|
"high-volume" | "large-cap" | "top-performers" | "worst-performers"
|
||||||
|
);
|
||||||
let fetch_limit = if needs_full_fetch { 500 } else { *limit };
|
let fetch_limit = if needs_full_fetch { 500 } else { *limit };
|
||||||
let bucket = cache_bucket(provider.kind(), "screen");
|
let bucket = cache_bucket(provider.kind(), "screen");
|
||||||
let key = format!("{filter}:{region}:{fetch_limit}");
|
let key = format!("{filter}:{region}:{fetch_limit}");
|
||||||
|
|
@ -958,16 +962,16 @@ fn sort_screener_quotes(quotes: &mut [Quote], filter: &str) {
|
||||||
match filter {
|
match filter {
|
||||||
"top-performers" => {
|
"top-performers" => {
|
||||||
quotes.sort_by(|a, b| {
|
quotes.sort_by(|a, b| {
|
||||||
b.change_pct
|
let pa = a.change_pct.unwrap_or(f64::MIN);
|
||||||
.partial_cmp(&a.change_pct)
|
let pb = b.change_pct.unwrap_or(f64::MIN);
|
||||||
.unwrap_or(Ordering::Equal)
|
pb.partial_cmp(&pa).unwrap_or(Ordering::Equal)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"worst-performers" => {
|
"worst-performers" => {
|
||||||
quotes.sort_by(|a, b| {
|
quotes.sort_by(|a, b| {
|
||||||
a.change_pct
|
let pa = a.change_pct.unwrap_or(f64::MAX);
|
||||||
.partial_cmp(&b.change_pct)
|
let pb = b.change_pct.unwrap_or(f64::MAX);
|
||||||
.unwrap_or(Ordering::Equal)
|
pa.partial_cmp(&pb).unwrap_or(Ordering::Equal)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"52w-high" => {
|
"52w-high" => {
|
||||||
|
|
@ -1047,9 +1051,9 @@ mod tests {
|
||||||
Quote {
|
Quote {
|
||||||
symbol: "A".into(),
|
symbol: "A".into(),
|
||||||
price: 100,
|
price: 100,
|
||||||
change: 1,
|
change: Some(1),
|
||||||
change_pct: 1.0,
|
change_pct: Some(1.0),
|
||||||
volume: 100,
|
volume: Some(100),
|
||||||
market_cap: None,
|
market_cap: None,
|
||||||
week52_high: None,
|
week52_high: None,
|
||||||
week52_low: None,
|
week52_low: None,
|
||||||
|
|
@ -1057,13 +1061,14 @@ mod tests {
|
||||||
range_signal: None,
|
range_signal: None,
|
||||||
prev_close: None,
|
prev_close: None,
|
||||||
avg_volume: None,
|
avg_volume: None,
|
||||||
|
as_of: None,
|
||||||
},
|
},
|
||||||
Quote {
|
Quote {
|
||||||
symbol: "B".into(),
|
symbol: "B".into(),
|
||||||
price: 200,
|
price: 200,
|
||||||
change: 10,
|
change: Some(10),
|
||||||
change_pct: 5.0,
|
change_pct: Some(5.0),
|
||||||
volume: 200,
|
volume: Some(200),
|
||||||
market_cap: None,
|
market_cap: None,
|
||||||
week52_high: None,
|
week52_high: None,
|
||||||
week52_low: None,
|
week52_low: None,
|
||||||
|
|
@ -1071,13 +1076,14 @@ mod tests {
|
||||||
range_signal: None,
|
range_signal: None,
|
||||||
prev_close: None,
|
prev_close: None,
|
||||||
avg_volume: None,
|
avg_volume: None,
|
||||||
|
as_of: None,
|
||||||
},
|
},
|
||||||
Quote {
|
Quote {
|
||||||
symbol: "C".into(),
|
symbol: "C".into(),
|
||||||
price: 150,
|
price: 150,
|
||||||
change: 5,
|
change: Some(5),
|
||||||
change_pct: 3.0,
|
change_pct: Some(3.0),
|
||||||
volume: 150,
|
volume: Some(150),
|
||||||
market_cap: None,
|
market_cap: None,
|
||||||
week52_high: None,
|
week52_high: None,
|
||||||
week52_low: None,
|
week52_low: None,
|
||||||
|
|
@ -1085,11 +1091,12 @@ mod tests {
|
||||||
range_signal: None,
|
range_signal: None,
|
||||||
prev_close: None,
|
prev_close: None,
|
||||||
avg_volume: None,
|
avg_volume: None,
|
||||||
|
as_of: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
super::sort_screener_quotes(&mut quotes, "top-performers");
|
super::sort_screener_quotes(&mut quotes, "top-performers");
|
||||||
let pcts: Vec<f64> = quotes.iter().map(|q| q.change_pct).collect();
|
let pcts: Vec<f64> = quotes.iter().filter_map(|q| q.change_pct).collect();
|
||||||
assert_eq!(pcts, vec![5.0, 3.0, 1.0]);
|
assert_eq!(pcts, vec![5.0, 3.0, 1.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1101,9 +1108,9 @@ mod tests {
|
||||||
Quote {
|
Quote {
|
||||||
symbol: "A".into(),
|
symbol: "A".into(),
|
||||||
price: 100,
|
price: 100,
|
||||||
change: 1,
|
change: Some(1),
|
||||||
change_pct: 1.0,
|
change_pct: Some(1.0),
|
||||||
volume: 100,
|
volume: Some(100),
|
||||||
market_cap: None,
|
market_cap: None,
|
||||||
week52_high: None,
|
week52_high: None,
|
||||||
week52_low: None,
|
week52_low: None,
|
||||||
|
|
@ -1111,13 +1118,14 @@ mod tests {
|
||||||
range_signal: None,
|
range_signal: None,
|
||||||
prev_close: None,
|
prev_close: None,
|
||||||
avg_volume: None,
|
avg_volume: None,
|
||||||
|
as_of: None,
|
||||||
},
|
},
|
||||||
Quote {
|
Quote {
|
||||||
symbol: "B".into(),
|
symbol: "B".into(),
|
||||||
price: 200,
|
price: 200,
|
||||||
change: -10,
|
change: Some(-10),
|
||||||
change_pct: -5.0,
|
change_pct: Some(-5.0),
|
||||||
volume: 200,
|
volume: Some(200),
|
||||||
market_cap: None,
|
market_cap: None,
|
||||||
week52_high: None,
|
week52_high: None,
|
||||||
week52_low: None,
|
week52_low: None,
|
||||||
|
|
@ -1125,13 +1133,14 @@ mod tests {
|
||||||
range_signal: None,
|
range_signal: None,
|
||||||
prev_close: None,
|
prev_close: None,
|
||||||
avg_volume: None,
|
avg_volume: None,
|
||||||
|
as_of: None,
|
||||||
},
|
},
|
||||||
Quote {
|
Quote {
|
||||||
symbol: "C".into(),
|
symbol: "C".into(),
|
||||||
price: 150,
|
price: 150,
|
||||||
change: -3,
|
change: Some(-3),
|
||||||
change_pct: -2.0,
|
change_pct: Some(-2.0),
|
||||||
volume: 150,
|
volume: Some(150),
|
||||||
market_cap: None,
|
market_cap: None,
|
||||||
week52_high: None,
|
week52_high: None,
|
||||||
week52_low: None,
|
week52_low: None,
|
||||||
|
|
@ -1139,14 +1148,46 @@ mod tests {
|
||||||
range_signal: None,
|
range_signal: None,
|
||||||
prev_close: None,
|
prev_close: None,
|
||||||
avg_volume: None,
|
avg_volume: None,
|
||||||
|
as_of: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
super::sort_screener_quotes(&mut quotes, "worst-performers");
|
super::sort_screener_quotes(&mut quotes, "worst-performers");
|
||||||
let pcts: Vec<f64> = quotes.iter().map(|q| q.change_pct).collect();
|
let pcts: Vec<f64> = quotes.iter().filter_map(|q| q.change_pct).collect();
|
||||||
assert_eq!(pcts, vec![-5.0, -2.0, 1.0]);
|
assert_eq!(pcts, vec![-5.0, -2.0, 1.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sort_screener_puts_unknown_change_last_both_ways() {
|
||||||
|
let mut quotes = vec![
|
||||||
|
sample_quote("U", None),
|
||||||
|
sample_quote("A", Some(1.0)),
|
||||||
|
sample_quote("B", Some(-5.0)),
|
||||||
|
];
|
||||||
|
super::sort_screener_quotes(&mut quotes, "worst-performers");
|
||||||
|
assert_eq!(quotes.last().unwrap().symbol, "U");
|
||||||
|
super::sort_screener_quotes(&mut quotes, "top-performers");
|
||||||
|
assert_eq!(quotes.last().unwrap().symbol, "U");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_quote(symbol: &str, change_pct: Option<f64>) -> crate::api::types::Quote {
|
||||||
|
crate::api::types::Quote {
|
||||||
|
symbol: symbol.into(),
|
||||||
|
price: 100,
|
||||||
|
change: None,
|
||||||
|
change_pct,
|
||||||
|
volume: None,
|
||||||
|
market_cap: None,
|
||||||
|
week52_high: None,
|
||||||
|
week52_low: None,
|
||||||
|
week52_position: None,
|
||||||
|
range_signal: None,
|
||||||
|
prev_close: None,
|
||||||
|
avg_volume: None,
|
||||||
|
as_of: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_screener_filter_returns_error() {
|
fn invalid_screener_filter_returns_error() {
|
||||||
let err = super::screener_filter_key("bogus").expect_err("invalid filter should fail");
|
let err = super::screener_filter_key("bogus").expect_err("invalid filter should fail");
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ pub enum IdxError {
|
||||||
PdfParseError(String),
|
PdfParseError(String),
|
||||||
#[error("invalid input: {0}")]
|
#[error("invalid input: {0}")]
|
||||||
InvalidInput(String),
|
InvalidInput(String),
|
||||||
|
#[error("no market data: {0}")]
|
||||||
|
NoMarketData(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||||
|
|
@ -50,6 +52,7 @@ pub enum ErrorCode {
|
||||||
DatabaseError,
|
DatabaseError,
|
||||||
PdfParseError,
|
PdfParseError,
|
||||||
InvalidInput,
|
InvalidInput,
|
||||||
|
NoMarketData,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IdxError {
|
impl IdxError {
|
||||||
|
|
@ -69,6 +72,7 @@ impl IdxError {
|
||||||
Self::DatabaseError(_) => ErrorCode::DatabaseError,
|
Self::DatabaseError(_) => ErrorCode::DatabaseError,
|
||||||
Self::PdfParseError(_) => ErrorCode::PdfParseError,
|
Self::PdfParseError(_) => ErrorCode::PdfParseError,
|
||||||
Self::InvalidInput(_) => ErrorCode::InvalidInput,
|
Self::InvalidInput(_) => ErrorCode::InvalidInput,
|
||||||
|
Self::NoMarketData(_) => ErrorCode::NoMarketData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,20 +53,22 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for q in quotes {
|
for q in quotes {
|
||||||
let pct = format!("{:+.2}%", q.change_pct);
|
let pct_cell = match q.change_pct {
|
||||||
let pct_cell = if no_color {
|
None => Cell::new("-"),
|
||||||
Cell::new(pct)
|
Some(pct) if no_color => Cell::new(format!("{pct:+.2}%")),
|
||||||
} else if q.change_pct >= 0.0 {
|
Some(pct) if pct >= 0.0 => Cell::new(format!("{pct:+.2}%")).fg(Color::Green),
|
||||||
Cell::new(pct).fg(Color::Green)
|
Some(pct) => Cell::new(format!("{pct:+.2}%")).fg(Color::Red),
|
||||||
} else {
|
|
||||||
Cell::new(pct).fg(Color::Red)
|
|
||||||
};
|
};
|
||||||
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!("{:+}", q.change)),
|
Cell::new(
|
||||||
|
q.change
|
||||||
|
.map(|c| format!("{c:+}"))
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
pct_cell,
|
pct_cell,
|
||||||
Cell::new(format_u64(q.volume)),
|
Cell::new(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())),
|
||||||
Cell::new(
|
Cell::new(
|
||||||
q.market_cap
|
q.market_cap
|
||||||
.map(format_u64)
|
.map(format_u64)
|
||||||
|
|
@ -77,9 +79,30 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{table}");
|
println!("{table}");
|
||||||
|
for note in stale_quote_notes(quotes, chrono::Utc::now()) {
|
||||||
|
println!("{note}");
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A quote whose last trade is over a week old is almost always a suspended
|
||||||
|
/// stock; say so under the table instead of letting it pass as current.
|
||||||
|
fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime<chrono::Utc>) -> Vec<String> {
|
||||||
|
quotes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|q| {
|
||||||
|
let as_of = q.as_of?;
|
||||||
|
(now.signed_duration_since(as_of) > chrono::Duration::days(7)).then(|| {
|
||||||
|
format!(
|
||||||
|
"Note: {} last traded on {}; this price is not current.",
|
||||||
|
q.symbol,
|
||||||
|
as_of.date_naive()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> {
|
pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> {
|
||||||
println!("{}", format!("History for {symbol}").bold());
|
println!("{}", format!("History for {symbol}").bold());
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
|
|
@ -768,6 +791,10 @@ pub fn print_earnings(report: &EarningsReport) -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_sentiment(data: &SentimentData) -> Result<(), IdxError> {
|
pub fn print_sentiment(data: &SentimentData) -> Result<(), IdxError> {
|
||||||
|
if data.statistics.is_empty() {
|
||||||
|
println!("No sentiment votes for {}.", data.symbol);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table
|
table
|
||||||
.load_preset(UTF8_FULL)
|
.load_preset(UTF8_FULL)
|
||||||
|
|
@ -858,9 +885,44 @@ fn truncate_url(url: &str) -> String {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
format_earnings_period, format_idr, format_signal, format_table_date, format_u64,
|
format_earnings_period, format_idr, format_signal, format_table_date, format_u64,
|
||||||
humanize_metric_key,
|
humanize_metric_key, stale_quote_notes,
|
||||||
};
|
};
|
||||||
use crate::analysis::signals::Signal;
|
use crate::analysis::signals::Signal;
|
||||||
|
use crate::api::types::Quote;
|
||||||
|
|
||||||
|
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
|
||||||
|
Quote {
|
||||||
|
symbol: symbol.into(),
|
||||||
|
price: 204,
|
||||||
|
change: None,
|
||||||
|
change_pct: None,
|
||||||
|
volume: None,
|
||||||
|
market_cap: None,
|
||||||
|
week52_high: None,
|
||||||
|
week52_low: None,
|
||||||
|
week52_position: None,
|
||||||
|
range_signal: None,
|
||||||
|
prev_close: None,
|
||||||
|
avg_volume: None,
|
||||||
|
as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notes_only_quotes_older_than_a_week() {
|
||||||
|
let now = chrono::DateTime::parse_from_rfc3339("2026-10-02T10:00:00Z")
|
||||||
|
.unwrap()
|
||||||
|
.to_utc();
|
||||||
|
let quotes = [
|
||||||
|
quote_as_of("WIKA.JK", "2025-02-17T16:14:58+07:00"),
|
||||||
|
quote_as_of("BBCA.JK", "2026-10-02T16:15:00+07:00"),
|
||||||
|
quote_as_of("TLKM.JK", "2026-09-26T16:15:00+07:00"),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
stale_quote_notes("es, now),
|
||||||
|
vec!["Note: WIKA.JK last traded on 2025-02-17; this price is not current."]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formats_idr_numbers() {
|
fn formats_idr_numbers() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue