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)]
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>()
|
||||
.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"]);
|
||||
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 {
|
||||
for (k, v) in &income.values {
|
||||
table.add_row(vec![Cell::new(k), Cell::new(format!("{v:.2}"))]);
|
||||
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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
22
tests/cli.rs
22
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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue