feat: flag quotes behind the latest IDX session (#50)

MSN can serve the previous session's close for hours after IDX closes;
on 2026-10-16 at 16:53 WIB it still returned the 15th. Quotes and
screener rows now carry `behind_session`, true when `as_of` is before
the latest weekday whose session has ended (16:15 WIB). It is computed
on return, not cached. The quote table notes such quotes, and the
get_quotes description explains the field.

Closes #50

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-18 17:38:48 +00:00
commit a0a2cac7cf
20 changed files with 155 additions and 24 deletions

View file

@ -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 /// 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<chrono::Utc>) -> Vec<String> { fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime<chrono::Utc>) -> Vec<String> {
quotes quotes
.iter() .iter()
.filter_map(|q| { .filter_map(|q| {
let as_of = q.as_of?; let as_of = q.as_of?;
(now.signed_duration_since(as_of) > chrono::Duration::days(7)).then(|| { if now.signed_duration_since(as_of) > chrono::Duration::days(7) {
format!( Some(format!(
"Note: {} last traded on {}; this price is not current.", "Note: {} last traded on {}; this price is not current.",
q.symbol, q.symbol,
as_of.date_naive() 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() .collect()
} }
@ -1399,6 +1409,7 @@ mod tests {
prev_close: None, prev_close: None,
avg_volume: None, avg_volume: None,
as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()), 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(&quotes, 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] #[test]
fn formats_idr_numbers() { fn formats_idr_numbers() {
assert_eq!(format_idr(9875), "9,875"); assert_eq!(format_idr(9875), "9,875");

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- 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 --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- 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 --- stderr

View file

@ -1,5 +1,5 @@
exit: 0 exit: 0
--- stdout --- 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}
{"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":"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 --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- 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 --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- 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 --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- 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 --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- 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 --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- 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 --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- 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 --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- 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 --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- 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 --- stderr

View file

@ -70,6 +70,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
prev_close, prev_close,
avg_volume: round_u64(quote.average_volume), avg_volume: round_u64(quote.average_volume),
as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()), as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()),
behind_session: false,
}) })
} }
@ -718,6 +719,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
prev_close, prev_close,
avg_volume: round_u64(q.average_volume), avg_volume: round_u64(q.average_volume),
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()), as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
behind_session: false,
}) })
}) })
.collect(); .collect();

View file

@ -50,6 +50,12 @@ pub struct Quote {
/// Suspended stocks can carry a last trade months in the past. /// Suspended stocks can carry a last trade months in the past.
#[serde(default)] #[serde(default)]
pub as_of: Option<DateTime<FixedOffset>>, pub as_of: Option<DateTime<FixedOffset>>,
/// 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. /// OHLC candle data normalized from Yahoo Finance chart indicators.

View file

@ -64,6 +64,7 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
.regular_market_time .regular_market_time
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)) .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
.map(|t| t.with_timezone(&crate::api::idx_offset())), .map(|t| t.with_timezone(&crate::api::idx_offset())),
behind_session: false,
}) })
} }

View file

@ -4,8 +4,10 @@ use std::cmp::Ordering;
use std::fmt; use std::fmt;
use std::str::FromStr; use std::str::FromStr;
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
use crate::api::types::{Interval, Ohlc, Period, Quote}; use crate::api::types::{Interval, Ohlc, Period, Quote};
use crate::api::{history_provider, history_source}; use crate::api::{history_provider, history_source, idx_offset};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::config::{HistoryProviderKind, ProviderKind}; use crate::config::{HistoryProviderKind, ProviderKind};
use crate::error::IdxError; use crate::error::IdxError;
@ -49,6 +51,10 @@ impl Market<'_> {
Ok, Ok,
)?); )?);
} }
let session = latest_session(Utc::now());
for quote in &mut quotes {
mark_behind_session(&mut quote.data, session);
}
Ok(quotes) Ok(quotes)
} }
@ -131,10 +137,38 @@ impl Market<'_> {
)?; )?;
sort_screener_quotes(&mut fetched.data, filter); sort_screener_quotes(&mut fetched.data, filter);
fetched.data.truncate(limit); fetched.data.truncate(limit);
let session = latest_session(Utc::now());
for quote in &mut fetched.data {
mark_behind_session(quote, session);
}
Ok(fetched) 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<Utc>) -> 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScreenQuery { pub struct ScreenQuery {
pub filter: ScreenFilter, pub filter: ScreenFilter,
@ -320,7 +354,9 @@ fn sort_screener_quotes(quotes: &mut [Quote], filter: ScreenFilter) {
#[cfg(test)] #[cfg(test)]
mod tests { 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::api::types::Quote;
use crate::error::IdxError; use crate::error::IdxError;
@ -339,6 +375,53 @@ mod tests {
prev_close: None, prev_close: None,
avg_volume: None, avg_volume: None,
as_of: None, as_of: None,
behind_session: false,
}
}
fn wib(raw: &str) -> chrono::DateTime<chrono::Utc> {
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:?}");
} }
} }

View file

@ -115,7 +115,8 @@ impl IdxServer {
impl IdxServer { impl IdxServer {
#[tool( #[tool(
description = "Latest quotes (price, change, volume, 52-week range, market cap) \ 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<SymbolsParams>) -> ToolResult { async fn get_quotes(&self, params: Parameters<SymbolsParams>) -> ToolResult {
let symbols = match checked_symbols(params.0.symbols) { let symbols = match checked_symbols(params.0.symbols) {

View file

@ -397,7 +397,7 @@
"name": "get_price_history" "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": { "inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {

View file

@ -75,8 +75,11 @@ MSN candle dates use WIB (UTC+7); Yahoo candle dates use `meta.gmtoffset`, with
WIB as the fallback. WIB as the fallback.
### Quote and Screener Data ### Quote and Screener Data
- Quotes expose nullable `as_of` last-trade timestamps in WIB. Table output flags - Quotes expose nullable `as_of` last-trade timestamps in WIB, and
quotes whose last trade was more than seven days ago. `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; - `change`, `change_pct`, and `volume` are nullable when the provider omits them;
JSON consumers must not assume these fields are always numeric. JSON consumers must not assume these fields are always numeric.
- Listed MSN instruments without a price return `NOMARKETDATA`; unknown symbols - Listed MSN instruments without a price return `NOMARKETDATA`; unknown symbols

View file

@ -107,7 +107,7 @@ OHLCV price history for one IDX ticker.
## `get_quotes` ## `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 | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |