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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM
This commit is contained in:
Claude 2026-10-02 14:34:19 +00:00
commit 135034966c
No known key found for this signature in database
6 changed files with 106 additions and 62 deletions

View file

@ -29,8 +29,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
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
@ -57,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),
@ -592,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()
@ -617,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),
@ -868,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));
@ -957,6 +955,18 @@ mod tests {
); );
} }
#[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] #[test]
fn listed_quote_without_price_is_no_market_data() { fn listed_quote_without_price_is_no_market_data() {
let raw: Vec<MsnQuote> = serde_json::from_str( let raw: Vec<MsnQuote> = serde_json::from_str(

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

View file

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

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

View file

@ -956,16 +956,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" => {
@ -1045,9 +1045,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,
@ -1060,9 +1060,9 @@ mod tests {
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,
@ -1075,9 +1075,9 @@ mod tests {
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,
@ -1090,7 +1090,7 @@ mod tests {
]; ];
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]);
} }
@ -1102,9 +1102,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,
@ -1117,9 +1117,9 @@ mod tests {
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,
@ -1132,9 +1132,9 @@ mod tests {
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,
@ -1147,10 +1147,41 @@ mod tests {
]; ];
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

@ -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)
@ -892,9 +894,9 @@ mod tests {
Quote { Quote {
symbol: symbol.into(), symbol: symbol.into(),
price: 204, price: 204,
change: 0, change: None,
change_pct: 0.0, change_pct: None,
volume: 0, volume: None,
market_cap: None, market_cap: None,
week52_high: None, week52_high: None,
week52_low: None, week52_low: None,