mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
4ddf1118d4
commit
135034966c
6 changed files with 106 additions and 62 deletions
|
|
@ -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("e).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(
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue