mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
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:
parent
a0da0a3adb
commit
2207c928b2
5 changed files with 251 additions and 38 deletions
|
|
@ -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 §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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue