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]
|
||||
fn msn_screen_rejects_invalid_totals_without_populating_cache() {
|
||||
for (name, count) in [("missing", None), ("incomplete", Some(504))] {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,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<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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue