diff --git a/src/api/mod.rs b/src/api/mod.rs index 18bd8de..6266c60 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,6 +2,8 @@ pub mod msn; pub mod types; pub mod yahoo; +use chrono::FixedOffset; + use crate::config::{HistoryProviderKind, ProviderKind}; use crate::error::IdxError; use types::{ @@ -9,6 +11,14 @@ use types::{ NewsItem, Period, Quote, SentimentData, }; +/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp +/// candles in UTC, so day boundaries must be taken in this zone. +pub(crate) const IDX_UTC_OFFSET_SECS: i32 = 7 * 3600; + +pub(crate) fn idx_offset() -> FixedOffset { + FixedOffset::east_opt(IDX_UTC_OFFSET_SECS).expect("valid WIB offset") +} + pub trait QuoteProvider { fn quote(&self, symbol: &str) -> Result; } diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index e777f0d..4e3a8fc 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -648,7 +648,10 @@ pub(super) fn parse_history( let volume = series_volume_at(series, idx); out.push(Ohlc { - date: timestamp.date_naive(), + // MSN stamps each IDX day at local midnight (17:00Z the day before). + date: timestamp + .with_timezone(&crate::api::idx_offset()) + .date_naive(), open, high, low, @@ -935,7 +938,8 @@ mod tests { let history = parse_history("BBCA.JK", &raw).expect("chart history should parse"); assert_eq!(history.len(), 3); - assert_eq!(history[0].date.to_string(), "2026-01-13"); + // `2026-01-13T17:00:00Z` is midnight WIB on the 14th, the trading day it belongs to. + assert_eq!(history[0].date.to_string(), "2026-01-14"); assert_eq!(history[0].open, 8000); assert_eq!(history[0].high, 8000); assert_eq!(history[0].low, 8000); diff --git a/src/api/msn/parse.rs b/src/api/msn/parse.rs index 3cb926e..7a963d7 100644 --- a/src/api/msn/parse.rs +++ b/src/api/msn/parse.rs @@ -78,7 +78,7 @@ mod tests { let history = parse_history_from_str("BBCA.JK", &raw).expect("chart fixture parsed"); assert_eq!(history.len(), 3); - assert_eq!(history[0].date.to_string(), "2026-01-13"); + assert_eq!(history[0].date.to_string(), "2026-01-14"); assert_eq!(history[0].close, 8000); } diff --git a/src/api/yahoo/map.rs b/src/api/yahoo/map.rs index aef8aed..9c2e9e1 100644 --- a/src/api/yahoo/map.rs +++ b/src/api/yahoo/map.rs @@ -87,6 +87,14 @@ pub(super) fn parse_history( .and_then(|i| i.quote.as_ref()) .and_then(|q| q.first()) .ok_or(IdxError::ProviderUnavailable)?; + // Yahoo stamps weekly/monthly bars at local midnight (17:00Z the day before), + // so take the date in the exchange's own offset, not UTC. + let exchange_tz = result + .meta + .as_ref() + .and_then(|m| m.gmtoffset) + .and_then(chrono::FixedOffset::east_opt) + .unwrap_or_else(crate::api::idx_offset); let mut out = Vec::new(); let mut dropped = 0usize; @@ -121,7 +129,7 @@ pub(super) fn parse_history( && let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0) { out.push(Ohlc { - date: dt.date_naive(), + date: dt.with_timezone(&exchange_tz).date_naive(), open, high, low, diff --git a/src/api/yahoo/parse.rs b/src/api/yahoo/parse.rs index fd25299..c5a7008 100644 --- a/src/api/yahoo/parse.rs +++ b/src/api/yahoo/parse.rs @@ -92,6 +92,22 @@ mod tests { assert_eq!(history[0].close, 9875); } + #[test] + fn history_dates_use_exchange_offset_not_utc() { + // Weekly bars are stamped at Monday 00:00 WIB, i.e. Sunday 17:00Z. + let raw = r#"{"chart":{"error":null,"result":[{ + "meta":{"gmtoffset":25200}, + "timestamp":[1790528400], + "indicators":{"quote":[{"open":[6000],"high":[6150],"low":[5975],"close":[6100],"volume":[1000]}]} + }]}}"#; + let history = parse_history_from_str("BBCA.JK", raw).expect("history parsed"); + assert_eq!(history[0].date.to_string(), "2026-09-28"); + + let without_offset = raw.replace(r#""gmtoffset":25200"#, r#""symbol":"BBCA.JK""#); + let history = parse_history_from_str("BBCA.JK", &without_offset).expect("history parsed"); + assert_eq!(history[0].date.to_string(), "2026-09-28"); + } + #[test] fn parses_realistic_fixture_json() { let quote_raw = diff --git a/src/api/yahoo/raw_types.rs b/src/api/yahoo/raw_types.rs index 5fe70f8..7764cd7 100644 --- a/src/api/yahoo/raw_types.rs +++ b/src/api/yahoo/raw_types.rs @@ -70,6 +70,7 @@ pub(super) struct ChartMeta { pub(super) fifty_two_week_low: Option, #[serde(rename = "averageDailyVolume3Month")] pub(super) average_daily_volume_3month: Option, + pub(super) gmtoffset: Option, } #[derive(Debug, Deserialize)]