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
This commit is contained in:
Ciphercat 2026-03-06 20:08:03 +00:00
commit 2207c928b2
5 changed files with 251 additions and 38 deletions

View file

@ -458,16 +458,62 @@ pub(super) fn parse_sentiment(
}) })
} }
pub(super) fn parse_insights(_symbol: &str, raw: &[RawInsight]) -> Result<InsightData, IdxError> { pub(super) fn parse_insights(symbol: &str, raw: &[RawInsight]) -> Result<InsightData, IdxError> {
let item = raw let item = raw
.first() .first()
.ok_or_else(|| IdxError::ParseError("no insights data".into()))?; .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<String> = 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<String> = 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 { Ok(InsightData {
id: item.id.clone().unwrap_or_default(), id: item
summary: item.summary.clone().unwrap_or_default(), .instrument_id
highlights: item.highlights.clone().unwrap_or_default(), .clone()
risks: item.risks.clone().unwrap_or_default(), .unwrap_or_else(|| symbol.to_string()),
last_updated: item.last_updated.clone().unwrap_or_default(), 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<Vec<Qu
.quote .quote
.as_ref() .as_ref()
.ok_or_else(|| IdxError::ParseError("no screener data".into()))?; .ok_or_else(|| IdxError::ParseError("no screener data".into()))?;
quotes
// Build Quote directly from screener MsnQuote data; skip stocks with no price
// (do not route through parse_quote which errors on missing price)
let results: Vec<Quote> = quotes
.iter() .iter()
.map(|q| parse_quote(q.symbol.as_deref().unwrap_or(""), std::slice::from_ref(q))) .filter_map(|q| {
.collect() 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 { 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(); let mut values = std::collections::HashMap::new();
for (k, v) in &section.data { for (k, v) in &section.data {
if ["currency", "source", "sourceDate", "reportDate", "endDate"].contains(&k.as_str()) { if skip_keys.contains(&k.as_str()) {
continue; continue;
} }
if let Some(num) = v.as_f64() { if let Some(num) = v.as_f64() {
// Direct numeric value at top level
values.insert(k.to_string(), num); 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);
}
}
} }
} }

View file

@ -68,7 +68,19 @@ impl HistoryProvider for MsnProvider {
_interval: &Interval, _interval: &Interval,
) -> Result<Vec<Bar>, IdxError> { ) -> Result<Vec<Bar>, IdxError> {
let chart_type = period_to_chart_type(period); 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) parse_chart_history(symbol, period, &raw)
} }
} }

View file

@ -146,6 +146,7 @@ pub(super) struct RawFinancialStatement {
pub(super) underlying_instrument: Option<RawInstrumentInfo>, pub(super) underlying_instrument: Option<RawInstrumentInfo>,
pub(super) balance_sheets: Option<RawStatementSection>, pub(super) balance_sheets: Option<RawStatementSection>,
pub(super) cash_flow: Option<RawStatementSection>, pub(super) cash_flow: Option<RawStatementSection>,
#[serde(rename = "incomeStatement")]
pub(super) income_statements: Option<RawStatementSection>, pub(super) income_statements: Option<RawStatementSection>,
} }
@ -220,14 +221,22 @@ pub(super) struct RawSentimentStat {
pub(super) neutral: Option<i32>, pub(super) neutral: Option<i32>,
} }
// Actual MSN insights API response: array of insight containers, each holding
// individual insight items grouped by category (Valuation, Risk, etc.)
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(super) struct RawInsight { pub(super) struct RawInsight {
pub(super) id: Option<String>, pub(super) instrument_id: Option<String>,
pub(super) summary: Option<String>, pub(super) display_name: Option<String>,
pub(super) highlights: Option<Vec<String>>, pub(super) insights: Option<Vec<RawInsightItem>>,
pub(super) risks: Option<Vec<String>>, }
pub(super) last_updated: Option<String>,
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawInsightItem {
pub(super) insight_name: Option<String>,
pub(super) category: Option<String>,
pub(super) insight_statement: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -524,26 +524,90 @@ pub fn print_profile(profile: &CompanyProfile) -> Result<(), IdxError> {
table table
.load_preset(UTF8_FULL) .load_preset(UTF8_FULL)
.set_header(vec!["FIELD", "VALUE"]); .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)]); // Use long_name with short_name as fallback (IDX stocks often only have shortName)
table.add_row(vec![Cell::new("Sector"), Cell::new(&profile.sector)]); let name = if !profile.long_name.is_empty() {
table.add_row(vec![Cell::new("Industry"), Cell::new(&profile.industry)]); &profile.long_name
table.add_row(vec![Cell::new("Website"), Cell::new(&profile.website)]); } 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::<Vec<_>>()
.join("\n"),
),
]);
}
println!("{table}"); println!("{table}");
Ok(()) Ok(())
} }
pub fn print_financials(fin: &FinancialStatements) -> Result<(), IdxError> { pub fn print_financials(fin: &FinancialStatements) -> Result<(), IdxError> {
let mut table = Table::new(); let print_section = |label: &str, section: &crate::api::types::StatementSection| {
table println!("\n── {label} ({}) ──", section.end_date);
.load_preset(UTF8_FULL) let mut t = Table::new();
.set_header(vec!["LINE ITEM", "VALUE"]); 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 { if let Some(income) = &fin.income_statement {
for (k, v) in &income.values { print_section("Income Statement", income);
table.add_row(vec![Cell::new(k), Cell::new(format!("{v:.2}"))]);
} }
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(()) Ok(())
} }

View file

@ -107,23 +107,27 @@ fn technical_with_mock_provider_json_contains_fields() {
} }
#[test] #[test]
fn msn_history_is_no_longer_unsupported() { fn msn_history_with_mock_returns_data() {
test_bin("msn-history-supported") // 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") .env("IDX_PROVIDER", "msn")
.args(["stocks", "history", "BBCA", "--period", "1mo"]) .env("IDX_USE_MOCK_PROVIDER", "1")
.args(["stocks", "history", "BBCA", "--period", "3mo"])
.assert() .assert()
.failure() .success();
.stderr(predicate::str::contains("MSN provider does not currently support").not());
} }
#[test] #[test]
fn msn_technical_json_is_no_longer_unsupported() { fn msn_technical_with_mock_returns_data() {
test_bin("msn-technical-supported") // 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_PROVIDER", "msn")
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stocks", "technical", "BBCA"]) .args(["-o", "json", "stocks", "technical", "BBCA"])
.assert() .assert()
.failure() .success();
.stderr(predicate::str::contains("\"code\": \"UNSUPPORTED\"").not());
} }
#[test] #[test]