mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
fix(history): date candles in Jakarta time, not UTC
MSN stamps each IDX trading day at local midnight (17:00Z the previous day), so every MSN candle was labelled one day early. Yahoo weekly and monthly bars have the same stamp and were labelled Sunday / last day of the previous month. Take the date in the exchange offset instead: Yahoo's meta.gmtoffset when present, else WIB (UTC+7). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM
This commit is contained in:
parent
793d765e5a
commit
0ba3d805a7
6 changed files with 43 additions and 4 deletions
|
|
@ -2,6 +2,8 @@ pub mod msn;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod yahoo;
|
pub mod yahoo;
|
||||||
|
|
||||||
|
use chrono::FixedOffset;
|
||||||
|
|
||||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use types::{
|
use types::{
|
||||||
|
|
@ -9,6 +11,14 @@ use types::{
|
||||||
NewsItem, Period, Quote, SentimentData,
|
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 {
|
pub trait QuoteProvider {
|
||||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -648,7 +648,10 @@ pub(super) fn parse_history(
|
||||||
let volume = series_volume_at(series, idx);
|
let volume = series_volume_at(series, idx);
|
||||||
|
|
||||||
out.push(Ohlc {
|
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,
|
open,
|
||||||
high,
|
high,
|
||||||
low,
|
low,
|
||||||
|
|
@ -935,7 +938,8 @@ mod tests {
|
||||||
let history = parse_history("BBCA.JK", &raw).expect("chart history should parse");
|
let history = parse_history("BBCA.JK", &raw).expect("chart history should parse");
|
||||||
|
|
||||||
assert_eq!(history.len(), 3);
|
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].open, 8000);
|
||||||
assert_eq!(history[0].high, 8000);
|
assert_eq!(history[0].high, 8000);
|
||||||
assert_eq!(history[0].low, 8000);
|
assert_eq!(history[0].low, 8000);
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ mod tests {
|
||||||
let history = parse_history_from_str("BBCA.JK", &raw).expect("chart fixture parsed");
|
let history = parse_history_from_str("BBCA.JK", &raw).expect("chart fixture parsed");
|
||||||
|
|
||||||
assert_eq!(history.len(), 3);
|
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);
|
assert_eq!(history[0].close, 8000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,14 @@ pub(super) fn parse_history(
|
||||||
.and_then(|i| i.quote.as_ref())
|
.and_then(|i| i.quote.as_ref())
|
||||||
.and_then(|q| q.first())
|
.and_then(|q| q.first())
|
||||||
.ok_or(IdxError::ProviderUnavailable)?;
|
.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 out = Vec::new();
|
||||||
let mut dropped = 0usize;
|
let mut dropped = 0usize;
|
||||||
|
|
@ -121,7 +129,7 @@ pub(super) fn parse_history(
|
||||||
&& let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0)
|
&& let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0)
|
||||||
{
|
{
|
||||||
out.push(Ohlc {
|
out.push(Ohlc {
|
||||||
date: dt.date_naive(),
|
date: dt.with_timezone(&exchange_tz).date_naive(),
|
||||||
open,
|
open,
|
||||||
high,
|
high,
|
||||||
low,
|
low,
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,22 @@ mod tests {
|
||||||
assert_eq!(history[0].close, 9875);
|
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]
|
#[test]
|
||||||
fn parses_realistic_fixture_json() {
|
fn parses_realistic_fixture_json() {
|
||||||
let quote_raw =
|
let quote_raw =
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ pub(super) struct ChartMeta {
|
||||||
pub(super) fifty_two_week_low: Option<f64>,
|
pub(super) fifty_two_week_low: Option<f64>,
|
||||||
#[serde(rename = "averageDailyVolume3Month")]
|
#[serde(rename = "averageDailyVolume3Month")]
|
||||||
pub(super) average_daily_volume_3month: Option<u64>,
|
pub(super) average_daily_volume_3month: Option<u64>,
|
||||||
|
pub(super) gmtoffset: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue