diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 39ca8cc..4481b62 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -94,19 +94,29 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> { } /// 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. +/// stock; say so under the table instead of letting it pass as current. One +/// from before the latest session is usually the provider lagging the close. 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!( + if now.signed_duration_since(as_of) > chrono::Duration::days(7) { + Some(format!( "Note: {} last traded on {}; this price is not current.", q.symbol, as_of.date_naive() - ) - }) + )) + } else if q.behind_session { + Some(format!( + "Note: {} last traded on {}, before the latest IDX session; \ + this price may be out of date.", + q.symbol, + as_of.date_naive() + )) + } else { + None + } }) .collect() } @@ -1399,6 +1409,7 @@ mod tests { prev_close: None, avg_volume: None, as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()), + behind_session: false, } } @@ -1418,6 +1429,30 @@ mod tests { ); } + #[test] + fn notes_quotes_behind_the_latest_session() { + let now = chrono::DateTime::parse_from_rfc3339("2026-10-16T16:53:00+07:00") + .unwrap() + .to_utc(); + let mut behind = quote_as_of("BBCA.JK", "2026-10-15T16:15:00+07:00"); + behind.behind_session = true; + let mut suspended = quote_as_of("WIKA.JK", "2025-02-17T16:14:58+07:00"); + suspended.behind_session = true; + let quotes = [ + behind, + suspended, + quote_as_of("BBRI.JK", "2026-10-16T16:15:00+07:00"), + ]; + assert_eq!( + stale_quote_notes("es, now), + vec![ + "Note: BBCA.JK last traded on 2026-10-15, before the latest IDX session; \ + this price may be out of date.", + "Note: WIKA.JK last traded on 2025-02-17; this price is not current.", + ] + ); + } + #[test] fn formats_idr_numbers() { assert_eq!(format_idr(9875), "9,875"); diff --git a/crates/idx-cli/tests/golden/auto-format-piped.golden b/crates/idx-cli/tests/golden/auto-format-piped.golden index 9df5da7..07b659c 100644 --- a/crates/idx-cli/tests/golden/auto-format-piped.golden +++ b/crates/idx-cli/tests/golden/auto-format-piped.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-quote-multi.json.golden b/crates/idx-cli/tests/golden/stocks-quote-multi.json.golden index 9df5da7..07b659c 100644 --- a/crates/idx-cli/tests/golden/stocks-quote-multi.json.golden +++ b/crates/idx-cli/tests/golden/stocks-quote-multi.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-quote-multi.jsonl.golden b/crates/idx-cli/tests/golden/stocks-quote-multi.jsonl.golden index 36b651b..c57ea57 100644 --- a/crates/idx-cli/tests/golden/stocks-quote-multi.jsonl.golden +++ b/crates/idx-cli/tests/golden/stocks-quote-multi.jsonl.golden @@ -1,5 +1,5 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null} -{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null} +{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false} +{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-quote-offline-warm.json.golden b/crates/idx-cli/tests/golden/stocks-quote-offline-warm.json.golden index 5f15d77..ffbf6c5 100644 --- a/crates/idx-cli/tests/golden/stocks-quote-offline-warm.json.golden +++ b/crates/idx-cli/tests/golden/stocks-quote-offline-warm.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-quote-stdin.json.golden b/crates/idx-cli/tests/golden/stocks-quote-stdin.json.golden index 9df5da7..07b659c 100644 --- a/crates/idx-cli/tests/golden/stocks-quote-stdin.json.golden +++ b/crates/idx-cli/tests/golden/stocks-quote-stdin.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-quote.json.golden b/crates/idx-cli/tests/golden/stocks-quote.json.golden index 5f15d77..ffbf6c5 100644 --- a/crates/idx-cli/tests/golden/stocks-quote.json.golden +++ b/crates/idx-cli/tests/golden/stocks-quote.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-screen-high-volume.json.golden b/crates/idx-cli/tests/golden/stocks-screen-high-volume.json.golden index 191abda..ee20b98 100644 --- a/crates/idx-cli/tests/golden/stocks-screen-high-volume.json.golden +++ b/crates/idx-cli/tests/golden/stocks-screen-high-volume.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-screen-large-cap.json.golden b/crates/idx-cli/tests/golden/stocks-screen-large-cap.json.golden index 5303d7b..644a14c 100644 --- a/crates/idx-cli/tests/golden/stocks-screen-large-cap.json.golden +++ b/crates/idx-cli/tests/golden/stocks-screen-large-cap.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-screen-worst.json.golden b/crates/idx-cli/tests/golden/stocks-screen-worst.json.golden index 5303d7b..644a14c 100644 --- a/crates/idx-cli/tests/golden/stocks-screen-worst.json.golden +++ b/crates/idx-cli/tests/golden/stocks-screen-worst.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-screen.json.golden b/crates/idx-cli/tests/golden/stocks-screen.json.golden index 191abda..ee20b98 100644 --- a/crates/idx-cli/tests/golden/stocks-screen.json.golden +++ b/crates/idx-cli/tests/golden/stocks-screen.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-cli/tests/golden/yahoo-quote.json.golden b/crates/idx-cli/tests/golden/yahoo-quote.json.golden index 74ddf4e..07942ab 100644 --- a/crates/idx-cli/tests/golden/yahoo-quote.json.golden +++ b/crates/idx-cli/tests/golden/yahoo-quote.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.1990161918425908,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}] +[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.1990161918425908,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}] --- stderr diff --git a/crates/idx-core/src/api/msn/map.rs b/crates/idx-core/src/api/msn/map.rs index b0f445f..a643841 100644 --- a/crates/idx-core/src/api/msn/map.rs +++ b/crates/idx-core/src/api/msn/map.rs @@ -70,6 +70,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result Result>, + /// True when `as_of` is before the latest IDX session that has ended: the + /// provider has not caught up yet (MSN can lag hours after the close), or + /// the stock did not trade. False when `as_of` is unknown. Set when the + /// quote is returned, not when it is cached. + #[serde(default)] + pub behind_session: bool, } /// OHLC candle data normalized from Yahoo Finance chart indicators. diff --git a/crates/idx-core/src/api/yahoo/map.rs b/crates/idx-core/src/api/yahoo/map.rs index 75c415f..f349b85 100644 --- a/crates/idx-core/src/api/yahoo/map.rs +++ b/crates/idx-core/src/api/yahoo/map.rs @@ -64,6 +64,7 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result { Ok, )?); } + let session = latest_session(Utc::now()); + for quote in &mut quotes { + mark_behind_session(&mut quote.data, session); + } Ok(quotes) } @@ -131,10 +137,38 @@ impl Market<'_> { )?; sort_screener_quotes(&mut fetched.data, filter); fetched.data.truncate(limit); + let session = latest_session(Utc::now()); + for quote in &mut fetched.data { + mark_behind_session(quote, session); + } Ok(fetched) } } +/// When IDX's post-trading session ends, in WIB; MSN stamps a day's closing +/// quote with this time. +const SESSION_END: NaiveTime = NaiveTime::from_hms_opt(16, 15, 0).expect("valid time"); + +/// The latest weekday whose IDX session has ended by `now`. Exchange holidays +/// are not known offline, so on a weekday holiday this is the holiday itself. +pub fn latest_session(now: DateTime) -> NaiveDate { + let now = now.with_timezone(&idx_offset()); + let mut day = now.date_naive(); + if now.time() < SESSION_END { + day -= Duration::days(1); + } + while matches!(day.weekday(), Weekday::Sat | Weekday::Sun) { + day -= Duration::days(1); + } + day +} + +fn mark_behind_session(quote: &mut Quote, session: NaiveDate) { + quote.behind_session = quote + .as_of + .is_some_and(|as_of| as_of.with_timezone(&idx_offset()).date_naive() < session); +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ScreenQuery { pub filter: ScreenFilter, @@ -320,7 +354,9 @@ fn sort_screener_quotes(quotes: &mut [Quote], filter: ScreenFilter) { #[cfg(test)] mod tests { - use super::{ScreenFilter, ScreenRegion, sort_screener_quotes}; + use super::{ + ScreenFilter, ScreenRegion, latest_session, mark_behind_session, sort_screener_quotes, + }; use crate::api::types::Quote; use crate::error::IdxError; @@ -339,6 +375,53 @@ mod tests { prev_close: None, avg_volume: None, as_of: None, + behind_session: false, + } + } + + fn wib(raw: &str) -> chrono::DateTime { + chrono::DateTime::parse_from_rfc3339(&format!("{raw}+07:00")) + .unwrap() + .to_utc() + } + + fn day(raw: &str) -> chrono::NaiveDate { + chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d").unwrap() + } + + #[test] + fn latest_session_is_the_last_weekday_past_the_post_trading_close() { + // 2026-10-16 is a Friday. + for (now, session) in [ + ("2026-10-16T16:53:00", "2026-10-16"), + ("2026-10-16T16:15:00", "2026-10-16"), + ("2026-10-16T16:14:59", "2026-10-15"), + ("2026-10-16T00:30:00", "2026-10-15"), + ("2026-10-17T12:00:00", "2026-10-16"), + ("2026-10-18T23:59:59", "2026-10-16"), + ("2026-10-19T10:00:00", "2026-10-16"), + ("2026-10-19T16:15:00", "2026-10-19"), + ] { + assert_eq!(latest_session(wib(now)), day(session), "now {now}"); + } + } + + #[test] + fn quote_from_before_the_latest_session_is_behind_it() { + // Issue #50: at 16:53 WIB on the 16th, MSN still served the 15th. + let session = latest_session(wib("2026-10-16T16:53:00")); + for (as_of, behind) in [ + (Some("2026-10-15T16:15:00+07:00"), true), + (Some("2026-10-16T16:15:00+07:00"), false), + (Some("2026-10-16T09:15:00Z"), false), + (Some("2026-10-15T17:30:00Z"), false), + (Some("2025-02-17T16:14:58+07:00"), true), + (None, false), + ] { + let mut quote = sample_quote("BBCA.JK", None); + quote.as_of = as_of.map(|t| chrono::DateTime::parse_from_rfc3339(t).unwrap()); + mark_behind_session(&mut quote, session); + assert_eq!(quote.behind_session, behind, "as_of {as_of:?}"); } } diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 9127c85..cf7a105 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -115,7 +115,8 @@ impl IdxServer { impl IdxServer { #[tool( description = "Latest quotes (price, change, volume, 52-week range, market cap) \ -for one or more IDX tickers." +for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true \ +when that is before the latest IDX session, so the price is not that session's close." )] async fn get_quotes(&self, params: Parameters) -> ToolResult { let symbols = match checked_symbols(params.0.symbols) { diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index ad343a7..41eaca8 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -397,7 +397,7 @@ "name": "get_price_history" }, { - "description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers.", + "description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c8149d9..eddb6ca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,8 +75,11 @@ MSN candle dates use WIB (UTC+7); Yahoo candle dates use `meta.gmtoffset`, with WIB as the fallback. ### Quote and Screener Data -- Quotes expose nullable `as_of` last-trade timestamps in WIB. Table output flags - quotes whose last trade was more than seven days ago. +- Quotes expose nullable `as_of` last-trade timestamps in WIB, and + `behind_session` when `as_of` is before the latest weekday whose session has + ended (16:15 WIB). It is computed on return, not cached; MSN can lag the close + by hours, and on an exchange holiday every quote reads as behind. Table output + notes behind quotes, and calls those over seven days old not current. - `change`, `change_pct`, and `volume` are nullable when the provider omits them; JSON consumers must not assume these fields are always numeric. - Listed MSN instruments without a price return `NOMARKETDATA`; unknown symbols diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 0380cc9..e08d1e6 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -107,7 +107,7 @@ OHLCV price history for one IDX ticker. ## `get_quotes` -Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. +Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close. | Input | Type | Required | Description | | --- | --- | --- | --- |