From 834e47b336857bf244e88737ce2a58ed7565e29d Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 19 Oct 2026 10:26:09 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-cli/tests/cli.rs | 56 ++++++++++++++++++++++++++ crates/idx-core/src/api/types.rs | 5 ++- crates/idx-core/src/market.rs | 24 ++++++----- crates/idx-mcp/src/server.rs | 6 ++- crates/idx-mcp/tests/golden/tools.json | 4 +- docs/ARCHITECTURE.md | 6 ++- web/src/content/docs/mcp/tools.md | 4 +- 7 files changed, 85 insertions(+), 20 deletions(-) diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 7cddf9d..f2bc244 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -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 { + 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] fn msn_screen_rejects_invalid_totals_without_populating_cache() { for (name, count) in [("missing", None), ("incomplete", Some(504))] { diff --git a/crates/idx-core/src/api/types.rs b/crates/idx-core/src/api/types.rs index f352699..deaaafa 100644 --- a/crates/idx-core/src/api/types.rs +++ b/crates/idx-core/src/api/types.rs @@ -51,8 +51,9 @@ pub struct Quote { #[serde(default)] pub as_of: Option>, /// 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 + /// provider has not caught up yet (MSN can lag hours after the close), 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. #[serde(default)] pub behind_session: bool, diff --git a/crates/idx-core/src/market.rs b/crates/idx-core/src/market.rs index 227b7be..748458a 100644 --- a/crates/idx-core/src/market.rs +++ b/crates/idx-core/src/market.rs @@ -34,10 +34,11 @@ impl Market<'_> { ) -> Result>, IdxError> { let idx = self.idx; let bucket = idx.bucket("quote"); + let session = latest_session(Utc::now()); let mut quotes = Vec::with_capacity(symbols.len()); for symbol in symbols { let resolved = idx.resolve(symbol.as_ref())?; - quotes.push(cached_fetch( + let mut quote = cached_fetch( &idx.cache, CacheSpec { bucket: &bucket, @@ -49,11 +50,12 @@ impl Market<'_> { policy, || idx.provider.market().quote(&resolved), Ok, - )?); - } - let session = latest_session(Utc::now()); - for quote in &mut quotes { - mark_behind_session(&mut quote.data, session); + )?; + // The session rule is IDX's; other exchanges keep other hours. + if resolved.ends_with(".JK") { + mark_behind_session(&mut quote.data, session); + } + quotes.push(quote); } Ok(quotes) } @@ -137,9 +139,11 @@ 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); + if region == ScreenRegion::Id { + let session = latest_session(Utc::now()); + for quote in &mut fetched.data { + mark_behind_session(quote, session); + } } 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 /// are not known offline, so on a weekday holiday this is the holiday itself. -pub fn latest_session(now: DateTime) -> NaiveDate { +pub(crate) fn latest_session(now: DateTime) -> NaiveDate { let now = now.with_timezone(&idx_offset()); let mut day = now.date_naive(); if now.time() < SESSION_END { diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index cf7a105..08580f9 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -116,7 +116,8 @@ impl IdxServer { #[tool( 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." +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) -> ToolResult { 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( 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) -> ToolResult { let ScreenParams { diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 41eaca8..d2ac9b4 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. `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": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -457,7 +457,7 @@ "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": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eddb6ca..ae55edd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -78,8 +78,10 @@ WIB as the fallback. - 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. + by hours, and on an exchange holiday every quote reads as behind. Only `.JK` + 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; 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 e08d1e6..13aa94d 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. `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 | | --- | --- | --- | --- | @@ -137,7 +137,7 @@ Download or refresh the KSEI ownership snapshot (several MB) into the local data ## `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 | | --- | --- | --- | --- |