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]
fn msn_screen_rejects_invalid_totals_without_populating_cache() {
for (name, count) in [("missing", None), ("incomplete", Some(504))] {

View file

@ -51,8 +51,9 @@ pub struct Quote {
#[serde(default)]
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
/// 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,

View file

@ -34,10 +34,11 @@ impl Market<'_> {
) -> Result<Vec<Fetched<Quote>>, 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,12 +50,13 @@ impl Market<'_> {
policy,
|| idx.provider.market().quote(&resolved),
Ok,
)?);
}
let session = latest_session(Utc::now());
for quote in &mut quotes {
)?;
// 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,10 +139,12 @@ impl Market<'_> {
)?;
sort_screener_quotes(&mut fetched.data, filter);
fetched.data.truncate(limit);
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<Utc>) -> NaiveDate {
pub(crate) 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 {

View file

@ -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<SymbolsParams>) -> 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<ScreenParams>) -> ToolResult {
let ScreenParams {

View file

@ -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": {

View file

@ -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

View file

@ -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 |
| --- | --- | --- | --- |