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
.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<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 {
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<Vec<Qu
.quote
.as_ref()
.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()
.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 &section.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);
}
}
}
}

View file

@ -68,7 +68,19 @@ impl HistoryProvider for MsnProvider {
_interval: &Interval,
) -> Result<Vec<Bar>, 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)
}
}

View file

@ -146,6 +146,7 @@ pub(super) struct RawFinancialStatement {
pub(super) underlying_instrument: Option<RawInstrumentInfo>,
pub(super) balance_sheets: Option<RawStatementSection>,
pub(super) cash_flow: Option<RawStatementSection>,
#[serde(rename = "incomeStatement")]
pub(super) income_statements: Option<RawStatementSection>,
}
@ -220,14 +221,22 @@ pub(super) struct RawSentimentStat {
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)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawInsight {
pub(super) id: Option<String>,
pub(super) summary: Option<String>,
pub(super) highlights: Option<Vec<String>>,
pub(super) risks: Option<Vec<String>>,
pub(super) last_updated: Option<String>,
pub(super) instrument_id: Option<String>,
pub(super) display_name: Option<String>,
pub(super) insights: Option<Vec<RawInsightItem>>,
}
#[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)]