idx-cli/crates/idx-core/src/financial.rs
hermes 1796e58105 feat(news): rank this company's news first and label each item
MSN's feed for a stock is often mostly general market news: of 10 items,
ADRO, BREN and CUAN had none about the company, BBRI one, yet they were
presented as the company's news. Fetch at least 50 items, label each
`relevance: company` (tagged by MSN with the stock's instrument id, or
naming the ticker or the company) or `market`, and return company items
first, newest first, trimmed to --limit.

Company names come from the quote's short and display names, best
effort: MSN tags Telkom articles with its NYSE ADR, not TLKM, so names
catch what tags miss. Classification stays a pure function in parse_news.

Output change, intentional: news JSON gains `relevance`, the table an
ABOUT column (goldens regenerated), and the get_company tool description
explains the field (tools.json and the website tools page regenerated).
News cache keys change so entries without the field are not reused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
2026-10-16 09:54:30 +00:00

398 lines
12 KiB
Rust

//! Company financial data: profile, statements, earnings, sentiment,
//! insights, and news. These require the MSN provider.
use crate::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem,
SentimentData,
};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::error::IdxError;
pub struct Financial<'a> {
pub(crate) idx: &'a Idx,
}
impl Financial<'_> {
pub fn profile(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<CompanyProfile>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let provider = self.idx.provider.profile_provider(&resolved)?;
self.fetch(
"profile-v2",
&resolved,
&resolved,
self.ttl(),
policy,
|| provider.profile(&resolved),
)
}
pub fn statements(
&self,
symbol: &str,
filter: &StatementFilter,
policy: CachePolicy,
) -> Result<Fetched<FinancialStatements>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let provider = self.idx.provider.financials_provider(&resolved)?;
let fetched = self.fetch(
"financials",
&resolved,
&resolved,
self.ttl(),
policy,
|| provider.financials(&resolved),
)?;
Ok(fetched.map(|mut financials| {
if financials.instrument.symbol.trim().is_empty()
|| !financials.instrument.symbol.contains('.')
{
financials.instrument.symbol = resolved.clone();
}
filter.apply(&financials)
}))
}
pub fn earnings(
&self,
symbol: &str,
filter: EarningsFilter,
policy: CachePolicy,
) -> Result<Fetched<EarningsReport>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let provider = self.idx.provider.earnings_provider(&resolved)?;
let fetched = self.fetch("earnings", &resolved, &resolved, self.ttl(), policy, || {
provider.earnings(&resolved)
})?;
Ok(fetched.map(|mut earnings| {
if earnings.symbol.is_empty() {
earnings.symbol = resolved.clone();
}
filter.apply(&earnings)
}))
}
pub fn sentiment(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<SentimentData>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let provider = self.idx.provider.sentiment_provider(&resolved)?;
self.fetch(
"sentiment",
&resolved,
&resolved,
self.ttl(),
policy,
|| provider.sentiment(&resolved),
)
}
pub fn insights(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<InsightData>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let provider = self.idx.provider.insights_provider(&resolved)?;
let fetched = self.fetch(
"insights-v2",
&resolved,
&resolved,
self.ttl(),
policy,
|| provider.insights(&resolved),
)?;
Ok(fetched.map(|mut insights| {
if insights.symbol.is_empty() {
insights.symbol = resolved.clone();
}
insights
}))
}
pub fn news(
&self,
symbol: &str,
limit: usize,
policy: CachePolicy,
) -> Result<Fetched<Vec<NewsItem>>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
// "ranked": entries cached before relevance ranking lack the field.
let key = format!("{resolved}-{limit}-ranked");
let provider = self.idx.provider.news_provider(&resolved)?;
let ttl = self.idx.config.quote_ttl;
let fetched = self.fetch("news", &key, &resolved, ttl, policy, || {
provider.news(&resolved, limit)
})?;
Ok(fetched.map(|mut news| {
for item in &mut news {
if item.symbol.is_empty() {
item.symbol = resolved.clone();
}
}
news
}))
}
fn ttl(&self) -> u64 {
self.idx.config.fundamental_ttl
}
fn fetch<T>(
&self,
bucket: &str,
key: &str,
subject: &str,
ttl_secs: u64,
policy: CachePolicy,
fetch: impl FnOnce() -> Result<T, IdxError>,
) -> Result<Fetched<T>, IdxError>
where
T: serde::Serialize + serde::de::DeserializeOwned,
{
let bucket = self.idx.bucket(bucket);
cached_fetch(
&self.idx.cache,
CacheSpec {
bucket: &bucket,
key,
ttl_secs,
subject,
source: self.idx.provider.kind().as_str(),
},
policy,
fetch,
Ok,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
pub enum StatementKind {
Income,
Balance,
Cashflow,
}
/// Statements to keep; empty keeps all. Excluded sections become `None`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StatementFilter {
pub statements: Vec<StatementKind>,
}
impl StatementFilter {
pub fn all() -> Self {
Self::default()
}
fn includes(&self, kind: StatementKind) -> bool {
self.statements.is_empty() || self.statements.contains(&kind)
}
pub fn apply(&self, financials: &FinancialStatements) -> FinancialStatements {
FinancialStatements {
instrument: financials.instrument.clone(),
balance_sheet: self
.includes(StatementKind::Balance)
.then(|| financials.balance_sheet.clone())
.flatten(),
cash_flow: self
.includes(StatementKind::Cashflow)
.then(|| financials.cash_flow.clone())
.flatten(),
income_statement: self
.includes(StatementKind::Income)
.then(|| financials.income_statement.clone())
.flatten(),
}
}
}
/// Earnings scope. With neither `forecast` nor `history` set both are kept;
/// with neither `annual` nor `quarterly` set every period is kept.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct EarningsFilter {
pub forecast: bool,
pub history: bool,
pub annual: bool,
pub quarterly: bool,
}
impl EarningsFilter {
fn include_forecast(self) -> bool {
self.forecast || !self.history
}
fn include_history(self) -> bool {
self.history || !self.forecast
}
fn includes_period(self, period_type: &str) -> bool {
if !self.annual && !self.quarterly {
return true;
}
match classify_earnings_period(period_type) {
EarningsPeriodKind::Annual => self.annual || !self.quarterly,
EarningsPeriodKind::Quarterly => self.quarterly || !self.annual,
EarningsPeriodKind::Unknown => false,
}
}
pub fn apply(self, report: &EarningsReport) -> EarningsReport {
let filter_rows = |rows: &[EarningsData]| {
rows.iter()
.filter(|row| self.includes_period(&row.period_type))
.cloned()
.collect()
};
EarningsReport {
symbol: report.symbol.clone(),
eps_last_year: report.eps_last_year,
revenue_last_year: report.revenue_last_year,
forecast: if self.include_forecast() {
filter_rows(&report.forecast)
} else {
Vec::new()
},
history: if self.include_history() {
filter_rows(&report.history)
} else {
Vec::new()
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EarningsPeriodKind {
Annual,
Quarterly,
Unknown,
}
fn classify_earnings_period(period_type: &str) -> EarningsPeriodKind {
let trimmed = period_type.trim();
if trimmed.is_empty() {
return EarningsPeriodKind::Unknown;
}
let upper = trimmed.to_ascii_uppercase();
if upper.starts_with('Q') {
return EarningsPeriodKind::Quarterly;
}
if upper.starts_with("FY") || (upper.len() == 4 && upper.chars().all(|ch| ch.is_ascii_digit()))
{
return EarningsPeriodKind::Annual;
}
EarningsPeriodKind::Unknown
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
EarningsFilter, EarningsPeriodKind, StatementFilter, StatementKind,
classify_earnings_period,
};
use crate::api::types::{
EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, StatementSection,
};
fn section(key: &str, value: f64) -> Option<StatementSection> {
Some(StatementSection {
values: BTreeMap::from([(key.into(), value)]),
currency: "IDR".into(),
report_date: "2026-01-01".into(),
end_date: "2025-12-31".into(),
})
}
#[test]
fn filters_financial_statements_to_requested_sections() {
let sample = FinancialStatements {
instrument: InstrumentInfo {
id: "123".into(),
symbol: "BBCA.JK".into(),
name: "BCA".into(),
},
balance_sheet: section("assets", 1.0),
cash_flow: section("cash", 2.0),
income_statement: section("income", 3.0),
};
let filtered = StatementFilter {
statements: vec![StatementKind::Cashflow],
}
.apply(&sample);
assert!(filtered.cash_flow.is_some());
assert!(filtered.income_statement.is_none());
assert!(filtered.balance_sheet.is_none());
assert_eq!(filtered.instrument.symbol, "BBCA.JK");
}
fn earnings_row(period_type: &str, eps_forecast: Option<f64>) -> EarningsData {
EarningsData {
eps_actual: None,
eps_forecast,
eps_surprise: None,
eps_surprise_pct: None,
revenue_actual: None,
revenue_forecast: None,
revenue_surprise: None,
earning_release_date: None,
period_type: period_type.into(),
}
}
#[test]
fn filters_earnings_by_scope_and_period() {
let report = EarningsReport {
symbol: "BBCA.JK".into(),
eps_last_year: 1_200.0,
revenue_last_year: 100_000_000_000.0,
forecast: vec![
earnings_row("2026", Some(1_300.0)),
earnings_row("Q12026", Some(330.0)),
],
history: vec![earnings_row("2025", None)],
};
let filtered = EarningsFilter {
forecast: true,
history: false,
annual: true,
quarterly: false,
}
.apply(&report);
assert_eq!(filtered.symbol, "BBCA.JK");
assert!(filtered.history.is_empty());
assert_eq!(filtered.forecast.len(), 1);
assert_eq!(filtered.forecast[0].period_type, "2026");
}
#[test]
fn classifies_earnings_periods() {
assert_eq!(classify_earnings_period("2025"), EarningsPeriodKind::Annual);
assert_eq!(
classify_earnings_period("FY2026"),
EarningsPeriodKind::Annual
);
assert_eq!(
classify_earnings_period("Q12026"),
EarningsPeriodKind::Quarterly
);
assert_eq!(classify_earnings_period(""), EarningsPeriodKind::Unknown);
}
}