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 change = prev_close
.map(|previous| price - previous)
.or_else(|| quote.price_change.map(round_price))
.unwrap_or(0);
.or_else(|| quote.price_change.map(round_price));
let ticker = quote
.symbol
@ -57,8 +56,8 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
symbol: normalized_symbol(symbol, &ticker),
price,
change,
change_pct: quote.price_change_percent.unwrap_or(0.0),
volume: round_u64(quote.accumulated_volume).unwrap_or(0),
change_pct: quote.price_change_percent,
volume: round_u64(quote.accumulated_volume),
market_cap: round_u64(quote.market_cap),
week52_high: quote.price_52w_high.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 change = prev_close
.map(|pc| price - pc)
.or_else(|| q.price_change.map(round_price))
.unwrap_or(0);
.or_else(|| q.price_change.map(round_price));
let ticker = q
.symbol
.as_deref()
@ -617,8 +615,8 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
symbol: normalized_symbol(&ticker, &ticker),
price,
change,
change_pct: q.price_change_percent.unwrap_or(0.0),
volume: round_u64(q.accumulated_volume).unwrap_or(0),
change_pct: q.price_change_percent,
volume: round_u64(q.accumulated_volume),
market_cap: round_u64(q.market_cap),
week52_high: q.price_52w_high.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[0].symbol, "BBCA.JK");
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].range_signal.as_deref(), Some("upper"));
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]
fn listed_quote_without_price_is_no_market_data() {
let raw: Vec<MsnQuote> = serde_json::from_str(