fix: mark only IDX quotes behind the session; test the wiring

Review findings on #71:
- The IDX session rule flagged non-IDX data: US screen rows on Monday
  evening WIB, and JP/HK/SG rows on their own holidays. Only `.JK`
  quotes and `--region id` screens are marked now.
- Nothing tested the marking call sites. A screen test checks IDX rows
  online and offline, and US rows unmarked. A quote test reads a cache
  entry in 0.3.3's format (no field) offline.
- The get_quotes description now says the flag also means the stock did
  not trade, or an exchange holiday; screen_stocks mentions the field.
- latest_session is pub(crate).

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-19 10:26:09 +00:00
commit 834e47b336
7 changed files with 85 additions and 20 deletions

View file

@ -2407,6 +2407,62 @@ fn msn_screen_ranks_complete_candidates_and_caches_before_limit() {
} }
} }
#[test]
fn msn_screen_flags_idx_rows_behind_the_session() {
let root = test_env_dir("screen-behind-session");
let fixture = write_large_screener_fixture(&root, Some(503));
let behind = |rows: &serde_json::Value| -> Vec<bool> {
rows.as_array()
.unwrap()
.iter()
.map(|row| row["behind_session"].as_bool().unwrap())
.collect()
};
// Every fixture row last traded on 2026-01-02, long before any session now.
let fresh = run_success_json(&mut screener_fixture_command(
&root,
&fixture,
"top-performers",
3,
));
assert_eq!(behind(&fresh), [true; 3]);
let mut offline = screener_fixture_command(&root, &fixture, "top-performers", 3);
offline.env("IDX_MOCK_ERROR", "1").arg("--offline");
assert_eq!(behind(&run_success_json(&mut offline)), [true; 3]);
// IDX's session rule says nothing about other exchanges.
let mut us = screener_fixture_command(&root, &fixture, "top-performers", 3);
us.args(["--region", "us"]);
assert_eq!(behind(&run_success_json(&mut us)), [false; 3]);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn quote_read_from_a_0_3_3_cache_entry_is_behind_the_session() {
let root = test_env_dir("quote-behind-session");
let mut warm = mock_bin_with_root(&root);
run_success_json(warm.args(["-o", "json", "stocks", "quote", "BBCA"]));
let entry = fs::read_dir(root.join("cache"))
.unwrap()
.flat_map(|dir| fs::read_dir(dir.unwrap().path()).unwrap())
.flat_map(|bucket| fs::read_dir(bucket.unwrap().path()).unwrap())
.map(|file| file.unwrap().path())
.find(|path| path.ends_with("BBCA.JK.json"))
.expect("cached quote");
// 0.3.3 wrote quotes without the field; this one last traded long ago.
let mut cached: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&entry).unwrap()).unwrap();
let data = cached["data"].as_object_mut().unwrap();
data.remove("behind_session")
.expect("field is cached unmarked");
data.insert("as_of".into(), "2026-01-02T16:15:00+07:00".into());
fs::write(&entry, cached.to_string()).unwrap();
let mut offline = mock_bin_with_root(&root);
let quotes =
run_success_json(offline.args(["--offline", "-o", "json", "stocks", "quote", "BBCA"]));
assert_eq!(quotes[0]["behind_session"], true);
fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn msn_screen_rejects_invalid_totals_without_populating_cache() { fn msn_screen_rejects_invalid_totals_without_populating_cache() {
for (name, count) in [("missing", None), ("incomplete", Some(504))] { for (name, count) in [("missing", None), ("incomplete", Some(504))] {

View file

@ -51,8 +51,9 @@ pub struct Quote {
#[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 /// 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 /// provider has not caught up yet (MSN can lag hours after the close), the
/// the stock did not trade. False when `as_of` is unknown. Set when the /// stock did not trade, or the day was an exchange holiday. False when
/// `as_of` is unknown and for non-IDX symbols and regions. Set when the
/// quote is returned, not when it is cached. /// quote is returned, not when it is cached.
#[serde(default)] #[serde(default)]
pub behind_session: bool, pub behind_session: bool,

View file

@ -34,10 +34,11 @@ impl Market<'_> {
) -> Result<Vec<Fetched<Quote>>, IdxError> { ) -> Result<Vec<Fetched<Quote>>, IdxError> {
let idx = self.idx; let idx = self.idx;
let bucket = idx.bucket("quote"); let bucket = idx.bucket("quote");
let session = latest_session(Utc::now());
let mut quotes = Vec::with_capacity(symbols.len()); let mut quotes = Vec::with_capacity(symbols.len());
for symbol in symbols { for symbol in symbols {
let resolved = idx.resolve(symbol.as_ref())?; let resolved = idx.resolve(symbol.as_ref())?;
quotes.push(cached_fetch( let mut quote = cached_fetch(
&idx.cache, &idx.cache,
CacheSpec { CacheSpec {
bucket: &bucket, bucket: &bucket,
@ -49,11 +50,12 @@ impl Market<'_> {
policy, policy,
|| idx.provider.market().quote(&resolved), || idx.provider.market().quote(&resolved),
Ok, Ok,
)?); )?;
} // The session rule is IDX's; other exchanges keep other hours.
let session = latest_session(Utc::now()); if resolved.ends_with(".JK") {
for quote in &mut quotes { mark_behind_session(&mut quote.data, session);
mark_behind_session(&mut quote.data, session); }
quotes.push(quote);
} }
Ok(quotes) Ok(quotes)
} }
@ -137,9 +139,11 @@ 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()); if region == ScreenRegion::Id {
for quote in &mut fetched.data { let session = latest_session(Utc::now());
mark_behind_session(quote, session); for quote in &mut fetched.data {
mark_behind_session(quote, session);
}
} }
Ok(fetched) Ok(fetched)
} }
@ -151,7 +155,7 @@ const SESSION_END: NaiveTime = NaiveTime::from_hms_opt(16, 15, 0).expect("valid
/// The latest weekday whose IDX session has ended by `now`. Exchange holidays /// 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. /// are not known offline, so on a weekday holiday this is the holiday itself.
pub fn latest_session(now: DateTime<Utc>) -> NaiveDate { pub(crate) fn latest_session(now: DateTime<Utc>) -> NaiveDate {
let now = now.with_timezone(&idx_offset()); let now = now.with_timezone(&idx_offset());
let mut day = now.date_naive(); let mut day = now.date_naive();
if now.time() < SESSION_END { if now.time() < SESSION_END {

View file

@ -116,7 +116,8 @@ 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. `as_of` is the last trade in WIB; `behind_session` is true \ 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." when that is before the latest IDX session, so the price is not that session's close: the \
provider lags, the stock did not trade, or the day was an exchange holiday."
)] )]
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) {
@ -149,7 +150,8 @@ when that is before the latest IDX session, so the price is not that session's c
#[tool( #[tool(
description = "Run a stock screen (top/worst performers, high dividend, low P/E, \ description = "Run a stock screen (top/worst performers, high dividend, low P/E, \
52-week highs/lows, high volume, large cap)." 52-week highs/lows, high volume, large cap). Rows carry `as_of` and, for the IDX region, \
`behind_session` as in get_quotes."
)] )]
async fn screen_stocks(&self, params: Parameters<ScreenParams>) -> ToolResult { async fn screen_stocks(&self, params: Parameters<ScreenParams>) -> ToolResult {
let ScreenParams { let ScreenParams {

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. `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.", "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: the provider lags, the stock did not trade, or the day was an exchange holiday.",
"inputSchema": { "inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {
@ -457,7 +457,7 @@
"name": "ownership_sync" "name": "ownership_sync"
}, },
{ {
"description": "Run a stock screen (top/worst performers, high dividend, low P/E, 52-week highs/lows, high volume, large cap).", "description": "Run a stock screen (top/worst performers, high dividend, low P/E, 52-week highs/lows, high volume, large cap). Rows carry `as_of` and, for the IDX region, `behind_session` as in get_quotes.",
"inputSchema": { "inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {

View file

@ -78,8 +78,10 @@ WIB as the fallback.
- Quotes expose nullable `as_of` last-trade timestamps in WIB, and - Quotes expose nullable `as_of` last-trade timestamps in WIB, and
`behind_session` when `as_of` is before the latest weekday whose session has `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 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 by hours, and on an exchange holiday every quote reads as behind. Only `.JK`
notes behind quotes, and calls those over seven days old not current. quotes and `--region id` screens are marked; other exchanges keep other hours.
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. `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. 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: the provider lags, the stock did not trade, or the day was an exchange holiday.
| Input | Type | Required | Description | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -137,7 +137,7 @@ Download or refresh the KSEI ownership snapshot (several MB) into the local data
## `screen_stocks` ## `screen_stocks`
Run a stock screen (top/worst performers, high dividend, low P/E, 52-week highs/lows, high volume, large cap). Run a stock screen (top/worst performers, high dividend, low P/E, 52-week highs/lows, high volume, large cap). Rows carry `as_of` and, for the IDX region, `behind_session` as in get_quotes.
| Input | Type | Required | Description | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |