mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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
This commit is contained in:
parent
537b01901f
commit
1796e58105
13 changed files with 254 additions and 38 deletions
|
|
@ -837,10 +837,11 @@ pub fn print_news(items: &[NewsItem]) -> Result<(), IdxError> {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table
|
table
|
||||||
.load_preset(UTF8_FULL)
|
.load_preset(UTF8_FULL)
|
||||||
.set_header(vec!["TITLE", "PROVIDER", "DATE", "URL"]);
|
.set_header(vec!["TITLE", "ABOUT", "PROVIDER", "DATE", "URL"]);
|
||||||
for item in items {
|
for item in items {
|
||||||
table.add_row(vec![
|
table.add_row(vec![
|
||||||
Cell::new(&item.title),
|
Cell::new(&item.title),
|
||||||
|
Cell::new(item.relevance.as_str()),
|
||||||
Cell::new(&item.provider),
|
Cell::new(&item.provider),
|
||||||
Cell::new(&item.published_at),
|
Cell::new(&item.published_at),
|
||||||
Cell::new(truncate_url(&item.url)),
|
Cell::new(truncate_url(&item.url)),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
[{"id":"news-1","symbol":"BBCA.JK","title":"BCA reports steady growth","url":"https://example.com/news/bca-growth","description":"BCA delivered steady quarterly growth across lending and deposits.","provider":"Contoso News","published_at":"2026-03-20T10:00:00Z","read_time_min":3}]
|
[{"id":"news-1","symbol":"BBCA.JK","title":"BCA reports steady growth","url":"https://example.com/news/bca-growth","description":"BCA delivered steady quarterly growth across lending and deposits.","provider":"Contoso News","published_at":"2026-03-20T10:00:00Z","read_time_min":3,"relevance":"market"}]
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
┌───────────────────────────┬──────────────┬──────────────────────┬─────────────────────────────────────┐
|
┌───────────────────────────┬────────┬──────────────┬──────────────────────┬─────────────────────────────────────┐
|
||||||
│ TITLE ┆ PROVIDER ┆ DATE ┆ URL │
|
│ TITLE ┆ ABOUT ┆ PROVIDER ┆ DATE ┆ URL │
|
||||||
╞═══════════════════════════╪══════════════╪══════════════════════╪═════════════════════════════════════╡
|
╞═══════════════════════════╪════════╪══════════════╪══════════════════════╪═════════════════════════════════════╡
|
||||||
│ BCA reports steady growth ┆ Contoso News ┆ 2026-03-20T10:00:00Z ┆ https://example.com/news/bca-growth │
|
│ BCA reports steady growth ┆ market ┆ Contoso News ┆ 2026-03-20T10:00:00Z ┆ https://example.com/news/bca-growth │
|
||||||
└───────────────────────────┴──────────────┴──────────────────────┴─────────────────────────────────────┘
|
└───────────────────────────┴────────┴──────────────┴──────────────────────┴─────────────────────────────────────┘
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ const MSN_ASSETS_BASE_URL: &str = "https://assets.msn.com/service/";
|
||||||
const MSN_API_BASE_URL: &str = "https://api.msn.com/msn/v0/pages/finance/";
|
const MSN_API_BASE_URL: &str = "https://api.msn.com/msn/v0/pages/finance/";
|
||||||
// Public API key from MSN Money website (embedded in frontend JS)
|
// Public API key from MSN Money website (embedded in frontend JS)
|
||||||
const MSN_API_KEY: &str = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM";
|
const MSN_API_KEY: &str = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM";
|
||||||
|
/// Items requested from a stock's news feed, at least; MSN returns ~40-50.
|
||||||
|
const NEWS_FETCH_MIN: usize = 50;
|
||||||
|
|
||||||
pub(super) struct MsnClient {
|
pub(super) struct MsnClient {
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
|
|
@ -272,8 +274,11 @@ impl MsnClient {
|
||||||
pub(super) fn fetch_news(&self, symbol: &str, limit: usize) -> Result<RawNewsFeed, IdxError> {
|
pub(super) fn fetch_news(&self, symbol: &str, limit: usize) -> Result<RawNewsFeed, IdxError> {
|
||||||
let id =
|
let id =
|
||||||
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||||
|
// Company items can sit below general market news in a stock's feed;
|
||||||
|
// fetch wider and let parse_news rank and trim to `limit`.
|
||||||
|
let top = limit.max(NEWS_FETCH_MIN);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{MSN_ASSETS_BASE_URL}MSN/Feed/me?$top={limit}&apikey={MSN_API_KEY}&cm=id-id&contentType=article,video,slideshow&it=web&query=ef_stock_{id}&queryType=entityfeed&responseSchema=cardview&scn=ANON&wrapodata=false"
|
"{MSN_ASSETS_BASE_URL}MSN/Feed/me?$top={top}&apikey={MSN_API_KEY}&cm=id-id&contentType=article,video,slideshow&it=web&query=ef_stock_{id}&queryType=entityfeed&responseSchema=cardview&scn=ANON&wrapodata=false"
|
||||||
);
|
);
|
||||||
self.get_json(&url, symbol, "news")
|
self.get_json(&url, symbol, "news")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use super::raw_types::{
|
||||||
use super::symbols::{normalized_symbol, ticker_from_symbol};
|
use super::symbols::{normalized_symbol, ticker_from_symbol};
|
||||||
use crate::api::types::{
|
use crate::api::types::{
|
||||||
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals, InsightData,
|
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals, InsightData,
|
||||||
InstrumentInfo, NewsItem, Officer, Ohlc, Quote, SentimentData, SentimentPeriod,
|
InstrumentInfo, NewsItem, NewsRelevance, Officer, Ohlc, Quote, SentimentData, SentimentPeriod,
|
||||||
StatementSection,
|
StatementSection,
|
||||||
};
|
};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
@ -551,30 +551,108 @@ pub(super) fn parse_insights(symbol: &str, raw: &[RawInsight]) -> Result<Insight
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn parse_news(symbol: &str, raw: &RawNewsFeed) -> Result<Vec<NewsItem>, IdxError> {
|
/// Parses a stock's news feed, ranks this company's items first and then the
|
||||||
|
/// rest (general market news), newest first within each, and keeps `limit`.
|
||||||
|
/// An item is about the company when MSN tags it with the stock's instrument
|
||||||
|
/// id (`msn_id`), or its title or description names the ticker or one of
|
||||||
|
/// `names`.
|
||||||
|
pub(super) fn parse_news(
|
||||||
|
symbol: &str,
|
||||||
|
msn_id: &str,
|
||||||
|
names: &[String],
|
||||||
|
raw: &RawNewsFeed,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<NewsItem>, IdxError> {
|
||||||
let source = raw
|
let source = raw
|
||||||
.sub_cards
|
.sub_cards
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.or(raw.value.as_ref())
|
.or(raw.value.as_ref())
|
||||||
.ok_or_else(|| IdxError::ParseError("no news data".into()))?;
|
.ok_or_else(|| IdxError::ParseError("no news data".into()))?;
|
||||||
|
let ticker = ticker_from_symbol(symbol).unwrap_or_default();
|
||||||
|
|
||||||
Ok(source
|
let mut items: Vec<NewsItem> = source
|
||||||
.iter()
|
.iter()
|
||||||
.map(|item| NewsItem {
|
.map(|item| {
|
||||||
id: item.id.clone().unwrap_or_default(),
|
let title = item.title.clone().unwrap_or_default();
|
||||||
symbol: symbol.to_string(),
|
let description = item.description.clone().unwrap_or_default();
|
||||||
title: item.title.clone().unwrap_or_default(),
|
let tagged = item.finance_metadata.as_ref().is_some_and(|meta| {
|
||||||
url: item.url.clone().unwrap_or_default(),
|
meta.stocks
|
||||||
description: item.description.clone().unwrap_or_default(),
|
.iter()
|
||||||
provider: item
|
.any(|stock| stock.stock_id.as_deref() == Some(msn_id))
|
||||||
.provider
|
});
|
||||||
.as_ref()
|
let named = [&title, &description]
|
||||||
.and_then(|p| p.name.clone())
|
.iter()
|
||||||
.unwrap_or_default(),
|
.any(|text| mentions_company(text, &ticker, names));
|
||||||
published_at: item.published_date_time.clone().unwrap_or_default(),
|
NewsItem {
|
||||||
read_time_min: item.read_time_min,
|
id: item.id.clone().unwrap_or_default(),
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
title,
|
||||||
|
url: item.url.clone().unwrap_or_default(),
|
||||||
|
description,
|
||||||
|
provider: item
|
||||||
|
.provider
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.name.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
published_at: item.published_date_time.clone().unwrap_or_default(),
|
||||||
|
read_time_min: item.read_time_min,
|
||||||
|
relevance: if tagged || named {
|
||||||
|
NewsRelevance::Company
|
||||||
|
} else {
|
||||||
|
NewsRelevance::Market
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect();
|
||||||
|
// ISO 8601 UTC timestamps sort as strings; missing dates sort last.
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
a.relevance
|
||||||
|
.cmp(&b.relevance)
|
||||||
|
.then_with(|| b.published_at.cmp(&a.published_at))
|
||||||
|
});
|
||||||
|
items.truncate(limit);
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `text` names the ticker as a whole word (tickers are written in
|
||||||
|
/// capitals) or contains one of the company `names`, ignoring case.
|
||||||
|
fn mentions_company(text: &str, ticker: &str, names: &[String]) -> bool {
|
||||||
|
let whole_word = |at: usize| {
|
||||||
|
let before = text[..at].chars().next_back();
|
||||||
|
let after = text[at + ticker.len()..].chars().next();
|
||||||
|
!before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric)
|
||||||
|
};
|
||||||
|
if !ticker.is_empty() && text.match_indices(ticker).any(|(at, _)| whole_word(at)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let text = text.to_lowercase();
|
||||||
|
names.iter().any(|name| text.contains(&name.to_lowercase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names an article may use for a company, from MSN's quote names: the short
|
||||||
|
/// name ("Telkom Indonesia") and the display name without legal suffixes
|
||||||
|
/// ("Telkom Indonesia (Persero) Tbk PT" -> "Telkom Indonesia"). Abbreviated
|
||||||
|
/// short names ("Bank Cntrl Asia") never match and are harmless.
|
||||||
|
pub(super) fn company_names(short_name: Option<&str>, display_name: Option<&str>) -> Vec<String> {
|
||||||
|
const SUFFIXES: [&str; 3] = ["(persero)", "tbk", "pt"];
|
||||||
|
let display = display_name.map(|name| {
|
||||||
|
name.split_whitespace()
|
||||||
|
.filter(|word| !SUFFIXES.contains(&word.to_lowercase().trim_end_matches('.')))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
});
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
for name in [short_name.map(str::to_string), display]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let name = name.trim().to_string();
|
||||||
|
// Short fragments ("PT", "BRI") would match unrelated text.
|
||||||
|
if name.len() >= 6 && !names.iter().any(|n| n.eq_ignore_ascii_case(&name)) {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Quote>, IdxError> {
|
pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Quote>, IdxError> {
|
||||||
|
|
@ -785,9 +863,10 @@ fn collect_earnings(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
KeyRatios, MsnQuote, RawChartResponse, RawFinancialStatement, RawNewsFeed,
|
KeyRatios, MsnQuote, NewsRelevance, RawChartResponse, RawFinancialStatement, RawNewsFeed,
|
||||||
RawScreenerResponse, RawSentiment, parse_financial_statements, parse_fundamentals,
|
RawScreenerResponse, RawSentiment, company_names, parse_financial_statements,
|
||||||
parse_history, parse_news, parse_quote, parse_screener_results, parse_sentiment,
|
parse_fundamentals, parse_history, parse_news, parse_quote, parse_screener_results,
|
||||||
|
parse_sentiment,
|
||||||
};
|
};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
|
@ -814,7 +893,7 @@ mod tests {
|
||||||
serde_json::from_str(include_str!("../../../tests/fixtures/msn_news_bbca.json"))
|
serde_json::from_str(include_str!("../../../tests/fixtures/msn_news_bbca.json"))
|
||||||
.expect("news fixture should deserialize");
|
.expect("news fixture should deserialize");
|
||||||
|
|
||||||
let items = parse_news("BBCA.JK", &raw).expect("news should parse");
|
let items = parse_news("BBCA.JK", "bn91jc", &[], &raw, 10).expect("news should parse");
|
||||||
|
|
||||||
assert_eq!(items.len(), 1);
|
assert_eq!(items.len(), 1);
|
||||||
assert_eq!(items[0].id, "news-1");
|
assert_eq!(items[0].id, "news-1");
|
||||||
|
|
@ -823,6 +902,84 @@ mod tests {
|
||||||
assert_eq!(items[0].provider, "Contoso News");
|
assert_eq!(items[0].provider, "Contoso News");
|
||||||
assert_eq!(items[0].published_at, "2026-03-20T10:00:00Z");
|
assert_eq!(items[0].published_at, "2026-03-20T10:00:00Z");
|
||||||
assert_eq!(items[0].read_time_min, Some(3));
|
assert_eq!(items[0].read_time_min, Some(3));
|
||||||
|
assert_eq!(items[0].relevance, NewsRelevance::Market);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn news_feed(items: serde_json::Value) -> RawNewsFeed {
|
||||||
|
serde_json::from_value(serde_json::json!({ "value": items })).expect("news feed")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_news_ranks_company_items_first_then_newest() {
|
||||||
|
let raw = news_feed(serde_json::json!([
|
||||||
|
{"id": "market-new", "title": "IHSG anjlok usai pengumuman MSCI",
|
||||||
|
"publishedDateTime": "2026-10-16T08:00:00Z",
|
||||||
|
"financeMetadata": {"stocks": [{"stockId": "a1xz1h"}]}},
|
||||||
|
{"id": "tagged", "title": "Laba bersih naik",
|
||||||
|
"publishedDateTime": "2026-10-14T08:00:00Z",
|
||||||
|
"financeMetadata": {"stocks": [{"stockId": "bn9o8m"}]}},
|
||||||
|
{"id": "ticker", "title": "Saham TLKM menguat",
|
||||||
|
"publishedDateTime": "2026-10-15T08:00:00Z"},
|
||||||
|
{"id": "name", "title": "Dirut baru", "abstract": "Telkom Indonesia menunjuk direksi",
|
||||||
|
"publishedDateTime": "2026-10-13T08:00:00Z"},
|
||||||
|
{"id": "market-old", "title": "Rupiah melemah",
|
||||||
|
"publishedDateTime": "2026-10-12T08:00:00Z"}
|
||||||
|
]));
|
||||||
|
let names = vec!["Telkom Indonesia".to_string()];
|
||||||
|
|
||||||
|
let items = parse_news("TLKM.JK", "bn9o8m", &names, &raw, 10).expect("news");
|
||||||
|
|
||||||
|
let order: Vec<_> = items.iter().map(|i| (i.id.as_str(), i.relevance)).collect();
|
||||||
|
assert_eq!(
|
||||||
|
order,
|
||||||
|
[
|
||||||
|
("ticker", NewsRelevance::Company),
|
||||||
|
("tagged", NewsRelevance::Company),
|
||||||
|
("name", NewsRelevance::Company),
|
||||||
|
("market-new", NewsRelevance::Market),
|
||||||
|
("market-old", NewsRelevance::Market),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let limited = parse_news("TLKM.JK", "bn9o8m", &names, &raw, 2).expect("news");
|
||||||
|
assert_eq!(limited.len(), 2);
|
||||||
|
assert_eq!(limited[1].id, "tagged");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticker_match_requires_a_whole_word() {
|
||||||
|
let raw = news_feed(serde_json::json!([
|
||||||
|
{"id": "a", "title": "BBRI dan BMRI melemah"},
|
||||||
|
{"id": "b", "title": "Saham XBBRI dan BBRIX"},
|
||||||
|
{"id": "c", "title": "bbri lowercase is not a ticker"}
|
||||||
|
]));
|
||||||
|
|
||||||
|
let items = parse_news("BBRI.JK", "bn91p2", &[], &raw, 10).expect("news");
|
||||||
|
|
||||||
|
let company: Vec<_> = items
|
||||||
|
.iter()
|
||||||
|
.filter(|i| i.relevance == NewsRelevance::Company)
|
||||||
|
.map(|i| i.id.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(company, ["a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn company_names_strip_legal_suffixes_and_skip_short_names() {
|
||||||
|
assert_eq!(
|
||||||
|
company_names(
|
||||||
|
Some("Telkom Indonesia"),
|
||||||
|
Some("Telkom Indonesia (Persero) Tbk PT")
|
||||||
|
),
|
||||||
|
["Telkom Indonesia"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
company_names(
|
||||||
|
Some("Bank BRI"),
|
||||||
|
Some("Bank Rakyat Indonesia (Persero) Tbk PT")
|
||||||
|
),
|
||||||
|
["Bank BRI", "Bank Rakyat Indonesia"]
|
||||||
|
);
|
||||||
|
assert!(company_names(Some("PT"), Some("Tbk PT")).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,11 @@ use crate::error::IdxError;
|
||||||
|
|
||||||
use client::MsnClient;
|
use client::MsnClient;
|
||||||
use map::{
|
use map::{
|
||||||
parse_earnings, parse_financial_statements, parse_fundamentals, parse_history, parse_insights,
|
company_names, parse_earnings, parse_financial_statements, parse_fundamentals, parse_history,
|
||||||
parse_news, parse_profile, parse_quote, parse_screener_results, parse_sentiment,
|
parse_insights, parse_news, parse_profile, parse_quote, parse_screener_results,
|
||||||
|
parse_sentiment,
|
||||||
};
|
};
|
||||||
|
use symbols::resolve_msn_id;
|
||||||
|
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
|
pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
|
||||||
|
|
@ -102,8 +104,19 @@ impl InsightsProvider for MsnProvider {
|
||||||
|
|
||||||
impl NewsProvider for MsnProvider {
|
impl NewsProvider for MsnProvider {
|
||||||
fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError> {
|
fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError> {
|
||||||
|
let id =
|
||||||
|
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||||
let raw = self.client.fetch_news(symbol, limit)?;
|
let raw = self.client.fetch_news(symbol, limit)?;
|
||||||
parse_news(symbol, &raw)
|
// Company names catch articles MSN did not tag; without them (quote
|
||||||
|
// unavailable) the ticker and tags still classify.
|
||||||
|
let names = self
|
||||||
|
.client
|
||||||
|
.fetch_quotes(symbol)
|
||||||
|
.ok()
|
||||||
|
.and_then(|quotes| quotes.into_iter().next())
|
||||||
|
.map(|quote| company_names(quote.short_name.as_deref(), quote.display_name.as_deref()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
parse_news(symbol, id, &names, &raw, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ pub(crate) struct MsnQuote {
|
||||||
pub(crate) symbol: Option<String>,
|
pub(crate) symbol: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) short_name: Option<String>,
|
pub(crate) short_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) display_name: Option<String>,
|
||||||
pub(crate) price: Option<f64>,
|
pub(crate) price: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) price_change: Option<f64>,
|
pub(crate) price_change: Option<f64>,
|
||||||
|
|
@ -281,6 +283,21 @@ pub(super) struct RawNewsItem {
|
||||||
pub(super) provider: Option<RawNewsProvider>,
|
pub(super) provider: Option<RawNewsProvider>,
|
||||||
pub(super) published_date_time: Option<String>,
|
pub(super) published_date_time: Option<String>,
|
||||||
pub(super) read_time_min: Option<i32>,
|
pub(super) read_time_min: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) finance_metadata: Option<RawNewsFinanceMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct RawNewsFinanceMetadata {
|
||||||
|
/// MSN instrument ids the article is tagged with.
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) stocks: Vec<RawNewsStock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct RawNewsStock {
|
||||||
|
#[serde(rename = "stockId")]
|
||||||
|
pub(super) stock_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,27 @@ pub struct NewsItem {
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub published_at: String,
|
pub published_at: String,
|
||||||
pub read_time_min: Option<i32>,
|
pub read_time_min: Option<i32>,
|
||||||
|
/// Whether the item is about this company or general market news; the
|
||||||
|
/// provider's feed for a stock can be mostly the latter.
|
||||||
|
#[serde(default)]
|
||||||
|
pub relevance: NewsRelevance,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum NewsRelevance {
|
||||||
|
Company,
|
||||||
|
#[default]
|
||||||
|
Market,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewsRelevance {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Company => "company",
|
||||||
|
Self::Market => "market",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,8 @@ impl Financial<'_> {
|
||||||
policy: CachePolicy,
|
policy: CachePolicy,
|
||||||
) -> Result<Fetched<Vec<NewsItem>>, IdxError> {
|
) -> Result<Fetched<Vec<NewsItem>>, IdxError> {
|
||||||
let resolved = self.idx.resolve(symbol)?;
|
let resolved = self.idx.resolve(symbol)?;
|
||||||
let key = format!("{resolved}-{limit}");
|
// "ranked": entries cached before relevance ranking lack the field.
|
||||||
|
let key = format!("{resolved}-{limit}-ranked");
|
||||||
let provider = self.idx.provider.news_provider(&resolved)?;
|
let provider = self.idx.provider.news_provider(&resolved)?;
|
||||||
let ttl = self.idx.config.quote_ttl;
|
let ttl = self.idx.config.quote_ttl;
|
||||||
let fetched = self.fetch("news", &key, &resolved, ttl, policy, || {
|
let fetched = self.fetch("news", &key, &resolved, ttl, policy, || {
|
||||||
|
|
|
||||||
|
|
@ -162,8 +162,9 @@ for one or more IDX tickers."
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "Company data for one IDX ticker: profile, financial statements, \
|
description = "Company data for one IDX ticker: profile, financial statements, \
|
||||||
earnings, analyst sentiment, insights, and news. Sections that fail are listed under \
|
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
||||||
`errors`; the call fails only if every section fails."
|
`company` (about this ticker) first, then `market` (general market news). Sections \
|
||||||
|
that fail are listed under `errors`; the call fails only if every section fails."
|
||||||
)]
|
)]
|
||||||
async fn get_company(&self, params: Parameters<CompanyParams>) -> ToolResult {
|
async fn get_company(&self, params: Parameters<CompanyParams>) -> ToolResult {
|
||||||
let CompanyParams { symbol, sections } = params.0;
|
let CompanyParams { symbol, sections } = params.0;
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@
|
||||||
"name": "compare_stocks"
|
"name": "compare_stocks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. Sections that fail are listed under `errors`; the call fails only if every section fails.",
|
"description": "Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. News items carry `relevance`: `company` (about this ticker) first, then `market` (general market news). Sections that fail are listed under `errors`; the call fails only if every section fails.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"$defs": {
|
"$defs": {
|
||||||
"CompanySection": {
|
"CompanySection": {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ them from stdin. `history` takes `--period` (`1d` to `5y`) and `--interval`
|
||||||
| `earnings` | reported and estimated earnings |
|
| `earnings` | reported and estimated earnings |
|
||||||
| `sentiment` | crowd sentiment |
|
| `sentiment` | crowd sentiment |
|
||||||
| `insights` | AI insights from the provider |
|
| `insights` | AI insights from the provider |
|
||||||
| `news` | recent news |
|
| `news` | recent news, this company's first (`ABOUT` = `company`), then general market news (`market`) |
|
||||||
|
|
||||||
## Analysis
|
## Analysis
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ Compare fundamental reports (growth, valuation, risk) across IDX tickers. Ticker
|
||||||
|
|
||||||
## `get_company`
|
## `get_company`
|
||||||
|
|
||||||
Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. Sections that fail are listed under `errors`; the call fails only if every section fails.
|
Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. News items carry `relevance`: `company` (about this ticker) first, then `market` (general market news). Sections that fail are listed under `errors`; the call fails only if every section fails.
|
||||||
|
|
||||||
| Input | Type | Required | Description |
|
| Input | Type | Required | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue