From 0ba3d805a7124b81a283cd3b027d6b976399cfdd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:27:49 +0000 Subject: [PATCH 1/6] fix(history): date candles in Jakarta time, not UTC MSN stamps each IDX trading day at local midnight (17:00Z the previous day), so every MSN candle was labelled one day early. Yahoo weekly and monthly bars have the same stamp and were labelled Sunday / last day of the previous month. Take the date in the exchange offset instead: Yahoo's meta.gmtoffset when present, else WIB (UTC+7). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM --- src/api/mod.rs | 10 ++++++++++ src/api/msn/map.rs | 8 ++++++-- src/api/msn/parse.rs | 2 +- src/api/yahoo/map.rs | 10 +++++++++- src/api/yahoo/parse.rs | 16 ++++++++++++++++ src/api/yahoo/raw_types.rs | 1 + 6 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 18bd8de..6266c60 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,6 +2,8 @@ pub mod msn; pub mod types; pub mod yahoo; +use chrono::FixedOffset; + use crate::config::{HistoryProviderKind, ProviderKind}; use crate::error::IdxError; use types::{ @@ -9,6 +11,14 @@ use types::{ 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") +} + pub trait QuoteProvider { fn quote(&self, symbol: &str) -> Result; } diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index e777f0d..4e3a8fc 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -648,7 +648,10 @@ pub(super) fn parse_history( let volume = series_volume_at(series, idx); 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, high, low, @@ -935,7 +938,8 @@ mod tests { let history = parse_history("BBCA.JK", &raw).expect("chart history should parse"); 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].high, 8000); assert_eq!(history[0].low, 8000); diff --git a/src/api/msn/parse.rs b/src/api/msn/parse.rs index 3cb926e..7a963d7 100644 --- a/src/api/msn/parse.rs +++ b/src/api/msn/parse.rs @@ -78,7 +78,7 @@ mod tests { let history = parse_history_from_str("BBCA.JK", &raw).expect("chart fixture parsed"); 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); } diff --git a/src/api/yahoo/map.rs b/src/api/yahoo/map.rs index aef8aed..9c2e9e1 100644 --- a/src/api/yahoo/map.rs +++ b/src/api/yahoo/map.rs @@ -87,6 +87,14 @@ pub(super) fn parse_history( .and_then(|i| i.quote.as_ref()) .and_then(|q| q.first()) .ok_or(IdxError::ProviderUnavailable)?; + // Yahoo stamps weekly/monthly bars at local midnight (17:00Z the day before), + // so take the date in the exchange's own offset, not UTC. + let exchange_tz = result + .meta + .as_ref() + .and_then(|m| m.gmtoffset) + .and_then(chrono::FixedOffset::east_opt) + .unwrap_or_else(crate::api::idx_offset); let mut out = Vec::new(); let mut dropped = 0usize; @@ -121,7 +129,7 @@ pub(super) fn parse_history( && let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0) { out.push(Ohlc { - date: dt.date_naive(), + date: dt.with_timezone(&exchange_tz).date_naive(), open, high, low, diff --git a/src/api/yahoo/parse.rs b/src/api/yahoo/parse.rs index fd25299..c5a7008 100644 --- a/src/api/yahoo/parse.rs +++ b/src/api/yahoo/parse.rs @@ -92,6 +92,22 @@ mod tests { assert_eq!(history[0].close, 9875); } + #[test] + fn history_dates_use_exchange_offset_not_utc() { + // Weekly bars are stamped at Monday 00:00 WIB, i.e. Sunday 17:00Z. + let raw = r#"{"chart":{"error":null,"result":[{ + "meta":{"gmtoffset":25200}, + "timestamp":[1790528400], + "indicators":{"quote":[{"open":[6000],"high":[6150],"low":[5975],"close":[6100],"volume":[1000]}]} + }]}}"#; + let history = parse_history_from_str("BBCA.JK", raw).expect("history parsed"); + assert_eq!(history[0].date.to_string(), "2026-09-28"); + + let without_offset = raw.replace(r#""gmtoffset":25200"#, r#""symbol":"BBCA.JK""#); + let history = parse_history_from_str("BBCA.JK", &without_offset).expect("history parsed"); + assert_eq!(history[0].date.to_string(), "2026-09-28"); + } + #[test] fn parses_realistic_fixture_json() { let quote_raw = diff --git a/src/api/yahoo/raw_types.rs b/src/api/yahoo/raw_types.rs index 5fe70f8..7764cd7 100644 --- a/src/api/yahoo/raw_types.rs +++ b/src/api/yahoo/raw_types.rs @@ -70,6 +70,7 @@ pub(super) struct ChartMeta { pub(super) fifty_two_week_low: Option, #[serde(rename = "averageDailyVolume3Month")] pub(super) average_daily_volume_3month: Option, + pub(super) gmtoffset: Option, } #[derive(Debug, Deserialize)] From f77f81c97b9b2c630272c97dd11d5c3d988eaa0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:28:52 +0000 Subject: [PATCH 2/6] fix(sentiment): treat an empty MSN response as no votes, not a parse error MSN SentimentBrowser returns `[]` for every IDX ticker checked live (BBCA, BBRI, TLKM), so `stocks sentiment` always failed with PARSEERROR. Return empty statistics instead; the table prints a one-line notice and JSON gets `"statistics": []` with exit 0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM --- src/api/msn/map.rs | 19 ++++++++++++++++--- src/output/table.rs | 4 ++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index 4e3a8fc..bb3581a 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -460,9 +460,15 @@ pub(super) fn parse_sentiment( symbol: &str, raw: &[RawSentiment], ) -> Result { - let item = raw - .first() - .ok_or_else(|| IdxError::ParseError("no sentiment data".into()))?; + // MSN answers `[]` for tickers nobody has voted on (all IDX names as of + // 2026-10). That is "no votes", not a malformed response. + 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 .sentiment_statistics .as_ref() @@ -929,6 +935,13 @@ mod tests { ); } + #[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] fn parses_msn_chart_price_only_fixture_as_synthetic_ohlc() { let raw: Vec = serde_json::from_str(include_str!( diff --git a/src/output/table.rs b/src/output/table.rs index 1b92e1b..429f92d 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -768,6 +768,10 @@ pub fn print_earnings(report: &EarningsReport) -> 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(); table .load_preset(UTF8_FULL) From 1785ac5c52806480ff55a301c3242aa19b0ff2e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:30:34 +0000 Subject: [PATCH 3/6] fix(quote): report listed-but-unpriced stocks as NOMARKETDATA MSN returns suspended names (21 of 896 on 2026-10-02, e.g. WIKA, SUPR) with symbol, name and market cap but no price. These were reported as SYMBOLNOTFOUND, which tells an agent the ticker does not exist. Add a NoMarketData error for this case; unknown tickers stay SYMBOLNOTFOUND. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM --- src/api/msn/map.rs | 33 +++++++++++++++++++++++++++------ src/error.rs | 4 ++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index bb3581a..d8bf008 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -15,9 +15,16 @@ use std::collections::HashMap; pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result { let quote = quotes.first().ok_or(IdxError::ProviderUnavailable)?; - let raw_price = quote - .price - .ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; + let Some(raw_price) = quote.price else { + // MSN still lists suspended/untraded names (symbol, name, market cap) + // 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 price = round_price(raw_price); let change = prev_close @@ -778,9 +785,9 @@ fn collect_earnings( #[cfg(test)] mod tests { use super::{ - KeyRatios, RawChartResponse, RawFinancialStatement, RawNewsFeed, RawScreenerResponse, - RawSentiment, parse_financial_statements, parse_fundamentals, parse_history, parse_news, - parse_screener_results, parse_sentiment, + KeyRatios, MsnQuote, RawChartResponse, RawFinancialStatement, RawNewsFeed, + RawScreenerResponse, RawSentiment, parse_financial_statements, parse_fundamentals, + parse_history, parse_news, parse_quote, parse_screener_results, parse_sentiment, }; use crate::error::IdxError; @@ -935,6 +942,20 @@ mod tests { ); } + #[test] + fn listed_quote_without_price_is_no_market_data() { + let raw: Vec = 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 = 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"); diff --git a/src/error.rs b/src/error.rs index 33dbf53..37ed148 100644 --- a/src/error.rs +++ b/src/error.rs @@ -32,6 +32,8 @@ pub enum IdxError { PdfParseError(String), #[error("invalid input: {0}")] InvalidInput(String), + #[error("no market data: {0}")] + NoMarketData(String), } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] @@ -50,6 +52,7 @@ pub enum ErrorCode { DatabaseError, PdfParseError, InvalidInput, + NoMarketData, } impl IdxError { @@ -69,6 +72,7 @@ impl IdxError { Self::DatabaseError(_) => ErrorCode::DatabaseError, Self::PdfParseError(_) => ErrorCode::PdfParseError, Self::InvalidInput(_) => ErrorCode::InvalidInput, + Self::NoMarketData(_) => ErrorCode::NoMarketData, } } From 4ddf1118d4f5cea74da6721aac75d73d7b869027 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:32:16 +0000 Subject: [PATCH 4/6] feat(quote): add as_of last-trade time and flag stale quotes Quotes had no timestamp, so a suspended stock's last price looked current: Yahoo returns WIKA at 204 with regularMarketTime 2025-02-17. Add `as_of` (RFC 3339 in WIB) from MSN `timeLastTraded` / Yahoo `regularMarketTime`, also on screener rows. The table prints a note under any quote whose last trade is over a week old. The field is additive and defaults to null, so existing cache entries still load. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM --- src/api/mod.rs | 7 +++++ src/api/msn/map.rs | 15 ++++++++++ src/api/msn/raw_types.rs | 2 ++ src/api/types.rs | 7 ++++- src/api/yahoo/map.rs | 4 +++ src/api/yahoo/raw_types.rs | 1 + src/cli/stocks.rs | 6 ++++ src/output/table.rs | 58 +++++++++++++++++++++++++++++++++++++- 8 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 6266c60..8e2258b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -19,6 +19,13 @@ 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::parse_from_rfc3339(raw?) + .ok() + .map(|t| t.with_timezone(&idx_offset())) +} + pub trait QuoteProvider { fn quote(&self, symbol: &str) -> Result; } diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index d8bf008..3893cae 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -66,6 +66,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result Result = 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 listed_quote_without_price_is_no_market_data() { let raw: Vec = serde_json::from_str( diff --git a/src/api/msn/raw_types.rs b/src/api/msn/raw_types.rs index 9a1c564..ab2381d 100644 --- a/src/api/msn/raw_types.rs +++ b/src/api/msn/raw_types.rs @@ -30,6 +30,8 @@ pub(crate) struct MsnQuote { pub(crate) market_cap: Option, #[serde(default)] pub(crate) return_ytd: Option, + #[serde(default)] + pub(crate) time_last_traded: Option, } #[derive(Debug, Deserialize)] diff --git a/src/api/types.rs b/src/api/types.rs index 606d0ba..1a33452 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -1,4 +1,4 @@ -use chrono::NaiveDate; +use chrono::{DateTime, FixedOffset, NaiveDate}; use clap::ValueEnum; use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; @@ -41,6 +41,11 @@ pub struct Quote { /// Average daily volume for the last 3 months (shares), from /// `averageDailyVolume3Month`. pub avg_volume: Option, + /// 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>, } /// OHLC candle data normalized from Yahoo Finance chart indicators. diff --git a/src/api/yahoo/map.rs b/src/api/yahoo/map.rs index 9c2e9e1..53dcced 100644 --- a/src/api/yahoo/map.rs +++ b/src/api/yahoo/map.rs @@ -60,6 +60,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result, pub(super) gmtoffset: Option, + pub(super) regular_market_time: Option, } #[derive(Debug, Deserialize)] diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 65d364a..0fc0bbf 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -1055,6 +1055,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, Quote { symbol: "B".into(), @@ -1069,6 +1070,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, Quote { symbol: "C".into(), @@ -1083,6 +1085,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, ]; @@ -1109,6 +1112,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, Quote { symbol: "B".into(), @@ -1123,6 +1127,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, Quote { symbol: "C".into(), @@ -1137,6 +1142,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, ]; diff --git a/src/output/table.rs b/src/output/table.rs index 429f92d..2fe1a44 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -77,9 +77,30 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> { } println!("{table}"); + for note in stale_quote_notes(quotes, chrono::Utc::now()) { + println!("{note}"); + } 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) -> Vec { + 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> { println!("{}", format!("History for {symbol}").bold()); let mut table = Table::new(); @@ -862,9 +883,44 @@ fn truncate_url(url: &str) -> String { mod tests { use super::{ 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::api::types::Quote; + + fn quote_as_of(symbol: &str, as_of: &str) -> Quote { + Quote { + symbol: symbol.into(), + price: 204, + change: 0, + change_pct: 0.0, + volume: 0, + 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] fn formats_idr_numbers() { From 135034966cb8987b27e9434b30877784c2cf54a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:34:19 +0000 Subject: [PATCH 5/6] fix(quote): report missing change and volume as null, not zero change, change_pct and volume were `unwrap_or(0)` when the provider omitted them (MSN quotes and screener rows; Yahoo with no previous close), so "unknown" read as "unchanged, zero volume". They are now Option fields that serialize as null when missing. The table shows "-", and the performers screens sort unknown changes last in both directions. Present values are unchanged and cached entries still load. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM --- src/api/msn/map.rs | 28 ++++++++++----- src/api/msn/parse.rs | 4 +-- src/api/types.rs | 13 ++++--- src/api/yahoo/map.rs | 14 +++----- src/cli/stocks.rs | 83 ++++++++++++++++++++++++++++++-------------- src/output/table.rs | 26 +++++++------- 6 files changed, 106 insertions(+), 62 deletions(-) diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index 3893cae..912e4ff 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -29,8 +29,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result Result Result Result = 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 = serde_json::from_str( diff --git a/src/api/msn/parse.rs b/src/api/msn/parse.rs index 7a963d7..24d45fd 100644 --- a/src/api/msn/parse.rs +++ b/src/api/msn/parse.rs @@ -49,8 +49,8 @@ mod tests { let quote = parse_quote_from_str("BBCA.JK", &raw).expect("quote parsed"); assert_eq!(quote.symbol, "BBCA.JK"); assert_eq!(quote.price, 9875); - assert_eq!(quote.change, 117); - assert_eq!(quote.volume, 12_300_000); + assert_eq!(quote.change, Some(117)); + assert_eq!(quote.volume, Some(12_300_000)); assert_eq!(quote.market_cap, Some(1_215_200_000_000_000)); assert_eq!(quote.avg_volume, Some(10_000_000)); } diff --git a/src/api/types.rs b/src/api/types.rs index 1a33452..5055235 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -13,13 +13,18 @@ pub struct Quote { 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, + /// `None` when the provider gives neither a previous close nor a change. + #[serde(default, deserialize_with = "de_opt_i64_from_number")] + pub change: Option, /// Percentage day change as decimal percent (`0-100` scale), computed from /// 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, /// 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, /// Company market capitalization in IDR, from `marketCap`. #[serde(default, deserialize_with = "de_opt_u64_from_number")] pub market_cap: Option, diff --git a/src/api/yahoo/map.rs b/src/api/yahoo/map.rs index 53dcced..20d8212 100644 --- a/src/api/yahoo/map.rs +++ b/src/api/yahoo/map.rs @@ -22,14 +22,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result Result { quotes.sort_by(|a, b| { - b.change_pct - .partial_cmp(&a.change_pct) - .unwrap_or(Ordering::Equal) + let pa = a.change_pct.unwrap_or(f64::MIN); + let pb = b.change_pct.unwrap_or(f64::MIN); + pb.partial_cmp(&pa).unwrap_or(Ordering::Equal) }); } "worst-performers" => { quotes.sort_by(|a, b| { - a.change_pct - .partial_cmp(&b.change_pct) - .unwrap_or(Ordering::Equal) + let pa = a.change_pct.unwrap_or(f64::MAX); + let pb = b.change_pct.unwrap_or(f64::MAX); + pa.partial_cmp(&pb).unwrap_or(Ordering::Equal) }); } "52w-high" => { @@ -1045,9 +1045,9 @@ mod tests { Quote { symbol: "A".into(), price: 100, - change: 1, - change_pct: 1.0, - volume: 100, + change: Some(1), + change_pct: Some(1.0), + volume: Some(100), market_cap: None, week52_high: None, week52_low: None, @@ -1060,9 +1060,9 @@ mod tests { Quote { symbol: "B".into(), price: 200, - change: 10, - change_pct: 5.0, - volume: 200, + change: Some(10), + change_pct: Some(5.0), + volume: Some(200), market_cap: None, week52_high: None, week52_low: None, @@ -1075,9 +1075,9 @@ mod tests { Quote { symbol: "C".into(), price: 150, - change: 5, - change_pct: 3.0, - volume: 150, + change: Some(5), + change_pct: Some(3.0), + volume: Some(150), market_cap: None, week52_high: None, week52_low: None, @@ -1090,7 +1090,7 @@ mod tests { ]; super::sort_screener_quotes(&mut quotes, "top-performers"); - let pcts: Vec = quotes.iter().map(|q| q.change_pct).collect(); + let pcts: Vec = quotes.iter().filter_map(|q| q.change_pct).collect(); assert_eq!(pcts, vec![5.0, 3.0, 1.0]); } @@ -1102,9 +1102,9 @@ mod tests { Quote { symbol: "A".into(), price: 100, - change: 1, - change_pct: 1.0, - volume: 100, + change: Some(1), + change_pct: Some(1.0), + volume: Some(100), market_cap: None, week52_high: None, week52_low: None, @@ -1117,9 +1117,9 @@ mod tests { Quote { symbol: "B".into(), price: 200, - change: -10, - change_pct: -5.0, - volume: 200, + change: Some(-10), + change_pct: Some(-5.0), + volume: Some(200), market_cap: None, week52_high: None, week52_low: None, @@ -1132,9 +1132,9 @@ mod tests { Quote { symbol: "C".into(), price: 150, - change: -3, - change_pct: -2.0, - volume: 150, + change: Some(-3), + change_pct: Some(-2.0), + volume: Some(150), market_cap: None, week52_high: None, week52_low: None, @@ -1147,10 +1147,41 @@ mod tests { ]; super::sort_screener_quotes(&mut quotes, "worst-performers"); - let pcts: Vec = quotes.iter().map(|q| q.change_pct).collect(); + let pcts: Vec = quotes.iter().filter_map(|q| q.change_pct).collect(); 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) -> 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] fn invalid_screener_filter_returns_error() { let err = super::screener_filter_key("bogus").expect_err("invalid filter should fail"); diff --git a/src/output/table.rs b/src/output/table.rs index 2fe1a44..4257345 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -53,20 +53,22 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> { ]); for q in quotes { - let pct = format!("{:+.2}%", q.change_pct); - let pct_cell = if no_color { - Cell::new(pct) - } else if q.change_pct >= 0.0 { - Cell::new(pct).fg(Color::Green) - } else { - Cell::new(pct).fg(Color::Red) + let pct_cell = match q.change_pct { + None => Cell::new("-"), + Some(pct) if no_color => Cell::new(format!("{pct:+.2}%")), + Some(pct) if pct >= 0.0 => Cell::new(format!("{pct:+.2}%")).fg(Color::Green), + Some(pct) => Cell::new(format!("{pct:+.2}%")).fg(Color::Red), }; table.add_row(vec![ Cell::new(&q.symbol), 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, - Cell::new(format_u64(q.volume)), + Cell::new(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())), Cell::new( q.market_cap .map(format_u64) @@ -892,9 +894,9 @@ mod tests { Quote { symbol: symbol.into(), price: 204, - change: 0, - change_pct: 0.0, - volume: 0, + change: None, + change_pct: None, + volume: None, market_cap: None, week52_high: None, week52_low: None, From 8a294d6728c7ccebd80b80bdbe1addfb98cc5329 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:35:00 +0000 Subject: [PATCH 6/6] fix(screen): fetch the full list before sorting performers screens MSN orders screener lists by 1-year return, but top/worst-performers re-sort by day change after truncating to --limit. So `--limit 3` sorted an arbitrary 3 stocks (worst-performers returned -1.33%, +0.25%, +0.95%) and gave different stocks than `--limit 25`. Fetch the whole list for these filters, as high-volume and large-cap already do. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM --- src/cli/stocks.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 859ff49..63b434c 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -613,9 +613,13 @@ pub fn handle( } => { let filter_key = screener_filter_key(filter)?; let region_key = screener_region_key(region)?; - // For filters that fall back to topperfs, fetch all stocks so - // client-side sorting picks the correct top N. - let needs_full_fetch = matches!(filter.as_str(), "high-volume" | "large-cap"); + // Filters re-sorted client-side need the whole MSN list: MSN orders + // by 1-year return, so truncating first and then sorting by day + // 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 bucket = cache_bucket(provider.kind(), "screen"); let key = format!("{filter}:{region}:{fetch_limit}");