feat: flag quotes behind the latest IDX session (#50)

MSN can serve the previous session's close for hours after IDX closes;
on 2026-10-16 at 16:53 WIB it still returned the 15th. Quotes and
screener rows now carry `behind_session`, true when `as_of` is before
the latest weekday whose session has ended (16:15 WIB). It is computed
on return, not cached. The quote table notes such quotes, and the
get_quotes description explains the field.

Closes #50

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-18 17:38:48 +00:00
commit a0a2cac7cf
20 changed files with 155 additions and 24 deletions

View file

@ -70,6 +70,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
prev_close,
avg_volume: round_u64(quote.average_volume),
as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()),
behind_session: false,
})
}
@ -718,6 +719,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
prev_close,
avg_volume: round_u64(q.average_volume),
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
behind_session: false,
})
})
.collect();

View file

@ -50,6 +50,12 @@ pub struct Quote {
/// Suspended stocks can carry a last trade months in the past.
#[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
/// quote is returned, not when it is cached.
#[serde(default)]
pub behind_session: bool,
}
/// OHLC candle data normalized from Yahoo Finance chart indicators.

View file

@ -64,6 +64,7 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
.regular_market_time
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
.map(|t| t.with_timezone(&crate::api::idx_offset())),
behind_session: false,
})
}

View file

@ -4,8 +4,10 @@ use std::cmp::Ordering;
use std::fmt;
use std::str::FromStr;
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
use crate::api::types::{Interval, Ohlc, Period, Quote};
use crate::api::{history_provider, history_source};
use crate::api::{history_provider, history_source, idx_offset};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::config::{HistoryProviderKind, ProviderKind};
use crate::error::IdxError;
@ -49,6 +51,10 @@ impl Market<'_> {
Ok,
)?);
}
let session = latest_session(Utc::now());
for quote in &mut quotes {
mark_behind_session(&mut quote.data, session);
}
Ok(quotes)
}
@ -131,10 +137,38 @@ 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);
}
Ok(fetched)
}
}
/// When IDX's post-trading session ends, in WIB; MSN stamps a day's closing
/// quote with this time.
const SESSION_END: NaiveTime = NaiveTime::from_hms_opt(16, 15, 0).expect("valid time");
/// 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 {
let now = now.with_timezone(&idx_offset());
let mut day = now.date_naive();
if now.time() < SESSION_END {
day -= Duration::days(1);
}
while matches!(day.weekday(), Weekday::Sat | Weekday::Sun) {
day -= Duration::days(1);
}
day
}
fn mark_behind_session(quote: &mut Quote, session: NaiveDate) {
quote.behind_session = quote
.as_of
.is_some_and(|as_of| as_of.with_timezone(&idx_offset()).date_naive() < session);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScreenQuery {
pub filter: ScreenFilter,
@ -320,7 +354,9 @@ fn sort_screener_quotes(quotes: &mut [Quote], filter: ScreenFilter) {
#[cfg(test)]
mod tests {
use super::{ScreenFilter, ScreenRegion, sort_screener_quotes};
use super::{
ScreenFilter, ScreenRegion, latest_session, mark_behind_session, sort_screener_quotes,
};
use crate::api::types::Quote;
use crate::error::IdxError;
@ -339,6 +375,53 @@ mod tests {
prev_close: None,
avg_volume: None,
as_of: None,
behind_session: false,
}
}
fn wib(raw: &str) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::parse_from_rfc3339(&format!("{raw}+07:00"))
.unwrap()
.to_utc()
}
fn day(raw: &str) -> chrono::NaiveDate {
chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d").unwrap()
}
#[test]
fn latest_session_is_the_last_weekday_past_the_post_trading_close() {
// 2026-10-16 is a Friday.
for (now, session) in [
("2026-10-16T16:53:00", "2026-10-16"),
("2026-10-16T16:15:00", "2026-10-16"),
("2026-10-16T16:14:59", "2026-10-15"),
("2026-10-16T00:30:00", "2026-10-15"),
("2026-10-17T12:00:00", "2026-10-16"),
("2026-10-18T23:59:59", "2026-10-16"),
("2026-10-19T10:00:00", "2026-10-16"),
("2026-10-19T16:15:00", "2026-10-19"),
] {
assert_eq!(latest_session(wib(now)), day(session), "now {now}");
}
}
#[test]
fn quote_from_before_the_latest_session_is_behind_it() {
// Issue #50: at 16:53 WIB on the 16th, MSN still served the 15th.
let session = latest_session(wib("2026-10-16T16:53:00"));
for (as_of, behind) in [
(Some("2026-10-15T16:15:00+07:00"), true),
(Some("2026-10-16T16:15:00+07:00"), false),
(Some("2026-10-16T09:15:00Z"), false),
(Some("2026-10-15T17:30:00Z"), false),
(Some("2025-02-17T16:14:58+07:00"), true),
(None, false),
] {
let mut quote = sample_quote("BBCA.JK", None);
quote.as_of = as_of.map(|t| chrono::DateTime::parse_from_rfc3339(t).unwrap());
mark_behind_session(&mut quote, session);
assert_eq!(quote.behind_session, behind, "as_of {as_of:?}");
}
}