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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM
This commit is contained in:
Claude 2026-10-02 14:32:16 +00:00
commit 4ddf1118d4
No known key found for this signature in database
8 changed files with 98 additions and 2 deletions

View file

@ -66,6 +66,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
range_signal,
prev_close,
avg_volume: round_u64(quote.average_volume),
as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()),
})
}
@ -625,6 +626,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
range_signal,
prev_close,
avg_volume: round_u64(q.average_volume),
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
})
})
.collect();
@ -942,6 +944,19 @@ mod tests {
);
}
#[test]
fn quote_as_of_is_last_trade_in_wib() {
let raw: Vec<MsnQuote> = 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<MsnQuote> = serde_json::from_str(