From 2207c928b2eb63d49c6757e83e52fb2b3019cdbb Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:08:03 +0000 Subject: [PATCH] fix(msn): correct API response parsing for profile, financials, insights, screener, history - financials: fix incomeStatement serde rename (was incomeStatements, API sends singular) - financials: flatten nested sub-objects (income/revenue/expense/cash) in parse_statement_section - insights: rewrite RawInsight to match actual API shape ({insights:[{insightName,insightStatement,category}]}) - insights: group insight items into highlights (non-risk) and risks by category - screener: build Quote directly in parse_screener_results, skip stocks with no price - profile: use short_name fallback when long_name is null, hide empty fields - history: map Finance/Charts 404 to Unsupported with clear IDX-specific message - tests: update MSN history/technical tests to use mock provider --- src/api/msn/map.rs | 144 ++++++++++++++++++++++++++++++++++++--- src/api/msn/mod.rs | 14 +++- src/api/msn/raw_types.rs | 19 ++++-- src/output/table.rs | 90 ++++++++++++++++++++---- tests/cli.rs | 22 +++--- 5 files changed, 251 insertions(+), 38 deletions(-) diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index cec5365..f93ba2a 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -458,16 +458,62 @@ pub(super) fn parse_sentiment( }) } -pub(super) fn parse_insights(_symbol: &str, raw: &[RawInsight]) -> Result { +pub(super) fn parse_insights(symbol: &str, raw: &[RawInsight]) -> Result { let item = raw .first() .ok_or_else(|| IdxError::ParseError("no insights data".into()))?; + + let insights = item.insights.as_deref().unwrap_or(&[]); + + // Group insight statements into highlights (non-risk) and risks by category + let highlights: Vec = insights + .iter() + .filter(|i| { + i.category + .as_deref() + .map(|c| !c.eq_ignore_ascii_case("risk")) + .unwrap_or(true) + }) + .filter_map(|i| { + let name = i.insight_name.as_deref().unwrap_or(""); + let stmt = i.insight_statement.as_deref().unwrap_or(""); + if stmt.is_empty() { + None + } else if name.is_empty() { + Some(stmt.to_string()) + } else { + Some(format!("{name}: {stmt}")) + } + }) + .collect(); + + let risks: Vec = insights + .iter() + .filter(|i| { + i.category + .as_deref() + .map(|c| c.eq_ignore_ascii_case("risk")) + .unwrap_or(false) + }) + .filter_map(|i| { + let stmt = i.insight_statement.as_deref().unwrap_or(""); + if stmt.is_empty() { + None + } else { + Some(stmt.to_string()) + } + }) + .collect(); + Ok(InsightData { - id: item.id.clone().unwrap_or_default(), - summary: item.summary.clone().unwrap_or_default(), - highlights: item.highlights.clone().unwrap_or_default(), - risks: item.risks.clone().unwrap_or_default(), - last_updated: item.last_updated.clone().unwrap_or_default(), + id: item + .instrument_id + .clone() + .unwrap_or_else(|| symbol.to_string()), + summary: item.display_name.clone().unwrap_or_default(), + highlights, + risks, + last_updated: String::new(), }) } @@ -501,20 +547,98 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result = quotes .iter() - .map(|q| parse_quote(q.symbol.as_deref().unwrap_or(""), std::slice::from_ref(q))) - .collect() + .filter_map(|q| { + let raw_price = q.price?; // skip if no price + let price = round_price(raw_price); + 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); + let ticker = q + .symbol + .as_deref() + .and_then(ticker_from_symbol) + .unwrap_or_default(); + let (week52_position, range_signal) = match (q.price_52w_low, q.price_52w_high) { + (Some(low), Some(high)) if high > low => { + let pos = (raw_price - low) / (high - low); + let sig = if pos > 0.66 { + "upper" + } else if pos < 0.33 { + "lower" + } else { + "middle" + }; + (Some(pos), Some(sig.to_string())) + } + _ => (None, None), + }; + Some(Quote { + 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), + 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), + week52_position, + range_signal, + prev_close, + avg_volume: round_u64(q.average_volume), + }) + }) + .collect(); + + if results.is_empty() { + return Err(IdxError::ParseError( + "screener returned no priced stocks".into(), + )); + } + Ok(results) } fn parse_statement_section(section: &RawStatementSection) -> StatementSection { + // MSN financial statement values are nested one level deep inside sub-objects + // (e.g., incomeStatement.income.{lineItems}, incomeStatement.revenue.{lineItems}) + // Flatten all numeric values from any depth-1 sub-object into a single map. + let skip_keys = [ + "currency", + "source", + "sourceDate", + "reportDate", + "endDate", + "fiscalYearEndMonth", + "statementType", + "type", + "_p", + "_t", + "year", + "underlyingInstrument", + "id", + ]; let mut values = std::collections::HashMap::new(); + for (k, v) in §ion.data { - if ["currency", "source", "sourceDate", "reportDate", "endDate"].contains(&k.as_str()) { + if skip_keys.contains(&k.as_str()) { continue; } if let Some(num) = v.as_f64() { + // Direct numeric value at top level values.insert(k.to_string(), num); + } else if let Some(obj) = v.as_object() { + // Nested sub-object — flatten one level (e.g., income.{lineItem: value}) + for (sub_k, sub_v) in obj { + if let Some(num) = sub_v.as_f64() { + values.insert(sub_k.to_string(), num); + } + } } } diff --git a/src/api/msn/mod.rs b/src/api/msn/mod.rs index 09d07b4..7c95531 100644 --- a/src/api/msn/mod.rs +++ b/src/api/msn/mod.rs @@ -68,7 +68,19 @@ impl HistoryProvider for MsnProvider { _interval: &Interval, ) -> Result, IdxError> { let chart_type = period_to_chart_type(period); - let raw = self.client.fetch_charts(symbol, chart_type)?; + let raw = self.client.fetch_charts(symbol, chart_type).map_err(|e| { + // Finance/Charts returns 404 for IDX stocks — MSN doesn't provide + // OHLCV chart history for the Indonesian exchange (XIDX). + if matches!(e, IdxError::SymbolNotFound(_)) { + IdxError::Unsupported( + "MSN Finance/Charts does not provide OHLCV history for IDX (XIDX) stocks. \ + Use --provider yahoo for historical data." + .to_string(), + ) + } else { + e + } + })?; parse_chart_history(symbol, period, &raw) } } diff --git a/src/api/msn/raw_types.rs b/src/api/msn/raw_types.rs index 49263b7..2829883 100644 --- a/src/api/msn/raw_types.rs +++ b/src/api/msn/raw_types.rs @@ -146,6 +146,7 @@ pub(super) struct RawFinancialStatement { pub(super) underlying_instrument: Option, pub(super) balance_sheets: Option, pub(super) cash_flow: Option, + #[serde(rename = "incomeStatement")] pub(super) income_statements: Option, } @@ -220,14 +221,22 @@ pub(super) struct RawSentimentStat { pub(super) neutral: Option, } +// Actual MSN insights API response: array of insight containers, each holding +// individual insight items grouped by category (Valuation, Risk, etc.) #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(super) struct RawInsight { - pub(super) id: Option, - pub(super) summary: Option, - pub(super) highlights: Option>, - pub(super) risks: Option>, - pub(super) last_updated: Option, + pub(super) instrument_id: Option, + pub(super) display_name: Option, + pub(super) insights: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawInsightItem { + pub(super) insight_name: Option, + pub(super) category: Option, + pub(super) insight_statement: Option, } #[derive(Debug, Deserialize)] diff --git a/src/output/table.rs b/src/output/table.rs index c5c5f27..a0f1167 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -524,26 +524,90 @@ pub fn print_profile(profile: &CompanyProfile) -> Result<(), IdxError> { table .load_preset(UTF8_FULL) .set_header(vec!["FIELD", "VALUE"]); - table.add_row(vec![Cell::new("Symbol"), Cell::new(&profile.symbol)]); - table.add_row(vec![Cell::new("Name"), Cell::new(&profile.long_name)]); - table.add_row(vec![Cell::new("Sector"), Cell::new(&profile.sector)]); - table.add_row(vec![Cell::new("Industry"), Cell::new(&profile.industry)]); - table.add_row(vec![Cell::new("Website"), Cell::new(&profile.website)]); + + // Use long_name with short_name as fallback (IDX stocks often only have shortName) + let name = if !profile.long_name.is_empty() { + &profile.long_name + } else { + &profile.short_name + }; + + let add_if_present = |t: &mut Table, label: &str, value: &str| { + if !value.is_empty() { + t.add_row(vec![Cell::new(label), Cell::new(value)]); + } + }; + + add_if_present(&mut table, "Symbol", &profile.symbol); + add_if_present(&mut table, "Name", name); + add_if_present(&mut table, "Sector", &profile.sector); + add_if_present(&mut table, "Industry", &profile.industry); + add_if_present(&mut table, "Website", &profile.website); + add_if_present(&mut table, "Country", &profile.country); + add_if_present(&mut table, "City", &profile.city); + add_if_present(&mut table, "Phone", &profile.phone); + if profile.employees > 0 { + table.add_row(vec![ + Cell::new("Employees"), + Cell::new(profile.employees.to_string()), + ]); + } + if !profile.description.is_empty() { + // Truncate long descriptions for table display + let desc = if profile.description.len() > 200 { + format!("{}...", &profile.description[..200]) + } else { + profile.description.clone() + }; + table.add_row(vec![Cell::new("Description"), Cell::new(desc)]); + } + if !profile.officers.is_empty() { + table.add_row(vec![ + Cell::new("Executives"), + Cell::new( + profile + .officers + .iter() + .take(5) + .map(|o| format!("{} ({})", o.name, o.title)) + .collect::>() + .join("\n"), + ), + ]); + } println!("{table}"); Ok(()) } pub fn print_financials(fin: &FinancialStatements) -> Result<(), IdxError> { - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_header(vec!["LINE ITEM", "VALUE"]); - if let Some(income) = &fin.income_statement { - for (k, v) in &income.values { - table.add_row(vec![Cell::new(k), Cell::new(format!("{v:.2}"))]); + let print_section = |label: &str, section: &crate::api::types::StatementSection| { + println!("\n── {label} ({}) ──", section.end_date); + let mut t = Table::new(); + let value_header = format!("VALUE ({})", section.currency); + t.load_preset(UTF8_FULL) + .set_header(vec!["LINE ITEM", value_header.as_str()]); + // Sort keys for deterministic output + let mut entries: Vec<(&String, &f64)> = section.values.iter().collect(); + entries.sort_by_key(|(k, _)| k.as_str()); + for (k, v) in entries { + t.add_row(vec![Cell::new(k), Cell::new(format_idr(*v as i64))]); } + println!("{t}"); + }; + + if let Some(income) = &fin.income_statement { + print_section("Income Statement", income); + } + if let Some(balance) = &fin.balance_sheet { + print_section("Balance Sheet", balance); + } + if let Some(cf) = &fin.cash_flow { + print_section("Cash Flow", cf); + } + + if fin.income_statement.is_none() && fin.balance_sheet.is_none() && fin.cash_flow.is_none() { + println!("No financial statement data available for this stock."); } - println!("{table}"); Ok(()) } diff --git a/tests/cli.rs b/tests/cli.rs index 5211a1e..c6283d0 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -107,23 +107,27 @@ fn technical_with_mock_provider_json_contains_fields() { } #[test] -fn msn_history_is_no_longer_unsupported() { - test_bin("msn-history-supported") +fn msn_history_with_mock_returns_data() { + // MSN chart parsing works with fixture data; real IDX stocks 404 on Finance/Charts + // which surfaces as Unsupported (not the old blanket UNSUPPORTED message). + test_bin("msn-history-mock") .env("IDX_PROVIDER", "msn") - .args(["stocks", "history", "BBCA", "--period", "1mo"]) + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["stocks", "history", "BBCA", "--period", "3mo"]) .assert() - .failure() - .stderr(predicate::str::contains("MSN provider does not currently support").not()); + .success(); } #[test] -fn msn_technical_json_is_no_longer_unsupported() { - test_bin("msn-technical-supported") +fn msn_technical_with_mock_returns_data() { + // Technical analysis works via MSN chart fixture data (mock); real IDX stocks + // return Unsupported from Finance/Charts (404 on XIDX). + test_bin("msn-technical-mock") .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") .args(["-o", "json", "stocks", "technical", "BBCA"]) .assert() - .failure() - .stderr(predicate::str::contains("\"code\": \"UNSUPPORTED\"").not()); + .success(); } #[test]