From 135034966cb8987b27e9434b30877784c2cf54a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:34:19 +0000 Subject: [PATCH] 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,