From 1785ac5c52806480ff55a301c3242aa19b0ff2e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:30:34 +0000 Subject: [PATCH] fix(quote): report listed-but-unpriced stocks as NOMARKETDATA MSN returns suspended names (21 of 896 on 2026-10-02, e.g. WIKA, SUPR) with symbol, name and market cap but no price. These were reported as SYMBOLNOTFOUND, which tells an agent the ticker does not exist. Add a NoMarketData error for this case; unknown tickers stay SYMBOLNOTFOUND. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld3fDSDiBzAaQvgZJX5NKM --- src/api/msn/map.rs | 33 +++++++++++++++++++++++++++------ src/error.rs | 4 ++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index bb3581a..d8bf008 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -15,9 +15,16 @@ use std::collections::HashMap; pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result { 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 @@ -778,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; @@ -935,6 +942,20 @@ mod tests { ); } + #[test] + fn listed_quote_without_price_is_no_market_data() { + let raw: Vec = 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 = 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"); diff --git a/src/error.rs b/src/error.rs index 33dbf53..37ed148 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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, } }