mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
a0a2cac7cf
commit
834e47b336
7 changed files with 85 additions and 20 deletions
|
|
@ -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))] {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue