Merge pull request #36: market data fixes

This commit is contained in:
hermes 2026-10-02 16:41:58 +00:00
commit 36c121e74d
11 changed files with 302 additions and 77 deletions

View file

@ -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>;
} }

View file

@ -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(&quote).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);

View file

@ -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);
} }

View file

@ -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)]

View file

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

View file

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

View file

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

View file

@ -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)]

View file

@ -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");

View file

@ -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,
} }
} }

View file

@ -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(&quotes, 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() {