mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
7d55be80c5
commit
a0a2cac7cf
20 changed files with 155 additions and 24 deletions
|
|
@ -94,19 +94,29 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A quote whose last trade is over a week old is almost always a suspended
|
/// A quote whose last trade is over a week old is almost always a suspended
|
||||||
/// stock; say so under the table instead of letting it pass as current.
|
/// stock; say so under the table instead of letting it pass as current. One
|
||||||
|
/// from before the latest session is usually the provider lagging the close.
|
||||||
fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime<chrono::Utc>) -> Vec<String> {
|
fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime<chrono::Utc>) -> Vec<String> {
|
||||||
quotes
|
quotes
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|q| {
|
.filter_map(|q| {
|
||||||
let as_of = q.as_of?;
|
let as_of = q.as_of?;
|
||||||
(now.signed_duration_since(as_of) > chrono::Duration::days(7)).then(|| {
|
if now.signed_duration_since(as_of) > chrono::Duration::days(7) {
|
||||||
format!(
|
Some(format!(
|
||||||
"Note: {} last traded on {}; this price is not current.",
|
"Note: {} last traded on {}; this price is not current.",
|
||||||
q.symbol,
|
q.symbol,
|
||||||
as_of.date_naive()
|
as_of.date_naive()
|
||||||
)
|
))
|
||||||
})
|
} else if q.behind_session {
|
||||||
|
Some(format!(
|
||||||
|
"Note: {} last traded on {}, before the latest IDX session; \
|
||||||
|
this price may be out of date.",
|
||||||
|
q.symbol,
|
||||||
|
as_of.date_naive()
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -1399,6 +1409,7 @@ mod tests {
|
||||||
prev_close: None,
|
prev_close: None,
|
||||||
avg_volume: None,
|
avg_volume: None,
|
||||||
as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()),
|
as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()),
|
||||||
|
behind_session: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1418,6 +1429,30 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notes_quotes_behind_the_latest_session() {
|
||||||
|
let now = chrono::DateTime::parse_from_rfc3339("2026-10-16T16:53:00+07:00")
|
||||||
|
.unwrap()
|
||||||
|
.to_utc();
|
||||||
|
let mut behind = quote_as_of("BBCA.JK", "2026-10-15T16:15:00+07:00");
|
||||||
|
behind.behind_session = true;
|
||||||
|
let mut suspended = quote_as_of("WIKA.JK", "2025-02-17T16:14:58+07:00");
|
||||||
|
suspended.behind_session = true;
|
||||||
|
let quotes = [
|
||||||
|
behind,
|
||||||
|
suspended,
|
||||||
|
quote_as_of("BBRI.JK", "2026-10-16T16:15:00+07:00"),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
stale_quote_notes("es, now),
|
||||||
|
vec![
|
||||||
|
"Note: BBCA.JK last traded on 2026-10-15, before the latest IDX session; \
|
||||||
|
this price may be out of date.",
|
||||||
|
"Note: WIKA.JK last traded on 2025-02-17; this price is not current.",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formats_idr_numbers() {
|
fn formats_idr_numbers() {
|
||||||
assert_eq!(format_idr(9875), "9,875");
|
assert_eq!(format_idr(9875), "9,875");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}
|
{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}
|
||||||
{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}
|
{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null}]
|
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null}]
|
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false},{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
[{"symbol":"BBRI.JK","price":5100,"change":210,"change_pct":4.3,"volume":22200000,"market_cap":780000000000000,"week52_high":5400,"week52_low":3900,"week52_position":0.8,"range_signal":"upper","prev_close":4890,"avg_volume":18000000,"as_of":null,"behind_session":false},{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.2,"volume":12300000,"market_cap":1215200000000000,"week52_high":10500,"week52_low":8000,"week52_position":0.75,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.1990161918425908,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null}]
|
[{"symbol":"BBCA.JK","price":9875,"change":117,"change_pct":1.1990161918425908,"volume":12300000,"market_cap":1215200000000000,"week52_high":10250,"week52_low":7800,"week52_position":0.8469387755102041,"range_signal":"upper","prev_close":9758,"avg_volume":10000000,"as_of":null,"behind_session":false}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
|
||||||
prev_close,
|
prev_close,
|
||||||
avg_volume: round_u64(quote.average_volume),
|
avg_volume: round_u64(quote.average_volume),
|
||||||
as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()),
|
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,
|
prev_close,
|
||||||
avg_volume: round_u64(q.average_volume),
|
avg_volume: round_u64(q.average_volume),
|
||||||
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
|
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
|
||||||
|
behind_session: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,12 @@ pub struct Quote {
|
||||||
/// Suspended stocks can carry a last trade months in the past.
|
/// Suspended stocks can carry a last trade months in the past.
|
||||||
#[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
|
||||||
|
/// 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.
|
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
|
||||||
.regular_market_time
|
.regular_market_time
|
||||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||||
.map(|t| t.with_timezone(&crate::api::idx_offset())),
|
.map(|t| t.with_timezone(&crate::api::idx_offset())),
|
||||||
|
behind_session: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ use std::cmp::Ordering;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
|
||||||
|
|
||||||
use crate::api::types::{Interval, Ohlc, Period, Quote};
|
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::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
@ -49,6 +51,10 @@ impl Market<'_> {
|
||||||
Ok,
|
Ok,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
|
let session = latest_session(Utc::now());
|
||||||
|
for quote in &mut quotes {
|
||||||
|
mark_behind_session(&mut quote.data, session);
|
||||||
|
}
|
||||||
Ok(quotes)
|
Ok(quotes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,10 +137,38 @@ 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());
|
||||||
|
for quote in &mut fetched.data {
|
||||||
|
mark_behind_session(quote, session);
|
||||||
|
}
|
||||||
Ok(fetched)
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ScreenQuery {
|
pub struct ScreenQuery {
|
||||||
pub filter: ScreenFilter,
|
pub filter: ScreenFilter,
|
||||||
|
|
@ -320,7 +354,9 @@ fn sort_screener_quotes(quotes: &mut [Quote], filter: ScreenFilter) {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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::api::types::Quote;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
|
@ -339,6 +375,53 @@ mod tests {
|
||||||
prev_close: None,
|
prev_close: None,
|
||||||
avg_volume: None,
|
avg_volume: None,
|
||||||
as_of: 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:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,8 @@ impl IdxServer {
|
||||||
impl IdxServer {
|
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."
|
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."
|
||||||
)]
|
)]
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"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.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,11 @@ MSN candle dates use WIB (UTC+7); Yahoo candle dates use `meta.gmtoffset`, with
|
||||||
WIB as the fallback.
|
WIB as the fallback.
|
||||||
|
|
||||||
### Quote and Screener Data
|
### Quote and Screener Data
|
||||||
- Quotes expose nullable `as_of` last-trade timestamps in WIB. Table output flags
|
- Quotes expose nullable `as_of` last-trade timestamps in WIB, and
|
||||||
quotes whose last trade was more than seven days ago.
|
`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.
|
||||||
- `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.
|
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.
|
||||||
|
|
||||||
| Input | Type | Required | Description |
|
| Input | Type | Required | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue