From 4ddf1118d4f5cea74da6721aac75d73d7b869027 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:32:16 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM --- src/api/mod.rs | 7 +++++ src/api/msn/map.rs | 15 ++++++++++ src/api/msn/raw_types.rs | 2 ++ src/api/types.rs | 7 ++++- src/api/yahoo/map.rs | 4 +++ src/api/yahoo/raw_types.rs | 1 + src/cli/stocks.rs | 6 ++++ src/output/table.rs | 58 +++++++++++++++++++++++++++++++++++++- 8 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 6266c60..8e2258b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -19,6 +19,13 @@ pub(crate) fn idx_offset() -> FixedOffset { FixedOffset::east_opt(IDX_UTC_OFFSET_SECS).expect("valid WIB offset") } +/// Parse a provider's RFC 3339 trade time and express it in WIB. +pub(crate) fn parse_as_of(raw: Option<&str>) -> Option> { + chrono::DateTime::parse_from_rfc3339(raw?) + .ok() + .map(|t| t.with_timezone(&idx_offset())) +} + pub trait QuoteProvider { fn quote(&self, symbol: &str) -> Result; } diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index d8bf008..3893cae 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -66,6 +66,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result Result = 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 = serde_json::from_str( diff --git a/src/api/msn/raw_types.rs b/src/api/msn/raw_types.rs index 9a1c564..ab2381d 100644 --- a/src/api/msn/raw_types.rs +++ b/src/api/msn/raw_types.rs @@ -30,6 +30,8 @@ pub(crate) struct MsnQuote { pub(crate) market_cap: Option, #[serde(default)] pub(crate) return_ytd: Option, + #[serde(default)] + pub(crate) time_last_traded: Option, } #[derive(Debug, Deserialize)] diff --git a/src/api/types.rs b/src/api/types.rs index 606d0ba..1a33452 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -1,4 +1,4 @@ -use chrono::NaiveDate; +use chrono::{DateTime, FixedOffset, NaiveDate}; use clap::ValueEnum; use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; @@ -41,6 +41,11 @@ pub struct Quote { /// Average daily volume for the last 3 months (shares), from /// `averageDailyVolume3Month`. pub avg_volume: Option, + /// Time of the last trade in WIB (UTC+7), from MSN `timeLastTraded` or + /// Yahoo `regularMarketTime`. `None` when the provider does not say. + /// Suspended stocks can carry a last trade months in the past. + #[serde(default)] + pub as_of: Option>, } /// OHLC candle data normalized from Yahoo Finance chart indicators. diff --git a/src/api/yahoo/map.rs b/src/api/yahoo/map.rs index 9c2e9e1..53dcced 100644 --- a/src/api/yahoo/map.rs +++ b/src/api/yahoo/map.rs @@ -60,6 +60,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result, pub(super) gmtoffset: Option, + pub(super) regular_market_time: Option, } #[derive(Debug, Deserialize)] diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 65d364a..0fc0bbf 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -1055,6 +1055,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, Quote { symbol: "B".into(), @@ -1069,6 +1070,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, Quote { symbol: "C".into(), @@ -1083,6 +1085,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, ]; @@ -1109,6 +1112,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, Quote { symbol: "B".into(), @@ -1123,6 +1127,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, Quote { symbol: "C".into(), @@ -1137,6 +1142,7 @@ mod tests { range_signal: None, prev_close: None, avg_volume: None, + as_of: None, }, ]; diff --git a/src/output/table.rs b/src/output/table.rs index 429f92d..2fe1a44 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -77,9 +77,30 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> { } println!("{table}"); + for note in stale_quote_notes(quotes, chrono::Utc::now()) { + println!("{note}"); + } Ok(()) } +/// A quote whose last trade is over a week old is almost always a suspended +/// stock; say so under the table instead of letting it pass as current. +fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime) -> Vec { + quotes + .iter() + .filter_map(|q| { + let as_of = q.as_of?; + (now.signed_duration_since(as_of) > chrono::Duration::days(7)).then(|| { + format!( + "Note: {} last traded on {}; this price is not current.", + q.symbol, + as_of.date_naive() + ) + }) + }) + .collect() +} + pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> { println!("{}", format!("History for {symbol}").bold()); let mut table = Table::new(); @@ -862,9 +883,44 @@ fn truncate_url(url: &str) -> String { mod tests { use super::{ format_earnings_period, format_idr, format_signal, format_table_date, format_u64, - humanize_metric_key, + humanize_metric_key, stale_quote_notes, }; use crate::analysis::signals::Signal; + use crate::api::types::Quote; + + fn quote_as_of(symbol: &str, as_of: &str) -> Quote { + Quote { + symbol: symbol.into(), + price: 204, + change: 0, + change_pct: 0.0, + volume: 0, + market_cap: None, + week52_high: None, + week52_low: None, + week52_position: None, + range_signal: None, + prev_close: None, + avg_volume: None, + as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()), + } + } + + #[test] + fn notes_only_quotes_older_than_a_week() { + let now = chrono::DateTime::parse_from_rfc3339("2026-10-02T10:00:00Z") + .unwrap() + .to_utc(); + let quotes = [ + quote_as_of("WIKA.JK", "2025-02-17T16:14:58+07:00"), + quote_as_of("BBCA.JK", "2026-10-02T16:15:00+07:00"), + quote_as_of("TLKM.JK", "2026-09-26T16:15:00+07:00"), + ]; + assert_eq!( + stale_quote_notes("es, now), + vec!["Note: WIKA.JK last traded on 2025-02-17; this price is not current."] + ); + } #[test] fn formats_idr_numbers() {