mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
Merge pull request #36: market data fixes
This commit is contained in:
commit
36c121e74d
11 changed files with 302 additions and 77 deletions
|
|
@ -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,21 @@ 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")
|
||||
}
|
||||
|
||||
/// 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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,15 +15,21 @@ use std::collections::HashMap;
|
|||
|
||||
pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, IdxError> {
|
||||
let quote = quotes.first().ok_or(IdxError::ProviderUnavailable)?;
|
||||
let raw_price = quote
|
||||
.price
|
||||
.ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||
let Some(raw_price) = quote.price else {
|
||||
// MSN still lists suspended/untraded names (symbol, name, market cap)
|
||||
// but sends no price. That is a real instrument with no market data.
|
||||
if quote.symbol.is_some() {
|
||||
return Err(IdxError::NoMarketData(format!(
|
||||
"{symbol} is listed but has no price from MSN (it may be suspended)"
|
||||
)));
|
||||
}
|
||||
return Err(IdxError::SymbolNotFound(symbol.to_string()));
|
||||
};
|
||||
let prev_close = quote.price_previous_close.map(round_price);
|
||||
let price = round_price(raw_price);
|
||||
let change = prev_close
|
||||
.map(|previous| price - previous)
|
||||
.or_else(|| quote.price_change.map(round_price))
|
||||
.unwrap_or(0);
|
||||
.or_else(|| quote.price_change.map(round_price));
|
||||
|
||||
let ticker = quote
|
||||
.symbol
|
||||
|
|
@ -50,8 +56,8 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
|
|||
symbol: normalized_symbol(symbol, &ticker),
|
||||
price,
|
||||
change,
|
||||
change_pct: quote.price_change_percent.unwrap_or(0.0),
|
||||
volume: round_u64(quote.accumulated_volume).unwrap_or(0),
|
||||
change_pct: quote.price_change_percent,
|
||||
volume: round_u64(quote.accumulated_volume),
|
||||
market_cap: round_u64(quote.market_cap),
|
||||
week52_high: quote.price_52w_high.map(round_price),
|
||||
week52_low: quote.price_52w_low.map(round_price),
|
||||
|
|
@ -59,6 +65,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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -460,9 +467,15 @@ pub(super) fn parse_sentiment(
|
|||
symbol: &str,
|
||||
raw: &[RawSentiment],
|
||||
) -> Result<SentimentData, IdxError> {
|
||||
let item = raw
|
||||
.first()
|
||||
.ok_or_else(|| IdxError::ParseError("no sentiment data".into()))?;
|
||||
// MSN answers `[]` for tickers nobody has voted on (all IDX names as of
|
||||
// 2026-10). That is "no votes", not a malformed response.
|
||||
let Some(item) = raw.first() else {
|
||||
let ticker = ticker_from_symbol(symbol).unwrap_or_default();
|
||||
return Ok(SentimentData {
|
||||
symbol: normalized_symbol(symbol, &ticker),
|
||||
statistics: Vec::new(),
|
||||
});
|
||||
};
|
||||
let stats = item
|
||||
.sentiment_statistics
|
||||
.as_ref()
|
||||
|
|
@ -578,8 +591,7 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
|
|||
let prev_close = q.price_previous_close.map(round_price);
|
||||
let change = prev_close
|
||||
.map(|pc| price - pc)
|
||||
.or_else(|| q.price_change.map(round_price))
|
||||
.unwrap_or(0);
|
||||
.or_else(|| q.price_change.map(round_price));
|
||||
let ticker = q
|
||||
.symbol
|
||||
.as_deref()
|
||||
|
|
@ -603,8 +615,8 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
|
|||
symbol: normalized_symbol(&ticker, &ticker),
|
||||
price,
|
||||
change,
|
||||
change_pct: q.price_change_percent.unwrap_or(0.0),
|
||||
volume: round_u64(q.accumulated_volume).unwrap_or(0),
|
||||
change_pct: q.price_change_percent,
|
||||
volume: round_u64(q.accumulated_volume),
|
||||
market_cap: round_u64(q.market_cap),
|
||||
week52_high: q.price_52w_high.map(round_price),
|
||||
week52_low: q.price_52w_low.map(round_price),
|
||||
|
|
@ -612,6 +624,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();
|
||||
|
|
@ -648,7 +661,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,
|
||||
|
|
@ -769,9 +785,9 @@ fn collect_earnings(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
KeyRatios, RawChartResponse, RawFinancialStatement, RawNewsFeed, RawScreenerResponse,
|
||||
RawSentiment, parse_financial_statements, parse_fundamentals, parse_history, parse_news,
|
||||
parse_screener_results, parse_sentiment,
|
||||
KeyRatios, MsnQuote, RawChartResponse, RawFinancialStatement, RawNewsFeed,
|
||||
RawScreenerResponse, RawSentiment, parse_financial_statements, parse_fundamentals,
|
||||
parse_history, parse_news, parse_quote, parse_screener_results, parse_sentiment,
|
||||
};
|
||||
use crate::error::IdxError;
|
||||
|
||||
|
|
@ -850,7 +866,7 @@ mod tests {
|
|||
assert_eq!(quotes.len(), 2);
|
||||
assert_eq!(quotes[0].symbol, "BBCA.JK");
|
||||
assert_eq!(quotes[0].price, 9_875);
|
||||
assert_eq!(quotes[0].change, 117);
|
||||
assert_eq!(quotes[0].change, Some(117));
|
||||
assert_eq!(quotes[0].market_cap, Some(1_215_200_000_000_000));
|
||||
assert_eq!(quotes[0].range_signal.as_deref(), Some("upper"));
|
||||
assert_eq!(quotes[0].avg_volume, Some(10_000_000));
|
||||
|
|
@ -926,6 +942,52 @@ 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 missing_change_and_volume_stay_missing() {
|
||||
let raw: Vec<MsnQuote> = serde_json::from_str(r#"[{"symbol":"BBCA","price":6100.0}]"#)
|
||||
.expect("quote should deserialize");
|
||||
let quote = parse_quote("BBCA.JK", &raw).expect("quote parsed");
|
||||
assert_eq!(quote.change, None);
|
||||
assert_eq!(quote.change_pct, None);
|
||||
assert_eq!(quote.volume, None);
|
||||
let json = serde_json::to_value("e).unwrap();
|
||||
assert!(json["change_pct"].is_null() && json["volume"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_quote_without_price_is_no_market_data() {
|
||||
let raw: Vec<MsnQuote> = serde_json::from_str(
|
||||
r#"[{"symbol":"WIKA","shortName":"Wijaya Karya","marketCap":8080746000000.0}]"#,
|
||||
)
|
||||
.expect("quote should deserialize");
|
||||
let err = parse_quote("WIKA.JK", &raw).unwrap_err();
|
||||
assert!(matches!(err, IdxError::NoMarketData(_)), "{err:?}");
|
||||
|
||||
let raw: Vec<MsnQuote> = serde_json::from_str("[{}]").expect("quote should deserialize");
|
||||
let err = parse_quote("ZZZZ.JK", &raw).unwrap_err();
|
||||
assert!(matches!(err, IdxError::SymbolNotFound(_)), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sentiment_response_is_empty_data_not_an_error() {
|
||||
let sentiment = parse_sentiment("BBCA", &[]).expect("empty sentiment is valid");
|
||||
assert_eq!(sentiment.symbol, "BBCA.JK");
|
||||
assert!(sentiment.statistics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_msn_chart_price_only_fixture_as_synthetic_ohlc() {
|
||||
let raw: Vec<RawChartResponse> = serde_json::from_str(include_str!(
|
||||
|
|
@ -935,7 +997,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);
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ mod tests {
|
|||
let quote = parse_quote_from_str("BBCA.JK", &raw).expect("quote parsed");
|
||||
assert_eq!(quote.symbol, "BBCA.JK");
|
||||
assert_eq!(quote.price, 9875);
|
||||
assert_eq!(quote.change, 117);
|
||||
assert_eq!(quote.volume, 12_300_000);
|
||||
assert_eq!(quote.change, Some(117));
|
||||
assert_eq!(quote.volume, Some(12_300_000));
|
||||
assert_eq!(quote.market_cap, Some(1_215_200_000_000_000));
|
||||
assert_eq!(quote.avg_volume, Some(10_000_000));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 _};
|
||||
|
||||
|
|
@ -13,13 +13,18 @@ pub struct Quote {
|
|||
pub price: i64,
|
||||
/// Absolute day change in IDR (whole Rupiah), computed as
|
||||
/// `regularMarketPrice - previousClose` using rounded integer prices.
|
||||
#[serde(deserialize_with = "de_i64_from_number")]
|
||||
pub change: i64,
|
||||
/// `None` when the provider gives neither a previous close nor a change.
|
||||
#[serde(default, deserialize_with = "de_opt_i64_from_number")]
|
||||
pub change: Option<i64>,
|
||||
/// Percentage day change as decimal percent (`0-100` scale), computed from
|
||||
/// Yahoo `regularMarketPrice` and `previousClose` raw floats.
|
||||
pub change_pct: f64,
|
||||
/// `None` when the provider does not report it (never a fake `0.0`).
|
||||
#[serde(default)]
|
||||
pub change_pct: Option<f64>,
|
||||
/// Traded regular market volume (shares), from `regularMarketVolume`.
|
||||
pub volume: u64,
|
||||
/// `None` when the provider does not report it (never a fake `0`).
|
||||
#[serde(default, deserialize_with = "de_opt_u64_from_number")]
|
||||
pub volume: Option<u64>,
|
||||
/// Company market capitalization in IDR, from `marketCap`.
|
||||
#[serde(default, deserialize_with = "de_opt_u64_from_number")]
|
||||
pub market_cap: Option<u64>,
|
||||
|
|
@ -41,6 +46,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.
|
||||
|
|
|
|||
|
|
@ -22,14 +22,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
|
|||
|
||||
let price = round_price(raw_price);
|
||||
let prev_close = raw_prev_close.map(round_price);
|
||||
let change = prev_close.map_or(0, |p| price - p);
|
||||
let change_pct = raw_prev_close.map_or(0.0, |p| {
|
||||
if p != 0.0 {
|
||||
((raw_price - p) / p) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
});
|
||||
let change = prev_close.map(|p| price - p);
|
||||
let change_pct = raw_prev_close
|
||||
.filter(|p| *p != 0.0)
|
||||
.map(|p| ((raw_price - p) / p) * 100.0);
|
||||
|
||||
let (week52_position, range_signal) = match (meta.fifty_two_week_low, meta.fifty_two_week_high)
|
||||
{
|
||||
|
|
@ -52,7 +48,7 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
|
|||
price,
|
||||
change,
|
||||
change_pct,
|
||||
volume: meta.regular_market_volume.unwrap_or(0),
|
||||
volume: meta.regular_market_volume,
|
||||
market_cap: meta.market_cap,
|
||||
week52_high: meta.fifty_two_week_high.map(round_price),
|
||||
week52_low: meta.fifty_two_week_low.map(round_price),
|
||||
|
|
@ -60,6 +56,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())),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ pub(super) struct ChartMeta {
|
|||
pub(super) fifty_two_week_low: Option<f64>,
|
||||
#[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)]
|
||||
|
|
|
|||
|
|
@ -615,9 +615,13 @@ pub fn handle(
|
|||
} => {
|
||||
let filter_key = screener_filter_key(filter)?;
|
||||
let region_key = screener_region_key(region)?;
|
||||
// For filters that fall back to topperfs, fetch all stocks so
|
||||
// client-side sorting picks the correct top N.
|
||||
let needs_full_fetch = matches!(filter.as_str(), "high-volume" | "large-cap");
|
||||
// Filters re-sorted client-side need the whole MSN list: MSN orders
|
||||
// by 1-year return, so truncating first and then sorting by day
|
||||
// change made `--limit 3` return different stocks than `--limit 25`.
|
||||
let needs_full_fetch = matches!(
|
||||
filter.as_str(),
|
||||
"high-volume" | "large-cap" | "top-performers" | "worst-performers"
|
||||
);
|
||||
let fetch_limit = if needs_full_fetch { 500 } else { *limit };
|
||||
let bucket = cache_bucket(provider.kind(), "screen");
|
||||
let key = format!("{filter}:{region}:{fetch_limit}");
|
||||
|
|
@ -958,16 +962,16 @@ fn sort_screener_quotes(quotes: &mut [Quote], filter: &str) {
|
|||
match filter {
|
||||
"top-performers" => {
|
||||
quotes.sort_by(|a, b| {
|
||||
b.change_pct
|
||||
.partial_cmp(&a.change_pct)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
let pa = a.change_pct.unwrap_or(f64::MIN);
|
||||
let pb = b.change_pct.unwrap_or(f64::MIN);
|
||||
pb.partial_cmp(&pa).unwrap_or(Ordering::Equal)
|
||||
});
|
||||
}
|
||||
"worst-performers" => {
|
||||
quotes.sort_by(|a, b| {
|
||||
a.change_pct
|
||||
.partial_cmp(&b.change_pct)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
let pa = a.change_pct.unwrap_or(f64::MAX);
|
||||
let pb = b.change_pct.unwrap_or(f64::MAX);
|
||||
pa.partial_cmp(&pb).unwrap_or(Ordering::Equal)
|
||||
});
|
||||
}
|
||||
"52w-high" => {
|
||||
|
|
@ -1047,9 +1051,9 @@ mod tests {
|
|||
Quote {
|
||||
symbol: "A".into(),
|
||||
price: 100,
|
||||
change: 1,
|
||||
change_pct: 1.0,
|
||||
volume: 100,
|
||||
change: Some(1),
|
||||
change_pct: Some(1.0),
|
||||
volume: Some(100),
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
|
|
@ -1057,13 +1061,14 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
Quote {
|
||||
symbol: "B".into(),
|
||||
price: 200,
|
||||
change: 10,
|
||||
change_pct: 5.0,
|
||||
volume: 200,
|
||||
change: Some(10),
|
||||
change_pct: Some(5.0),
|
||||
volume: Some(200),
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
|
|
@ -1071,13 +1076,14 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
Quote {
|
||||
symbol: "C".into(),
|
||||
price: 150,
|
||||
change: 5,
|
||||
change_pct: 3.0,
|
||||
volume: 150,
|
||||
change: Some(5),
|
||||
change_pct: Some(3.0),
|
||||
volume: Some(150),
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
|
|
@ -1085,11 +1091,12 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
];
|
||||
|
||||
super::sort_screener_quotes(&mut quotes, "top-performers");
|
||||
let pcts: Vec<f64> = quotes.iter().map(|q| q.change_pct).collect();
|
||||
let pcts: Vec<f64> = quotes.iter().filter_map(|q| q.change_pct).collect();
|
||||
assert_eq!(pcts, vec![5.0, 3.0, 1.0]);
|
||||
}
|
||||
|
||||
|
|
@ -1101,9 +1108,9 @@ mod tests {
|
|||
Quote {
|
||||
symbol: "A".into(),
|
||||
price: 100,
|
||||
change: 1,
|
||||
change_pct: 1.0,
|
||||
volume: 100,
|
||||
change: Some(1),
|
||||
change_pct: Some(1.0),
|
||||
volume: Some(100),
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
|
|
@ -1111,13 +1118,14 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
Quote {
|
||||
symbol: "B".into(),
|
||||
price: 200,
|
||||
change: -10,
|
||||
change_pct: -5.0,
|
||||
volume: 200,
|
||||
change: Some(-10),
|
||||
change_pct: Some(-5.0),
|
||||
volume: Some(200),
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
|
|
@ -1125,13 +1133,14 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
Quote {
|
||||
symbol: "C".into(),
|
||||
price: 150,
|
||||
change: -3,
|
||||
change_pct: -2.0,
|
||||
volume: 150,
|
||||
change: Some(-3),
|
||||
change_pct: Some(-2.0),
|
||||
volume: Some(150),
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
|
|
@ -1139,14 +1148,46 @@ mod tests {
|
|||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
},
|
||||
];
|
||||
|
||||
super::sort_screener_quotes(&mut quotes, "worst-performers");
|
||||
let pcts: Vec<f64> = quotes.iter().map(|q| q.change_pct).collect();
|
||||
let pcts: Vec<f64> = quotes.iter().filter_map(|q| q.change_pct).collect();
|
||||
assert_eq!(pcts, vec![-5.0, -2.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_screener_puts_unknown_change_last_both_ways() {
|
||||
let mut quotes = vec![
|
||||
sample_quote("U", None),
|
||||
sample_quote("A", Some(1.0)),
|
||||
sample_quote("B", Some(-5.0)),
|
||||
];
|
||||
super::sort_screener_quotes(&mut quotes, "worst-performers");
|
||||
assert_eq!(quotes.last().unwrap().symbol, "U");
|
||||
super::sort_screener_quotes(&mut quotes, "top-performers");
|
||||
assert_eq!(quotes.last().unwrap().symbol, "U");
|
||||
}
|
||||
|
||||
fn sample_quote(symbol: &str, change_pct: Option<f64>) -> crate::api::types::Quote {
|
||||
crate::api::types::Quote {
|
||||
symbol: symbol.into(),
|
||||
price: 100,
|
||||
change: None,
|
||||
change_pct,
|
||||
volume: None,
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
week52_position: None,
|
||||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_screener_filter_returns_error() {
|
||||
let err = super::screener_filter_key("bogus").expect_err("invalid filter should fail");
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ pub enum IdxError {
|
|||
PdfParseError(String),
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("no market data: {0}")]
|
||||
NoMarketData(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
|
|
@ -50,6 +52,7 @@ pub enum ErrorCode {
|
|||
DatabaseError,
|
||||
PdfParseError,
|
||||
InvalidInput,
|
||||
NoMarketData,
|
||||
}
|
||||
|
||||
impl IdxError {
|
||||
|
|
@ -69,6 +72,7 @@ impl IdxError {
|
|||
Self::DatabaseError(_) => ErrorCode::DatabaseError,
|
||||
Self::PdfParseError(_) => ErrorCode::PdfParseError,
|
||||
Self::InvalidInput(_) => ErrorCode::InvalidInput,
|
||||
Self::NoMarketData(_) => ErrorCode::NoMarketData,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,20 +53,22 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
|||
]);
|
||||
|
||||
for q in quotes {
|
||||
let pct = format!("{:+.2}%", q.change_pct);
|
||||
let pct_cell = if no_color {
|
||||
Cell::new(pct)
|
||||
} else if q.change_pct >= 0.0 {
|
||||
Cell::new(pct).fg(Color::Green)
|
||||
} else {
|
||||
Cell::new(pct).fg(Color::Red)
|
||||
let pct_cell = match q.change_pct {
|
||||
None => Cell::new("-"),
|
||||
Some(pct) if no_color => Cell::new(format!("{pct:+.2}%")),
|
||||
Some(pct) if pct >= 0.0 => Cell::new(format!("{pct:+.2}%")).fg(Color::Green),
|
||||
Some(pct) => Cell::new(format!("{pct:+.2}%")).fg(Color::Red),
|
||||
};
|
||||
table.add_row(vec![
|
||||
Cell::new(&q.symbol),
|
||||
Cell::new(format_idr(q.price)),
|
||||
Cell::new(format!("{:+}", q.change)),
|
||||
Cell::new(
|
||||
q.change
|
||||
.map(|c| format!("{c:+}"))
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
pct_cell,
|
||||
Cell::new(format_u64(q.volume)),
|
||||
Cell::new(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())),
|
||||
Cell::new(
|
||||
q.market_cap
|
||||
.map(format_u64)
|
||||
|
|
@ -77,9 +79,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();
|
||||
|
|
@ -768,6 +791,10 @@ pub fn print_earnings(report: &EarningsReport) -> Result<(), IdxError> {
|
|||
}
|
||||
|
||||
pub fn print_sentiment(data: &SentimentData) -> Result<(), IdxError> {
|
||||
if data.statistics.is_empty() {
|
||||
println!("No sentiment votes for {}.", data.symbol);
|
||||
return Ok(());
|
||||
}
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
|
|
@ -858,9 +885,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: None,
|
||||
change_pct: None,
|
||||
volume: None,
|
||||
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