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

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