mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 01:35:36 +00:00
feat(quote): add as_of last-trade time and flag stale quotes
Quotes had no timestamp, so a suspended stock's last price looked current: Yahoo returns WIKA at 204 with regularMarketTime 2025-02-17. Add `as_of` (RFC 3339 in WIB) from MSN `timeLastTraded` / Yahoo `regularMarketTime`, also on screener rows. The table prints a note under any quote whose last trade is over a week old. The field is additive and defaults to null, so existing cache entries still load. 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
1785ac5c52
commit
4ddf1118d4
8 changed files with 98 additions and 2 deletions
|
|
@ -19,6 +19,13 @@ pub(crate) fn idx_offset() -> FixedOffset {
|
|||
FixedOffset::east_opt(IDX_UTC_OFFSET_SECS).expect("valid WIB offset")
|
||||
}
|
||||
|
||||
/// Parse a provider's RFC 3339 trade time and express it in WIB.
|
||||
pub(crate) fn parse_as_of(raw: Option<&str>) -> Option<chrono::DateTime<FixedOffset>> {
|
||||
chrono::DateTime::parse_from_rfc3339(raw?)
|
||||
.ok()
|
||||
.map(|t| t.with_timezone(&idx_offset()))
|
||||
}
|
||||
|
||||
pub trait QuoteProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
|
|||
range_signal,
|
||||
prev_close,
|
||||
avg_volume: round_u64(quote.average_volume),
|
||||
as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -625,6 +626,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
|
|||
range_signal,
|
||||
prev_close,
|
||||
avg_volume: round_u64(q.average_volume),
|
||||
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -942,6 +944,19 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_as_of_is_last_trade_in_wib() {
|
||||
let raw: Vec<MsnQuote> = serde_json::from_str(
|
||||
r#"[{"symbol":"BBCA","price":6100.0,"timeLastTraded":"2026-10-02T09:15:00Z"}]"#,
|
||||
)
|
||||
.expect("quote should deserialize");
|
||||
let quote = parse_quote("BBCA.JK", &raw).expect("quote parsed");
|
||||
assert_eq!(
|
||||
quote.as_of.unwrap().to_rfc3339(),
|
||||
"2026-10-02T16:15:00+07:00"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_quote_without_price_is_no_market_data() {
|
||||
let raw: Vec<MsnQuote> = serde_json::from_str(
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub(crate) struct MsnQuote {
|
|||
pub(crate) market_cap: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) return_ytd: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) time_last_traded: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use chrono::NaiveDate;
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate};
|
||||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
|
||||
|
||||
|
|
@ -41,6 +41,11 @@ pub struct Quote {
|
|||
/// Average daily volume for the last 3 months (shares), from
|
||||
/// `averageDailyVolume3Month`.
|
||||
pub avg_volume: Option<u64>,
|
||||
/// Time of the last trade in WIB (UTC+7), from MSN `timeLastTraded` or
|
||||
/// Yahoo `regularMarketTime`. `None` when the provider does not say.
|
||||
/// Suspended stocks can carry a last trade months in the past.
|
||||
#[serde(default)]
|
||||
pub as_of: Option<DateTime<FixedOffset>>,
|
||||
}
|
||||
|
||||
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
|
|||
range_signal,
|
||||
prev_close,
|
||||
avg_volume: meta.average_daily_volume_3month,
|
||||
as_of: meta
|
||||
.regular_market_time
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.map(|t| t.with_timezone(&crate::api::idx_offset())),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ pub(super) struct ChartMeta {
|
|||
#[serde(rename = "averageDailyVolume3Month")]
|
||||
pub(super) average_daily_volume_3month: Option<u64>,
|
||||
pub(super) gmtoffset: Option<i32>,
|
||||
pub(super) regular_market_time: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1055,6 +1055,7 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
Quote {
|
||||
symbol: "B".into(),
|
||||
|
|
@ -1069,6 +1070,7 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
Quote {
|
||||
symbol: "C".into(),
|
||||
|
|
@ -1083,6 +1085,7 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -1109,6 +1112,7 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
Quote {
|
||||
symbol: "B".into(),
|
||||
|
|
@ -1123,6 +1127,7 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
Quote {
|
||||
symbol: "C".into(),
|
||||
|
|
@ -1137,6 +1142,7 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -77,9 +77,30 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
|||
}
|
||||
|
||||
println!("{table}");
|
||||
for note in stale_quote_notes(quotes, chrono::Utc::now()) {
|
||||
println!("{note}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime<chrono::Utc>) -> Vec<String> {
|
||||
quotes
|
||||
.iter()
|
||||
.filter_map(|q| {
|
||||
let as_of = q.as_of?;
|
||||
(now.signed_duration_since(as_of) > chrono::Duration::days(7)).then(|| {
|
||||
format!(
|
||||
"Note: {} last traded on {}; this price is not current.",
|
||||
q.symbol,
|
||||
as_of.date_naive()
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> {
|
||||
println!("{}", format!("History for {symbol}").bold());
|
||||
let mut table = Table::new();
|
||||
|
|
@ -862,9 +883,44 @@ fn truncate_url(url: &str) -> String {
|
|||
mod tests {
|
||||
use super::{
|
||||
format_earnings_period, format_idr, format_signal, format_table_date, format_u64,
|
||||
humanize_metric_key,
|
||||
humanize_metric_key, stale_quote_notes,
|
||||
};
|
||||
use crate::analysis::signals::Signal;
|
||||
use crate::api::types::Quote;
|
||||
|
||||
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
|
||||
Quote {
|
||||
symbol: symbol.into(),
|
||||
price: 204,
|
||||
change: 0,
|
||||
change_pct: 0.0,
|
||||
volume: 0,
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
week52_position: None,
|
||||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_only_quotes_older_than_a_week() {
|
||||
let now = chrono::DateTime::parse_from_rfc3339("2026-10-02T10:00:00Z")
|
||||
.unwrap()
|
||||
.to_utc();
|
||||
let quotes = [
|
||||
quote_as_of("WIKA.JK", "2025-02-17T16:14:58+07:00"),
|
||||
quote_as_of("BBCA.JK", "2026-10-02T16:15:00+07:00"),
|
||||
quote_as_of("TLKM.JK", "2026-09-26T16:15:00+07:00"),
|
||||
];
|
||||
assert_eq!(
|
||||
stale_quote_notes("es, now),
|
||||
vec!["Note: WIKA.JK last traded on 2025-02-17; this price is not current."]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_idr_numbers() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue