From 2c93c73b6a0ce985eb0e01adf245174dd18e335a Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 04:28:38 +0000 Subject: [PATCH 01/32] feat(core): broker summaries from IPOT Adds a BrokerSummaryProvider capability served by Indo Premier's public broker summary page: the top 10 buyers and sellers of a stock by lots over a date range, with totals. No login or key. - api/ipot: pure parser (decimal scaling of `1.6 M`-style figures), a client that retries the server's spurious 404s, and a fixture mock. - broker: Broker::summary/latest over the cache (7 days for closed ranges, 5 minutes for today), range checks, and IDX member names bundled from GetBrokerCodeList. - foreign_net_value is null for filtered summaries: the source does not apply the filters to it. The endpoint's behavior was first mapped by fzn0x/idx (MIT). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-core/src/api/ipot/client.rs | 62 ++++ crates/idx-core/src/api/ipot/mod.rs | 101 ++++++ crates/idx-core/src/api/ipot/parse.rs | 317 ++++++++++++++++++ crates/idx-core/src/api/mock.rs | 49 ++- crates/idx-core/src/api/mod.rs | 30 ++ crates/idx-core/src/broker/codes.tsv | 92 +++++ crates/idx-core/src/broker/mod.rs | 222 ++++++++++++ crates/idx-core/src/broker/types.rs | 146 ++++++++ crates/idx-core/src/client.rs | 4 + crates/idx-core/src/lib.rs | 1 + ..._brokersummary_bbca_20261012_20261016.html | 181 ++++++++++ .../ipot_brokersummary_bbca_20261016.html | 181 ++++++++++ ...summary_bbca_20261016_foreign_regular.html | 181 ++++++++++ .../ipot_brokersummary_bbca_20261017.html | 181 ++++++++++ .../ipot_brokersummary_zzzz_invalid.html | 1 + docs/ARCHITECTURE.md | 19 ++ 16 files changed, 1767 insertions(+), 1 deletion(-) create mode 100644 crates/idx-core/src/api/ipot/client.rs create mode 100644 crates/idx-core/src/api/ipot/mod.rs create mode 100644 crates/idx-core/src/api/ipot/parse.rs create mode 100644 crates/idx-core/src/broker/codes.tsv create mode 100644 crates/idx-core/src/broker/mod.rs create mode 100644 crates/idx-core/src/broker/types.rs create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html diff --git a/crates/idx-core/src/api/ipot/client.rs b/crates/idx-core/src/api/ipot/client.rs new file mode 100644 index 0000000..6e8135c --- /dev/null +++ b/crates/idx-core/src/api/ipot/client.rs @@ -0,0 +1,62 @@ +use std::time::Duration; + +use crate::error::IdxError; + +const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; +/// Tries per request. In some stretches of seconds the server answers its +/// own nginx 404 page to a third or more of requests and then recovers, so a +/// 404 is retried (an unknown ticker is a 200, not a 404). +const ATTEMPTS: u32 = 5; +const FIRST_PAUSE: Duration = Duration::from_millis(250); + +pub(super) struct IpotClient { + agent: ureq::Agent, +} + +impl IpotClient { + pub(super) fn new() -> Self { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(5))) + .timeout_recv_body(Some(Duration::from_secs(10))) + // A server that stalls before sending headers must not hold a + // call (and an MCP call slot) forever. + .timeout_global(Some(Duration::from_secs(20))) + .build() + .into(); + Self { agent } + } + + pub(super) fn fetch_html(&self, url: &str) -> Result { + let mut pause = FIRST_PAUSE; + let mut last = None; + for attempt in 1..=ATTEMPTS { + if attempt > 1 { + std::thread::sleep(pause); + pause *= 2; + } + let response = self + .agent + .get(url) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html") + .call(); + match response { + Ok(ok) => { + return ok + .into_body() + .read_to_string() + .map_err(|e| IdxError::Http(format!("ipot broker summary: {e}"))); + } + Err(ureq::Error::StatusCode(code)) if code == 404 || code >= 500 => { + last = Some(code); + } + Err(ureq::Error::StatusCode(429)) => return Err(IdxError::RateLimited), + Err(err) => return Err(IdxError::Http(format!("ipot broker summary: {err}"))), + } + } + Err(IdxError::Http(format!( + "ipot broker summary: status {} after {ATTEMPTS} attempts", + last.unwrap_or_default() + ))) + } +} diff --git a/crates/idx-core/src/api/ipot/mod.rs b/crates/idx-core/src/api/ipot/mod.rs new file mode 100644 index 0000000..49f42dd --- /dev/null +++ b/crates/idx-core/src/api/ipot/mod.rs @@ -0,0 +1,101 @@ +//! Indo Premier Sekuritas (IPOT) broker summary: which brokers bought and +//! sold a stock. No login or key. +//! +//! `GET https://www.indopremier.com/module/saham/include/data-brokersummary.php` +//! `?code=BBCA&start=MM/DD/YYYY&end=MM/DD/YYYY&fd=all|F|D&board=all|RG|TN|NG` +//! answers an HTML fragment with the top 10 buyers and the top 10 sellers, +//! ranked by lots, and totals for the range. Figures of a million and more +//! are abbreviated to one decimal (`1.6 M`). A range without trades gives +//! blank rows and zero totals; an unknown code gives `Invalid stock code!` +//! with HTTP 200. The endpoint's behavior was first mapped by fzn0x/idx +//! (MIT). + +mod client; +pub mod parse; + +use client::IpotClient; +pub use parse::{BrokerSummaryRaw, IpotPage, parse_broker_summary, parse_figure}; + +use super::BrokerSummaryProvider; +use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; +use crate::error::IdxError; + +const ENDPOINT: &str = "https://www.indopremier.com/module/saham/include/data-brokersummary.php"; + +/// The request URL for `code` (a bare IDX code such as `BBCA`). +pub fn broker_summary_url(code: &str, query: &BrokerQuery) -> String { + let investor = match query.investor { + BrokerInvestor::All => "all", + BrokerInvestor::Foreign => "F", + BrokerInvestor::Domestic => "D", + }; + let board = match query.board { + BrokerBoard::All => "all", + BrokerBoard::Regular => "RG", + BrokerBoard::Cash => "TN", + BrokerBoard::Negotiated => "NG", + }; + format!( + "{ENDPOINT}?code={code}&start={}&end={}&fd={investor}&board={board}", + query.from.format("%m/%d/%Y"), + query.to.format("%m/%d/%Y"), + ) +} + +/// A parsed page as a summary; an unknown code is `SymbolNotFound`. +pub(crate) fn summary_from_html(code: &str, html: &str) -> Result { + match parse_broker_summary(html)? { + IpotPage::Invalid => Err(IdxError::SymbolNotFound(code.to_string())), + IpotPage::Summary(raw) => Ok(raw), + } +} + +pub struct IpotProvider { + client: IpotClient, +} + +impl IpotProvider { + pub fn new() -> Self { + Self { + client: IpotClient::new(), + } + } +} + +impl Default for IpotProvider { + fn default() -> Self { + Self::new() + } +} + +impl BrokerSummaryProvider for IpotProvider { + fn broker_summary( + &self, + code: &str, + query: &BrokerQuery, + ) -> Result { + let html = self.client.fetch_html(&broker_summary_url(code, query))?; + summary_from_html(code, &html) + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::*; + + #[test] + fn test_broker_summary_url_maps_filters_to_source_codes() { + let query = BrokerQuery { + from: NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(), + to: NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(), + investor: BrokerInvestor::Foreign, + board: BrokerBoard::Regular, + }; + assert_eq!( + broker_summary_url("BBCA", &query), + "https://www.indopremier.com/module/saham/include/data-brokersummary.php?code=BBCA&start=10/12/2026&end=10/16/2026&fd=F&board=RG" + ); + } +} diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs new file mode 100644 index 0000000..9dd822a --- /dev/null +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -0,0 +1,317 @@ +//! Pure parsers for IPOT's broker summary fragment, tested against the +//! recorded pages in `tests/fixtures/ipot_brokersummary_*.html`. +//! +//! The fragment is a 9-column table: buyer code, lots, value, average, rank, +//! seller code, lots, value, average. Each code sits in a +//! ``; the footer holds +//! `T. Val`, `F. NVal`, `T.Lot` and `Avg`, and the date inputs echo the +//! range the source applied. + +use chrono::NaiveDate; + +use crate::broker::types::{BrokerFlow, BrokerKind}; +use crate::error::IdxError; + +/// What one page said. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IpotPage { + /// `Invalid stock code!`: the source does not know the ticker. + Invalid, + Summary(BrokerSummaryRaw), +} + +/// One parsed summary page, before the symbol and query are attached. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BrokerSummaryRaw { + pub from: Option, + pub to: Option, + pub buyers: Vec, + pub sellers: Vec, + pub total_value: Option, + pub total_lots: Option, + pub average_price: Option, + pub foreign_net_value: Option, + pub rounded: bool, +} + +/// A figure as published: `1.6 M` -> `(1_600_000, true)`, `687,067` -> +/// `(687_067, false)`, `-2.3 T` -> `(-2_300_000_000_000, true)`. Scaled in +/// decimal, so `1.6 M` is exact. Empty or unparsable -> `None`. +pub fn parse_figure(text: &str) -> Option<(i64, bool)> { + let text = text.trim().replace(',', ""); + let (number, exponent) = match text.chars().last()?.to_ascii_uppercase() { + 'K' => (&text[..text.len() - 1], 3), + 'M' => (&text[..text.len() - 1], 6), + 'B' => (&text[..text.len() - 1], 9), + 'T' => (&text[..text.len() - 1], 12), + _ => (text.as_str(), 0), + }; + let number = number.trim(); + let (negative, digits) = match number.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, number), + }; + let (whole, fraction) = digits.split_once('.').unwrap_or((digits, "")); + if whole.is_empty() + || !whole.bytes().all(|b| b.is_ascii_digit()) + || !fraction.bytes().all(|b| b.is_ascii_digit()) + || (exponent > 0 && fraction.len() > exponent as usize) + { + return None; + } + let scale = 10_i64.checked_pow(exponent)?; + let mut value = whole.parse::().ok()?.checked_mul(scale)?; + if exponent == 0 { + // A plain decimal: round to whole units. + if fraction + .as_bytes() + .first() + .is_some_and(|digit| *digit >= b'5') + { + value = value.checked_add(1)?; + } + } else if !fraction.is_empty() { + let unit = 10_i64.checked_pow(exponent - fraction.len() as u32)?; + value = value.checked_add(fraction.parse::().ok()?.checked_mul(unit)?)?; + } + Some((if negative { -value } else { value }, exponent > 0)) +} + +struct Cell { + class: Option, + text: String, +} + +fn strip_tags(html: &str) -> String { + let mut out = String::with_capacity(html.len()); + let mut in_tag = false; + for ch in html.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(ch), + _ => {} + } + } + out.replace(" ", " ").trim().to_string() +} + +fn cells(row: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = row; + while let Some(start) = rest.find("') else { + break; + }; + let inner_start = open_end + 1; + let Some(close) = after[inner_start..].find("") else { + break; + }; + let inner = &after[inner_start..inner_start + close]; + let class = inner.find("class=\"").and_then(|at| { + let value = &inner[at + 7..]; + value.find('"').map(|end| value[..end].to_string()) + }); + out.push(Cell { + class, + text: strip_tags(inner), + }); + rest = &after[inner_start + close + 5..]; + } + out +} + +fn broker_kind(class: Option<&str>) -> Option { + match class?.strip_prefix("text-")? { + "local" => Some(BrokerKind::Local), + "foreign" => Some(BrokerKind::Foreign), + "bumn" => Some(BrokerKind::StateOwned), + _ => None, + } +} + +/// The `MM/DD/YYYY` value of the input with this id. +fn input_date(html: &str, id: &str) -> Option { + let at = html.find(&format!("id=\"{id}\""))?; + let tag_start = html[..at].rfind('<')?; + let tag_end = at + html[at..].find('>')?; + let tag = &html[tag_start..tag_end]; + let value = &tag[tag.find("value=\"")? + 7..]; + NaiveDate::parse_from_str(&value[..value.find('"')?], "%m/%d/%Y").ok() +} + +/// Text after `label` up to the next tag, e.g. `6.6 T` for `T. Val :`. +fn footer<'a>(html: &'a str, label: &str) -> Option<&'a str> { + let rest = &html[html.find(label)? + label.len()..]; + Some(rest[..rest.find('<')?].trim()) +} + +pub fn parse_broker_summary(html: &str) -> Result { + if html.to_ascii_lowercase().contains("invalid stock code") { + return Ok(IpotPage::Invalid); + } + let (Some(body_start), Some(body_end)) = (html.find(""), html.find("")) else { + return Err(IdxError::ParseError( + "ipot broker summary: no table in the page".to_string(), + )); + }; + let mut raw = BrokerSummaryRaw { + from: input_date(html, "startDateBrokerSummary"), + to: input_date(html, "endDateBrokerSummary"), + ..BrokerSummaryRaw::default() + }; + let mut rounded = false; + let mut figure = |text: &str| { + parse_figure(text).map(|(value, abbreviated)| { + rounded |= abbreviated; + value + }) + }; + for (index, row) in html[body_start..body_end].split("").enumerate() { + let row = cells(row); + if row.len() < 9 { + continue; + } + let rank = row[4].text.parse::().unwrap_or(index as u32 + 1); + for (side, at) in [(&mut raw.buyers, 0), (&mut raw.sellers, 5)] { + let code = row[at].text.to_ascii_uppercase(); + if code.is_empty() { + continue; + } + if code.len() != 2 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) { + return Err(IdxError::ParseError(format!( + "ipot broker summary: unexpected broker code {code:?}" + ))); + } + side.push(BrokerFlow { + rank, + code, + name: None, + kind: broker_kind(row[at].class.as_deref()), + lots: figure(&row[at + 1].text), + value: figure(&row[at + 2].text), + average_price: figure(&row[at + 3].text), + }); + } + } + let mut total = |label: &str| footer(html, label).and_then(&mut figure); + raw.total_value = total("T. Val :"); + raw.foreign_net_value = total("F. NVal :"); + raw.total_lots = total("T.Lot :"); + // The page prints `Avg : 0` when nothing traded; there is no average then. + raw.average_price = total("Avg :").filter(|_| raw.total_lots.is_some_and(|lots| lots > 0)); + raw.rounded = rounded; + Ok(IpotPage::Summary(raw)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: &str = include_str!("../../../tests/fixtures/ipot_brokersummary_bbca_20261016.html"); + const RANGE: &str = + include_str!("../../../tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html"); + const NO_TRADES: &str = + include_str!("../../../tests/fixtures/ipot_brokersummary_bbca_20261017.html"); + const INVALID: &str = + include_str!("../../../tests/fixtures/ipot_brokersummary_zzzz_invalid.html"); + + fn summary(html: &str) -> BrokerSummaryRaw { + match parse_broker_summary(html).unwrap() { + IpotPage::Summary(raw) => raw, + IpotPage::Invalid => panic!("expected a summary"), + } + } + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + #[test] + fn test_parse_figure_scales_in_decimal() { + assert_eq!(parse_figure("1.6 M"), Some((1_600_000, true))); + assert_eq!(parse_figure("841.2 B"), Some((841_200_000_000, true))); + assert_eq!(parse_figure("-2.3 T"), Some((-2_300_000_000_000, true))); + assert_eq!(parse_figure("12 K"), Some((12_000, true))); + assert_eq!(parse_figure("687,067 "), Some((687_067, false))); + assert_eq!(parse_figure("5,191.5"), Some((5_192, false))); + assert_eq!(parse_figure("0"), Some((0, false))); + for bad in ["", " ", "n/a", "1.2.3 M", "M", "-", "1.6789 K"] { + assert_eq!(parse_figure(bad), None, "{bad:?}"); + } + } + + #[test] + fn test_parse_broker_summary_one_day() { + let raw = summary(DAY); + assert_eq!( + (raw.from, raw.to), + (Some(date(2026, 10, 16)), Some(date(2026, 10, 16))) + ); + assert_eq!((raw.buyers.len(), raw.sellers.len()), (10, 10)); + + let top = &raw.buyers[0]; + assert_eq!( + (top.rank, top.code.as_str(), top.kind), + (1, "MG", Some(BrokerKind::Local)) + ); + assert_eq!( + (top.lots, top.value, top.average_price), + (Some(1_600_000), Some(841_200_000_000), Some(5_191)) + ); + let seller = &raw.sellers[0]; + assert_eq!( + (seller.code.as_str(), seller.kind, seller.value), + ("CC", Some(BrokerKind::StateOwned), Some(2_200_000_000_000)) + ); + assert_eq!(raw.sellers[1].kind, Some(BrokerKind::Foreign)); + let last = &raw.buyers[9]; + assert_eq!((last.rank, last.code.as_str()), (10, "AK")); + assert_eq!(last.lots, Some(398_474), "small figures are exact"); + + assert_eq!(raw.total_value, Some(6_600_000_000_000)); + assert_eq!(raw.foreign_net_value, Some(-2_300_000_000_000)); + assert_eq!(raw.total_lots, Some(12_600_000)); + assert_eq!(raw.average_price, Some(5_228)); + assert!(raw.rounded); + } + + #[test] + fn test_parse_broker_summary_range_echoes_its_dates() { + let raw = summary(RANGE); + assert_eq!( + (raw.from, raw.to), + (Some(date(2026, 10, 12)), Some(date(2026, 10, 16))) + ); + assert_eq!(raw.buyers.len(), 10); + assert!( + raw.total_value + .is_some_and(|value| value > 6_600_000_000_000) + ); + } + + #[test] + fn test_parse_broker_summary_without_trades_is_empty() { + let raw = summary(NO_TRADES); + assert!(raw.buyers.is_empty() && raw.sellers.is_empty()); + assert_eq!((raw.total_value, raw.total_lots), (Some(0), Some(0))); + assert_eq!(raw.average_price, None, "Avg : 0 means no average"); + assert!(!raw.rounded); + assert_eq!(raw.to, Some(date(2026, 10, 17))); + } + + #[test] + fn test_parse_broker_summary_rejects_unknown_codes_and_markup() { + assert_eq!(parse_broker_summary(INVALID).unwrap(), IpotPage::Invalid); + assert!(matches!( + parse_broker_summary("maintenance"), + Err(IdxError::ParseError(_)) + )); + let odd = DAY.replacen(">MG<", ">M G<", 1); + assert!(matches!( + parse_broker_summary(&odd), + Err(IdxError::ParseError(_)) + )); + } +} diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index 9959599..2292039 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -2,7 +2,10 @@ //! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it. use super::types::{Bar, Fundamentals, Interval, Period, Quote}; -use super::{FundamentalsProvider, HistoryProvider, QuoteProvider, msn, yahoo}; +use super::{ + BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider, ipot, msn, yahoo, +}; +use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; use crate::config::ProviderKind; use crate::error::IdxError; @@ -10,6 +13,15 @@ pub(crate) fn enabled() -> bool { std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() } +/// `IDX_MOCK_TODAY` (YYYY-MM-DD) pins today's date under the mock provider. +pub(crate) fn today() -> Option { + if !enabled() { + return None; + } + let raw = std::env::var("IDX_MOCK_TODAY").ok()?; + chrono::NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok() +} + pub struct MockProvider { quote: Result, fundamentals: Result, @@ -117,3 +129,38 @@ impl HistoryProvider for MockProvider { self.history.clone() } } + +/// Broker summaries from `tests/fixtures/ipot_brokersummary__ +/// [_][__].html`; a query without a fixture reads as an +/// unknown code. +pub struct MockBrokerProvider; + +impl BrokerSummaryProvider for MockBrokerProvider { + fn broker_summary( + &self, + code: &str, + query: &BrokerQuery, + ) -> Result { + if std::env::var("IDX_MOCK_ERROR").is_ok() { + return Err(IdxError::ProviderUnavailable); + } + let mut name = format!( + "ipot_brokersummary_{}_{}", + code.to_ascii_lowercase(), + query.from.format("%Y%m%d") + ); + if query.to != query.from { + name.push_str(&query.to.format("_%Y%m%d").to_string()); + } + if (query.investor, query.board) != (BrokerInvestor::All, BrokerBoard::All) { + name.push_str(&format!( + "_{}_{}", + query.investor.as_str(), + query.board.as_str() + )); + } + let html = std::fs::read_to_string(format!("tests/fixtures/{name}.html")) + .unwrap_or_else(|_| "Invalid stock code!".to_string()); + ipot::summary_from_html(code, &html) + } +} diff --git a/crates/idx-core/src/api/mod.rs b/crates/idx-core/src/api/mod.rs index 1a7bf04..7b7d459 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -1,3 +1,4 @@ +pub mod ipot; #[cfg(feature = "testing")] pub mod mock; pub mod msn; @@ -6,6 +7,7 @@ pub mod yahoo; use chrono::FixedOffset; +use crate::broker::types::BrokerQuery; use crate::config::{HistoryProviderKind, ProviderKind}; use crate::error::IdxError; use types::{ @@ -41,6 +43,17 @@ pub trait HistoryProvider { ) -> Result, IdxError>; } +/// Which brokers bought and sold a stock: a separate capability, served by +/// IPOT whichever market provider is selected. +pub trait BrokerSummaryProvider { + /// `code` is the bare IDX code, e.g. `BBCA`. + fn broker_summary( + &self, + code: &str, + query: &BrokerQuery, + ) -> Result; +} + pub trait FundamentalsProvider { fn fundamentals(&self, symbol: &str) -> Result; } @@ -258,6 +271,23 @@ fn msn_capability_error(subject: &str) -> IdxError { IdxError::Unsupported(format!("{subject}: command requires --provider msn")) } +pub fn broker_provider() -> Box { + #[cfg(feature = "testing")] + if mock::enabled() { + return Box::new(mock::MockBrokerProvider); + } + Box::new(ipot::IpotProvider::new()) +} + +/// Today in WIB, the date IDX trades on. +pub(crate) fn today_wib() -> chrono::NaiveDate { + #[cfg(feature = "testing")] + if let Some(today) = mock::today() { + return today; + } + chrono::Utc::now().with_timezone(&idx_offset()).date_naive() +} + /// The provider that serves history for `provider` under `history_mode`. pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind { match history_mode { diff --git a/crates/idx-core/src/broker/codes.tsv b/crates/idx-core/src/broker/codes.tsv new file mode 100644 index 0000000..ffb5c85 --- /dev/null +++ b/crates/idx-core/src/broker/codes.tsv @@ -0,0 +1,92 @@ +# IDX exchange members: codename, from idx.co.id +# primary/ExchangeMember/GetBrokerCodeList, fetched 2026-10-18 (89 members). +# A code missing here is shown without a name. +AD SUKADANA PRIMA SEKURITAS +AF HARITA KENCANA SEKURITAS +AG KIWOOM SEKURITAS INDONESIA +AH SHINHAN SEKURITAS INDONESIA +AI KAY HIAN SEKURITAS +AK UBS SEKURITAS INDONESIA +AO ERDIKHA ELIT SEKURITAS +AP PACIFIC SEKURITAS INDONESIA +AR BINAARTHA SEKURITAS +AT PHINTRACO SEKURITAS +AZ SUCOR SEKURITAS +BB VERDHANA SEKURITAS INDONESIA +BF INTI FIKASA SEKURITAS +BK J.P. MORGAN SEKURITAS INDONESIA +BQ KOREA INVESTMENT AND SEKURITAS INDONESIA +BR TRUST SEKURITAS +BS EQUITY SEKURITAS INDONESIA +CC MANDIRI SEKURITAS +CD MEGA CAPITAL SEKURITAS +CP KB VALBURY SEKURITAS +DD MAKINDO SEKURITAS +DH SINARMAS SEKURITAS +DP DBS VICKERS SEKURITAS INDONESIA +DR RHB SEKURITAS INDONESIA +DU KAF SEKURITAS INDONESIA +DX BAHANA SEKURITAS +EL EVERGREEN SEKURITAS INDONESIA +EP MNC SEKURITAS +ES EKOKAPITAL SEKURITAS +FO FORTE GLOBAL SEKURITAS +FS YUANTA SEKURITAS INDONESIA +FZ WATERFRONT SEKURITAS INDONESIA +GA BNC SEKURITAS INDONESIA +GI WEBULL SEKURITAS INDONESIA +GR PANIN SEKURITAS TBK. +HD KGI SEKURITAS INDONESIA +HP HENAN PUTIHRAI SEKURITAS +IC INTEGRITY CAPITAL SEKURITAS +ID ANUGERAH SEKURITAS INDONESIA +IF SAMUEL SEKURITAS INDONESIA +IH INDO HARVEST SEKURITAS +II DANATAMA MAKMUR SEKURITAS +IN INVESTINDO NUSANTARA SEKURITAS +IT INTI TELADAN SEKURITAS +IU INDO CAPITAL SEKURITAS +KI CIPTADANA SEKURITAS ASIA +KK PHILLIP SEKURITAS INDONESIA +KZ CLSA SEKURITAS INDONESIA +LG TRIMEGAH SEKURITAS INDONESIA TBK. +LS RELIANCE SEKURITAS INDONESIA TBK. +MG SEMESTA INDOVEST SEKURITAS +MI VICTORIA SEKURITAS INDONESIA +MU MINNA PADI INVESTAMA SEKURITAS TBK +NI BNI SEKURITAS +OD BRI DANAREKSA SEKURITAS +OK NET SEKURITAS +PC FAC SEKURITAS INDONESIA +PD INDO PREMIER SEKURITAS +PF DANASAKTI SEKURITAS INDONESIA +PG PANCA GLOBAL SEKURITAS +PI MAGENTA KAPITAL SEKURITAS INDONESIA +PO PILARMAS INVESTINDO SEKURITAS +PP ALDIRACITA SEKURITAS INDONESIA +PS PARAMITRA ALFA SEKURITAS +QA TUNTUN SEKURITAS INDONESIA +RB INA SEKURITAS INDONESIA +RF BUANA CAPITAL SEKURITAS +RG PROFINDO SEKURITAS INDONESIA +RO PLUANG MAJU SEKURITAS +RS YULIE SEKURITAS INDONESIA TBK. +RX MACQUARIE SEKURITAS INDONESIA +SA ELIT SUKSES SEKURITAS +SF SURYA FAJAR SEKURITAS +SH ARTHA SEKURITAS INDONESIA +SQ BCA SEKURITAS +SS SUPRA SEKURITAS INDONESIA +TF LABA SEKURITAS INDONESIA +TP OCBC SEKURITAS INDONESIA +TS DWIDANA SAKTI SEKURITAS +XA NH KORINDO SEKURITAS INDONESIA +XC AJAIB SEKURITAS ASIA +XL STOCKBIT SEKURITAS DIGITAL +YB YAKIN BERTUMBUH SEKURITAS +YJ LOTUS ANDALAN SEKURITAS +YO AMANTARA SEKURITAS INDONESIA +YP MIRAE ASSET SEKURITAS INDONESIA +YU CGS INTERNATIONAL SEKURITAS INDONESIA +ZP MAYBANK SEKURITAS INDONESIA +ZR BUMIPUTERA SEKURITAS diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs new file mode 100644 index 0000000..dab8815 --- /dev/null +++ b/crates/idx-core/src/broker/mod.rs @@ -0,0 +1,222 @@ +//! Broker summaries: which IDX brokers bought and sold a stock over a date +//! range (IPOT, end of day). See docs/ARCHITECTURE.md. + +pub mod types; + +use std::collections::HashMap; +use std::sync::OnceLock; + +use chrono::{Datelike, Duration, NaiveDate, Weekday}; + +use crate::api::{broker_provider, today_wib}; +use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::error::IdxError; +use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerSummary}; + +const SOURCE: &str = "ipot"; +/// Longest range one summary may cover. +pub const MAX_RANGE_DAYS: i64 = 366; +/// Calendar days searched back for the latest session with trades. +const LATEST_LOOKBACK_DAYS: i64 = 10; +/// A range that ended before today no longer changes. +const TTL_CLOSED_SECS: u64 = 7 * 24 * 3600; +/// Today's summary moves with every trade. +const TTL_TODAY_SECS: u64 = 5 * 60; + +const CODES: &str = include_str!("codes.tsv"); + +/// Member name for an IDX broker code such as `CC`; `None` when the code is +/// not on the bundled member list. +pub fn broker_name(code: &str) -> Option<&'static str> { + static NAMES: OnceLock> = OnceLock::new(); + NAMES + .get_or_init(|| { + CODES + .lines() + .filter(|line| !line.starts_with('#')) + .filter_map(|line| line.split_once('\t')) + .collect() + }) + .get(code.trim().to_ascii_uppercase().as_str()) + .copied() +} + +pub struct Broker<'a> { + pub(crate) idx: &'a Idx, +} + +impl Broker<'_> { + /// The broker summary for `symbol` over `query`'s range. An empty + /// summary means no trades (weekend, holiday, suspension). + pub fn summary( + &self, + symbol: &str, + query: &BrokerQuery, + policy: CachePolicy, + ) -> Result, IdxError> { + let (resolved, code) = self.idx_code(symbol)?; + let today = today_wib(); + check_range(query, today)?; + let key = format!( + "{code}-{}-{}-{}-{}", + query.from, + query.to, + query.investor.as_str(), + query.board.as_str() + ); + let provider = broker_provider(); + cached_fetch( + &self.idx.cache, + CacheSpec { + bucket: "ipot-broker", + key: &key, + ttl_secs: if query.to < today { + TTL_CLOSED_SECS + } else { + TTL_TODAY_SECS + }, + subject: &resolved, + source: SOURCE, + }, + policy, + || provider.broker_summary(&code, query), + |raw| { + let filtered = + (query.investor, query.board) != (BrokerInvestor::All, BrokerBoard::All); + let mut summary = BrokerSummary { + symbol: resolved.clone(), + from: raw.from.unwrap_or(query.from), + to: raw.to.unwrap_or(query.to), + investor: query.investor, + board: query.board, + buyers: raw.buyers, + sellers: raw.sellers, + total_value: raw.total_value, + total_lots: raw.total_lots, + average_price: raw.average_price, + foreign_net_value: raw.foreign_net_value.filter(|_| !filtered), + rounded: raw.rounded, + }; + for flow in summary.buyers.iter_mut().chain(summary.sellers.iter_mut()) { + flow.name = broker_name(&flow.code).map(str::to_string); + } + Ok(summary) + }, + ) + } + + /// The summary of the latest session with trades, searching back from + /// today (WIB) over weekdays. + pub fn latest( + &self, + symbol: &str, + investor: BrokerInvestor, + board: BrokerBoard, + policy: CachePolicy, + ) -> Result, IdxError> { + let (resolved, _) = self.idx_code(symbol)?; + let today = today_wib(); + for back in 0..LATEST_LOOKBACK_DAYS { + let day = today - Duration::days(back); + if matches!(day.weekday(), Weekday::Sat | Weekday::Sun) { + continue; + } + let query = BrokerQuery { + from: day, + to: day, + investor, + board, + }; + match self.summary(symbol, &query, policy) { + Ok(fetched) if !fetched.data.is_empty() => return Ok(fetched), + Ok(_) => {} + // Offline, an uncached day says nothing; look further back. + Err(IdxError::CacheMiss(_)) if policy == CachePolicy::Offline => {} + Err(err) => return Err(err), + } + } + if policy == CachePolicy::Offline { + return Err(IdxError::CacheMiss(format!( + "ipot-broker/{resolved} (no cached session in the last {LATEST_LOOKBACK_DAYS} days)" + ))); + } + Err(IdxError::NoMarketData(format!( + "{resolved}: no trades in the last {LATEST_LOOKBACK_DAYS} days" + ))) + } + + /// The resolved symbol and the bare IDX code IPOT takes. + fn idx_code(&self, symbol: &str) -> Result<(String, String), IdxError> { + let resolved = self.idx.resolve(symbol)?; + match resolved.strip_suffix(".JK") { + Some(code) if !code.is_empty() => Ok((resolved.clone(), code.to_string())), + _ => Err(IdxError::Unsupported(format!( + "{resolved}: broker summaries cover IDX (.JK) stocks only" + ))), + } + } +} + +fn check_range(query: &BrokerQuery, today: NaiveDate) -> Result<(), IdxError> { + if query.from > query.to { + return Err(IdxError::InvalidInput(format!( + "--from {} is after --to {}", + query.from, query.to + ))); + } + if query.to > today { + return Err(IdxError::InvalidInput(format!( + "{} is in the future (today is {today} in WIB)", + query.to + ))); + } + if (query.to - query.from).num_days() >= MAX_RANGE_DAYS { + return Err(IdxError::InvalidInput(format!( + "a broker summary covers at most {MAX_RANGE_DAYS} days" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + fn query(from: NaiveDate, to: NaiveDate) -> BrokerQuery { + BrokerQuery { + from, + to, + investor: BrokerInvestor::All, + board: BrokerBoard::All, + } + } + + #[test] + fn test_broker_name_reads_the_bundled_member_list() { + assert_eq!(broker_name("cc"), Some("MANDIRI SEKURITAS")); + assert_eq!(broker_name("XL"), Some("STOCKBIT SEKURITAS DIGITAL")); + assert_eq!(broker_name("??"), None); + } + + #[test] + fn test_check_range_bounds() { + let today = date(2026, 10, 18); + assert!(check_range(&query(date(2026, 10, 12), date(2026, 10, 16)), today).is_ok()); + assert!(check_range(&query(today, today), today).is_ok()); + assert!(check_range(&query(date(2025, 10, 16), date(2026, 10, 16)), today).is_ok()); + for bad in [ + query(date(2026, 10, 16), date(2026, 10, 12)), + query(date(2026, 10, 19), date(2026, 10, 19)), + query(date(2025, 10, 15), date(2026, 10, 16)), + ] { + assert!( + matches!(check_range(&bad, today), Err(IdxError::InvalidInput(_))), + "{bad:?}" + ); + } + } +} diff --git a/crates/idx-core/src/broker/types.rs b/crates/idx-core/src/broker/types.rs new file mode 100644 index 0000000..84a9a87 --- /dev/null +++ b/crates/idx-core/src/broker/types.rs @@ -0,0 +1,146 @@ +//! Broker summary types: which IDX brokers bought and sold a stock. + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +/// Whose trades a broker summary counts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "snake_case")] +pub enum BrokerInvestor { + /// Every trade. + #[default] + All, + /// Trades by foreign investors only. + Foreign, + /// Trades by domestic investors only. + Domestic, +} + +impl BrokerInvestor { + pub fn as_str(self) -> &'static str { + match self { + Self::All => "all", + Self::Foreign => "foreign", + Self::Domestic => "domestic", + } + } +} + +/// Which market board's trades a broker summary counts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "snake_case")] +pub enum BrokerBoard { + /// Every board. + #[default] + All, + /// Regular market (RG), the continuous order book. + Regular, + /// Cash market (TN). + Cash, + /// Negotiated market (NG): pre-arranged block trades. + Negotiated, +} + +impl BrokerBoard { + pub fn as_str(self) -> &'static str { + match self { + Self::All => "all", + Self::Regular => "regular", + Self::Cash => "cash", + Self::Negotiated => "negotiated", + } + } +} + +/// The source's classification of a securities house. This is the broker's +/// own status, not the nationality of its clients. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrokerKind { + Local, + Foreign, + /// State-owned securities house (BUMN). + StateOwned, +} + +impl BrokerKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Foreign => "foreign", + Self::StateOwned => "state_owned", + } + } +} + +/// The trading days a broker summary covers, both ends included (WIB dates). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrokerQuery { + pub from: NaiveDate, + pub to: NaiveDate, + #[serde(default)] + pub investor: BrokerInvestor, + #[serde(default)] + pub board: BrokerBoard, +} + +/// One broker on one side of a summary: gross buys for a buyer, gross sells +/// for a seller. A broker can appear on both sides. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrokerFlow { + /// Position on its side as the source ranks it, 1 = most lots. + pub rank: u32, + /// IDX member code, e.g. `CC`. + pub code: String, + /// Member name from the bundled IDX member list; `None` for a code that + /// is not on it. + pub name: Option, + /// The source's classification; `None` when it gives none. + pub kind: Option, + /// Lots bought or sold (one lot is 100 shares). + pub lots: Option, + /// Value bought or sold, whole IDR. + pub value: Option, + /// Average price, whole IDR. + pub average_price: Option, +} + +/// The top buyers and sellers of one stock over a date range, as published. +/// Nothing is netted or estimated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrokerSummary { + /// Resolved ticker, e.g. `BBCA.JK`. + pub symbol: String, + /// First trading date covered, as the source applied it. + pub from: NaiveDate, + /// Last trading date covered, as the source applied it. + pub to: NaiveDate, + pub investor: BrokerInvestor, + pub board: BrokerBoard, + /// Top buyers by lots, most first (up to 10). + pub buyers: Vec, + /// Top sellers by lots, most first (up to 10). + pub sellers: Vec, + /// Value traded over the range, whole IDR. + pub total_value: Option, + /// Lots traded over the range. + pub total_lots: Option, + /// Average traded price, whole IDR; `None` when nothing traded. + pub average_price: Option, + /// Foreign buy value minus foreign sell value over the range, whole IDR, + /// across all boards. The source does not apply the investor or board + /// filter to it, so it is `None` for a filtered summary. + pub foreign_net_value: Option, + /// True when the source abbreviated at least one figure (`1.6 M` lots, + /// `841.2 B` IDR), so those figures carry only one decimal of precision. + pub rounded: bool, +} + +impl BrokerSummary { + /// No trades in the range (weekend, holiday, suspension). + pub fn is_empty(&self) -> bool { + self.buyers.is_empty() && self.sellers.is_empty() + } +} diff --git a/crates/idx-core/src/client.rs b/crates/idx-core/src/client.rs index c92d58d..f9db17c 100644 --- a/crates/idx-core/src/client.rs +++ b/crates/idx-core/src/client.rs @@ -86,6 +86,10 @@ impl Idx { crate::market::Market { idx: self } } + pub fn broker(&self) -> crate::broker::Broker<'_> { + crate::broker::Broker { idx: self } + } + pub fn financial(&self) -> crate::financial::Financial<'_> { crate::financial::Financial { idx: self } } diff --git a/crates/idx-core/src/lib.rs b/crates/idx-core/src/lib.rs index 0e67aca..559c5e6 100644 --- a/crates/idx-core/src/lib.rs +++ b/crates/idx-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod analysis; pub mod api; +pub mod broker; pub mod cache; mod client; pub mod config; diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html new file mode 100644 index 0000000..564e859 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_20261016.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ1.7 M963.0 B5,573 1CC5.1 M2.7 T5,336
MG1.6 M842.0 B5,192 2RX1.8 M928.5 B5,295
ZP1.6 M900.9 B5,780 3ZP1.6 M942.1 B5,756
YU1.4 M781.7 B5,626 4MG1.5 M800.5 B5,239
CC1.4 M737.9 B5,408 5AK1.4 M786.5 B5,785
XL1.3 M687.2 B5,313 6BB852,704 457.7 B5,368
AK1.1 M643.9 B5,771 7YU830,940 466.7 B5,617
SQ973,921 519.4 B5,333 8BK709,658 393.3 B5,542
YP919,655 487.1 B5,296 9KZ621,425 363.3 B5,847
PD646,162 341.2 B5,280 10XL441,903 242.6 B5,490
+
+ T. Val : 9.9 T + F. NVal : -4.1 T + T.Lot : 18.1 M + Avg : 5,471 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html new file mode 100644 index 0000000..769eb2b --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
MG1.6 M841.2 B5,191 1CC4.3 M2.2 T5,211
XL1.1 M600.0 B5,229 2RX1.7 M908.2 B5,282
CC1.0 M543.5 B5,227 3MG1.5 M799.4 B5,238
KZ1.0 M522.4 B5,198 4BB687,067 357.4 B5,202
SQ828,163 432.4 B5,221 5ZP529,223 275.4 B5,203
YP827,340 431.7 B5,218 6AK444,696 233.5 B5,251
YU687,603 356.6 B5,186 7BK419,266 218.8 B5,219
PD592,667 309.2 B5,218 8YU418,986 218.0 B5,202
ZP441,000 230.2 B5,220 9NI357,177 186.1 B5,212
AK398,474 209.9 B5,268 10XL296,269 155.0 B5,232
+
+ T. Val : 6.6 T + F. NVal : -2.3 T + T.Lot : 12.6 M + Avg : 5,228 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html new file mode 100644 index 0000000..34e6cda --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_regular.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ1.0 M521.3 B5,198 1CC3.9 M2.0 T5,211
YU575,965 298.4 B5,181 2RX1.6 M820.1 B5,208
BK369,446 196.0 B5,304 3ZP474,808 247.1 B5,203
AK366,209 193.1 B5,274 4AK444,447 233.4 B5,251
ZP287,065 150.0 B5,226 5BK419,265 218.8 B5,219
CC198,140 104.4 B5,267 6YU252,383 131.3 B5,203
BB123,966 64.8 B5,225 7DX252,099 130.5 B5,175
TP70,621 36.6 B5,189 8KZ115,380 60.1 B5,212
RX17,322 9.0 B5,202 9BB109,509 56.9 B5,200
DX15,000 7.8 B5,181 10TP34,490 17.9 B5,195
+
+ T. Val : 1.6 T + F. NVal : -2.3 T + T.Lot : 3.1 M + Avg : 5,225 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html new file mode 100644 index 0000000..758d1b4 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261017.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
1
2
3
4
5
6
7
8
9
10
+
+ T. Val : 0 + F. NVal : 0 + T.Lot : 0 + Avg : 0 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html new file mode 100644 index 0000000..0346068 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_zzzz_invalid.html @@ -0,0 +1 @@ +Invalid stock code! diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb7d1dd..1afb718 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -51,6 +51,8 @@ command parses arguments, makes one idx-core call, and renders the result. ### Dual Provider Model - **MSN Finance** — default provider. Rich data: quotes, fundamentals, profile, earnings, financials, sentiment, insights, news, screener, and explicit price-only chart history for supported IDX windows. - **Yahoo Finance** — default/auto history source. Reliable OHLCV data via `/v8/finance/chart/`. +- **IPOT (Indo Premier)** — broker summaries, whichever provider is selected + (`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries). ### MSN Endpoints - `https://assets.msn.com/service/`: quotes, charts, equities, earnings, @@ -83,6 +85,22 @@ WIB as the fallback. when the initial batch is incomplete, and rejects a still-truncated response. - Full-list screen caches use an `all` key, separate from older limited results. +### Broker Summaries +- `GET https://www.indopremier.com/module/saham/include/data-brokersummary.php` + takes `code`, `start`/`end` (`MM/DD/YYYY`), `fd` (`all|F|D`) and `board` + (`all|RG|TN|NG`). It needs no login and answers an HTML fragment: the top 10 + buyers and top 10 sellers ranked by lots (gross, so a broker can be on both + sides), plus range totals. `api/ipot/parse.rs` parses it purely. +- Figures of a million and more arrive abbreviated (`1.6 M`); they are scaled + in decimal and the summary's `rounded` flag says so. `F. NVal` ignores the + investor and board filters, so `foreign_net_value` is null for a filtered + summary. +- An unknown code is a 200 with `Invalid stock code!` (`SYMBOLNOTFOUND`). The + server also answers spurious nginx 404s for seconds at a time, so the client + retries 404 and 5xx with backoff. A range without trades is an empty summary. +- Data is end of day: IDX hides broker codes during trading. Summaries that + ended before today are cached for 7 days, today's for 5 minutes. Broker + names come from `broker/codes.tsv` (IDX `GetBrokerCodeList`). ### Capability Gating ``` @@ -92,6 +110,7 @@ HistoryProvider = separate trait, not all providers implement Factory functions: default_provider(kind) → Box history_provider(kind, mode, verbose) → Result<(ProviderKind, Box)> + broker_provider() → Box ``` ## Ownership Module (SQLite-backed) From 7c8151a3a644878a0bfc62ce31246eb8f105ad01 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 05:32:29 +0000 Subject: [PATCH 02/32] fix(core): no broker totals for one investor side With fd=F or fd=D, IPOT's footer covers only that side's buying: BBCA's foreign/regular summary for 2026-10-16 gives T. Val 1.6 T while its top seller alone sold 2.0 T. Passing it through as the range total gave consumers a denominator smaller than one listed broker, so total_value, total_lots and average_price are now null for an investor-filtered summary. The page-to-summary step is a pure function, tested on both fixtures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-core/src/broker/mod.rs | 89 ++++++++++++++++++++++------- crates/idx-core/src/broker/types.rs | 10 +++- docs/ARCHITECTURE.md | 4 +- 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs index dab8815..36fd2cd 100644 --- a/crates/idx-core/src/broker/mod.rs +++ b/crates/idx-core/src/broker/mod.rs @@ -8,6 +8,7 @@ use std::sync::OnceLock; use chrono::{Datelike, Duration, NaiveDate, Weekday}; +use crate::api::ipot::BrokerSummaryRaw; use crate::api::{broker_provider, today_wib}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::error::IdxError; @@ -80,28 +81,7 @@ impl Broker<'_> { }, policy, || provider.broker_summary(&code, query), - |raw| { - let filtered = - (query.investor, query.board) != (BrokerInvestor::All, BrokerBoard::All); - let mut summary = BrokerSummary { - symbol: resolved.clone(), - from: raw.from.unwrap_or(query.from), - to: raw.to.unwrap_or(query.to), - investor: query.investor, - board: query.board, - buyers: raw.buyers, - sellers: raw.sellers, - total_value: raw.total_value, - total_lots: raw.total_lots, - average_price: raw.average_price, - foreign_net_value: raw.foreign_net_value.filter(|_| !filtered), - rounded: raw.rounded, - }; - for flow in summary.buyers.iter_mut().chain(summary.sellers.iter_mut()) { - flow.name = broker_name(&flow.code).map(str::to_string); - } - Ok(summary) - }, + |raw| Ok(summary_from_raw(&resolved, query, raw)), ) } @@ -157,6 +137,33 @@ impl Broker<'_> { } } +/// The public summary of one parsed page. With one investor side selected, +/// the page's footer covers only part of the trades the lists show (foreign +/// buying, not foreign selling), so no totals are given; `F. NVal` ignores +/// both filters, so the foreign net goes with any filter. +fn summary_from_raw(symbol: &str, query: &BrokerQuery, raw: BrokerSummaryRaw) -> BrokerSummary { + let one_side = query.investor != BrokerInvestor::All; + let filtered = one_side || query.board != BrokerBoard::All; + let mut summary = BrokerSummary { + symbol: symbol.to_string(), + from: raw.from.unwrap_or(query.from), + to: raw.to.unwrap_or(query.to), + investor: query.investor, + board: query.board, + buyers: raw.buyers, + sellers: raw.sellers, + total_value: raw.total_value.filter(|_| !one_side), + total_lots: raw.total_lots.filter(|_| !one_side), + average_price: raw.average_price.filter(|_| !one_side), + foreign_net_value: raw.foreign_net_value.filter(|_| !filtered), + rounded: raw.rounded, + }; + for flow in summary.buyers.iter_mut().chain(summary.sellers.iter_mut()) { + flow.name = broker_name(&flow.code).map(str::to_string); + } + summary +} + fn check_range(query: &BrokerQuery, today: NaiveDate) -> Result<(), IdxError> { if query.from > query.to { return Err(IdxError::InvalidInput(format!( @@ -202,6 +209,44 @@ mod tests { assert_eq!(broker_name("??"), None); } + #[test] + fn test_summary_from_raw_drops_totals_that_do_not_cover_the_lists() { + let day = date(2026, 10, 16); + let read = |name: &str| { + let html = std::fs::read_to_string(format!("tests/fixtures/{name}")).unwrap(); + crate::api::ipot::summary_from_html("BBCA", &html).unwrap() + }; + let all = summary_from_raw( + "BBCA.JK", + &query(day, day), + read("ipot_brokersummary_bbca_20261016.html"), + ); + assert_eq!(all.total_value, Some(6_600_000_000_000)); + assert_eq!(all.foreign_net_value, Some(-2_300_000_000_000)); + assert_eq!(all.sellers[0].name.as_deref(), Some("MANDIRI SEKURITAS")); + + // Foreign, regular board: the footer's 1.6 T is less than the 2.0 T + // its top seller alone sold. + let foreign = BrokerQuery { + investor: BrokerInvestor::Foreign, + board: BrokerBoard::Regular, + ..query(day, day) + }; + let raw = read("ipot_brokersummary_bbca_20261016_foreign_regular.html"); + assert!(raw.total_value < raw.sellers[0].value); + let filtered = summary_from_raw("BBCA.JK", &foreign, raw); + assert_eq!( + ( + filtered.total_value, + filtered.total_lots, + filtered.average_price + ), + (None, None, None) + ); + assert_eq!(filtered.foreign_net_value, None); + assert_eq!(filtered.sellers[0].value, Some(2_000_000_000_000)); + } + #[test] fn test_check_range_bounds() { let today = date(2026, 10, 18); diff --git a/crates/idx-core/src/broker/types.rs b/crates/idx-core/src/broker/types.rs index 84a9a87..4ed7a1f 100644 --- a/crates/idx-core/src/broker/types.rs +++ b/crates/idx-core/src/broker/types.rs @@ -123,11 +123,15 @@ pub struct BrokerSummary { pub buyers: Vec, /// Top sellers by lots, most first (up to 10). pub sellers: Vec, - /// Value traded over the range, whole IDR. + /// Value traded over the range on the selected board, whole IDR. `None` + /// with one investor side selected: the source's footer then covers only + /// that side's buying, not the trades the lists show. pub total_value: Option, - /// Lots traded over the range. + /// Lots traded over the range on the selected board; `None` as for + /// `total_value`. pub total_lots: Option, - /// Average traded price, whole IDR; `None` when nothing traded. + /// Average traded price, whole IDR; `None` when nothing traded, or as + /// for `total_value`. pub average_price: Option, /// Foreign buy value minus foreign sell value over the range, whole IDR, /// across all boards. The source does not apply the investor or board diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1afb718..9278f89 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -94,7 +94,9 @@ WIB as the fallback. - Figures of a million and more arrive abbreviated (`1.6 M`); they are scaled in decimal and the summary's `rounded` flag says so. `F. NVal` ignores the investor and board filters, so `foreign_net_value` is null for a filtered - summary. + summary. With `fd=F` or `fd=D` the footer covers only that side's buying + (BBCA 2026-10-16 foreign: `T. Val` 1.6 T while seller CC alone sold + 2.0 T), so totals are null for an investor-filtered summary. - An unknown code is a 200 with `Invalid stock code!` (`SYMBOLNOTFOUND`). The server also answers spurious nginx 404s for seconds at a time, so the client retries 404 and 5xx with backoff. A range without trades is an empty summary. From 860d4daf7708bf38042460f433516ae242a5c833 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 04:38:58 +0000 Subject: [PATCH 03/32] feat(cli,mcp): idx stocks broker and get_broker_summary `idx stocks broker SYMBOL` shows the top 10 buyers and sellers of a stock by lots for the latest session with trades, one `--date`, or a `--from`/`--to` range, filtered by `--investor` and `--board`. The MCP server exposes the same as get_broker_summary. - Range and filter parsing lives in idx-core (BrokerRange::from_args, FromStr for the filters), so both frontends accept the same input. - Without dates, the latest session is found by walking back over weekdays; offline, uncached days are skipped. - Table output lists foreign and state-owned brokers, and says when the source rounded figures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-cli/src/cli/stocks.rs | 61 ++++++- crates/idx-cli/src/output/mod.rs | 11 ++ crates/idx-cli/src/output/table.rs | 121 ++++++++++++++ crates/idx-cli/tests/cli.rs | 151 ++++++++++++++++++ crates/idx-cli/tests/golden/cases.toml | 18 +++ .../tests/golden/completions-bash.golden | 85 +++++++++- .../error-broker-unknown-symbol.json.golden | 4 + .../error-broker-unknown-symbol.table.golden | 4 + .../idx-cli/tests/golden/help-stocks.golden | 1 + .../stocks-broker-no-trades.json.golden | 4 + .../stocks-broker-no-trades.table.golden | 5 + .../golden/stocks-broker-range.json.golden | 4 + .../golden/stocks-broker-range.table.golden | 32 ++++ .../tests/golden/stocks-broker.json.golden | 4 + .../tests/golden/stocks-broker.table.golden | 32 ++++ crates/idx-core/src/broker/mod.rs | 63 +++++++- crates/idx-core/src/broker/types.rs | 82 ++++++++++ crates/idx-mcp/src/params.rs | 33 ++++ crates/idx-mcp/src/server.rs | 42 ++++- crates/idx-mcp/tests/golden/tools.json | 62 +++++++ crates/idx-mcp/tests/mcp.rs | 21 ++- flake.nix | 2 +- web/src/content/docs/cli/reference.md | 73 +++++++++ web/src/content/docs/cli/stocks.md | 24 ++- web/src/content/docs/mcp/tools.md | 13 ++ 25 files changed, 939 insertions(+), 13 deletions(-) create mode 100644 crates/idx-cli/tests/golden/error-broker-unknown-symbol.json.golden create mode 100644 crates/idx-cli/tests/golden/error-broker-unknown-symbol.table.golden create mode 100644 crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-broker-no-trades.table.golden create mode 100644 crates/idx-cli/tests/golden/stocks-broker-range.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-broker-range.table.golden create mode 100644 crates/idx-cli/tests/golden/stocks-broker.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-broker.table.golden diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index 21f14ab..d97dda2 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -4,11 +4,12 @@ use clap::{Args, Subcommand}; use crate::config::{HistoryProviderKind, IdxConfig}; use crate::output::{ - render_compare, render_earnings, render_financials, render_fundamental, render_growth, - render_history, render_insights, render_news, render_profile, render_quotes, render_risk, - render_screener, render_sentiment, render_technical, render_valuation, + render_broker_summary, render_compare, render_earnings, render_financials, render_fundamental, + render_growth, render_history, render_insights, render_news, render_profile, render_quotes, + render_risk, render_screener, render_sentiment, render_technical, render_valuation, }; use idx_core::api::types::{Interval, Period}; +use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use idx_core::error::IdxError; use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind}; use idx_core::market::ScreenQuery; @@ -76,6 +77,37 @@ impl From for EarningsFilter { } } +/// Which sessions and trades a broker command covers. +#[derive(Debug, Clone, Args)] +pub struct BrokerScopeArgs { + /// One session, YYYY-MM-DD. Default: the latest session with trades. + #[arg(long, conflicts_with_all = ["from", "to"])] + date: Option, + /// First session of a range, YYYY-MM-DD (at most 366 days). + #[arg(long)] + from: Option, + /// Last session of the range, YYYY-MM-DD. Default: today. + #[arg(long, requires = "from")] + to: Option, + /// Whose trades to count. + #[arg(long, value_enum, default_value_t = BrokerInvestor::All)] + investor: BrokerInvestor, + /// Which board's trades to count; `regular` leaves out negotiated block trades. + #[arg(long, value_enum, default_value_t = BrokerBoard::All)] + board: BrokerBoard, +} + +impl BrokerScopeArgs { + fn range(&self, idx: &Idx) -> Result { + BrokerRange::from_args( + self.date.as_deref(), + self.from.as_deref(), + self.to.as_deref(), + idx.broker().today(), + ) + } +} + #[derive(Debug, Args)] #[command(about = "Stock data and analysis")] pub struct StocksCmd { @@ -150,6 +182,19 @@ pub enum StocksSubcommand { /// Single ticker symbol (e.g. BBCA). symbol: String, }, + #[command( + about = "Which brokers bought and sold a stock", + long_about = "Which brokers bought and sold a stock: the top 10 buyers and top 10 \ +sellers by lots, with value and average price, from Indo Premier (IPOT). End of day: IDX \ +does not show broker codes during trading. Without dates, the latest session with trades.", + after_help = "Examples:\n idx stocks broker BBCA\n idx stocks broker BBCA --date 2026-10-16\n idx stocks broker BBCA --from 2026-10-12 --to 2026-10-16 --board regular\n idx -o json stocks broker BBCA --investor foreign" + )] + Broker { + /// Single ticker symbol (e.g. BBCA). + symbol: String, + #[command(flatten)] + scope: BrokerScopeArgs, + }, #[command(about = "Get company profile")] Profile { symbol: String }, #[command( @@ -256,6 +301,16 @@ pub fn handle( let report = idx.analysis().fundamental(symbol, policy)?; render_fundamental(&report.data, format, no_color) } + StocksSubcommand::Broker { symbol, scope } => { + let summary = idx.broker().fetch( + symbol, + scope.range(&idx)?, + scope.investor, + scope.board, + policy, + )?; + render_broker_summary(&summary.data, format) + } StocksSubcommand::Profile { symbol } => { let profile = idx.financial().profile(symbol, policy)?; render_profile(&profile.data, format) diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index f9057b2..a9fda2e 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -16,6 +16,7 @@ use idx_core::api::types::{ CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, SentimentData, }; +use idx_core::broker::types::BrokerSummary; use idx_core::error::IdxError; /// Output format requested by the user (flag, `IDX_OUTPUT`, or config file). @@ -73,6 +74,16 @@ pub fn render_quotes( } } +pub fn render_broker_summary( + summary: &BrokerSummary, + format: &OutputFormat, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_broker_summary(summary), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(summary), + } +} + pub fn render_history( symbol: &str, history: &[Ohlc], diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 05f0907..d24bbe2 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -11,6 +11,7 @@ use idx_core::api::types::{ CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, SentimentData, }; +use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary}; use idx_core::error::IdxError; pub fn format_idr(value: i64) -> String { @@ -893,6 +894,126 @@ fn truncate_url(url: &str) -> String { truncate_chars(url, 72) } +/// A figure at the precision the broker summary source publishes: +/// `841.2B`, `1.6M`, exact below a million. +fn format_compact(value: i64) -> String { + let abs = value.unsigned_abs(); + let (unit, suffix) = match abs { + 1_000_000_000_000.. => (1e12, "T"), + 1_000_000_000.. => (1e9, "B"), + 1_000_000.. => (1e6, "M"), + _ => return format_idr(value), + }; + format!("{:.1}{suffix}", value as f64 / unit) +} + +fn format_compact_option(value: Option) -> String { + value.map(format_compact).unwrap_or_else(|| "-".to_string()) +} + +fn broker_cells(flow: Option<&BrokerFlow>) -> [Cell; 4] { + match flow { + Some(flow) => [ + cell(&flow.code), + cell(format_compact_option(flow.lots)), + cell(format_compact_option(flow.value)), + cell( + flow.average_price + .map(format_idr) + .unwrap_or_else(|| "-".to_string()), + ), + ], + None => [cell(""), cell(""), cell(""), cell("")], + } +} + +/// `2026-10-16`, or `2026-10-12 to 2026-10-16`, plus any filters. +pub(crate) fn broker_scope( + from: chrono::NaiveDate, + to: chrono::NaiveDate, + investor: BrokerInvestor, + board: BrokerBoard, +) -> String { + let mut scope = if from == to { + from.to_string() + } else { + format!("{from} to {to}") + }; + if investor != BrokerInvestor::All { + scope.push_str(&format!(", {} investors", investor.as_str())); + } + if board != BrokerBoard::All { + scope.push_str(&format!(", {} board", board.as_str())); + } + scope +} + +pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> { + println!( + "{}", + format!( + "Broker summary for {}, {}", + terminal_safe(&summary.symbol), + broker_scope(summary.from, summary.to, summary.investor, summary.board) + ) + .bold() + ); + if summary.is_empty() { + println!("No trades."); + return Ok(()); + } + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "#", "BUYER", "LOTS", "VALUE", "AVG", "SELLER", "LOTS", "VALUE", "AVG", + ]); + let rows = summary.buyers.len().max(summary.sellers.len()); + for index in 0..rows { + let mut row = vec![cell(index + 1)]; + row.extend(broker_cells(summary.buyers.get(index))); + row.extend(broker_cells(summary.sellers.get(index))); + table.add_row(row); + } + println!("{table}"); + + let mut totals = format!( + "Total {} IDR, {} lots", + format_compact_option(summary.total_value), + format_compact_option(summary.total_lots) + ); + if let Some(average) = summary.average_price { + totals.push_str(&format!(", average {}", format_idr(average))); + } + if let Some(net) = summary.foreign_net_value { + totals.push_str(&format!("; foreign net {:+}", format_compact(net))); + } + println!("{totals}."); + for (kind, label) in [ + (BrokerKind::Foreign, "Foreign brokers"), + (BrokerKind::StateOwned, "State-owned brokers"), + ] { + let mut codes: Vec<&str> = summary + .buyers + .iter() + .chain(&summary.sellers) + .filter(|flow| flow.kind == Some(kind)) + .map(|flow| flow.code.as_str()) + .collect(); + codes.sort_unstable(); + codes.dedup(); + if !codes.is_empty() { + println!("{label}: {}.", terminal_safe(&codes.join(", "))); + } + } + println!("Buyers and sellers are each ranked by lots; a broker can be on both sides."); + if summary.rounded { + println!("Note: the source rounds figures of a million and more to one decimal."); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 0fb9812..98c4962 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2434,3 +2434,154 @@ fn msn_screen_rejects_invalid_totals_without_populating_cache() { fs::remove_dir_all(root).unwrap(); } } + +/// A command on the fixture-backed mock provider, never the network. +fn mock_bin(name: &str) -> Command { + let mut cmd = test_bin(name); + cmd.env("IDX_USE_MOCK_PROVIDER", "1"); + cmd +} + +fn mock_bin_with_root(root: &Path) -> Command { + let mut cmd = bin_with_root(root); + cmd.env("IDX_USE_MOCK_PROVIDER", "1"); + cmd +} + +#[test] +fn broker_summary_json_names_brokers_and_keeps_source_totals() { + let summary = run_success_json(mock_bin("broker-json").args([ + "-o", + "json", + "stocks", + "broker", + "bbca", + "--date", + "2026-10-16", + ])); + assert_eq!(summary["symbol"], "BBCA.JK"); + assert_eq!( + (summary["from"].as_str(), summary["to"].as_str()), + (Some("2026-10-16"), Some("2026-10-16")) + ); + assert_eq!(summary["buyers"].as_array().unwrap().len(), 10); + let top = &summary["buyers"][0]; + assert_eq!(top["code"], "MG"); + assert_eq!(top["name"], "SEMESTA INDOVEST SEKURITAS"); + assert_eq!(top["kind"], "local"); + assert_eq!(top["lots"], 1_600_000); + assert_eq!(top["value"], 841_200_000_000_i64); + assert_eq!(top["average_price"], 5_191); + assert_eq!(summary["sellers"][0]["kind"], "state_owned"); + assert_eq!(summary["total_value"], 6_600_000_000_000_i64); + assert_eq!(summary["foreign_net_value"], -2_300_000_000_000_i64); + assert_eq!(summary["rounded"], true); + + // The source does not filter its foreign net figure, so a filtered + // summary leaves it out rather than passing it off as filtered. + let filtered = run_success_json(mock_bin("broker-filtered").args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + "--board", + "regular", + ])); + assert_eq!(filtered["investor"], "foreign"); + assert_eq!(filtered["board"], "regular"); + assert_eq!(filtered["buyers"][0]["code"], "KZ"); + assert!(filtered["foreign_net_value"].is_null()); +} + +#[test] +fn broker_summary_without_dates_finds_the_latest_session_offline() { + let root = test_env_dir("broker-latest"); + run_success_json(mock_bin_with_root(&root).args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + ])); + // Monday 2026-10-19 is not cached and the weekend is skipped, so the + // offline lookup lands on Friday's cached session. + let latest = run_success_json( + mock_bin_with_root(&root) + .env("IDX_MOCK_TODAY", "2026-10-19") + .args(["-o", "json", "--offline", "stocks", "broker", "BBCA"]), + ); + assert_eq!(latest["from"], "2026-10-16"); + assert_eq!(latest["buyers"][0]["code"], "MG"); + run_error_json( + mock_bin_with_root(&root) + .env("IDX_MOCK_TODAY", "2026-10-19") + .args(["-o", "json", "--offline", "stocks", "broker", "BBRI"]), + "CACHEMISS", + ); +} + +#[test] +fn broker_summary_session_without_trades_is_empty_not_an_error() { + let summary = run_success_json(mock_bin("broker-empty").args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-17", + ])); + assert_eq!(summary["buyers"], serde_json::json!([])); + assert_eq!(summary["total_value"], 0); + assert!(summary["average_price"].is_null()); + + let table = run_success_stdout(mock_bin("broker-empty-table").args([ + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-17", + ])); + assert!(table.contains("No trades."), "{table}"); +} + +#[test] +fn broker_summary_errors_follow_the_output_contract() { + for (args, code) in [ + (vec!["ZZZZ", "--date", "2026-10-16"], "SYMBOLNOTFOUND"), + (vec!["BBCA.US", "--date", "2026-10-16"], "UNSUPPORTED"), + (vec!["BBCA", "--date", "16/10/2026"], "INVALIDINPUT"), + ( + vec!["BBCA", "--from", "2026-10-16", "--to", "2026-10-12"], + "INVALIDINPUT", + ), + (vec!["BBCA", "--to", "2026-10-16"], "INVALIDINPUT"), + ( + vec!["BBCA", "--date", "2026-10-16", "--from", "2026-10-12"], + "INVALIDINPUT", + ), + (vec!["BBCA", "--date", "2999-01-01"], "INVALIDINPUT"), + ] { + let mut cmd = mock_bin("broker-errors"); + cmd.args(["-o", "json", "stocks", "broker"]).args(&args); + run_error_json(&mut cmd, code); + } + let mut failing = mock_bin("broker-provider-error"); + failing.env("IDX_MOCK_ERROR", "1").args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + ]); + run_error_json(&mut failing, "PROVIDERUNAVAILABLE"); +} diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 2a44511..85a1a53 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -244,6 +244,24 @@ name = "yahoo-fundamental" args = ["stocks", "fundamental", "BBCA"] env = { IDX_PROVIDER = "yahoo" } +# --- stocks: broker summaries (IPOT) ------------------------------------------- + +[[case]] +name = "stocks-broker" +args = ["stocks", "broker", "BBCA", "--date", "2026-10-16"] + +[[case]] +name = "stocks-broker-range" +args = ["stocks", "broker", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"] + +[[case]] +name = "stocks-broker-no-trades" +args = ["stocks", "broker", "BBCA", "--date", "2026-10-17"] + +[[case]] +name = "error-broker-unknown-symbol" +args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"] + # --- errors --------------------------------------------------------------------- [[case]] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index f9c1e71..55d5ca2 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -174,6 +174,9 @@ _idx() { idx__help__ownership__resolve,unresolved) cmd="idx__help__ownership__resolve__unresolved" ;; + idx__help__stocks,broker) + cmd="idx__help__stocks__broker" + ;; idx__help__stocks,compare) cmd="idx__help__stocks__compare" ;; @@ -336,6 +339,9 @@ _idx() { idx__ownership__resolve__help,unresolved) cmd="idx__ownership__resolve__help__unresolved" ;; + idx__stocks,broker) + cmd="idx__stocks__broker" + ;; idx__stocks,compare) cmd="idx__stocks__compare" ;; @@ -384,6 +390,9 @@ _idx() { idx__stocks,valuation) cmd="idx__stocks__valuation" ;; + idx__stocks__help,broker) + cmd="idx__stocks__help__broker" + ;; idx__stocks__help,compare) cmd="idx__stocks__help__compare" ;; @@ -1191,7 +1200,7 @@ _idx() { return 0 ;; idx__help__stocks) - opts="quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare" + opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1204,6 +1213,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stocks__broker) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks__compare) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2193,7 +2216,7 @@ _idx() { return 0 ;; idx__stocks) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare help" + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2214,6 +2237,48 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__broker) + opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --date) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --investor) + COMPREPLY=($(compgen -W "all foreign domestic" -- "${cur}")) + return 0 + ;; + --board) + COMPREPLY=($(compgen -W "all regular cash negotiated" -- "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__compare) opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help ..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2329,7 +2394,7 @@ _idx() { return 0 ;; idx__stocks__help) - opts="quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare help" + opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2342,6 +2407,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__broker) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__help__compare) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/crates/idx-cli/tests/golden/error-broker-unknown-symbol.json.golden b/crates/idx-cli/tests/golden/error-broker-unknown-symbol.json.golden new file mode 100644 index 0000000..a54bade --- /dev/null +++ b/crates/idx-cli/tests/golden/error-broker-unknown-symbol.json.golden @@ -0,0 +1,4 @@ +exit: 3 +--- stdout +--- stderr +{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: ZZZZ"} diff --git a/crates/idx-cli/tests/golden/error-broker-unknown-symbol.table.golden b/crates/idx-cli/tests/golden/error-broker-unknown-symbol.table.golden new file mode 100644 index 0000000..97ba417 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-broker-unknown-symbol.table.golden @@ -0,0 +1,4 @@ +exit: 3 +--- stdout +--- stderr +Error: symbol not found: ZZZZ diff --git a/crates/idx-cli/tests/golden/help-stocks.golden b/crates/idx-cli/tests/golden/help-stocks.golden index 8210119..8fb9414 100644 --- a/crates/idx-cli/tests/golden/help-stocks.golden +++ b/crates/idx-cli/tests/golden/help-stocks.golden @@ -12,6 +12,7 @@ Commands: valuation Run valuation analysis on a stock risk Run risk analysis on a stock fundamental Run full fundamental analysis on a stock + broker Which brokers bought and sold a stock profile Get company profile financials Get financial statements earnings Get earnings report diff --git a/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden b/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden new file mode 100644 index 0000000..aa27fb8 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","from":"2026-10-17","to":"2026-10-17","investor":"all","board":"all","buyers":[],"sellers":[],"total_value":0,"total_lots":0,"average_price":null,"foreign_net_value":0,"rounded":false} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker-no-trades.table.golden b/crates/idx-cli/tests/golden/stocks-broker-no-trades.table.golden new file mode 100644 index 0000000..e9ac964 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker-no-trades.table.golden @@ -0,0 +1,5 @@ +exit: 0 +--- stdout +Broker summary for BBCA.JK, 2026-10-17 +No trades. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker-range.json.golden b/crates/idx-cli/tests/golden/stocks-broker-range.json.golden new file mode 100644 index 0000000..0b4fb84 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker-range.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":963000000000,"average_price":5573},{"rank":2,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":842000000000,"average_price":5192},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":900900000000,"average_price":5780},{"rank":4,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":781700000000,"average_price":5626},{"rank":5,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1400000,"value":737900000000,"average_price":5408},{"rank":6,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1300000,"value":687200000000,"average_price":5313},{"rank":7,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1100000,"value":643900000000,"average_price":5771},{"rank":8,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":973921,"value":519400000000,"average_price":5333},{"rank":9,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":919655,"value":487100000000,"average_price":5296},{"rank":10,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":646162,"value":341200000000,"average_price":5280}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":5100000,"value":2700000000000,"average_price":5336},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1800000,"value":928500000000,"average_price":5295},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":942100000000,"average_price":5756},{"rank":4,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":800500000000,"average_price":5239},{"rank":5,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":786500000000,"average_price":5785},{"rank":6,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":852704,"value":457700000000,"average_price":5368},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":830940,"value":466700000000,"average_price":5617},{"rank":8,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":709658,"value":393300000000,"average_price":5542},{"rank":9,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":621425,"value":363300000000,"average_price":5847},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":441903,"value":242600000000,"average_price":5490}],"total_value":9900000000000,"total_lots":18100000,"average_price":5471,"foreign_net_value":-4100000000000,"rounded":true} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker-range.table.golden b/crates/idx-cli/tests/golden/stocks-broker-range.table.golden new file mode 100644 index 0000000..2387095 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker-range.table.golden @@ -0,0 +1,32 @@ +exit: 0 +--- stdout +Broker summary for BBCA.JK, 2026-10-12 to 2026-10-16 +┌────┬───────┬─────────┬────────┬───────┬────────┬─────────┬────────┬───────┐ +│ # ┆ BUYER ┆ LOTS ┆ VALUE ┆ AVG ┆ SELLER ┆ LOTS ┆ VALUE ┆ AVG │ +╞════╪═══════╪═════════╪════════╪═══════╪════════╪═════════╪════════╪═══════╡ +│ 1 ┆ KZ ┆ 1.7M ┆ 963.0B ┆ 5,573 ┆ CC ┆ 5.1M ┆ 2.7T ┆ 5,336 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 2 ┆ MG ┆ 1.6M ┆ 842.0B ┆ 5,192 ┆ RX ┆ 1.8M ┆ 928.5B ┆ 5,295 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 3 ┆ ZP ┆ 1.6M ┆ 900.9B ┆ 5,780 ┆ ZP ┆ 1.6M ┆ 942.1B ┆ 5,756 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 4 ┆ YU ┆ 1.4M ┆ 781.7B ┆ 5,626 ┆ MG ┆ 1.5M ┆ 800.5B ┆ 5,239 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 5 ┆ CC ┆ 1.4M ┆ 737.9B ┆ 5,408 ┆ AK ┆ 1.4M ┆ 786.5B ┆ 5,785 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 6 ┆ XL ┆ 1.3M ┆ 687.2B ┆ 5,313 ┆ BB ┆ 852,704 ┆ 457.7B ┆ 5,368 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 7 ┆ AK ┆ 1.1M ┆ 643.9B ┆ 5,771 ┆ YU ┆ 830,940 ┆ 466.7B ┆ 5,617 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 8 ┆ SQ ┆ 973,921 ┆ 519.4B ┆ 5,333 ┆ BK ┆ 709,658 ┆ 393.3B ┆ 5,542 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 9 ┆ YP ┆ 919,655 ┆ 487.1B ┆ 5,296 ┆ KZ ┆ 621,425 ┆ 363.3B ┆ 5,847 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 10 ┆ PD ┆ 646,162 ┆ 341.2B ┆ 5,280 ┆ XL ┆ 441,903 ┆ 242.6B ┆ 5,490 │ +└────┴───────┴─────────┴────────┴───────┴────────┴─────────┴────────┴───────┘ +Total 9.9T IDR, 18.1M lots, average 5,471; foreign net -4.1T. +Foreign brokers: AK, BK, KZ, RX, YP, YU, ZP. +State-owned brokers: CC. +Buyers and sellers are each ranked by lots; a broker can be on both sides. +Note: the source rounds figures of a million and more to one decimal. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker.json.golden b/crates/idx-cli/tests/golden/stocks-broker.json.golden new file mode 100644 index 0000000..8aade15 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":841200000000,"average_price":5191},{"rank":2,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1100000,"value":600000000000,"average_price":5229},{"rank":3,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1000000,"value":543500000000,"average_price":5227},{"rank":4,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1000000,"value":522400000000,"average_price":5198},{"rank":5,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":828163,"value":432400000000,"average_price":5221},{"rank":6,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":827340,"value":431700000000,"average_price":5218},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":687603,"value":356600000000,"average_price":5186},{"rank":8,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":592667,"value":309200000000,"average_price":5218},{"rank":9,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":441000,"value":230200000000,"average_price":5220},{"rank":10,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":398474,"value":209900000000,"average_price":5268}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":4300000,"value":2200000000000,"average_price":5211},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":908200000000,"average_price":5282},{"rank":3,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":799400000000,"average_price":5238},{"rank":4,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":687067,"value":357400000000,"average_price":5202},{"rank":5,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":529223,"value":275400000000,"average_price":5203},{"rank":6,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":444696,"value":233500000000,"average_price":5251},{"rank":7,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":419266,"value":218800000000,"average_price":5219},{"rank":8,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":418986,"value":218000000000,"average_price":5202},{"rank":9,"code":"NI","name":"BNI SEKURITAS","kind":"state_owned","lots":357177,"value":186100000000,"average_price":5212},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":296269,"value":155000000000,"average_price":5232}],"total_value":6600000000000,"total_lots":12600000,"average_price":5228,"foreign_net_value":-2300000000000,"rounded":true} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker.table.golden b/crates/idx-cli/tests/golden/stocks-broker.table.golden new file mode 100644 index 0000000..af8125c --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-broker.table.golden @@ -0,0 +1,32 @@ +exit: 0 +--- stdout +Broker summary for BBCA.JK, 2026-10-16 +┌────┬───────┬─────────┬────────┬───────┬────────┬─────────┬────────┬───────┐ +│ # ┆ BUYER ┆ LOTS ┆ VALUE ┆ AVG ┆ SELLER ┆ LOTS ┆ VALUE ┆ AVG │ +╞════╪═══════╪═════════╪════════╪═══════╪════════╪═════════╪════════╪═══════╡ +│ 1 ┆ MG ┆ 1.6M ┆ 841.2B ┆ 5,191 ┆ CC ┆ 4.3M ┆ 2.2T ┆ 5,211 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 2 ┆ XL ┆ 1.1M ┆ 600.0B ┆ 5,229 ┆ RX ┆ 1.7M ┆ 908.2B ┆ 5,282 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 3 ┆ CC ┆ 1.0M ┆ 543.5B ┆ 5,227 ┆ MG ┆ 1.5M ┆ 799.4B ┆ 5,238 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 4 ┆ KZ ┆ 1.0M ┆ 522.4B ┆ 5,198 ┆ BB ┆ 687,067 ┆ 357.4B ┆ 5,202 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 5 ┆ SQ ┆ 828,163 ┆ 432.4B ┆ 5,221 ┆ ZP ┆ 529,223 ┆ 275.4B ┆ 5,203 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 6 ┆ YP ┆ 827,340 ┆ 431.7B ┆ 5,218 ┆ AK ┆ 444,696 ┆ 233.5B ┆ 5,251 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 7 ┆ YU ┆ 687,603 ┆ 356.6B ┆ 5,186 ┆ BK ┆ 419,266 ┆ 218.8B ┆ 5,219 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 8 ┆ PD ┆ 592,667 ┆ 309.2B ┆ 5,218 ┆ YU ┆ 418,986 ┆ 218.0B ┆ 5,202 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 9 ┆ ZP ┆ 441,000 ┆ 230.2B ┆ 5,220 ┆ NI ┆ 357,177 ┆ 186.1B ┆ 5,212 │ +├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 10 ┆ AK ┆ 398,474 ┆ 209.9B ┆ 5,268 ┆ XL ┆ 296,269 ┆ 155.0B ┆ 5,232 │ +└────┴───────┴─────────┴────────┴───────┴────────┴─────────┴────────┴───────┘ +Total 6.6T IDR, 12.6M lots, average 5,228; foreign net -2.3T. +Foreign brokers: AK, BK, KZ, RX, YP, YU, ZP. +State-owned brokers: CC, NI. +Buyers and sellers are each ranked by lots; a broker can be on both sides. +Note: the source rounds figures of a million and more to one decimal. +--- stderr diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs index 36fd2cd..19015c1 100644 --- a/crates/idx-core/src/broker/mod.rs +++ b/crates/idx-core/src/broker/mod.rs @@ -12,7 +12,7 @@ use crate::api::ipot::BrokerSummaryRaw; use crate::api::{broker_provider, today_wib}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::error::IdxError; -use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerSummary}; +use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerRange, BrokerSummary}; const SOURCE: &str = "ipot"; /// Longest range one summary may cover. @@ -47,6 +47,35 @@ pub struct Broker<'a> { } impl Broker<'_> { + /// Today in WIB, the date `BrokerRange::from_args` defaults `to` to. + pub fn today(&self) -> NaiveDate { + today_wib() + } + + /// The summary a frontend asked for: the latest session or a range. + pub fn fetch( + &self, + symbol: &str, + range: BrokerRange, + investor: BrokerInvestor, + board: BrokerBoard, + policy: CachePolicy, + ) -> Result, IdxError> { + match range { + BrokerRange::Latest => self.latest(symbol, investor, board, policy), + BrokerRange::Dates { from, to } => self.summary( + symbol, + &BrokerQuery { + from, + to, + investor, + board, + }, + policy, + ), + } + } + /// The broker summary for `symbol` over `query`'s range. An empty /// summary means no trades (weekend, holiday, suspension). pub fn summary( @@ -247,6 +276,38 @@ mod tests { assert_eq!(filtered.sellers[0].value, Some(2_000_000_000_000)); } + #[test] + fn test_broker_range_from_args() { + let today = date(2026, 10, 18); + let range = |date, from, to| BrokerRange::from_args(date, from, to, today); + assert_eq!(range(None, None, None).unwrap(), BrokerRange::Latest); + assert_eq!( + range(Some("2026-10-16"), None, None).unwrap(), + BrokerRange::Dates { + from: date(2026, 10, 16), + to: date(2026, 10, 16) + } + ); + assert_eq!( + range(None, Some("2026-10-12"), None).unwrap(), + BrokerRange::Dates { + from: date(2026, 10, 12), + to: today + } + ); + for (d, f, t) in [ + (None, None, Some("2026-10-16")), + (Some("2026-10-16"), Some("2026-10-12"), None), + (Some("16/10/2026"), None, None), + (None, Some("2026-10-12"), Some("tomorrow")), + ] { + assert!( + matches!(range(d, f, t), Err(IdxError::InvalidInput(_))), + "{d:?} {f:?} {t:?}" + ); + } + } + #[test] fn test_check_range_bounds() { let today = date(2026, 10, 18); diff --git a/crates/idx-core/src/broker/types.rs b/crates/idx-core/src/broker/types.rs index 4ed7a1f..5f8b313 100644 --- a/crates/idx-core/src/broker/types.rs +++ b/crates/idx-core/src/broker/types.rs @@ -1,8 +1,12 @@ //! Broker summary types: which IDX brokers bought and sold a stock. +use std::str::FromStr; + use chrono::NaiveDate; use serde::{Deserialize, Serialize}; +use crate::error::IdxError; + /// Whose trades a broker summary counts. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] @@ -18,6 +22,8 @@ pub enum BrokerInvestor { } impl BrokerInvestor { + pub const ALL: [Self; 3] = [Self::All, Self::Foreign, Self::Domestic]; + pub fn as_str(self) -> &'static str { match self { Self::All => "all", @@ -27,6 +33,21 @@ impl BrokerInvestor { } } +impl FromStr for BrokerInvestor { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "all" => Ok(Self::All), + "foreign" => Ok(Self::Foreign), + "domestic" => Ok(Self::Domestic), + _ => Err(IdxError::InvalidInput(format!( + "invalid investor '{value}'; use all, foreign or domestic" + ))), + } + } +} + /// Which market board's trades a broker summary counts. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] @@ -44,6 +65,8 @@ pub enum BrokerBoard { } impl BrokerBoard { + pub const ALL: [Self; 4] = [Self::All, Self::Regular, Self::Cash, Self::Negotiated]; + pub fn as_str(self) -> &'static str { match self { Self::All => "all", @@ -54,6 +77,22 @@ impl BrokerBoard { } } +impl FromStr for BrokerBoard { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "all" => Ok(Self::All), + "regular" => Ok(Self::Regular), + "cash" => Ok(Self::Cash), + "negotiated" => Ok(Self::Negotiated), + _ => Err(IdxError::InvalidInput(format!( + "invalid board '{value}'; use all, regular, cash or negotiated" + ))), + } + } +} + /// The source's classification of a securities house. This is the broker's /// own status, not the nationality of its clients. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -75,6 +114,49 @@ impl BrokerKind { } } +/// Which sessions a frontend asked for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrokerRange { + /// The latest session with trades. + Latest, + /// `from..=to`, WIB dates. + Dates { from: NaiveDate, to: NaiveDate }, +} + +impl BrokerRange { + /// From a frontend's `date`, or `from` with an optional `to` (default + /// today), as `YYYY-MM-DD`; none of them means the latest session. + pub fn from_args( + date: Option<&str>, + from: Option<&str>, + to: Option<&str>, + today: NaiveDate, + ) -> Result { + let parse = |name: &str, value: &str| { + NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| { + IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD")) + }) + }; + match (date, from, to) { + (None, None, None) => Ok(Self::Latest), + (Some(date), None, None) => { + let day = parse("date", date)?; + Ok(Self::Dates { from: day, to: day }) + } + (None, Some(from), to) => Ok(Self::Dates { + from: parse("from", from)?, + to: to.map(|to| parse("to", to)).transpose()?.unwrap_or(today), + }), + (None, None, Some(_)) => Err(IdxError::InvalidInput( + "to needs from (or use date for one session)".to_string(), + )), + (Some(_), _, _) => Err(IdxError::InvalidInput( + "date cannot be combined with from or to".to_string(), + )), + } + } +} + /// The trading days a broker summary covers, both ends included (WIB dates). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct BrokerQuery { diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 4bab5c7..90309da 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -2,6 +2,7 @@ //! accepted values and error messages match the CLI; the schemas list them. use idx_core::api::types::{Interval, Period}; +use idx_core::broker::types::{BrokerBoard, BrokerInvestor}; use idx_core::market::{ScreenFilter, ScreenRegion}; use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::Deserialize; @@ -114,6 +115,30 @@ pub struct AnalyzeParams { pub kinds: Option>, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct BrokerParams { + /// IDX ticker, e.g. "BBCA". + pub symbol: String, + /// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. + #[serde(default)] + pub date: Option, + /// First session of a range, YYYY-MM-DD (at most 366 days). + #[serde(default)] + pub from: Option, + /// Last session of the range, YYYY-MM-DD (default today). + #[serde(default)] + pub to: Option, + /// Whose trades to count, by investor domicile (default "all"). + #[serde(default)] + #[schemars(schema_with = "investor_schema")] + pub investor: Option, + /// Which board's trades to count (default "all"); "regular" leaves out + /// negotiated block trades. + #[serde(default)] + #[schemars(schema_with = "board_schema")] + pub board: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct ShareholdersParams { /// IDX ticker, e.g. "BBCA". @@ -178,3 +203,11 @@ fn filter_schema(_: &mut SchemaGenerator) -> Schema { fn region_schema(_: &mut SchemaGenerator) -> Schema { string_enum(ScreenRegion::ALL.map(ScreenRegion::as_str).to_vec()) } + +fn investor_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(BrokerInvestor::ALL.map(BrokerInvestor::as_str).to_vec()) +} + +fn board_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(BrokerBoard::ALL.map(BrokerBoard::as_str).to_vec()) +} diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index d66819b..2386d64 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use idx_core::analysis::fundamental::FundamentalReport; +use idx_core::broker::types::BrokerRange; use idx_core::config::CoreConfig; use idx_core::error::IdxError; use idx_core::financial::{EarningsFilter, StatementFilter}; @@ -21,8 +22,9 @@ use serde_json::{Map, Value, json}; use tokio::sync::Semaphore; use crate::params::{ - AnalysisKind, AnalyzeParams, ChangesParams, CompanyParams, CompanySection, EntityParams, - HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams, + AnalysisKind, AnalyzeParams, BrokerParams, ChangesParams, CompanyParams, CompanySection, + EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, + SyncParams, }; const SYNC_TOOL: &str = "ownership_sync"; @@ -165,6 +167,42 @@ for one or more IDX tickers." .await } + #[tool( + description = "Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers \ +by lots over a session or date range, each with lots (1 lot = 100 shares), value and average \ +price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can \ +appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the \ +broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means \ +figures of a million and more carry one decimal of precision." + )] + async fn get_broker_summary(&self, params: Parameters) -> ToolResult { + let BrokerParams { + symbol, + date, + from, + to, + investor, + board, + } = params.0; + self.live(move |idx| { + let broker = idx.broker(); + let range = BrokerRange::from_args( + date.as_deref(), + from.as_deref(), + to.as_deref(), + broker.today(), + )?; + broker.fetch( + &symbol, + range, + investor.as_deref().unwrap_or("all").parse()?, + board.as_deref().unwrap_or("all").parse()?, + CachePolicy::Default, + ) + }) + .await + } + #[tool( description = "Company data for one IDX ticker: profile, financial statements, \ earnings, analyst sentiment, insights, and news. News items carry `relevance`: \ diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 8464d38..1d0253c 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -57,6 +57,68 @@ }, "name": "compare_stocks" }, + { + "description": "Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means figures of a million and more carry one decimal of precision.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "board": { + "default": null, + "description": "Which board's trades to count (default \"all\"); \"regular\" leaves out\nnegotiated block trades.", + "enum": [ + "all", + "regular", + "cash", + "negotiated" + ], + "type": "string" + }, + "date": { + "default": null, + "description": "One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.", + "type": [ + "string", + "null" + ] + }, + "from": { + "default": null, + "description": "First session of a range, YYYY-MM-DD (at most 366 days).", + "type": [ + "string", + "null" + ] + }, + "investor": { + "default": null, + "description": "Whose trades to count, by investor domicile (default \"all\").", + "enum": [ + "all", + "foreign", + "domestic" + ], + "type": "string" + }, + "symbol": { + "description": "IDX ticker, e.g. \"BBCA\".", + "type": "string" + }, + "to": { + "default": null, + "description": "Last session of the range, YYYY-MM-DD (default today).", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "symbol" + ], + "type": "object" + }, + "name": "get_broker_summary" + }, { "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": { diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index 8438bfc..7e28d78 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -9,9 +9,10 @@ use idx_core::config::CoreConfig; use idx_core::investor::{ImportSource, Investor}; use serde_json::{Value, json}; -const TOOLS: [&str; 11] = [ +const TOOLS: [&str; 12] = [ "analyze_stock", "compare_stocks", + "get_broker_summary", "get_company", "get_investor_holdings", "get_ownership_changes", @@ -247,6 +248,16 @@ fn live_tools_wrap_data_with_freshness() { assert_fetched(&analysis[kind]); } + let brokers = client.ok( + "get_broker_summary", + json!({"symbol": "BBCA", "date": "2026-10-16"}), + ); + assert_fetched(&brokers); + assert_eq!(brokers["source"], "ipot"); + assert_eq!(brokers["data"]["buyers"][0]["code"], "MG"); + assert_eq!(brokers["data"]["sellers"][0]["name"], "MANDIRI SEKURITAS"); + assert_eq!(brokers["data"]["foreign_net_value"], -2_300_000_000_000_i64); + let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]})); assert_eq!(compared["reports"].as_array().unwrap().len(), 2); assert_eq!(compared["failures"], json!([])); @@ -273,6 +284,14 @@ fn errors_carry_cli_codes() { ("compare_stocks", json!({"symbols": []})), ("get_company", json!({"symbol": "BBCA", "sections": []})), ("analyze_stock", json!({"symbol": "BBCA", "kinds": []})), + ( + "get_broker_summary", + json!({"symbol": "BBCA", "date": "2026-10-16", "from": "2026-10-12"}), + ), + ( + "get_broker_summary", + json!({"symbol": "BBCA", "date": "2026-10-16", "board": "dark"}), + ), ] { let (is_error, body) = client.call(tool, arguments); assert!(is_error, "{tool}: {body}"); diff --git a/flake.nix b/flake.nix index 805e309..9962c1d 100644 --- a/flake.nix +++ b/flake.nix @@ -288,7 +288,7 @@ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' sleep 2 } | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl - jq -se 'map(select(.id == 2))[0].result.tools | length == 11' out.jsonl + jq -se 'map(select(.id == 2))[0].result.tools | length == 12' out.jsonl touch "$out" ''; # The website's generated pages match the CLI help and the MCP diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 095347b..3cfe6c7 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -66,6 +66,7 @@ Commands: valuation Run valuation analysis on a stock risk Run risk analysis on a stock fundamental Run full fundamental analysis on a stock + broker Which brokers bought and sold a stock profile Get company profile financials Get financial statements earnings Get earnings report @@ -278,6 +279,78 @@ Examples: idx -o json stocks fundamental BBCA ``` +### `idx stocks broker` + +```text +Which brokers bought and sold a stock: the top 10 buyers and top 10 sellers by lots, with value and average price, from Indo Premier (IPOT). End of day: IDX does not show broker codes during trading. Without dates, the latest session with trades. + +Usage: idx stocks broker [OPTIONS] + +Arguments: + + Single ticker symbol (e.g. BBCA) + +Options: + --date + One session, YYYY-MM-DD. Default: the latest session with trades + + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --from + First session of a range, YYYY-MM-DD (at most 366 days) + + --no-color + Disable colored output + + -q, --quiet + Suppress non-essential output + + --to + Last session of the range, YYYY-MM-DD. Default: today + + --investor + Whose trades to count + + Possible values: + - all: Every trade + - foreign: Trades by foreign investors only + - domestic: Trades by domestic investors only + + [default: all] + + -v, --verbose... + Increase verbosity (-v, -vv) + + --board + Which board's trades to count; `regular` leaves out negotiated block trades + + Possible values: + - all: Every board + - regular: Regular market (RG), the continuous order book + - cash: Cash market (TN) + - negotiated: Negotiated market (NG): pre-arranged block trades + + [default: all] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stocks broker BBCA + idx stocks broker BBCA --date 2026-10-16 + idx stocks broker BBCA --from 2026-10-12 --to 2026-10-16 --board regular + idx -o json stocks broker BBCA --investor foreign +``` + ### `idx stocks profile` ```text diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index 3f366c4..61f4a8c 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -1,6 +1,6 @@ --- title: Stocks -description: Quotes, history, company data, analysis and screening. +description: Quotes, history, company data, broker summaries, analysis and screening. --- All `stocks` commands take IDX tickers (`BBCA`, or `BBCA.JK`). Every flag is @@ -28,6 +28,25 @@ them from stdin. `history` takes `--period` (`1d` to `5y`) and `--interval` | `insights` | AI insights from the provider | | `news` | recent news, this company's first (`ABOUT` = `company`), then general market news (`market`) | +## Broker summary + +```bash +idx stocks broker BBCA +idx stocks broker BBCA --date 2026-10-16 +idx stocks broker BBCA --from 2026-10-12 --to 2026-10-16 --board regular +``` + +Which brokers bought and sold a stock: the top 10 buyers and the top 10 +sellers by lots, each with lots, value and average price, plus totals and the +net foreign value. Without dates it shows the latest session with trades. +`--investor foreign|domestic` counts only one side's clients, and +`--board regular` leaves out negotiated block trades. + +The data comes from Indo Premier (IPOT) and is end of day: IDX hides broker +codes during trading. Buyers and sellers are ranked separately on gross +lots, so a broker can appear on both sides. The source rounds figures of a +million and more to one decimal, and the output says so. + ## Analysis | Command | What it computes | @@ -62,6 +81,7 @@ idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compa ## Providers MSN is the default for quotes, company data and screening; Yahoo serves -price history. Switch with `IDX_PROVIDER=msn|yahoo` and +price history, and Indo Premier (IPOT) serves broker summaries whichever +provider is selected. Switch with `IDX_PROVIDER=msn|yahoo` and `IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the matching [config keys](/start/config/). diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 55bf06b..0c1825a 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -27,6 +27,19 @@ Compare fundamental reports (growth, valuation, risk) across IDX tickers. Ticker | --- | --- | --- | --- | | `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. | +## `get_broker_summary` + +Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means figures of a million and more carry one decimal of precision. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `board` | `all` / `regular` / `cash` / `negotiated` | no | Which board's trades to count (default "all"); "regular" leaves out negotiated block trades. | +| `date` | string | no | One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. | +| `from` | string | no | First session of a range, YYYY-MM-DD (at most 366 days). | +| `investor` | `all` / `foreign` / `domestic` | no | Whose trades to count, by investor domicile (default "all"). | +| `symbol` | string | yes | IDX ticker, e.g. "BBCA". | +| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). | + ## `get_company` 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. From ae7a1e2b5c2c5ea6bb7f05b62c0742ad815f8a97 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 05:34:29 +0000 Subject: [PATCH 04/32] fix(cli): no totals line for one investor side A foreign- or domestic-only summary has no totals now, so the table says why instead of printing "Total - IDR". The CLI test checks the nulls and the table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-cli/src/output/table.rs | 26 +++++++++++++++----------- crates/idx-cli/tests/cli.rs | 22 +++++++++++++++++++++- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index d24bbe2..641d434 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -978,18 +978,22 @@ pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> { } println!("{table}"); - let mut totals = format!( - "Total {} IDR, {} lots", - format_compact_option(summary.total_value), - format_compact_option(summary.total_lots) - ); - if let Some(average) = summary.average_price { - totals.push_str(&format!(", average {}", format_idr(average))); + if summary.total_value.is_some() { + let mut totals = format!( + "Total {} IDR, {} lots", + format_compact_option(summary.total_value), + format_compact_option(summary.total_lots) + ); + if let Some(average) = summary.average_price { + totals.push_str(&format!(", average {}", format_idr(average))); + } + if let Some(net) = summary.foreign_net_value { + totals.push_str(&format!("; foreign net {:+}", format_compact(net))); + } + println!("{totals}."); + } else if summary.investor != BrokerInvestor::All { + println!("No totals: for one investor side the source totals only that side's buying."); } - if let Some(net) = summary.foreign_net_value { - totals.push_str(&format!("; foreign net {:+}", format_compact(net))); - } - println!("{totals}."); for (kind, label) in [ (BrokerKind::Foreign, "Foreign brokers"), (BrokerKind::StateOwned, "State-owned brokers"), diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 98c4962..453830c 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2495,7 +2495,27 @@ fn broker_summary_json_names_brokers_and_keeps_source_totals() { assert_eq!(filtered["investor"], "foreign"); assert_eq!(filtered["board"], "regular"); assert_eq!(filtered["buyers"][0]["code"], "KZ"); - assert!(filtered["foreign_net_value"].is_null()); + for field in [ + "total_value", + "total_lots", + "average_price", + "foreign_net_value", + ] { + assert!(filtered[field].is_null(), "{field}: {filtered}"); + } + let table = run_success_stdout(mock_bin("broker-filtered-table").args([ + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + "--board", + "regular", + ])); + assert!(!table.contains("Total "), "{table}"); + assert!(table.contains("No totals"), "{table}"); } #[test] From 970da26a0e4ddd0f76870227c5f37770effe6392 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 04:55:25 +0000 Subject: [PATCH 05/32] feat: bandarmology (idx stocks bandar, analyze_bandar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `idx stocks bandar SYMBOL...` reads the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value in basis points of value traded, with a signal from big accumulation to big distribution on the headline tier (--top, default 5). Several symbols give a screen ranked by that figure (at most 100, one request each), which pipes from `stocks screen`. The MCP server exposes it as analyze_bandar (20 symbols). - analysis/bandar.rs is pure and versioned (`top_concentration_v1`): integer basis points, no figure missing counted as zero, buy/sell VWAP from exact averages, and the brokers on both sides listed because their gross figures overstate their direction. - Thresholds (±5%/±15%) follow fzn0x/idx (MIT); docs call them heuristics. - Invalid input fails a screen at once; other errors fail one symbol and are warned about on stderr. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-cli/src/cli/stocks.rs | 55 ++- crates/idx-cli/src/output/mod.rs | 16 + crates/idx-cli/src/output/table.rs | 168 ++++++++ crates/idx-cli/tests/cli.rs | 117 ++++++ crates/idx-cli/tests/golden/cases.toml | 9 + .../tests/golden/completions-bash.golden | 89 ++++- .../idx-cli/tests/golden/help-stocks.golden | 1 + .../golden/stocks-bandar-screen.json.golden | 4 + .../golden/stocks-bandar-screen.jsonl.golden | 6 + .../golden/stocks-bandar-screen.table.golden | 13 + .../tests/golden/stocks-bandar.json.golden | 4 + .../tests/golden/stocks-bandar.table.golden | 20 + crates/idx-core/src/analysis/bandar.rs | 360 ++++++++++++++++++ crates/idx-core/src/analysis/mod.rs | 65 ++++ .../ipot_brokersummary_bbri_20261016.html | 181 +++++++++ .../ipot_brokersummary_tlkm_20261016.html | 181 +++++++++ crates/idx-mcp/src/params.rs | 24 +- crates/idx-mcp/src/server.rs | 90 +++-- crates/idx-mcp/tests/golden/tools.json | 76 ++++ crates/idx-mcp/tests/mcp.rs | 21 +- docs/ARCHITECTURE.md | 5 + flake.nix | 2 +- web/src/content/docs/cli/reference.md | 78 ++++ web/src/content/docs/cli/stocks.md | 26 ++ web/src/content/docs/mcp/tools.md | 14 + 25 files changed, 1588 insertions(+), 37 deletions(-) create mode 100644 crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden create mode 100644 crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden create mode 100644 crates/idx-cli/tests/golden/stocks-bandar.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-bandar.table.golden create mode 100644 crates/idx-core/src/analysis/bandar.rs create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index d97dda2..cf316ca 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -4,10 +4,13 @@ use clap::{Args, Subcommand}; use crate::config::{HistoryProviderKind, IdxConfig}; use crate::output::{ - render_broker_summary, render_compare, render_earnings, render_financials, render_fundamental, - render_growth, render_history, render_insights, render_news, render_profile, render_quotes, - render_risk, render_screener, render_sentiment, render_technical, render_valuation, + render_bandar, render_broker_summary, render_compare, render_earnings, render_financials, + render_fundamental, render_growth, render_history, render_insights, render_news, + render_profile, render_quotes, render_risk, render_screener, render_sentiment, + render_technical, render_valuation, }; +use idx_core::analysis::BandarQuery; +use idx_core::analysis::bandar::DEFAULT_TOP; use idx_core::api::types::{Interval, Period}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use idx_core::error::IdxError; @@ -17,6 +20,9 @@ use idx_core::{CachePolicy, Idx, runtime}; /// Splits comma-separated symbol arguments; `-` reads symbols from stdin, /// separated by whitespace or commas. +/// Most symbols one `bandar` screen fetches, one request each. +const MAX_BANDAR_SYMBOLS: usize = 100; + fn expand_symbols(args: &[String]) -> Result, IdxError> { let mut symbols = Vec::new(); for arg in args { @@ -195,6 +201,24 @@ does not show broker codes during trading. Without dates, the latest session wit #[command(flatten)] scope: BrokerScopeArgs, }, + #[command( + about = "Bandarmology: is buying more concentrated than selling", + long_about = "Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value \ +against the same number of sellers', as a share of value traded, with a signal from big \ +accumulation to big distribution. Several symbols give a screen ranked by the headline tier \ +(at most 100, one request each). A heuristic over end-of-day broker codes, not advice.", + after_help = "Examples:\n idx stocks bandar BBCA\n idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3\n idx stocks bandar BBCA,BBRI,BMRI,TLKM\n idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -" + )] + Bandar { + /// One or more symbols, comma-separated or space-separated; `-` reads them from stdin. + #[arg(required = true)] + symbols: Vec, + #[command(flatten)] + scope: BrokerScopeArgs, + /// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side. + #[arg(long, default_value_t = DEFAULT_TOP)] + top: u8, + }, #[command(about = "Get company profile")] Profile { symbol: String }, #[command( @@ -311,6 +335,31 @@ pub fn handle( )?; render_broker_summary(&summary.data, format) } + StocksSubcommand::Bandar { + symbols, + scope, + top, + } => { + let symbols = expand_symbols(symbols)?; + if symbols.len() > MAX_BANDAR_SYMBOLS { + return Err(IdxError::InvalidInput(format!( + "at most {MAX_BANDAR_SYMBOLS} symbols per bandar screen (got {})", + symbols.len() + ))); + } + let query = BandarQuery { + range: scope.range(&idx)?, + investor: scope.investor, + board: scope.board, + top: *top, + }; + let screen = idx.analysis().bandar_screen(&symbols, &query, policy)?; + for (resolved, err) in screen.failures { + runtime::warn(format!("failed to read brokers for {resolved}: {err}")); + } + let reports: Vec<_> = screen.reports.into_iter().map(|r| r.data).collect(); + render_bandar(&reports, symbols.len() > 1, format, no_color) + } StocksSubcommand::Profile { symbol } => { let profile = idx.financial().profile(symbol, policy)?; render_profile(&profile.data, format) diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index a9fda2e..aeb1e54 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -8,6 +8,7 @@ use std::io::IsTerminal; use clap::ValueEnum; use serde::Serialize; +use idx_core::analysis::bandar::BandarReport; use idx_core::analysis::fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, }; @@ -84,6 +85,21 @@ pub fn render_broker_summary( } } +/// One symbol gets the detailed table, several the ranked screen (even when +/// only one of them succeeded); JSON is always the array. +pub fn render_bandar( + reports: &[BandarReport], + screen: bool, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match (format, reports) { + (OutputFormat::Table, [report]) if !screen => table::print_bandar_report(report, no_color), + (OutputFormat::Table, _) => table::print_bandar_screen(reports, no_color), + (OutputFormat::Json | OutputFormat::Jsonl, _) => json::print_json(reports), + } +} + pub fn render_history( symbol: &str, history: &[Ohlc], diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 641d434..f05e4a9 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -2,6 +2,9 @@ use crate::output::style::Paint; use crate::output::text::{cell, painted, terminal_safe, truncate_chars}; use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL}; +use idx_core::analysis::bandar::{ + ACCUMULATION_BPS, BIG_BPS, BandarReport, BandarSignal, METHOD as BANDAR_METHOD, +}; use idx_core::analysis::fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, }; @@ -1018,6 +1021,171 @@ pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> { Ok(()) } +fn format_bps_pct(bps: Option) -> String { + bps.map(|bps| format!("{:+.1}%", bps as f64 / 100.0)) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_bps_share(bps: Option) -> String { + bps.map(|bps| format!("{:.1}%", bps as f64 / 100.0)) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_bandar_signal(signal: Option, no_color: bool) -> String { + let Some(signal) = signal else { + return "-".to_string(); + }; + let label = signal.as_str().replace('_', " "); + if no_color { + return label; + } + match signal { + BandarSignal::BigAccumulation | BandarSignal::Accumulation => label.green(), + BandarSignal::BigDistribution | BandarSignal::Distribution => label.red(), + BandarSignal::Neutral => label, + } +} + +fn bandar_method_note(top: u8) -> String { + format!( + "Method {BANDAR_METHOD}: the top {top} buyers' value minus the top {top} sellers' value, \ +as a share of value traded; {}% or more reads accumulation, {}% or more big (negative: \ +distribution). Brokers trade for many clients, so this is a heuristic, not advice.", + ACCUMULATION_BPS / 100, + BIG_BPS / 100 + ) +} + +pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), IdxError> { + println!( + "{}", + format!( + "Bandar reading for {}, {}", + terminal_safe(&report.symbol), + broker_scope(report.from, report.to, report.investor, report.board) + ) + .bold() + ); + if report.total_value.is_none_or(|value| value <= 0) { + println!("No trades."); + return Ok(()); + } + println!( + "Signal (top {}): {}, {} of value", + report.top, + format_bandar_signal(report.signal, no_color), + format_bps_pct(report.imbalance_bps) + ); + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "TOP", "BUY", "SELL", "NET", "NET %", "BUY AVG", "SELL AVG", "SIGNAL", + ]); + for tier in &report.tiers { + table.add_row(vec![ + cell(tier.top), + cell(format_compact_option(tier.buy_value)), + cell(format_compact_option(tier.sell_value)), + cell(format_compact_option(tier.imbalance_value)), + cell(format_bps_pct(tier.imbalance_bps)), + cell( + tier.buy_average + .map(format_idr) + .unwrap_or_else(|| "-".to_string()), + ), + cell( + tier.sell_average + .map(format_idr) + .unwrap_or_else(|| "-".to_string()), + ), + painted(format_bandar_signal(tier.signal, no_color)), + ]); + } + println!("{table}"); + let mut totals = format!("Total {} IDR", format_compact_option(report.total_value)); + if let Some(average) = report.average_price { + totals.push_str(&format!(", average {}", format_idr(average))); + } + if let Some(net) = report.foreign_net_value { + totals.push_str(&format!( + "; foreign net {:+} ({} of value)", + format_compact(net), + format_bps_pct(report.foreign_net_bps) + )); + } + println!("{totals}."); + if !report.both_sides.is_empty() { + println!( + "On both sides: {}; their gross figures overstate which way they traded.", + terminal_safe(&report.both_sides.join(", ")) + ); + } + println!("{}", bandar_method_note(report.top)); + if report.rounded { + println!("Note: the source rounds figures of a million and more to one decimal."); + } + Ok(()) +} + +pub fn print_bandar_screen(reports: &[BandarReport], no_color: bool) -> Result<(), IdxError> { + let top = reports.first().map(|report| report.top).unwrap_or_default(); + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "SYMBOL".to_string(), + "DATES".to_string(), + "SIGNAL".to_string(), + format!("TOP {top} NET %"), + "BUY SHARE".to_string(), + "SELL SHARE".to_string(), + "VALUE".to_string(), + "FOREIGN NET".to_string(), + ]); + for report in reports { + let headline = report.tiers.iter().find(|tier| tier.top == report.top); + table.add_row(vec![ + cell(&report.symbol), + cell(broker_scope( + report.from, + report.to, + BrokerInvestor::All, + BrokerBoard::All, + )), + painted(format_bandar_signal(report.signal, no_color)), + cell(format_bps_pct(report.imbalance_bps)), + cell(format_bps_share( + headline.and_then(|tier| tier.buy_share_bps), + )), + cell(format_bps_share( + headline.and_then(|tier| tier.sell_share_bps), + )), + cell(format_compact_option(report.total_value)), + cell( + report + .foreign_net_value + .map(|net| format!("{:+}", format_compact(net))) + .unwrap_or_else(|| "-".to_string()), + ), + ]); + } + println!("{table}"); + if let Some(first) = reports.first() + && (first.investor, first.board) != (BrokerInvestor::All, BrokerBoard::All) + { + println!( + "Counting {} investors on the {} board.", + first.investor.as_str(), + first.board.as_str() + ); + } + println!("{}", bandar_method_note(top)); + Ok(()) +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 453830c..bcee661 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2605,3 +2605,120 @@ fn broker_summary_errors_follow_the_output_contract() { ]); run_error_json(&mut failing, "PROVIDERUNAVAILABLE"); } + +#[test] +fn bandar_reads_the_broker_summary_tiers() { + let reports = run_success_json(mock_bin("bandar-json").args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + ])); + let report = &reports[0]; + assert_eq!(reports.as_array().unwrap().len(), 1); + assert_eq!(report["symbol"], "BBCA.JK"); + assert_eq!(report["method"], "top_concentration_v1"); + let tiers: Vec = report["tiers"] + .as_array() + .unwrap() + .iter() + .map(|tier| tier["top"].as_i64().unwrap()) + .collect(); + assert_eq!(tiers, [1, 3, 5, 10]); + // Top 5 bought 2,939.5B and sold 4,540.4B of 6.6T traded. + let top5 = &report["tiers"][2]; + assert_eq!(top5["buy_value"], 2_939_500_000_000_i64); + assert_eq!(top5["sell_value"], 4_540_400_000_000_i64); + assert_eq!(top5["imbalance_bps"], -2426); + assert_eq!(report["top"], 5); + assert_eq!(report["imbalance_bps"], -2426); + assert_eq!(report["signal"], "big_distribution"); + assert_eq!( + report["both_sides"], + serde_json::json!(["AK", "CC", "MG", "XL", "YU", "ZP"]) + ); +} + +#[test] +fn bandar_screen_ranks_symbols_and_reports_failures_on_stderr() { + let output = mock_bin("bandar-screen") + .args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA,ZZZZ", + "TLKM", + "BBRI", + "--date", + "2026-10-16", + "--top", + "3", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let reports: Value = serde_json::from_slice(&output.stdout).unwrap(); + let ranked: Vec<(&str, i64)> = reports + .as_array() + .unwrap() + .iter() + .map(|r| { + ( + r["symbol"].as_str().unwrap(), + r["imbalance_bps"].as_i64().unwrap(), + ) + }) + .collect(); + assert_eq!(ranked.len(), 3); + assert!( + ranked.windows(2).all(|pair| pair[0].1 >= pair[1].1), + "{ranked:?}" + ); + assert!(reports.as_array().unwrap().iter().all(|r| r["top"] == 3)); + let warning: Value = serde_json::from_slice(&output.stderr).expect("one JSON warning line"); + assert_eq!(warning["level"], "warning"); + assert!(warning["message"].as_str().unwrap().contains("ZZZZ.JK")); + + // A one-symbol screen that loses a symbol still prints the screen table. + let table = run_success_stdout(mock_bin("bandar-screen-table").args([ + "--no-color", + "stocks", + "bandar", + "BBCA,ZZZZ", + "--date", + "2026-10-16", + ])); + assert!(table.contains("TOP 5 NET %"), "{table}"); +} + +#[test] +fn bandar_errors_follow_the_output_contract() { + let many: Vec = (0..101).map(|i| format!("S{i}")).collect(); + for (args, code) in [ + ( + vec!["BBCA".to_string(), "--top".into(), "4".into()], + "INVALIDINPUT", + ), + (vec![many.join(",")], "INVALIDINPUT"), + ( + vec!["ZZZZ".to_string(), "--date".into(), "2026-10-16".into()], + "SYMBOLNOTFOUND", + ), + ( + vec![ + "BBCA,BBRI".to_string(), + "--date".into(), + "2999-01-01".into(), + ], + "INVALIDINPUT", + ), + ] { + let mut cmd = mock_bin("bandar-errors"); + cmd.args(["-o", "json", "stocks", "bandar"]).args(&args); + run_error_json(&mut cmd, code); + } +} diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 85a1a53..51cd001 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -258,6 +258,15 @@ args = ["stocks", "broker", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16" name = "stocks-broker-no-trades" args = ["stocks", "broker", "BBCA", "--date", "2026-10-17"] +[[case]] +name = "stocks-bandar" +args = ["stocks", "bandar", "BBCA", "--date", "2026-10-16"] + +[[case]] +name = "stocks-bandar-screen" +formats = ["table", "json", "jsonl"] +args = ["stocks", "bandar", "BBCA,BBRI,TLKM", "--date", "2026-10-16", "--top", "3"] + [[case]] name = "error-broker-unknown-symbol" args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index 55d5ca2..08222dd 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -174,6 +174,9 @@ _idx() { idx__help__ownership__resolve,unresolved) cmd="idx__help__ownership__resolve__unresolved" ;; + idx__help__stocks,bandar) + cmd="idx__help__stocks__bandar" + ;; idx__help__stocks,broker) cmd="idx__help__stocks__broker" ;; @@ -339,6 +342,9 @@ _idx() { idx__ownership__resolve__help,unresolved) cmd="idx__ownership__resolve__help__unresolved" ;; + idx__stocks,bandar) + cmd="idx__stocks__bandar" + ;; idx__stocks,broker) cmd="idx__stocks__broker" ;; @@ -390,6 +396,9 @@ _idx() { idx__stocks,valuation) cmd="idx__stocks__valuation" ;; + idx__stocks__help,bandar) + cmd="idx__stocks__help__bandar" + ;; idx__stocks__help,broker) cmd="idx__stocks__help__broker" ;; @@ -1200,7 +1209,7 @@ _idx() { return 0 ;; idx__help__stocks) - opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare" + opts="quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1213,6 +1222,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stocks__bandar) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks__broker) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2216,7 +2239,7 @@ _idx() { return 0 ;; idx__stocks) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare help" + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2237,6 +2260,52 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__bandar) + opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --offline --no-cache --help ..." + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --date) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --investor) + COMPREPLY=($(compgen -W "all foreign domestic" -- "${cur}")) + return 0 + ;; + --board) + COMPREPLY=($(compgen -W "all regular cash negotiated" -- "${cur}")) + return 0 + ;; + --top) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__broker) opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2394,7 +2463,7 @@ _idx() { return 0 ;; idx__stocks__help) - opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare help" + opts="quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2407,6 +2476,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__bandar) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__help__broker) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/crates/idx-cli/tests/golden/help-stocks.golden b/crates/idx-cli/tests/golden/help-stocks.golden index 8fb9414..7bf301e 100644 --- a/crates/idx-cli/tests/golden/help-stocks.golden +++ b/crates/idx-cli/tests/golden/help-stocks.golden @@ -13,6 +13,7 @@ Commands: risk Run risk analysis on a stock fundamental Run full fundamental analysis on a stock broker Which brokers bought and sold a stock + bandar Bandarmology: is buying more concentrated than selling profile Get company profile financials Get financial statements earnings Get earnings report diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden new file mode 100644 index 0000000..d8f3bcf --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1749,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1503,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1749,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1655,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1322,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3334,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true},{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1994,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1546,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1994,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1733,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-560,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true},{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2914,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden new file mode 100644 index 0000000..16dc84e --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden @@ -0,0 +1,6 @@ +exit: 0 +--- stdout +{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1749,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1503,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1749,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1655,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1322,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3334,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true} +{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1994,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1546,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1994,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1733,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-560,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true} +{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2914,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden new file mode 100644 index 0000000..3ac8d90 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden @@ -0,0 +1,13 @@ +exit: 0 +--- stdout +┌─────────┬────────────┬──────────────────┬─────────────┬───────────┬────────────┬───────┬─────────────┐ +│ SYMBOL ┆ DATES ┆ SIGNAL ┆ TOP 3 NET % ┆ BUY SHARE ┆ SELL SHARE ┆ VALUE ┆ FOREIGN NET │ +╞═════════╪════════════╪══════════════════╪═════════════╪═══════════╪════════════╪═══════╪═════════════╡ +│ BBRI.JK ┆ 2026-10-16 ┆ big distribution ┆ -17.5% ┆ 38.3% ┆ 55.7% ┆ 3.3T ┆ -1.1T │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ TLKM.JK ┆ 2026-10-16 ┆ big distribution ┆ -19.9% ┆ 43.1% ┆ 63.1% ┆ 2.2T ┆ -90.2B │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ BBCA.JK ┆ 2026-10-16 ┆ big distribution ┆ -29.1% ┆ 30.1% ┆ 59.2% ┆ 6.6T ┆ -2.3T │ +└─────────┴────────────┴──────────────────┴─────────────┴───────────┴────────────┴───────┴─────────────┘ +Method top_concentration_v1: the top 3 buyers' value minus the top 3 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar.json.golden b/crates/idx-cli/tests/golden/stocks-bandar.json.golden new file mode 100644 index 0000000..ec8a432 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":5,"signal":"big_distribution","imbalance_bps":-2426,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar.table.golden b/crates/idx-cli/tests/golden/stocks-bandar.table.golden new file mode 100644 index 0000000..ebc704f --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar.table.golden @@ -0,0 +1,20 @@ +exit: 0 +--- stdout +Bandar reading for BBCA.JK, 2026-10-16 +Signal (top 5): big distribution, -24.3% of value +┌─────┬────────┬──────┬───────┬────────┬─────────┬──────────┬──────────────────┐ +│ TOP ┆ BUY ┆ SELL ┆ NET ┆ NET % ┆ BUY AVG ┆ SELL AVG ┆ SIGNAL │ +╞═════╪════════╪══════╪═══════╪════════╪═════════╪══════════╪══════════════════╡ +│ 1 ┆ 841.2B ┆ 2.2T ┆ -1.4T ┆ -20.6% ┆ 5,191 ┆ 5,211 ┆ big distribution │ +├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 3 ┆ 2.0T ┆ 3.9T ┆ -1.9T ┆ -29.1% ┆ 5,212 ┆ 5,233 ┆ big distribution │ +├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 5 ┆ 2.9T ┆ 4.5T ┆ -1.6T ┆ -24.3% ┆ 5,211 ┆ 5,229 ┆ big distribution │ +├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 10 ┆ 4.5T ┆ 5.6T ┆ -1.1T ┆ -16.3% ┆ 5,213 ┆ 5,228 ┆ big distribution │ +└─────┴────────┴──────┴───────┴────────┴─────────┴──────────┴──────────────────┘ +Total 6.6T IDR, average 5,228; foreign net -2.3T (-34.9% of value). +On both sides: AK, CC, MG, XL, YU, ZP; their gross figures overstate which way they traded. +Method top_concentration_v1: the top 5 buyers' value minus the top 5 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. +Note: the source rounds figures of a million and more to one decimal. +--- stderr diff --git a/crates/idx-core/src/analysis/bandar.rs b/crates/idx-core/src/analysis/bandar.rs new file mode 100644 index 0000000..f800535 --- /dev/null +++ b/crates/idx-core/src/analysis/bandar.rs @@ -0,0 +1,360 @@ +//! Bandarmology over a broker summary: is buying more concentrated in a few +//! brokers than selling? No network; see docs/ARCHITECTURE.md. +//! +//! The summary lists the top 10 buyers and the top 10 sellers by gross lots. +//! For N in 1, 3, 5 and 10 the top N buyers' value is set against the top N +//! sellers' value, as a share of the value traded: +//! +//! | top-N buy - sell value, % of value | signal | +//! |------------------------------------|--------------------| +//! | >= +15 | `big_accumulation` | +//! | +5 to +15 | `accumulation` | +//! | within +-5 | `neutral` | +//! | -15 to -5 | `distribution` | +//! | <= -15 | `big_distribution` | +//! +//! The thresholds follow fzn0x/idx (MIT), which set them against the 40 most +//! traded IDX stocks in October 2026. They are heuristics: a broker code is +//! not an investor, and gross figures overstate a broker that both bought +//! and sold (listed in `both_sides`). + +use serde::{Deserialize, Serialize}; + +use crate::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerSummary}; +use crate::error::IdxError; +use chrono::NaiveDate; + +/// Names the rules below; bumped when they change. +pub const METHOD: &str = "top_concentration_v1"; +/// Tiers every report carries. +pub const TIERS: [u8; 4] = [1, 3, 5, 10]; +/// Tier behind the headline signal unless asked otherwise. +pub const DEFAULT_TOP: u8 = 5; +/// Imbalance, in basis points of value traded, from which a tier reads +/// accumulation (or, negative, distribution). +pub const ACCUMULATION_BPS: i64 = 500; +/// Imbalance from which the reading is big. +pub const BIG_BPS: i64 = 1500; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BandarSignal { + BigAccumulation, + Accumulation, + Neutral, + Distribution, + BigDistribution, +} + +impl BandarSignal { + pub fn as_str(self) -> &'static str { + match self { + Self::BigAccumulation => "big_accumulation", + Self::Accumulation => "accumulation", + Self::Neutral => "neutral", + Self::Distribution => "distribution", + Self::BigDistribution => "big_distribution", + } + } + + pub fn from_imbalance_bps(bps: i64) -> Self { + match bps { + b if b >= BIG_BPS => Self::BigAccumulation, + b if b >= ACCUMULATION_BPS => Self::Accumulation, + b if b <= -BIG_BPS => Self::BigDistribution, + b if b <= -ACCUMULATION_BPS => Self::Distribution, + _ => Self::Neutral, + } + } +} + +/// The top N buyers against the top N sellers. Every figure is `None` when +/// a broker in the tier lacks it, or when nothing traded. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BandarTier { + pub top: u8, + /// Value the top N buyers bought, whole IDR. + pub buy_value: Option, + /// Value the top N sellers sold, whole IDR. + pub sell_value: Option, + /// `buy_value - sell_value`, whole IDR. + pub imbalance_value: Option, + /// `imbalance_value` in basis points of the value traded. + pub imbalance_bps: Option, + /// The top N buyers' share of the value traded, basis points. + pub buy_share_bps: Option, + /// The top N sellers' share of the value traded, basis points. + pub sell_share_bps: Option, + /// Volume-weighted average price the top N buyers paid, whole IDR. + pub buy_average: Option, + /// Volume-weighted average price the top N sellers got, whole IDR. + pub sell_average: Option, + pub signal: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BandarReport { + pub symbol: String, + pub from: NaiveDate, + pub to: NaiveDate, + pub investor: BrokerInvestor, + pub board: BrokerBoard, + /// The rules applied, e.g. `top_concentration_v1`. + pub method: String, + /// Tier behind the headline `signal`. + pub top: u8, + /// The headline tier's signal; `None` when nothing traded or a figure + /// is missing. + pub signal: Option, + /// The headline tier's imbalance, basis points of value traded. + pub imbalance_bps: Option, + pub tiers: Vec, + /// Value traded over the range, whole IDR. + pub total_value: Option, + /// Average traded price, whole IDR. + pub average_price: Option, + /// Foreign net value, whole IDR; `None` for a filtered summary. + pub foreign_net_value: Option, + /// `foreign_net_value` in basis points of the value traded. + pub foreign_net_bps: Option, + /// Brokers among the top 10 on both sides, whose gross figures overstate + /// which way they traded. + pub both_sides: Vec, + /// The source rounded figures of a million and more to one decimal. + pub rounded: bool, +} + +/// The headline tier must be one the report carries. +pub fn check_top(top: u8) -> Result { + if TIERS.contains(&top) { + Ok(top) + } else { + Err(IdxError::InvalidInput(format!( + "top must be 1, 3, 5 or 10 (got {top})" + ))) + } +} + +/// `part` in basis points of `whole`; `None` without a positive whole. +fn bps(part: i64, whole: Option) -> Option { + let whole = whole.filter(|whole| *whole > 0)?; + i64::try_from((i128::from(part) * 10_000).div_euclid(i128::from(whole))).ok() +} + +/// Sum of `field` over `flows`; `None` when any flow lacks it. +fn sum(flows: &[BrokerFlow], field: fn(&BrokerFlow) -> Option) -> Option { + flows + .iter() + .try_fold(0_i64, |total, flow| total.checked_add(field(flow)?)) +} + +/// Value over shares, the shares taken from each broker's value and its +/// exact published average (lots of a million and more are rounded, +/// averages never are). +fn vwap(flows: &[BrokerFlow]) -> Option { + let (mut value, mut shares) = (0.0_f64, 0.0_f64); + for flow in flows { + let (flow_value, average) = (flow.value?, flow.average_price?); + if average <= 0 { + return None; + } + value += flow_value as f64; + shares += flow_value as f64 / average as f64; + } + (shares > 0.0).then(|| (value / shares).round() as i64) +} + +pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier { + let n = usize::from(top); + let buyers = &summary.buyers[..n.min(summary.buyers.len())]; + let sellers = &summary.sellers[..n.min(summary.sellers.len())]; + let buy_value = sum(buyers, |flow| flow.value); + let sell_value = sum(sellers, |flow| flow.value); + let imbalance_value = buy_value + .zip(sell_value) + .and_then(|(buy, sell)| buy.checked_sub(sell)); + let imbalance_bps = imbalance_value.and_then(|value| bps(value, summary.total_value)); + BandarTier { + top, + buy_value, + sell_value, + imbalance_value, + imbalance_bps, + buy_share_bps: buy_value.and_then(|value| bps(value, summary.total_value)), + sell_share_bps: sell_value.and_then(|value| bps(value, summary.total_value)), + buy_average: vwap(buyers), + sell_average: vwap(sellers), + signal: imbalance_bps.map(BandarSignal::from_imbalance_bps), + } +} + +/// The full reading of one summary, headlined by the top `top` tier (one of +/// [`TIERS`]; see [`check_top`]). +pub fn analyze_bandar(summary: &BrokerSummary, top: u8) -> BandarReport { + let tiers: Vec = TIERS.iter().map(|n| bandar_tier(summary, *n)).collect(); + let headline = tiers + .iter() + .find(|tier| tier.top == top) + .cloned() + .unwrap_or_else(|| bandar_tier(summary, top)); + let mut both_sides: Vec = summary + .buyers + .iter() + .filter(|buyer| { + summary + .sellers + .iter() + .any(|seller| seller.code == buyer.code) + }) + .map(|buyer| buyer.code.clone()) + .collect(); + both_sides.sort_unstable(); + BandarReport { + symbol: summary.symbol.clone(), + from: summary.from, + to: summary.to, + investor: summary.investor, + board: summary.board, + method: METHOD.to_string(), + top, + signal: headline.signal, + imbalance_bps: headline.imbalance_bps, + tiers, + total_value: summary.total_value, + average_price: summary.average_price, + foreign_net_value: summary.foreign_net_value, + foreign_net_bps: summary + .foreign_net_value + .and_then(|value| bps(value, summary.total_value)), + both_sides, + rounded: summary.rounded, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn flow(code: &str, value: Option, average: i64) -> BrokerFlow { + BrokerFlow { + rank: 0, + code: code.to_string(), + name: None, + kind: None, + lots: value.map(|value| value / average / 100), + value, + average_price: Some(average), + } + } + + fn summary(buyers: Vec, sellers: Vec, total: i64) -> BrokerSummary { + let day = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(); + BrokerSummary { + symbol: "TEST.JK".to_string(), + from: day, + to: day, + investor: BrokerInvestor::All, + board: BrokerBoard::All, + buyers, + sellers, + total_value: Some(total), + total_lots: None, + average_price: None, + foreign_net_value: Some(-total / 10), + rounded: false, + } + } + + #[test] + fn test_bandar_signal_thresholds_are_inclusive() { + use BandarSignal::*; + for (bps, signal) in [ + (1500, BigAccumulation), + (1499, Accumulation), + (500, Accumulation), + (499, Neutral), + (-499, Neutral), + (-500, Distribution), + (-1499, Distribution), + (-1500, BigDistribution), + ] { + assert_eq!(BandarSignal::from_imbalance_bps(bps), signal, "{bps}"); + } + } + + #[test] + fn test_analyze_bandar_weighs_top_buyers_against_top_sellers() { + let s = summary( + vec![ + flow("AA", Some(400), 100), + flow("BB", Some(200), 100), + flow("CC", Some(100), 50), + ], + vec![ + flow("DD", Some(300), 110), + flow("AA", Some(100), 110), + flow("EE", Some(50), 110), + ], + 1000, + ); + let report = analyze_bandar(&s, 3); + let top1 = &report.tiers[0]; + assert_eq!((top1.top, top1.imbalance_value), (1, Some(100))); + assert_eq!(top1.imbalance_bps, Some(1000)); + assert_eq!(top1.signal, Some(BandarSignal::Accumulation)); + + let top3 = &report.tiers[1]; + assert_eq!((top3.buy_value, top3.sell_value), (Some(700), Some(450))); + assert_eq!( + (top3.buy_share_bps, top3.sell_share_bps), + (Some(7000), Some(4500)) + ); + assert_eq!(top3.imbalance_bps, Some(2500)); + // 700 IDR over 4 + 2 + 2 shares. + assert_eq!(top3.buy_average, Some(88)); + assert_eq!(top3.sell_average, Some(110)); + + // Short lists: every broker on a side is listed, so the sums hold. + assert_eq!(report.tiers[3].imbalance_value, Some(250)); + assert_eq!( + (report.top, report.signal), + (3, Some(BandarSignal::BigAccumulation)) + ); + assert_eq!(report.imbalance_bps, Some(2500)); + assert_eq!(report.foreign_net_bps, Some(-1000)); + assert_eq!(report.both_sides, vec!["AA".to_string()]); + assert_eq!(report.method, METHOD); + } + + #[test] + fn test_analyze_bandar_never_counts_a_missing_figure_as_zero() { + let s = summary( + vec![flow("AA", Some(400), 100), flow("BB", None, 100)], + vec![flow("DD", Some(300), 100)], + 1000, + ); + let report = analyze_bandar(&s, 3); + assert_eq!(report.tiers[0].imbalance_bps, Some(1000)); + let top3 = &report.tiers[1]; + assert_eq!( + (top3.buy_value, top3.imbalance_bps, top3.signal), + (None, None, None) + ); + assert_eq!(top3.buy_average, None); + assert_eq!(report.signal, None); + } + + #[test] + fn test_analyze_bandar_without_trades_has_no_signal() { + let report = analyze_bandar(&summary(vec![], vec![], 0), DEFAULT_TOP); + assert!(report.tiers.iter().all(|tier| tier.signal.is_none())); + assert_eq!((report.signal, report.imbalance_bps), (None, None)); + assert_eq!(report.tiers[2].buy_value, Some(0)); + } + + #[test] + fn test_check_top_allows_only_report_tiers() { + assert_eq!(check_top(5).unwrap(), 5); + assert!(matches!(check_top(4), Err(IdxError::InvalidInput(_)))); + } +} diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index 87c3aea..63dfb46 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -1,3 +1,4 @@ +pub mod bandar; pub mod fundamental; pub mod report; pub mod signals; @@ -8,9 +9,11 @@ use serde::de::DeserializeOwned; use crate::api::history_provider; use crate::api::types::{Fundamentals, Interval, Period}; +use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::error::IdxError; use crate::market::HistorySource; +use bandar::{BandarReport, analyze_bandar, check_top}; use fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental, analyze_growth, analyze_risk, analyze_valuation, @@ -28,7 +31,69 @@ pub struct Comparison { pub failures: Vec<(String, IdxError)>, } +/// Bandar readings for several symbols, strongest accumulation first; +/// symbols that failed are listed with their error. +pub struct BandarScreen { + pub reports: Vec>, + pub failures: Vec<(String, IdxError)>, +} + +/// The sessions and trades a bandar reading covers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BandarQuery { + pub range: BrokerRange, + pub investor: BrokerInvestor, + pub board: BrokerBoard, + /// Headline tier, one of `bandar::TIERS`. + pub top: u8, +} + impl Analysis<'_> { + /// Bandarmology over the broker summary `query` selects. + pub fn bandar( + &self, + symbol: &str, + query: &BandarQuery, + policy: CachePolicy, + ) -> Result, IdxError> { + let top = check_top(query.top)?; + let summary = + self.idx + .broker() + .fetch(symbol, query.range, query.investor, query.board, policy)?; + Ok(summary.map(|summary| analyze_bandar(&summary, top))) + } + + /// [`Self::bandar`] for each symbol, ranked by the headline imbalance. + /// Invalid input fails the whole screen; other errors fail one symbol. + pub fn bandar_screen>( + &self, + symbols: &[S], + query: &BandarQuery, + policy: CachePolicy, + ) -> Result { + check_top(query.top)?; + let mut reports = Vec::new(); + let mut failures = Vec::new(); + for symbol in symbols { + let resolved = self.idx.resolve(symbol.as_ref())?; + match self.bandar(&resolved, query, policy) { + Ok(report) => reports.push(report), + Err(err @ IdxError::InvalidInput(_)) => return Err(err), + Err(err) => failures.push((resolved, err)), + } + } + if reports.is_empty() { + return Err(failures + .pop() + .map(|(_, err)| err) + .unwrap_or_else(|| IdxError::InvalidInput("no symbols to screen".to_string()))); + } + // Strongest accumulation first; readings without an imbalance last. + reports.sort_by_key(|report| std::cmp::Reverse(report.data.imbalance_bps)); + Ok(BandarScreen { reports, failures }) + } + /// Technical analysis over one year of daily history from `source` /// (see `Market::history_source`). pub fn technical( diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html new file mode 100644 index 0000000..024b547 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
CC1.9 M504.3 B2,709 1AK3.7 M1.0 T2,716
XL1.6 M436.0 B2,720 2KZ1.8 M481.4 B2,672
AK1.2 M322.4 B2,719 3CC1.3 M358.2 B2,717
YP1.0 M281.3 B2,694 4BK933,299 253.5 B2,716
SQ712,722 193.1 B2,709 5XL697,845 189.9 B2,721
OD518,938 140.6 B2,709 6ZP477,399 129.4 B2,711
NI496,800 133.9 B2,695 7YU447,840 120.9 B2,700
YU420,264 112.3 B2,672 8YP431,663 117.6 B2,725
PD403,468 109.5 B2,713 9NI234,641 63.1 B2,690
XC381,871 103.7 B2,717 10XA217,888 59.1 B2,714
+
+ T. Val : 3.3 T + F. NVal : -1.1 T + T.Lot : 12.2 M + Avg : 2,708 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html new file mode 100644 index 0000000..a0f62e7 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ1.9 M381.6 B1,960 1KZ3.7 M721.6 B1,933
CC1.5 M292.8 B1,932 2CC1.8 M353.9 B1,933
BK1.4 M274.7 B1,917 3BK1.6 M312.2 B1,926
RX1.0 M200.0 B1,933 4ZP1.0 M198.1 B1,926
ZP992,588 192.3 B1,937 5AK704,361 136.7 B1,941
YU944,581 180.4 B1,910 6MG529,257 101.4 B1,916
AK715,278 138.2 B1,932 7YU324,973 62.5 B1,924
MG528,650 100.6 B1,903 8NI208,165 39.8 B1,911
XL331,760 64.1 B1,931 9XL173,939 33.7 B1,937
YP234,664 44.9 B1,913 10YP168,848 32.7 B1,938
+
+ T. Val : 2.2 T + F. NVal : -90.2 B + T.Lot : 11.3 M + Avg : 1,931 +
+ +
+
+ + + diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 90309da..2975031 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -115,10 +115,9 @@ pub struct AnalyzeParams { pub kinds: Option>, } +/// Which sessions and trades a broker tool covers. #[derive(Debug, Deserialize, JsonSchema)] -pub struct BrokerParams { - /// IDX ticker, e.g. "BBCA". - pub symbol: String, +pub struct BrokerScope { /// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. #[serde(default)] pub date: Option, @@ -139,6 +138,25 @@ pub struct BrokerParams { pub board: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct BrokerParams { + /// IDX ticker, e.g. "BBCA". + pub symbol: String, + #[serde(flatten)] + pub scope: BrokerScope, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct BandarParams { + /// IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. + pub symbols: Vec, + #[serde(flatten)] + pub scope: BrokerScope, + /// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5). + #[serde(default)] + pub top: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct ShareholdersParams { /// IDX ticker, e.g. "BBCA". diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 2386d64..e73bdbf 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -4,6 +4,8 @@ use std::collections::BTreeMap; +use idx_core::analysis::BandarQuery; +use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP}; use idx_core::analysis::fundamental::FundamentalReport; use idx_core::broker::types::BrokerRange; use idx_core::config::CoreConfig; @@ -22,9 +24,9 @@ use serde_json::{Map, Value, json}; use tokio::sync::Semaphore; use crate::params::{ - AnalysisKind, AnalyzeParams, BrokerParams, ChangesParams, CompanyParams, CompanySection, - EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, - SyncParams, + AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams, + CompanySection, EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, + SymbolsParams, SyncParams, }; const SYNC_TOOL: &str = "ownership_sync"; @@ -176,33 +178,66 @@ broker's own status (local, foreign, state_owned), not its clients'. `rounded` = figures of a million and more carry one decimal of precision." )] async fn get_broker_summary(&self, params: Parameters) -> ToolResult { - let BrokerParams { - symbol, - date, - from, - to, - investor, - board, - } = params.0; + let BrokerParams { symbol, scope } = params.0; self.live(move |idx| { let broker = idx.broker(); let range = BrokerRange::from_args( - date.as_deref(), - from.as_deref(), - to.as_deref(), + scope.date.as_deref(), + scope.from.as_deref(), + scope.to.as_deref(), broker.today(), )?; broker.fetch( &symbol, range, - investor.as_deref().unwrap_or("all").parse()?, - board.as_deref().unwrap_or("all").parse()?, + scope.investor.as_deref().unwrap_or("all").parse()?, + scope.board.as_deref().unwrap_or("all").parse()?, CachePolicy::Default, ) }) .await } + #[tool( + description = "Bandarmology for one or more IDX tickers from the broker summary: for \ +the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value \ +traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral \ +(within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come \ +strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over \ +end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose \ +gross figures overstate their direction. `method` names the rules applied." + )] + async fn analyze_bandar(&self, params: Parameters) -> ToolResult { + let BandarParams { + scope, + symbols, + top, + } = params.0; + let symbols = match checked_symbols(symbols) { + Ok(symbols) => symbols, + Err(err) => return err, + }; + self.live(move |idx| { + let query = BandarQuery { + range: BrokerRange::from_args( + scope.date.as_deref(), + scope.from.as_deref(), + scope.to.as_deref(), + idx.broker().today(), + )?, + investor: scope.investor.as_deref().unwrap_or("all").parse()?, + board: scope.board.as_deref().unwrap_or("all").parse()?, + top: top.unwrap_or(DEFAULT_TOP), + }; + let screen = idx + .analysis() + .bandar_screen(&symbols, &query, CachePolicy::Default)?; + let reports: &[Fetched] = &screen.reports; + Ok(json!({ "reports": reports, "failures": failures_json(&screen.failures) })) + }) + .await + } + #[tool( description = "Company data for one IDX ticker: profile, financial statements, \ earnings, analyst sentiment, insights, and news. News items carry `relevance`: \ @@ -294,17 +329,8 @@ tickers. Tickers that fail are listed under `failures`." }; self.live(move |idx| { let comparison = idx.analysis().compare(&symbols, CachePolicy::Default)?; - let failures: Vec = comparison - .failures - .iter() - .map(|(symbol, err)| { - let mut body = error_body(err); - body.insert("symbol".into(), json!(symbol)); - Value::Object(body) - }) - .collect(); let reports: &[Fetched] = &comparison.reports; - Ok(json!({ "reports": reports, "failures": failures })) + Ok(json!({ "reports": reports, "failures": failures_json(&comparison.failures) })) }) .await } @@ -480,6 +506,18 @@ where }) } +/// Per-symbol failures as error bodies carrying their symbol. +fn failures_json(failures: &[(String, IdxError)]) -> Vec { + failures + .iter() + .map(|(symbol, err)| { + let mut body = error_body(err); + body.insert("symbol".into(), json!(symbol)); + Value::Object(body) + }) + .collect() +} + fn input_error(message: String) -> ToolResult { let err = IdxError::InvalidInput(message); Ok(CallToolResult::structured_error(Value::Object(error_body( diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 1d0253c..938ba16 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -1,4 +1,80 @@ [ + { + "description": "Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "board": { + "default": null, + "description": "Which board's trades to count (default \"all\"); \"regular\" leaves out\nnegotiated block trades.", + "enum": [ + "all", + "regular", + "cash", + "negotiated" + ], + "type": "string" + }, + "date": { + "default": null, + "description": "One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.", + "type": [ + "string", + "null" + ] + }, + "from": { + "default": null, + "description": "First session of a range, YYYY-MM-DD (at most 366 days).", + "type": [ + "string", + "null" + ] + }, + "investor": { + "default": null, + "description": "Whose trades to count, by investor domicile (default \"all\").", + "enum": [ + "all", + "foreign", + "domestic" + ], + "type": "string" + }, + "symbols": { + "description": "IDX tickers, e.g. [\"BBCA\", \"BBRI\"]; the `.JK` suffix is optional. At most 20.", + "items": { + "type": "string" + }, + "type": "array" + }, + "to": { + "default": null, + "description": "Last session of the range, YYYY-MM-DD (default today).", + "type": [ + "string", + "null" + ] + }, + "top": { + "default": null, + "description": "Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5).", + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "symbols" + ], + "type": "object" + }, + "name": "analyze_bandar" + }, { "description": "Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails.", "inputSchema": { diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index 7e28d78..ef89a07 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -9,7 +9,8 @@ use idx_core::config::CoreConfig; use idx_core::investor::{ImportSource, Investor}; use serde_json::{Value, json}; -const TOOLS: [&str; 12] = [ +const TOOLS: [&str; 13] = [ + "analyze_bandar", "analyze_stock", "compare_stocks", "get_broker_summary", @@ -258,6 +259,22 @@ fn live_tools_wrap_data_with_freshness() { assert_eq!(brokers["data"]["sellers"][0]["name"], "MANDIRI SEKURITAS"); assert_eq!(brokers["data"]["foreign_net_value"], -2_300_000_000_000_i64); + let bandar = client.ok( + "analyze_bandar", + json!({"symbols": ["BBCA", "BBRI", "ZZZZ"], "date": "2026-10-16", "top": 3}), + ); + let reports = bandar["reports"].as_array().unwrap(); + assert_eq!(reports.len(), 2); + assert_fetched(&reports[0]); + assert_eq!(reports[0]["data"]["top"], 3); + assert_eq!(reports[0]["data"]["method"], "top_concentration_v1"); + assert!( + reports[0]["data"]["imbalance_bps"].as_i64() + >= reports[1]["data"]["imbalance_bps"].as_i64() + ); + assert_eq!(bandar["failures"][0]["symbol"], "ZZZZ.JK"); + assert_eq!(bandar["failures"][0]["code"], "SYMBOLNOTFOUND"); + let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]})); assert_eq!(compared["reports"].as_array().unwrap().len(), 2); assert_eq!(compared["failures"], json!([])); @@ -292,6 +309,8 @@ fn errors_carry_cli_codes() { "get_broker_summary", json!({"symbol": "BBCA", "date": "2026-10-16", "board": "dark"}), ), + ("analyze_bandar", json!({"symbols": ["BBCA"], "top": 4})), + ("analyze_bandar", json!({"symbols": []})), ] { let (is_error, body) = client.call(tool, arguments); assert!(is_error, "{tool}: {body}"); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9278f89..c148c02 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -103,6 +103,11 @@ WIB as the fallback. - Data is end of day: IDX hides broker codes during trading. Summaries that ended before today are cached for 7 days, today's for 5 minutes. Broker names come from `broker/codes.tsv` (IDX `GetBrokerCodeList`). +- Bandarmology (`analysis/bandar.rs`, method `top_concentration_v1`) is pure + over one summary: for the top 1/3/5/10 per side, buy value minus sell value + in basis points of value traded, with thresholds ±500/±1500 bps taken from + fzn0x/idx. A tier with a missing figure has no reading; missing values are + never summed as zero. A change to the rules gets a new method name. ### Capability Gating ``` diff --git a/flake.nix b/flake.nix index 9962c1d..8644232 100644 --- a/flake.nix +++ b/flake.nix @@ -288,7 +288,7 @@ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' sleep 2 } | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl - jq -se 'map(select(.id == 2))[0].result.tools | length == 12' out.jsonl + jq -se 'map(select(.id == 2))[0].result.tools | length == 13' out.jsonl touch "$out" ''; # The website's generated pages match the CLI help and the MCP diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 3cfe6c7..5ffd38a 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -67,6 +67,7 @@ Commands: risk Run risk analysis on a stock fundamental Run full fundamental analysis on a stock broker Which brokers bought and sold a stock + bandar Bandarmology: is buying more concentrated than selling profile Get company profile financials Get financial statements earnings Get earnings report @@ -351,6 +352,83 @@ Examples: idx -o json stocks broker BBCA --investor foreign ``` +### `idx stocks bandar` + +```text +Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value against the same number of sellers', as a share of value traded, with a signal from big accumulation to big distribution. Several symbols give a screen ranked by the headline tier (at most 100, one request each). A heuristic over end-of-day broker codes, not advice. + +Usage: idx stocks bandar [OPTIONS] ... + +Arguments: + ... + One or more symbols, comma-separated or space-separated; `-` reads them from stdin + +Options: + --date + One session, YYYY-MM-DD. Default: the latest session with trades + + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --from + First session of a range, YYYY-MM-DD (at most 366 days) + + --no-color + Disable colored output + + -q, --quiet + Suppress non-essential output + + --to + Last session of the range, YYYY-MM-DD. Default: today + + --investor + Whose trades to count + + Possible values: + - all: Every trade + - foreign: Trades by foreign investors only + - domestic: Trades by domestic investors only + + [default: all] + + -v, --verbose... + Increase verbosity (-v, -vv) + + --board + Which board's trades to count; `regular` leaves out negotiated block trades + + Possible values: + - all: Every board + - regular: Regular market (RG), the continuous order book + - cash: Cash market (TN) + - negotiated: Negotiated market (NG): pre-arranged block trades + + [default: all] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + --top + Tier behind the headline signal: 1, 3, 5 or 10 brokers per side + + [default: 5] + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stocks bandar BBCA + idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3 + idx stocks bandar BBCA,BBRI,BMRI,TLKM + idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar - +``` + ### `idx stocks profile` ```text diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index 61f4a8c..7bb02b7 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -47,6 +47,32 @@ codes during trading. Buyers and sellers are ranked separately on gross lots, so a broker can appear on both sides. The source rounds figures of a million and more to one decimal, and the output says so. +## Bandarmology + +```bash +idx stocks bandar BBCA +idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3 +idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar - +``` + +`bandar` reads the broker summary: for the top 1, 3, 5 and 10 brokers on each +side, the buyers' value minus the sellers' value as a share of the value +traded. The headline tier (`--top`, default 5) gives the signal: + +| Top-N buy minus sell, % of value | Signal | +| --- | --- | +| +15% or more | big accumulation | +| +5% to +15% | accumulation | +| within ±5% | neutral | +| -15% to -5% | distribution | +| -15% or less | big distribution | + +Several symbols give a screen ranked by that figure (at most 100, one request +each), and it takes the same date and filter flags as `broker`. It is a +heuristic: a broker trades for many clients, and a broker on both sides of +the summary is listed, since its gross figures overstate which way it traded. +The JSON names the rules in `method` (`top_concentration_v1`). + ## Analysis | Command | What it computes | diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 0c1825a..8664986 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -10,6 +10,20 @@ starts with `--allow-sync`. Results that come from a live provider are wrapped as `{data, source, fetched_at, stale}`; failures return `{error, code, message}` with `isError` set. +## `analyze_bandar` + +Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `board` | `all` / `regular` / `cash` / `negotiated` | no | Which board's trades to count (default "all"); "regular" leaves out negotiated block trades. | +| `date` | string | no | One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. | +| `from` | string | no | First session of a range, YYYY-MM-DD (at most 366 days). | +| `investor` | `all` / `foreign` / `domestic` | no | Whose trades to count, by investor domicile (default "all"). | +| `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. | +| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). | +| `top` | integer | no | Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5). | + ## `analyze_stock` Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails. From 4c2f0acff321f831a769c43a0b6a392c9d17e58e Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 05:39:05 +0000 Subject: [PATCH 06/32] fix(core): symmetric bandar thresholds; bandar needs all investors - Basis points truncate toward zero instead of flooring, so a reading of -4.9952% stays neutral like its mirror image and -14.9952% stays distribution; tests cover both signs at both thresholds through bandar_tier. Goldens move by the rounding (BBCA top 5: -2426 -> -2425). - With one investor side selected the summary has no total to weigh brokers against, so bandar and analyze_bandar reject it (INVALIDINPUT) instead of reading every tier as missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-cli/src/cli/stocks.rs | 3 +- crates/idx-cli/tests/cli.rs | 8 +++- .../golden/stocks-bandar-screen.json.golden | 2 +- .../golden/stocks-bandar-screen.jsonl.golden | 6 +-- .../tests/golden/stocks-bandar.json.golden | 2 +- .../tests/golden/stocks-bandar.table.golden | 6 +-- crates/idx-core/src/analysis/bandar.rs | 37 ++++++++++++++++++- crates/idx-core/src/analysis/mod.rs | 15 ++++++++ crates/idx-mcp/src/server.rs | 3 +- crates/idx-mcp/tests/golden/tools.json | 2 +- web/src/content/docs/cli/reference.md | 2 +- web/src/content/docs/cli/stocks.md | 3 +- web/src/content/docs/mcp/tools.md | 2 +- 13 files changed, 73 insertions(+), 18 deletions(-) diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index cf316ca..8ab5885 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -206,7 +206,8 @@ does not show broker codes during trading. Without dates, the latest session wit long_about = "Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value \ against the same number of sellers', as a share of value traded, with a signal from big \ accumulation to big distribution. Several symbols give a screen ranked by the headline tier \ -(at most 100, one request each). A heuristic over end-of-day broker codes, not advice.", +(at most 100, one request each). Counts all investors (`--investor` must be `all`). A \ +heuristic over end-of-day broker codes, not advice.", after_help = "Examples:\n idx stocks bandar BBCA\n idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3\n idx stocks bandar BBCA,BBRI,BMRI,TLKM\n idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -" )] Bandar { diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index bcee661..83fd2d7 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2632,9 +2632,9 @@ fn bandar_reads_the_broker_summary_tiers() { let top5 = &report["tiers"][2]; assert_eq!(top5["buy_value"], 2_939_500_000_000_i64); assert_eq!(top5["sell_value"], 4_540_400_000_000_i64); - assert_eq!(top5["imbalance_bps"], -2426); + assert_eq!(top5["imbalance_bps"], -2425); assert_eq!(report["top"], 5); - assert_eq!(report["imbalance_bps"], -2426); + assert_eq!(report["imbalance_bps"], -2425); assert_eq!(report["signal"], "big_distribution"); assert_eq!( report["both_sides"], @@ -2716,6 +2716,10 @@ fn bandar_errors_follow_the_output_contract() { ], "INVALIDINPUT", ), + ( + vec!["BBCA".to_string(), "--investor".into(), "foreign".into()], + "INVALIDINPUT", + ), ] { let mut cmd = mock_bin("bandar-errors"); cmd.args(["-o", "json", "stocks", "bandar"]).args(&args); diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden index d8f3bcf..4ff0e74 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1749,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1503,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1749,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1655,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1322,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3334,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true},{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1994,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1546,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1994,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1733,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-560,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true},{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2914,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +[{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1748,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1654,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true},{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1993,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1732,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true},{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden index 16dc84e..bb5a3cc 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden @@ -1,6 +1,6 @@ exit: 0 --- stdout -{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1749,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1503,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1749,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1655,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1322,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3334,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true} -{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1994,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1546,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1994,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1733,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-560,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true} -{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2914,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true} +{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1748,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1654,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true} +{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1993,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1732,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true} +{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar.json.golden b/crates/idx-cli/tests/golden/stocks-bandar.json.golden index ec8a432..5d6bad6 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar.json.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":5,"signal":"big_distribution","imbalance_bps":-2426,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +[{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":5,"signal":"big_distribution","imbalance_bps":-2425,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar.table.golden b/crates/idx-cli/tests/golden/stocks-bandar.table.golden index ebc704f..7ea52f6 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar.table.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar.table.golden @@ -1,7 +1,7 @@ exit: 0 --- stdout Bandar reading for BBCA.JK, 2026-10-16 -Signal (top 5): big distribution, -24.3% of value +Signal (top 5): big distribution, -24.2% of value ┌─────┬────────┬──────┬───────┬────────┬─────────┬──────────┬──────────────────┐ │ TOP ┆ BUY ┆ SELL ┆ NET ┆ NET % ┆ BUY AVG ┆ SELL AVG ┆ SIGNAL │ ╞═════╪════════╪══════╪═══════╪════════╪═════════╪══════════╪══════════════════╡ @@ -9,11 +9,11 @@ Signal (top 5): big distribution, -24.3% of value ├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ 3 ┆ 2.0T ┆ 3.9T ┆ -1.9T ┆ -29.1% ┆ 5,212 ┆ 5,233 ┆ big distribution │ ├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ -│ 5 ┆ 2.9T ┆ 4.5T ┆ -1.6T ┆ -24.3% ┆ 5,211 ┆ 5,229 ┆ big distribution │ +│ 5 ┆ 2.9T ┆ 4.5T ┆ -1.6T ┆ -24.2% ┆ 5,211 ┆ 5,229 ┆ big distribution │ ├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ 10 ┆ 4.5T ┆ 5.6T ┆ -1.1T ┆ -16.3% ┆ 5,213 ┆ 5,228 ┆ big distribution │ └─────┴────────┴──────┴───────┴────────┴─────────┴──────────┴──────────────────┘ -Total 6.6T IDR, average 5,228; foreign net -2.3T (-34.9% of value). +Total 6.6T IDR, average 5,228; foreign net -2.3T (-34.8% of value). On both sides: AK, CC, MG, XL, YU, ZP; their gross figures overstate which way they traded. Method top_concentration_v1: the top 5 buyers' value minus the top 5 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. Note: the source rounds figures of a million and more to one decimal. diff --git a/crates/idx-core/src/analysis/bandar.rs b/crates/idx-core/src/analysis/bandar.rs index f800535..8fc1fda 100644 --- a/crates/idx-core/src/analysis/bandar.rs +++ b/crates/idx-core/src/analysis/bandar.rs @@ -135,10 +135,12 @@ pub fn check_top(top: u8) -> Result { } } -/// `part` in basis points of `whole`; `None` without a positive whole. +/// `part` in basis points of `whole`, truncated toward zero so a negative +/// reading never reaches a threshold its magnitude has not; `None` without +/// a positive whole. fn bps(part: i64, whole: Option) -> Option { let whole = whole.filter(|whole| *whole > 0)?; - i64::try_from((i128::from(part) * 10_000).div_euclid(i128::from(whole))).ok() + i64::try_from(i128::from(part) * 10_000 / i128::from(whole)).ok() } /// Sum of `field` over `flows`; `None` when any flow lacks it. @@ -326,6 +328,37 @@ mod tests { assert_eq!(report.method, METHOD); } + #[test] + fn test_bandar_tier_thresholds_are_symmetric_for_sign() { + let reading = |buy: i64, sell: i64| { + let s = summary( + vec![flow("AA", Some(buy), 100)], + vec![flow("BB", Some(sell), 100)], + 2_100_000_000, + ); + let tier = bandar_tier(&s, 1); + (tier.imbalance_bps, tier.signal) + }; + // -4.9952% and -14.9952% stay short of the thresholds, like their + // mirror images. + assert_eq!( + reading(504_300_000, 609_200_000), + (Some(-499), Some(BandarSignal::Neutral)) + ); + assert_eq!( + reading(609_200_000, 504_300_000), + (Some(499), Some(BandarSignal::Neutral)) + ); + assert_eq!( + reading(504_300_000, 819_200_000), + (Some(-1499), Some(BandarSignal::Distribution)) + ); + assert_eq!( + reading(819_200_000, 504_300_000), + (Some(1499), Some(BandarSignal::Accumulation)) + ); + } + #[test] fn test_analyze_bandar_never_counts_a_missing_figure_as_zero() { let s = summary( diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index 63dfb46..0304678 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -31,6 +31,19 @@ pub struct Comparison { pub failures: Vec<(String, IdxError)>, } +/// Readings weigh brokers against the value traded, which the source does +/// not total for one investor side. +fn check_investor(investor: BrokerInvestor) -> Result<(), IdxError> { + if investor == BrokerInvestor::All { + Ok(()) + } else { + Err(IdxError::InvalidInput(format!( + "bandar needs --investor all: the broker summary has no total for {} investors alone", + investor.as_str() + ))) + } +} + /// Bandar readings for several symbols, strongest accumulation first; /// symbols that failed are listed with their error. pub struct BandarScreen { @@ -57,6 +70,7 @@ impl Analysis<'_> { policy: CachePolicy, ) -> Result, IdxError> { let top = check_top(query.top)?; + check_investor(query.investor)?; let summary = self.idx .broker() @@ -73,6 +87,7 @@ impl Analysis<'_> { policy: CachePolicy, ) -> Result { check_top(query.top)?; + check_investor(query.investor)?; let mut reports = Vec::new(); let mut failures = Vec::new(); for symbol in symbols { diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index e73bdbf..dfbcd46 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -205,7 +205,8 @@ traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) throu (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come \ strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over \ end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose \ -gross figures overstate their direction. `method` names the rules applied." +gross figures overstate their direction. `method` names the rules applied. `investor` must be \ +`all`: the summary has no total for one investor side." )] async fn analyze_bandar(&self, params: Parameters) -> ToolResult { let BandarParams { diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 938ba16..44abf49 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -1,6 +1,6 @@ [ { - "description": "Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied.", + "description": "Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. `investor` must be `all`: the summary has no total for one investor side.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 5ffd38a..65290e3 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -355,7 +355,7 @@ Examples: ### `idx stocks bandar` ```text -Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value against the same number of sellers', as a share of value traded, with a signal from big accumulation to big distribution. Several symbols give a screen ranked by the headline tier (at most 100, one request each). A heuristic over end-of-day broker codes, not advice. +Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value against the same number of sellers', as a share of value traded, with a signal from big accumulation to big distribution. Several symbols give a screen ranked by the headline tier (at most 100, one request each). Counts all investors (`--investor` must be `all`). A heuristic over end-of-day broker codes, not advice. Usage: idx stocks bandar [OPTIONS] ... diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index 7bb02b7..163d34e 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -68,7 +68,8 @@ traded. The headline tier (`--top`, default 5) gives the signal: | -15% or less | big distribution | Several symbols give a screen ranked by that figure (at most 100, one request -each), and it takes the same date and filter flags as `broker`. It is a +each), and it takes the same date and board flags as `broker` (all investors +only: the summary has no total for one side). It is a heuristic: a broker trades for many clients, and a broker on both sides of the summary is listed, since its gross figures overstate which way it traded. The JSON names the rules in `method` (`top_concentration_v1`). diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 8664986..471bd8e 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -12,7 +12,7 @@ message}` with `isError` set. ## `analyze_bandar` -Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. +Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. `investor` must be `all`: the summary has no total for one investor side. | Input | Type | Required | Description | | --- | --- | --- | --- | From 29f143fa1b59844ddaf5ebb74bf2481171c04703 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 05:09:21 +0000 Subject: [PATCH 07/32] feat: foreign flow (idx stocks flow, get_foreign_flow) `idx stocks flow SYMBOL` shows what foreign investors bought and sold of a stock, session by session, from IDX's own daily trading summary: foreign buy, sell and net volume, net value at the session's average price, and the share of value traded outside the regular board, with range totals. Latest 10 sessions by default (--days, at most 60) or --from/--to. The MCP server exposes it as get_foreign_flow. - api/idx: pure parser for GetStockSummary (rows dated another session are dropped) and a provider over curl-impersonate's profile fallback with a 30 s limit; Cloudflare passes only some fingerprints. - flow: each session is cached whole by date, so other stocks' flows for the same sessions need no request. Weekends are never requested. - Idx::idx_code is shared by broker and flow for the .JK-only check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- ROADMAP.md | 12 + crates/idx-cli/src/cli/stocks.rs | 37 ++- crates/idx-cli/src/output/mod.rs | 8 + crates/idx-cli/src/output/table.rs | 74 +++++ crates/idx-cli/tests/cli.rs | 131 ++++++++ crates/idx-cli/tests/golden/cases.toml | 12 +- .../tests/golden/completions-bash.golden | 77 ++++- .../idx-cli/tests/golden/help-stocks.golden | 1 + .../golden/stocks-flow-latest.json.golden | 4 + .../golden/stocks-flow-latest.table.golden | 15 + .../tests/golden/stocks-flow.json.golden | 4 + .../tests/golden/stocks-flow.jsonl.golden | 4 + .../tests/golden/stocks-flow.table.golden | 19 ++ crates/idx-core/src/api/idx/mod.rs | 73 +++++ crates/idx-core/src/api/idx/parse.rs | 114 +++++++ crates/idx-core/src/api/mock.rs | 26 +- crates/idx-core/src/api/mod.rs | 18 ++ crates/idx-core/src/broker/mod.rs | 15 +- crates/idx-core/src/client.rs | 16 + crates/idx-core/src/flow/mod.rs | 291 ++++++++++++++++++ crates/idx-core/src/flow/types.rs | 117 +++++++ crates/idx-core/src/lib.rs | 1 + .../fixtures/idx_stocksummary_20261012.json | 1 + .../fixtures/idx_stocksummary_20261013.json | 1 + .../fixtures/idx_stocksummary_20261014.json | 1 + .../fixtures/idx_stocksummary_20261015.json | 1 + .../fixtures/idx_stocksummary_20261016.json | 1 + .../fixtures/idx_stocksummary_20261017.json | 1 + crates/idx-mcp/src/params.rs | 15 + crates/idx-mcp/src/server.rs | 27 +- crates/idx-mcp/tests/golden/tools.json | 43 +++ crates/idx-mcp/tests/mcp.rs | 16 +- docs/ARCHITECTURE.md | 19 ++ docs/SMOKE.md | 4 +- flake.nix | 2 +- scripts/live-smoke.sh | 16 + web/src/content/docs/cli/reference.md | 52 ++++ web/src/content/docs/cli/stocks.md | 22 +- web/src/content/docs/mcp/tools.md | 11 + web/src/content/docs/start/install.mdx | 5 +- 40 files changed, 1278 insertions(+), 29 deletions(-) create mode 100644 crates/idx-cli/tests/golden/stocks-flow-latest.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-flow-latest.table.golden create mode 100644 crates/idx-cli/tests/golden/stocks-flow.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-flow.jsonl.golden create mode 100644 crates/idx-cli/tests/golden/stocks-flow.table.golden create mode 100644 crates/idx-core/src/api/idx/mod.rs create mode 100644 crates/idx-core/src/api/idx/parse.rs create mode 100644 crates/idx-core/src/flow/mod.rs create mode 100644 crates/idx-core/src/flow/types.rs create mode 100644 crates/idx-core/tests/fixtures/idx_stocksummary_20261012.json create mode 100644 crates/idx-core/tests/fixtures/idx_stocksummary_20261013.json create mode 100644 crates/idx-core/tests/fixtures/idx_stocksummary_20261014.json create mode 100644 crates/idx-core/tests/fixtures/idx_stocksummary_20261015.json create mode 100644 crates/idx-core/tests/fixtures/idx_stocksummary_20261016.json create mode 100644 crates/idx-core/tests/fixtures/idx_stocksummary_20261017.json diff --git a/ROADMAP.md b/ROADMAP.md index 319318d..2194c72 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,6 +11,13 @@ history lives in git. - `screen presets` / `screen run `: built-in screener presets (graduate `screen` out of `stocks` only if these grow into a richer workflow) - Multi-period financial statements (today `financials` is single-period) +- Order book (bid/offer depth): no free, login-free source exists. IDX sells + delayed depth to vendors and Stockbit's needs a user login. Revisit if a + permitted source appears; do not ship the closing best bid/offer as one. +- Bandarmology follow-ups: a daily series (per-session readings and the + cumulative top-N net) and a price-move phase (quiet accumulation, markup), + calibration of the `top_concentration_v1` thresholds across more sessions, + and a market-wide foreign flow screen from one IDX trading summary - News beyond MSN in `news` / `get_company` (`docs/IDX_NEWS.md`): Google News RSS per ticker, and IDX disclosures and exchange news through a maintainer-published feed @@ -26,6 +33,11 @@ history lives in git. - Composite `analyze` command (quote, fundamentals, ownership, sentiment) ## Known limits +- `stocks broker` and `stocks bandar` show only IPOT's top 10 buyers and + sellers, ranked by gross lots, with figures of a million and more rounded + to one decimal. +- `stocks flow` depends on IDX's Cloudflare passing a curl-impersonate + profile (only `curl_safari184` in October 2026). - Yahoo can return `429` from datacenter IPs; requests retry with backoff but can still fail. - `stocks technical` reports the SMA200 trend as unavailable when fewer than diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index 8ab5885..046734a 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -5,8 +5,8 @@ use clap::{Args, Subcommand}; use crate::config::{HistoryProviderKind, IdxConfig}; use crate::output::{ render_bandar, render_broker_summary, render_compare, render_earnings, render_financials, - render_fundamental, render_growth, render_history, render_insights, render_news, - render_profile, render_quotes, render_risk, render_screener, render_sentiment, + render_foreign_flow, render_fundamental, render_growth, render_history, render_insights, + render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment, render_technical, render_valuation, }; use idx_core::analysis::BandarQuery; @@ -15,6 +15,7 @@ use idx_core::api::types::{Interval, Period}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use idx_core::error::IdxError; use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind}; +use idx_core::flow::types::FlowRange; use idx_core::market::ScreenQuery; use idx_core::{CachePolicy, Idx, runtime}; @@ -220,6 +221,27 @@ heuristic over end-of-day broker codes, not advice.", #[arg(long, default_value_t = DEFAULT_TOP)] top: u8, }, + #[command( + about = "Foreign buying and selling of a stock, session by session", + long_about = "Foreign buying and selling of a stock, session by session, from IDX's daily \ +trading summary: foreign buy, sell and net volume, the net value at the session's average \ +price, and the share of value traded outside the regular board. End of day. Each session is \ +one request to idx.co.id, which needs curl-impersonate.", + after_help = "Examples:\n idx stocks flow BBCA\n idx stocks flow BBCA --days 20\n idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16\n idx -o json stocks flow BBCA | jq '.foreign_net'" + )] + Flow { + /// Single ticker symbol (e.g. BBCA). + symbol: String, + /// Latest sessions to show (default 10, at most 60). + #[arg(long, conflicts_with_all = ["from", "to"])] + days: Option, + /// First session of a range, YYYY-MM-DD (at most 92 days). + #[arg(long)] + from: Option, + /// Last session of the range, YYYY-MM-DD. Default: today. + #[arg(long, requires = "from")] + to: Option, + }, #[command(about = "Get company profile")] Profile { symbol: String }, #[command( @@ -361,6 +383,17 @@ pub fn handle( let reports: Vec<_> = screen.reports.into_iter().map(|r| r.data).collect(); render_bandar(&reports, symbols.len() > 1, format, no_color) } + StocksSubcommand::Flow { + symbol, + days, + from, + to, + } => { + let range = + FlowRange::from_args(*days, from.as_deref(), to.as_deref(), idx.flow().today())?; + let flow = idx.flow().foreign(symbol, range, policy)?; + render_foreign_flow(&flow.data, format) + } StocksSubcommand::Profile { symbol } => { let profile = idx.financial().profile(symbol, policy)?; render_profile(&profile.data, format) diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index aeb1e54..3429e04 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -19,6 +19,7 @@ use idx_core::api::types::{ }; use idx_core::broker::types::BrokerSummary; use idx_core::error::IdxError; +use idx_core::flow::types::ForeignFlow; /// Output format requested by the user (flag, `IDX_OUTPUT`, or config file). #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)] @@ -100,6 +101,13 @@ pub fn render_bandar( } } +pub fn render_foreign_flow(flow: &ForeignFlow, format: &OutputFormat) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_foreign_flow(flow), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(flow), + } +} + pub fn render_history( symbol: &str, history: &[Ohlc], diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index f05e4a9..c79ec5f 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -16,6 +16,7 @@ use idx_core::api::types::{ }; use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary}; use idx_core::error::IdxError; +use idx_core::flow::types::ForeignFlow; pub fn format_idr(value: i64) -> String { let sign = if value.is_negative() { "-" } else { "" }; @@ -1186,6 +1187,79 @@ pub fn print_bandar_screen(reports: &[BandarReport], no_color: bool) -> Result<( Ok(()) } +fn format_signed_compact(value: Option) -> String { + value + .map(|value| { + let text = format_compact(value); + if value > 0 { format!("+{text}") } else { text } + }) + .unwrap_or_else(|| "-".to_string()) +} + +pub fn print_foreign_flow(flow: &ForeignFlow) -> Result<(), IdxError> { + let span = if flow.from == flow.to { + flow.from.to_string() + } else { + format!("{} to {}", flow.from, flow.to) + }; + let sessions = flow.days.len(); + println!( + "{}", + format!( + "Foreign flow for {}, {span} ({sessions} session{})", + terminal_safe(&flow.symbol), + if sessions == 1 { "" } else { "s" } + ) + .bold() + ); + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "DATE", + "CLOSE", + "CHG%", + "VALUE", + "FOREIGN BUY", + "FOREIGN SELL", + "FOREIGN NET", + "NET VALUE", + "NON-REG", + ]); + for day in &flow.days { + table.add_row(vec![ + cell(day.date), + cell(day.close.map(format_idr).unwrap_or_else(|| "-".to_string())), + cell(format_bps_pct(day.change_bps)), + cell(format_compact_option(day.value)), + cell(format_compact_option(day.foreign_buy)), + cell(format_compact_option(day.foreign_sell)), + cell(format_signed_compact(day.foreign_net)), + cell(format_signed_compact(day.foreign_net_value)), + cell(format_bps_share(day.non_regular_bps)), + ]); + } + println!("{table}"); + let mut summary = format!( + "Net foreign: {} shares, about {} IDR", + format_signed_compact(flow.foreign_net), + format_signed_compact(flow.foreign_net_value) + ); + if let Some(share) = flow.foreign_share_bps { + summary.push_str(&format!( + "; foreign investors made up {} of trading", + format_bps_share(Some(share)) + )); + } + println!("{summary}."); + println!( + "Volumes are shares. Net value is estimated at each session's average price; \ +NON-REG is the share of value traded outside the regular board (negotiated and cash)." + ); + Ok(()) +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 83fd2d7..7da8bb6 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2726,3 +2726,134 @@ fn bandar_errors_follow_the_output_contract() { run_error_json(&mut cmd, code); } } + +#[test] +fn foreign_flow_reads_sessions_oldest_first() { + let flow = run_success_json( + mock_bin("flow-json") + .env("IDX_MOCK_TODAY", "2026-10-18") + .args(["-o", "json", "stocks", "flow", "BBCA", "--days", "3"]), + ); + assert_eq!(flow["symbol"], "BBCA.JK"); + let dates: Vec<&str> = flow["days"] + .as_array() + .unwrap() + .iter() + .map(|day| day["date"].as_str().unwrap()) + .collect(); + assert_eq!(dates, ["2026-10-14", "2026-10-15", "2026-10-16"]); + let last = &flow["days"][2]; + assert_eq!(last["close"], 5175); + assert_eq!(last["change_bps"], -1481); + assert_eq!(last["foreign_net"], 309_392_800_i64 - 757_227_900_i64); + assert_eq!(flow["from"], "2026-10-14"); + + // A range skips the weekend and the Saturday without trading. + let range = run_success_json( + mock_bin("flow-range") + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", + "json", + "stocks", + "flow", + "BBCA", + "--from", + "2026-10-12", + "--to", + "2026-10-17", + ]), + ); + assert_eq!(range["days"].as_array().unwrap().len(), 5); + let net: i64 = range["days"] + .as_array() + .unwrap() + .iter() + .map(|day| day["foreign_net"].as_i64().unwrap()) + .sum(); + assert_eq!(range["foreign_net"], net); +} + +#[test] +fn foreign_flow_offline_uses_cached_sessions_only() { + let root = test_env_dir("flow-offline"); + run_success_json( + mock_bin_with_root(&root) + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", + "json", + "stocks", + "flow", + "BBCA", + "--from", + "2026-10-15", + "--to", + "2026-10-16", + ]), + ); + // Another stock's flow for the same sessions needs no new request. + let offline = run_success_json( + mock_bin_with_root(&root) + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", + "json", + "--offline", + "stocks", + "flow", + "TLKM", + "--days", + "5", + ]), + ); + assert_eq!(offline["from"], "2026-10-15"); + assert_eq!(offline["days"].as_array().unwrap().len(), 2); + run_error_json( + mock_bin_with_root(&root) + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", + "json", + "--offline", + "stocks", + "flow", + "TLKM", + "--from", + "2026-10-12", + "--to", + "2026-10-16", + ]), + "CACHEMISS", + ); +} + +#[test] +fn foreign_flow_errors_follow_the_output_contract() { + for (args, code) in [ + (vec!["ZZZZ", "--days", "2"], "SYMBOLNOTFOUND"), + (vec!["BBCA.US"], "UNSUPPORTED"), + (vec!["BBCA", "--days", "61"], "INVALIDINPUT"), + (vec!["BBCA", "--days", "0"], "INVALIDINPUT"), + ( + vec!["BBCA", "--days", "3", "--from", "2026-10-12"], + "INVALIDINPUT", + ), + ( + vec!["BBCA", "--from", "2026-06-01", "--to", "2026-10-16"], + "INVALIDINPUT", + ), + ] { + let mut cmd = mock_bin("flow-errors"); + cmd.env("IDX_MOCK_TODAY", "2026-10-18") + .args(["-o", "json", "stocks", "flow"]) + .args(&args); + run_error_json(&mut cmd, code); + } + let mut failing = mock_bin("flow-provider-error"); + failing + .env("IDX_MOCK_ERROR", "1") + .env("IDX_MOCK_TODAY", "2026-10-18") + .args(["-o", "json", "stocks", "flow", "BBCA"]); + run_error_json(&mut failing, "PROVIDERUNAVAILABLE"); +} diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 51cd001..2e83d91 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -244,7 +244,7 @@ name = "yahoo-fundamental" args = ["stocks", "fundamental", "BBCA"] env = { IDX_PROVIDER = "yahoo" } -# --- stocks: broker summaries (IPOT) ------------------------------------------- +# --- stocks: broker summaries (IPOT) and foreign flow (IDX) -------------------- [[case]] name = "stocks-broker" @@ -267,6 +267,16 @@ name = "stocks-bandar-screen" formats = ["table", "json", "jsonl"] args = ["stocks", "bandar", "BBCA,BBRI,TLKM", "--date", "2026-10-16", "--top", "3"] +[[case]] +name = "stocks-flow" +formats = ["table", "json", "jsonl"] +args = ["stocks", "flow", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"] + +[[case]] +name = "stocks-flow-latest" +args = ["stocks", "flow", "TLKM", "--days", "3"] +env = { IDX_MOCK_TODAY = "2026-10-18" } + [[case]] name = "error-broker-unknown-symbol" args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index 08222dd..f6630c2 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -189,6 +189,9 @@ _idx() { idx__help__stocks,financials) cmd="idx__help__stocks__financials" ;; + idx__help__stocks,flow) + cmd="idx__help__stocks__flow" + ;; idx__help__stocks,fundamental) cmd="idx__help__stocks__fundamental" ;; @@ -357,6 +360,9 @@ _idx() { idx__stocks,financials) cmd="idx__stocks__financials" ;; + idx__stocks,flow) + cmd="idx__stocks__flow" + ;; idx__stocks,fundamental) cmd="idx__stocks__fundamental" ;; @@ -411,6 +417,9 @@ _idx() { idx__stocks__help,financials) cmd="idx__stocks__help__financials" ;; + idx__stocks__help,flow) + cmd="idx__stocks__help__flow" + ;; idx__stocks__help,fundamental) cmd="idx__stocks__help__fundamental" ;; @@ -1209,7 +1218,7 @@ _idx() { return 0 ;; idx__help__stocks) - opts="quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare" + opts="quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1292,6 +1301,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stocks__flow) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks__fundamental) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2239,7 +2262,7 @@ _idx() { return 0 ;; idx__stocks) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare help" + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2418,6 +2441,40 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__flow) + opts="-o -q -v -h --days --from --to --output --no-color --quiet --verbose --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --days) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__fundamental) opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2463,7 +2520,7 @@ _idx() { return 0 ;; idx__stocks__help) - opts="quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare help" + opts="quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2546,6 +2603,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__flow) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__help__fundamental) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/crates/idx-cli/tests/golden/help-stocks.golden b/crates/idx-cli/tests/golden/help-stocks.golden index 7bf301e..368e4a9 100644 --- a/crates/idx-cli/tests/golden/help-stocks.golden +++ b/crates/idx-cli/tests/golden/help-stocks.golden @@ -14,6 +14,7 @@ Commands: fundamental Run full fundamental analysis on a stock broker Which brokers bought and sold a stock bandar Bandarmology: is buying more concentrated than selling + flow Foreign buying and selling of a stock, session by session profile Get company profile financials Get financial statements earnings Get earnings report diff --git a/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden b/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden new file mode 100644 index 0000000..850c9af --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"TLKM.JK","from":"2026-10-14","to":"2026-10-16","days":[{"date":"2026-10-14","close":2250,"change_bps":45,"volume":87159900,"value":197725738000,"foreign_buy":63030200,"foreign_sell":60811200,"foreign_net":2219000,"foreign_net_value":5033890729,"non_regular_bps":1085},{"date":"2026-10-15","close":2230,"change_bps":-89,"volume":150485300,"value":333287991000,"foreign_buy":97385100,"foreign_sell":105521800,"foreign_net":-8136700,"foreign_net_value":-18020792704,"non_regular_bps":78},{"date":"2026-10-16","close":1900,"change_bps":-1480,"volume":963614200,"value":1855341292500,"foreign_buy":679004400,"foreign_sell":727289800,"foreign_net":-48285400,"foreign_net_value":-92968634589,"non_regular_bps":1744}],"foreign_net":-54203100,"foreign_net_value":-105955536564,"foreign_share_bps":7213} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-latest.table.golden b/crates/idx-cli/tests/golden/stocks-flow-latest.table.golden new file mode 100644 index 0000000..d931858 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-latest.table.golden @@ -0,0 +1,15 @@ +exit: 0 +--- stdout +Foreign flow for TLKM.JK, 2026-10-14 to 2026-10-16 (3 sessions) +┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐ +│ DATE ┆ CLOSE ┆ CHG% ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ NET VALUE ┆ NON-REG │ +╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡ +│ 2026-10-14 ┆ 2,250 ┆ +0.5% ┆ 197.7B ┆ 63.0M ┆ 60.8M ┆ +2.2M ┆ +5.0B ┆ 10.8% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ 2,230 ┆ -0.9% ┆ 333.3B ┆ 97.4M ┆ 105.5M ┆ -8.1M ┆ -18.0B ┆ 0.8% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-16 ┆ 1,900 ┆ -14.8% ┆ 1.9T ┆ 679.0M ┆ 727.3M ┆ -48.3M ┆ -93.0B ┆ 17.4% │ +└────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘ +Net foreign: -54.2M shares, about -106.0B IDR; foreign investors made up 72.1% of trading. +Volumes are shares. Net value is estimated at each session's average price; NON-REG is the share of value traded outside the regular board (negotiated and cash). +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.json.golden b/crates/idx-cli/tests/golden/stocks-flow.json.golden new file mode 100644 index 0000000..7714bb2 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_net_value":-56178999154,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_net_value":29800279088,"non_regular_bps":162},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_net_value":216356236186,"non_regular_bps":3178},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_net_value":286060704877,"non_regular_bps":1168},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_net_value":-2336400823441,"non_regular_bps":155}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden b/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden new file mode 100644 index 0000000..7714bb2 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_net_value":-56178999154,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_net_value":29800279088,"non_regular_bps":162},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_net_value":216356236186,"non_regular_bps":3178},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_net_value":286060704877,"non_regular_bps":1168},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_net_value":-2336400823441,"non_regular_bps":155}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.table.golden b/crates/idx-cli/tests/golden/stocks-flow.table.golden new file mode 100644 index 0000000..55bed56 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow.table.golden @@ -0,0 +1,19 @@ +exit: 0 +--- stdout +Foreign flow for BBCA.JK, 2026-10-12 to 2026-10-16 (5 sessions) +┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐ +│ DATE ┆ CLOSE ┆ CHG% ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ NET VALUE ┆ NON-REG │ +╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡ +│ 2026-10-12 ┆ 5,950 ┆ -1.6% ┆ 586.5B ┆ 51.5M ┆ 60.9M ┆ -9.4M ┆ -56.2B ┆ 0.1% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-13 ┆ 5,950 ┆ +0.0% ┆ 726.6B ┆ 88.8M ┆ 83.7M ┆ +5.0M ┆ +29.8B ┆ 1.6% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-14 ┆ 6,125 ┆ +2.9% ┆ 828.9B ┆ 119.9M ┆ 84.3M ┆ +35.6M ┆ +216.4B ┆ 31.8% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ 6,075 ┆ -0.8% ┆ 816.6B ┆ 120.8M ┆ 73.6M ┆ +47.2M ┆ +286.1B ┆ 11.7% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-16 ┆ 5,175 ┆ -14.8% ┆ 6.5T ┆ 309.4M ┆ 757.2M ┆ -447.8M ┆ -2.3T ┆ 1.6% │ +└────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘ +Net foreign: -369.4M shares, about -1.9T IDR; foreign investors made up 50.5% of trading. +Volumes are shares. Net value is estimated at each session's average price; NON-REG is the share of value traded outside the regular board (negotiated and cash). +--- stderr diff --git a/crates/idx-core/src/api/idx/mod.rs b/crates/idx-core/src/api/idx/mod.rs new file mode 100644 index 0000000..4ad369d --- /dev/null +++ b/crates/idx-core/src/api/idx/mod.rs @@ -0,0 +1,73 @@ +//! IDX's own site: the daily trading summary of every listed stock. +//! +//! `GET https://www.idx.co.id/primary/TradingSummary/GetStockSummary` +//! `?length=9999&start=0&date=YYYYMMDD` answers JSON with one row per stock: +//! prices, volume and value, foreign buy and sell volume, and non-regular +//! (negotiated and cash) volume and value. A day without trading has no +//! rows. Cloudflare in front of the site rejects some TLS fingerprints, so +//! the request goes through curl-impersonate's profile fallback. + +mod parse; + +use chrono::NaiveDate; +pub use parse::parse_stock_summary; + +use super::TradingSummaryProvider; +use crate::curl_impersonate; +use crate::error::IdxError; +use crate::flow::types::StockDay; + +const STOCK_SUMMARY_URL: &str = "https://www.idx.co.id/primary/TradingSummary/GetStockSummary"; +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15"; +/// One session's summary is about 650 KB; it must not hold a command or an +/// MCP call for long. +const MAX_TIME_SECS: &str = "30"; + +pub fn stock_summary_url(date: NaiveDate) -> String { + format!( + "{STOCK_SUMMARY_URL}?length=9999&start=0&date={}", + date.format("%Y%m%d") + ) +} + +/// A Cloudflare challenge page is not the JSON asked for: try the next +/// profile. +fn accept_json(body: &[u8]) -> Result<(), IdxError> { + match body.iter().find(|byte| !byte.is_ascii_whitespace()) { + Some(b'{') => Ok(()), + _ => Err(IdxError::Http( + "IDX stock summary: answered a challenge page instead of JSON (HTTP 403)".to_string(), + )), + } +} + +pub struct IdxSiteProvider; + +impl TradingSummaryProvider for IdxSiteProvider { + fn stock_summary(&self, date: NaiveDate) -> Result, IdxError> { + let args: Vec = [ + "--silent", + "--show-error", + "--location", + "--fail", + "--compressed", + "--max-time", + MAX_TIME_SECS, + "--header", + &format!("User-Agent: {USER_AGENT}"), + "--header", + "Accept: application/json,text/plain,*/*", + "--header", + "Referer: https://www.idx.co.id/id/data-pasar/ringkasan-perdagangan/ringkasan-saham/", + &stock_summary_url(date), + ] + .iter() + .map(|arg| arg.to_string()) + .collect(); + let output = + curl_impersonate::run_owned_with_fallback("IDX stock summary", &args, &accept_json)?; + let body = String::from_utf8(output.stdout) + .map_err(|e| IdxError::Http(format!("IDX stock summary: not UTF-8: {e}")))?; + parse_stock_summary(&body, date) + } +} diff --git a/crates/idx-core/src/api/idx/parse.rs b/crates/idx-core/src/api/idx/parse.rs new file mode 100644 index 0000000..cfcd6e4 --- /dev/null +++ b/crates/idx-core/src/api/idx/parse.rs @@ -0,0 +1,114 @@ +//! Pure parser for IDX's `GetStockSummary` JSON, tested against +//! `tests/fixtures/idx_stocksummary_*.json`. + +use chrono::NaiveDate; +use serde::Deserialize; + +use crate::error::IdxError; +use crate::flow::types::StockDay; + +#[derive(Deserialize)] +struct StockSummaryResponse { + data: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct StockSummaryRawRow { + date: String, + stock_code: String, + previous: Option, + high: Option, + low: Option, + close: Option, + volume: Option, + value: Option, + frequency: Option, + foreign_buy: Option, + foreign_sell: Option, + non_regular_volume: Option, + non_regular_value: Option, +} + +fn whole(value: Option) -> Option { + value + .filter(|value| value.is_finite()) + .map(|value| value.round() as i64) +} + +/// The rows of one session. A day without trading (weekend, holiday) is +/// empty; rows dated other than `date` are dropped, so another session's +/// figures never pass as this one's. +pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result, IdxError> { + let response: StockSummaryResponse = serde_json::from_str(raw) + .map_err(|e| IdxError::ParseError(format!("idx stock summary: {e}")))?; + Ok(response + .data + .into_iter() + .filter(|row| { + row.date + .get(..10) + .and_then(|day| NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()) + == Some(date) + }) + .map(|row| StockDay { + code: row.stock_code.trim().to_ascii_uppercase(), + date, + previous: whole(row.previous), + high: whole(row.high), + low: whole(row.low), + close: whole(row.close), + volume: whole(row.volume), + value: whole(row.value), + frequency: whole(row.frequency), + foreign_buy: whole(row.foreign_buy), + foreign_sell: whole(row.foreign_sell), + non_regular_volume: whole(row.non_regular_volume), + non_regular_value: whole(row.non_regular_value), + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: &str = include_str!("../../../tests/fixtures/idx_stocksummary_20261016.json"); + const SATURDAY: &str = include_str!("../../../tests/fixtures/idx_stocksummary_20261017.json"); + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + #[test] + fn test_parse_stock_summary_reads_foreign_and_non_regular_figures() { + let rows = parse_stock_summary(DAY, date(2026, 10, 16)).unwrap(); + let bbca = rows.iter().find(|row| row.code == "BBCA").unwrap(); + assert_eq!((bbca.previous, bbca.close), (Some(6075), Some(5175))); + assert_eq!(bbca.volume, Some(1_241_098_200)); + assert_eq!(bbca.value, Some(6_474_934_315_000)); + assert_eq!( + (bbca.foreign_buy, bbca.foreign_sell), + (Some(309_392_800), Some(757_227_900)) + ); + assert_eq!(bbca.non_regular_value, Some(100_183_151_475)); + } + + #[test] + fn test_parse_stock_summary_keeps_only_the_requested_session() { + assert!( + parse_stock_summary(SATURDAY, date(2026, 10, 17)) + .unwrap() + .is_empty() + ); + assert!( + parse_stock_summary(DAY, date(2026, 10, 15)) + .unwrap() + .is_empty() + ); + assert!(matches!( + parse_stock_summary("Just a moment...", date(2026, 10, 16)), + Err(IdxError::ParseError(_)) + )); + } +} diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index 2292039..64c8694 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -3,7 +3,8 @@ use super::types::{Bar, Fundamentals, Interval, Period, Quote}; use super::{ - BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider, ipot, msn, yahoo, + BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider, + TradingSummaryProvider, idx, ipot, msn, yahoo, }; use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; use crate::config::ProviderKind; @@ -164,3 +165,26 @@ impl BrokerSummaryProvider for MockBrokerProvider { ipot::summary_from_html(code, &html) } } + +/// Trading summaries from `tests/fixtures/idx_stocksummary_.json`; a +/// date without a fixture had no trading. +pub struct MockTradingSummaryProvider; + +impl TradingSummaryProvider for MockTradingSummaryProvider { + fn stock_summary( + &self, + date: chrono::NaiveDate, + ) -> Result, IdxError> { + if std::env::var("IDX_MOCK_ERROR").is_ok() { + return Err(IdxError::ProviderUnavailable); + } + let path = format!( + "tests/fixtures/idx_stocksummary_{}.json", + date.format("%Y%m%d") + ); + match std::fs::read_to_string(path) { + Ok(raw) => idx::parse_stock_summary(&raw, date), + Err(_) => Ok(Vec::new()), + } + } +} diff --git a/crates/idx-core/src/api/mod.rs b/crates/idx-core/src/api/mod.rs index 7b7d459..3d3a8fe 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -1,3 +1,4 @@ +pub mod idx; pub mod ipot; #[cfg(feature = "testing")] pub mod mock; @@ -54,6 +55,15 @@ pub trait BrokerSummaryProvider { ) -> Result; } +/// Every stock's trading summary for one session (IDX's own site). +pub trait TradingSummaryProvider { + /// Empty for a day without trading. + fn stock_summary( + &self, + date: chrono::NaiveDate, + ) -> Result, IdxError>; +} + pub trait FundamentalsProvider { fn fundamentals(&self, symbol: &str) -> Result; } @@ -279,6 +289,14 @@ pub fn broker_provider() -> Box { Box::new(ipot::IpotProvider::new()) } +pub fn trading_summary_provider() -> Box { + #[cfg(feature = "testing")] + if mock::enabled() { + return Box::new(mock::MockTradingSummaryProvider); + } + Box::new(idx::IdxSiteProvider) +} + /// Today in WIB, the date IDX trades on. pub(crate) fn today_wib() -> chrono::NaiveDate { #[cfg(feature = "testing")] diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs index 19015c1..07aae3d 100644 --- a/crates/idx-core/src/broker/mod.rs +++ b/crates/idx-core/src/broker/mod.rs @@ -84,7 +84,7 @@ impl Broker<'_> { query: &BrokerQuery, policy: CachePolicy, ) -> Result, IdxError> { - let (resolved, code) = self.idx_code(symbol)?; + let (resolved, code) = self.idx.idx_code(symbol, "a broker summary")?; let today = today_wib(); check_range(query, today)?; let key = format!( @@ -123,7 +123,7 @@ impl Broker<'_> { board: BrokerBoard, policy: CachePolicy, ) -> Result, IdxError> { - let (resolved, _) = self.idx_code(symbol)?; + let (resolved, _) = self.idx.idx_code(symbol, "a broker summary")?; let today = today_wib(); for back in 0..LATEST_LOOKBACK_DAYS { let day = today - Duration::days(back); @@ -153,17 +153,6 @@ impl Broker<'_> { "{resolved}: no trades in the last {LATEST_LOOKBACK_DAYS} days" ))) } - - /// The resolved symbol and the bare IDX code IPOT takes. - fn idx_code(&self, symbol: &str) -> Result<(String, String), IdxError> { - let resolved = self.idx.resolve(symbol)?; - match resolved.strip_suffix(".JK") { - Some(code) if !code.is_empty() => Ok((resolved.clone(), code.to_string())), - _ => Err(IdxError::Unsupported(format!( - "{resolved}: broker summaries cover IDX (.JK) stocks only" - ))), - } - } } /// The public summary of one parsed page. With one investor side selected, diff --git a/crates/idx-core/src/client.rs b/crates/idx-core/src/client.rs index f9db17c..140ce13 100644 --- a/crates/idx-core/src/client.rs +++ b/crates/idx-core/src/client.rs @@ -90,6 +90,10 @@ impl Idx { crate::broker::Broker { idx: self } } + pub fn flow(&self) -> crate::flow::Flow<'_> { + crate::flow::Flow { idx: self } + } + pub fn financial(&self) -> crate::financial::Financial<'_> { crate::financial::Financial { idx: self } } @@ -107,6 +111,18 @@ impl Idx { crate::api::resolve_symbol(symbol, &self.config.exchange) } + /// The resolved symbol and its bare IDX code, for sources that cover + /// IDX (`.JK`) stocks only; `what` names the data in the error. + pub(crate) fn idx_code(&self, symbol: &str, what: &str) -> Result<(String, String), IdxError> { + let resolved = self.resolve(symbol)?; + match resolved.strip_suffix(".JK") { + Some(code) if !code.is_empty() => Ok((resolved.clone(), code.to_string())), + _ => Err(IdxError::Unsupported(format!( + "{resolved}: {what} covers IDX (.JK) stocks only" + ))), + } + } + pub(crate) fn bucket(&self, key: &str) -> String { format!("{}-{key}", self.provider.kind().as_str()) } diff --git a/crates/idx-core/src/flow/mod.rs b/crates/idx-core/src/flow/mod.rs new file mode 100644 index 0000000..dfb9dc5 --- /dev/null +++ b/crates/idx-core/src/flow/mod.rs @@ -0,0 +1,291 @@ +//! Foreign flow: what foreign investors bought and sold of a stock, session +//! by session, from IDX's daily trading summary (end of day). One request +//! covers every stock for one session and is cached by date. + +pub mod types; + +use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday}; + +use crate::api::{today_wib, trading_summary_provider}; +use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::error::IdxError; +use types::{FlowRange, ForeignFlow, ForeignFlowDay, StockDay}; + +const SOURCE: &str = "idx"; +/// Most sessions `FlowRange::Latest` reads, one request each. +pub const MAX_SESSIONS: usize = 60; +/// Longest `FlowRange::Dates` range, calendar days. +pub const MAX_RANGE_DAYS: i64 = 92; +/// A past session no longer changes. +const TTL_CLOSED_SECS: u64 = 30 * 24 * 3600; +const TTL_TODAY_SECS: u64 = 5 * 60; + +pub struct Flow<'a> { + pub(crate) idx: &'a Idx, +} + +impl Flow<'_> { + /// Today in WIB. + pub fn today(&self) -> NaiveDate { + today_wib() + } + + /// Foreign buying and selling of `symbol` over `range`. + pub fn foreign( + &self, + symbol: &str, + range: FlowRange, + policy: CachePolicy, + ) -> Result, IdxError> { + let (resolved, code) = self.idx.idx_code(symbol, "foreign flow")?; + let today = today_wib(); + let (dates, wanted, skip_misses) = match range { + FlowRange::Latest { sessions } => { + if !(1..=MAX_SESSIONS).contains(&sessions) { + return Err(IdxError::InvalidInput(format!( + "days must be 1 to {MAX_SESSIONS} (got {sessions})" + ))); + } + let lookback = 2 * sessions as i64 + 10; + let dates: Vec = (0..lookback) + .map(|back| today - Duration::days(back)) + .collect(); + // Offline, an uncached day says nothing; look further back. + (dates, sessions, policy == CachePolicy::Offline) + } + FlowRange::Dates { from, to } => { + check_range(from, to, today)?; + let dates = (0..=(to - from).num_days()) + .rev() + .map(|offset| from + Duration::days(offset)) + .collect(); + (dates, usize::MAX, false) + } + }; + + let mut rows = Vec::new(); + let mut sessions = 0; + let mut fetched_at: Option> = None; + let mut stale = false; + for date in dates { + if sessions == wanted { + break; + } + if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) { + continue; + } + let day = match self.session(date, today, policy) { + Ok(day) => day, + Err(IdxError::CacheMiss(_)) if skip_misses => continue, + Err(err) => return Err(err), + }; + if day.data.is_empty() { + continue; + } + sessions += 1; + fetched_at = Some(fetched_at.map_or(day.fetched_at, |at| at.min(day.fetched_at))); + stale |= day.stale; + // A stock missing from a session did not trade (suspension). + if let Some(row) = day.data.into_iter().find(|row| row.code == code) { + rows.push(row); + } + } + if sessions == 0 { + return Err(if policy == CachePolicy::Offline { + IdxError::CacheMiss(format!("idx-stocksummary/{resolved} (no cached session)")) + } else { + IdxError::NoMarketData(format!("{resolved}: no IDX sessions in range")) + }); + } + rows.reverse(); + let flow = foreign_flow(&resolved, &rows).ok_or(IdxError::SymbolNotFound(code))?; + Ok(Fetched { + data: flow, + source: SOURCE, + fetched_at: fetched_at.unwrap_or_else(Utc::now), + stale, + }) + } + + /// Every stock's summary for one session; empty without trading. + fn session( + &self, + date: NaiveDate, + today: NaiveDate, + policy: CachePolicy, + ) -> Result>, IdxError> { + let provider = trading_summary_provider(); + let key = date.to_string(); + cached_fetch( + &self.idx.cache, + CacheSpec { + bucket: "idx-stocksummary", + key: &key, + ttl_secs: if date < today { + TTL_CLOSED_SECS + } else { + TTL_TODAY_SECS + }, + subject: &key, + source: SOURCE, + }, + policy, + || provider.stock_summary(date), + Ok, + ) + } +} + +fn check_range(from: NaiveDate, to: NaiveDate, today: NaiveDate) -> Result<(), IdxError> { + if from > to { + return Err(IdxError::InvalidInput(format!( + "--from {from} is after --to {to}" + ))); + } + if to > today { + return Err(IdxError::InvalidInput(format!( + "{to} is in the future (today is {today} in WIB)" + ))); + } + if (to - from).num_days() >= MAX_RANGE_DAYS { + return Err(IdxError::InvalidInput(format!( + "a foreign flow covers at most {MAX_RANGE_DAYS} days" + ))); + } + Ok(()) +} + +/// `part` in basis points of `whole`, rounded; `None` without a positive whole. +fn bps(part: i64, whole: i64) -> Option { + (whole > 0).then(|| (part as f64 * 10_000.0 / whole as f64).round() as i64) +} + +fn flow_day(row: &StockDay) -> ForeignFlowDay { + let foreign_net = row + .foreign_buy + .zip(row.foreign_sell) + .map(|(buy, sell)| buy - sell); + let foreign_net_value = match (foreign_net, row.value, row.volume) { + (Some(net), Some(value), Some(volume)) if volume > 0 => { + i64::try_from(i128::from(net) * i128::from(value) / i128::from(volume)).ok() + } + _ => None, + }; + ForeignFlowDay { + date: row.date, + close: row.close, + change_bps: row + .close + .zip(row.previous) + .and_then(|(close, previous)| bps(close - previous, previous)), + volume: row.volume, + value: row.value, + foreign_buy: row.foreign_buy, + foreign_sell: row.foreign_sell, + foreign_net, + foreign_net_value, + non_regular_bps: row + .non_regular_value + .zip(row.value) + .and_then(|(part, whole)| bps(part, whole)), + } +} + +fn sum(days: &[ForeignFlowDay], field: fn(&ForeignFlowDay) -> Option) -> Option { + days.iter() + .try_fold(0_i64, |total, day| total.checked_add(field(day)?)) +} + +/// The flow over `rows` (one stock, oldest first); `None` without rows. +pub fn foreign_flow(symbol: &str, rows: &[StockDay]) -> Option { + let (first, last) = (rows.first()?, rows.last()?); + let days: Vec = rows.iter().map(flow_day).collect(); + let foreign_share_bps = match ( + sum(&days, |day| day.foreign_buy), + sum(&days, |day| day.foreign_sell), + sum(&days, |day| day.volume), + ) { + (Some(buy), Some(sell), Some(volume)) => bps(buy + sell, 2 * volume), + _ => None, + }; + Some(ForeignFlow { + symbol: symbol.to_string(), + from: first.date, + to: last.date, + foreign_net: sum(&days, |day| day.foreign_net), + foreign_net_value: sum(&days, |day| day.foreign_net_value), + foreign_share_bps, + days, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(day: u32, close: i64, buy: i64, sell: Option) -> StockDay { + StockDay { + code: "TEST".to_string(), + date: NaiveDate::from_ymd_opt(2026, 10, day).unwrap(), + previous: Some(1000), + high: None, + low: None, + close: Some(close), + volume: Some(1_000), + value: Some(1_100_000), + frequency: None, + foreign_buy: Some(buy), + foreign_sell: sell, + non_regular_volume: None, + non_regular_value: Some(110_000), + } + } + + #[test] + fn test_foreign_flow_sums_sessions_and_estimates_value() { + let flow = foreign_flow( + "TEST.JK", + &[row(15, 1100, 600, Some(200)), row(16, 950, 100, Some(300))], + ) + .unwrap(); + let first = &flow.days[0]; + assert_eq!( + (first.foreign_net, first.change_bps), + (Some(400), Some(1000)) + ); + // 400 shares at 1,100 (value over volume). + assert_eq!(first.foreign_net_value, Some(440_000)); + assert_eq!(first.non_regular_bps, Some(1000)); + assert_eq!(flow.days[1].change_bps, Some(-500)); + assert_eq!( + (flow.foreign_net, flow.foreign_net_value), + (Some(200), Some(220_000)) + ); + // (700 + 500) / (2 * 2,000). + assert_eq!(flow.foreign_share_bps, Some(3000)); + assert_eq!((flow.from.day(), flow.to.day()), (15, 16)); + } + + #[test] + fn test_foreign_flow_never_counts_a_missing_figure_as_zero() { + let flow = foreign_flow( + "TEST.JK", + &[row(15, 1100, 600, Some(200)), row(16, 950, 100, None)], + ) + .unwrap(); + assert_eq!(flow.days[1].foreign_net, None); + assert_eq!((flow.foreign_net, flow.foreign_share_bps), (None, None)); + assert!(foreign_flow("TEST.JK", &[]).is_none()); + } + + #[test] + fn test_check_range_bounds() { + let day = |d| NaiveDate::from_ymd_opt(2026, 10, d).unwrap(); + let today = day(18); + assert!(check_range(day(1), day(16), today).is_ok()); + assert!(check_range(day(16), day(12), today).is_err()); + assert!(check_range(day(16), day(19), today).is_err()); + let long_ago = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(); + assert!(check_range(long_ago, day(16), today).is_err()); + } +} diff --git a/crates/idx-core/src/flow/types.rs b/crates/idx-core/src/flow/types.rs new file mode 100644 index 0000000..376d0dc --- /dev/null +++ b/crates/idx-core/src/flow/types.rs @@ -0,0 +1,117 @@ +//! Foreign flow types: what foreign investors bought and sold of a stock, +//! session by session (IDX trading summary, end of day). + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +use crate::error::IdxError; + +/// Sessions shown when a frontend gives neither days nor dates. +pub const DEFAULT_SESSIONS: usize = 10; + +/// One stock's trading summary for one session, as IDX publishes it. +/// Prices are whole IDR, volumes are shares. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StockDay { + /// Bare IDX code, e.g. `BBCA`. + pub code: String, + pub date: NaiveDate, + pub previous: Option, + pub high: Option, + pub low: Option, + pub close: Option, + pub volume: Option, + /// Value traded, whole IDR, all boards. + pub value: Option, + pub frequency: Option, + /// Shares bought by foreign investors. + pub foreign_buy: Option, + /// Shares sold by foreign investors. + pub foreign_sell: Option, + /// Shares traded outside the regular board (negotiated and cash). + pub non_regular_volume: Option, + /// Value traded outside the regular board, whole IDR. + pub non_regular_value: Option, +} + +/// Which sessions a foreign flow covers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlowRange { + /// The latest `sessions` sessions with trades. + Latest { sessions: usize }, + /// Every session in `from..=to` (WIB dates). + Dates { from: NaiveDate, to: NaiveDate }, +} + +impl FlowRange { + /// From a frontend's `days`, or `from` with an optional `to` (default + /// today) as `YYYY-MM-DD`; neither means the latest + /// [`DEFAULT_SESSIONS`]. + pub fn from_args( + days: Option, + from: Option<&str>, + to: Option<&str>, + today: NaiveDate, + ) -> Result { + let parse = |name: &str, value: &str| { + NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| { + IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD")) + }) + }; + match (days, from, to) { + (days, None, None) => Ok(Self::Latest { + sessions: days.unwrap_or(DEFAULT_SESSIONS), + }), + (None, Some(from), to) => Ok(Self::Dates { + from: parse("from", from)?, + to: to.map(|to| parse("to", to)).transpose()?.unwrap_or(today), + }), + (None, None, Some(_)) => Err(IdxError::InvalidInput("to needs from".to_string())), + (Some(_), _, _) => Err(IdxError::InvalidInput( + "days cannot be combined with from or to".to_string(), + )), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForeignFlowDay { + pub date: NaiveDate, + pub close: Option, + /// Close against the previous close, basis points. + pub change_bps: Option, + /// Shares traded. + pub volume: Option, + /// Value traded, whole IDR. + pub value: Option, + /// Shares foreign investors bought. + pub foreign_buy: Option, + /// Shares foreign investors sold. + pub foreign_sell: Option, + /// `foreign_buy - foreign_sell`, shares. + pub foreign_net: Option, + /// `foreign_net` at the session's average price (value over volume), + /// whole IDR: an estimate. + pub foreign_net_value: Option, + /// Value traded outside the regular board (negotiated and cash), basis + /// points of the session's value. + pub non_regular_bps: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForeignFlow { + /// Resolved ticker, e.g. `BBCA.JK`. + pub symbol: String, + /// First and last session covered. + pub from: NaiveDate, + pub to: NaiveDate, + /// Sessions the stock traded, oldest first. + pub days: Vec, + /// Net shares over the sessions; `None` when a session lacks a figure. + pub foreign_net: Option, + /// Sum of the sessions' estimated net values, whole IDR. + pub foreign_net_value: Option, + /// Foreign buys and sells over twice the volume: foreign investors' + /// share of trading, basis points. + pub foreign_share_bps: Option, +} diff --git a/crates/idx-core/src/lib.rs b/crates/idx-core/src/lib.rs index 559c5e6..bd01c1c 100644 --- a/crates/idx-core/src/lib.rs +++ b/crates/idx-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod config; pub mod curl_impersonate; pub mod error; pub mod financial; +pub mod flow; #[cfg(feature = "ownership")] pub mod investor; pub mod market; diff --git a/crates/idx-core/tests/fixtures/idx_stocksummary_20261012.json b/crates/idx-core/tests/fixtures/idx_stocksummary_20261012.json new file mode 100644 index 0000000..8cbbd33 --- /dev/null +++ b/crates/idx-core/tests/fixtures/idx_stocksummary_20261012.json @@ -0,0 +1 @@ +{"draw":0,"recordsTotal":4,"recordsFiltered":4,"data":[{"No":98,"IDStockSummary":4111663,"Date":"2026-10-12T00:00:00","StockCode":"BBCA","StockName":"Bank Central Asia Tbk.","Remarks":"--SO1UQNCNU100G111------------","Previous":6050.0,"OpenPrice":6050.0,"FirstTrade":6050.0,"High":6100.0,"Low":5950.0,"Close":5950.0,"Change":-100.0,"Volume":97965100.0,"Value":586491892500.0,"Frequency":25910.0,"IndexIndividual":16988.4,"Offer":5950.0,"OfferVolume":2201900.0,"Bid":5925.0,"BidVolume":3571200.0,"ListedShares":122042299500.0,"TradebleShares":122042299500.0,"WeightForIndex":39458951953.0,"ForeignSell":60883100.0,"ForeignBuy":51499200.0,"DelistingDate":"","NonRegularVolume":110246.0,"NonRegularValue":798569400.0,"NonRegularFrequency":10.0,"persen":null,"percentage":null},{"No":104,"IDStockSummary":4111669,"Date":"2026-10-12T00:00:00","StockCode":"BBRI","StockName":"Bank Rakyat Indonesia (Persero) Tbk.","Remarks":"--MO1UQSPNU100G111------------","Previous":3100.0,"OpenPrice":3100.0,"FirstTrade":3100.0,"High":3140.0,"Low":3070.0,"Close":3110.0,"Change":10.0,"Volume":129810600.0,"Value":402372587000.0,"Frequency":22542.0,"IndexIndividual":3647.6,"Offer":3110.0,"OfferVolume":668400.0,"Bid":3100.0,"BidVolume":247900.0,"ListedShares":150043411587.0,"TradebleShares":150043411587.0,"WeightForIndex":63813462948.0,"ForeignSell":55331400.0,"ForeignBuy":81277900.0,"DelistingDate":"","NonRegularVolume":29917536.0,"NonRegularValue":92714443560.0,"NonRegularFrequency":6.0,"persen":null,"percentage":null},{"No":350,"IDStockSummary":4111915,"Date":"2026-10-12T00:00:00","StockCode":"GOTO","StockName":"GoTo Gojek Tokopedia Tbk.","Remarks":"--UO2H40544000I111----------N-","Previous":30.0,"OpenPrice":29.0,"FirstTrade":29.0,"High":30.0,"Low":26.0,"Close":27.0,"Change":-3.0,"Volume":28057826200.0,"Value":773057901000.0,"Frequency":49329.0,"IndexIndividual":8.0,"Offer":27.0,"OfferVolume":1907171500.0,"Bid":26.0,"BidVolume":4019082600.0,"ListedShares":1140573267220.0,"TradebleShares":1140573267220.0,"WeightForIndex":799998089628.0,"ForeignSell":5861571800.0,"ForeignBuy":10748736800.0,"DelistingDate":"","NonRegularVolume":700900700.0,"NonRegularValue":20301801400.0,"NonRegularFrequency":2.0,"persen":null,"percentage":null},{"No":877,"IDStockSummary":4112442,"Date":"2026-10-12T00:00:00","StockCode":"TLKM","StockName":"Telkom Indonesia (Persero) Tbk.","Remarks":"--MO1XDSWW0000J312------------","Previous":2320.0,"OpenPrice":2330.0,"FirstTrade":2330.0,"High":2340.0,"Low":2260.0,"Close":2280.0,"Change":-40.0,"Volume":149200000.0,"Value":342019043000.0,"Frequency":14057.0,"IndexIndividual":1198.2,"Offer":2290.0,"OfferVolume":973500.0,"Bid":2280.0,"BidVolume":194400.0,"ListedShares":99062216600.0,"TradebleShares":99062216600.0,"WeightForIndex":39902260846.0,"ForeignSell":118523800.0,"ForeignBuy":106560700.0,"DelistingDate":"","NonRegularVolume":15000273.0,"NonRegularValue":34245625670.0,"NonRegularFrequency":5.0,"persen":null,"percentage":null}]} diff --git a/crates/idx-core/tests/fixtures/idx_stocksummary_20261013.json b/crates/idx-core/tests/fixtures/idx_stocksummary_20261013.json new file mode 100644 index 0000000..4723c93 --- /dev/null +++ b/crates/idx-core/tests/fixtures/idx_stocksummary_20261013.json @@ -0,0 +1 @@ +{"draw":0,"recordsTotal":4,"recordsFiltered":4,"data":[{"No":98,"IDStockSummary":4112626,"Date":"2026-10-13T00:00:00","StockCode":"BBCA","StockName":"Bank Central Asia Tbk.","Remarks":"--SO1UQNCNU100G111------------","Previous":5950.0,"OpenPrice":5925.0,"FirstTrade":5950.0,"High":5950.0,"Low":5850.0,"Close":5950.0,"Change":0.0,"Volume":122716900.0,"Value":726575115000.0,"Frequency":23340.0,"IndexIndividual":16988.4,"Offer":5950.0,"OfferVolume":994700.0,"Bid":5925.0,"BidVolume":177900.0,"ListedShares":122042299500.0,"TradebleShares":122042299500.0,"WeightForIndex":39458951953.0,"ForeignSell":83738900.0,"ForeignBuy":88772100.0,"DelistingDate":"","NonRegularVolume":1988532.0,"NonRegularValue":11743985775.0,"NonRegularFrequency":5.0,"persen":null,"percentage":null},{"No":104,"IDStockSummary":4112632,"Date":"2026-10-13T00:00:00","StockCode":"BBRI","StockName":"Bank Rakyat Indonesia (Persero) Tbk.","Remarks":"--MO1UQSPNU100G111------------","Previous":3110.0,"OpenPrice":3100.0,"FirstTrade":3090.0,"High":3100.0,"Low":3030.0,"Close":3080.0,"Change":-30.0,"Volume":102010600.0,"Value":312155170000.0,"Frequency":22096.0,"IndexIndividual":3612.4,"Offer":3080.0,"OfferVolume":2006200.0,"Bid":3070.0,"BidVolume":1886500.0,"ListedShares":150043411587.0,"TradebleShares":150043411587.0,"WeightForIndex":63813462948.0,"ForeignSell":58087200.0,"ForeignBuy":36737800.0,"DelistingDate":"","NonRegularVolume":38643164.0,"NonRegularValue":118209441300.0,"NonRegularFrequency":4.0,"persen":null,"percentage":null},{"No":350,"IDStockSummary":4112878,"Date":"2026-10-13T00:00:00","StockCode":"GOTO","StockName":"GoTo Gojek Tokopedia Tbk.","Remarks":"--UO2H40544000I111----------N-","Previous":27.0,"OpenPrice":27.0,"FirstTrade":26.0,"High":27.0,"Low":25.0,"Close":27.0,"Change":0.0,"Volume":11323075500.0,"Value":298601974900.0,"Frequency":21340.0,"IndexIndividual":8.0,"Offer":27.0,"OfferVolume":1668009300.0,"Bid":26.0,"BidVolume":2243966200.0,"ListedShares":1140573267220.0,"TradebleShares":1140573267220.0,"WeightForIndex":799998089628.0,"ForeignSell":5847077100.0,"ForeignBuy":3865517000.0,"DelistingDate":"","NonRegularVolume":208738.0,"NonRegularValue":1530356.0,"NonRegularFrequency":3.0,"persen":null,"percentage":null},{"No":877,"IDStockSummary":4113405,"Date":"2026-10-13T00:00:00","StockCode":"TLKM","StockName":"Telkom Indonesia (Persero) Tbk.","Remarks":"--MO1XDSWW0000J312------------","Previous":2280.0,"OpenPrice":2280.0,"FirstTrade":2270.0,"High":2280.0,"Low":2210.0,"Close":2240.0,"Change":-40.0,"Volume":109424100.0,"Value":244244887000.0,"Frequency":15860.0,"IndexIndividual":1177.2,"Offer":2250.0,"OfferVolume":408200.0,"Bid":2240.0,"BidVolume":24800.0,"ListedShares":99062216600.0,"TradebleShares":99062216600.0,"WeightForIndex":39902260846.0,"ForeignSell":94827700.0,"ForeignBuy":61323200.0,"DelistingDate":"","NonRegularVolume":308339.0,"NonRegularValue":922008120.0,"NonRegularFrequency":4.0,"persen":null,"percentage":null}]} diff --git a/crates/idx-core/tests/fixtures/idx_stocksummary_20261014.json b/crates/idx-core/tests/fixtures/idx_stocksummary_20261014.json new file mode 100644 index 0000000..3d993a5 --- /dev/null +++ b/crates/idx-core/tests/fixtures/idx_stocksummary_20261014.json @@ -0,0 +1 @@ +{"draw":0,"recordsTotal":4,"recordsFiltered":4,"data":[{"No":98,"IDStockSummary":4113589,"Date":"2026-10-14T00:00:00","StockCode":"BBCA","StockName":"Bank Central Asia Tbk.","Remarks":"--SO1UQNCNU100G111------------","Previous":5950.0,"OpenPrice":6000.0,"FirstTrade":6000.0,"High":6125.0,"Low":5950.0,"Close":6125.0,"Change":175.0,"Volume":136303100.0,"Value":828862772500.0,"Frequency":18178.0,"IndexIndividual":17488.0,"Offer":6125.0,"OfferVolume":1376900.0,"Bid":6100.0,"BidVolume":41700.0,"ListedShares":122042299500.0,"TradebleShares":122042299500.0,"WeightForIndex":39458951953.0,"ForeignSell":84298000.0,"ForeignBuy":119876900.0,"DelistingDate":"","NonRegularVolume":43235191.0,"NonRegularValue":263446538075.0,"NonRegularFrequency":15.0,"persen":null,"percentage":null},{"No":104,"IDStockSummary":4113595,"Date":"2026-10-14T00:00:00","StockCode":"BBRI","StockName":"Bank Rakyat Indonesia (Persero) Tbk.","Remarks":"--MO1UQSPNU100G111------------","Previous":3080.0,"OpenPrice":3090.0,"FirstTrade":3090.0,"High":3140.0,"Low":3070.0,"Close":3090.0,"Change":10.0,"Volume":109152100.0,"Value":338481482000.0,"Frequency":18138.0,"IndexIndividual":3624.1,"Offer":3090.0,"OfferVolume":4530000.0,"Bid":3080.0,"BidVolume":3641200.0,"ListedShares":150043411587.0,"TradebleShares":150043411587.0,"WeightForIndex":63813462948.0,"ForeignSell":68198900.0,"ForeignBuy":49831700.0,"DelistingDate":"","NonRegularVolume":55577571.0,"NonRegularValue":172327653286.0,"NonRegularFrequency":13.0,"persen":null,"percentage":null},{"No":350,"IDStockSummary":4113841,"Date":"2026-10-14T00:00:00","StockCode":"GOTO","StockName":"GoTo Gojek Tokopedia Tbk.","Remarks":"--UO2H40544000I111----------N-","Previous":27.0,"OpenPrice":27.0,"FirstTrade":27.0,"High":28.0,"Low":26.0,"Close":28.0,"Change":1.0,"Volume":9369136400.0,"Value":256667124200.0,"Frequency":19390.0,"IndexIndividual":8.3,"Offer":28.0,"OfferVolume":2173468100.0,"Bid":27.0,"BidVolume":1340613300.0,"ListedShares":1140573267220.0,"TradebleShares":1140573267220.0,"WeightForIndex":799998089628.0,"ForeignSell":3475737500.0,"ForeignBuy":3444546600.0,"DelistingDate":"","NonRegularVolume":700006960.0,"NonRegularValue":20300235380.0,"NonRegularFrequency":4.0,"persen":null,"percentage":null},{"No":877,"IDStockSummary":4114368,"Date":"2026-10-14T00:00:00","StockCode":"TLKM","StockName":"Telkom Indonesia (Persero) Tbk.","Remarks":"--MO1XDSWW0000J312------------","Previous":2240.0,"OpenPrice":2260.0,"FirstTrade":2260.0,"High":2310.0,"Low":2250.0,"Close":2250.0,"Change":10.0,"Volume":87159900.0,"Value":197725738000.0,"Frequency":13661.0,"IndexIndividual":1182.4,"Offer":2260.0,"OfferVolume":1297300.0,"Bid":2250.0,"BidVolume":331200.0,"ListedShares":99062216600.0,"TradebleShares":99062216600.0,"WeightForIndex":39902260846.0,"ForeignSell":60811200.0,"ForeignBuy":63030200.0,"DelistingDate":"","NonRegularVolume":9457362.0,"NonRegularValue":21449294102.0,"NonRegularFrequency":6.0,"persen":null,"percentage":null}]} diff --git a/crates/idx-core/tests/fixtures/idx_stocksummary_20261015.json b/crates/idx-core/tests/fixtures/idx_stocksummary_20261015.json new file mode 100644 index 0000000..7439b2a --- /dev/null +++ b/crates/idx-core/tests/fixtures/idx_stocksummary_20261015.json @@ -0,0 +1 @@ +{"draw":0,"recordsTotal":4,"recordsFiltered":4,"data":[{"No":98,"IDStockSummary":4114552,"Date":"2026-10-15T00:00:00","StockCode":"BBCA","StockName":"Bank Central Asia Tbk.","Remarks":"--SO1UQNCNU100G111------------","Previous":6125.0,"OpenPrice":6075.0,"FirstTrade":6075.0,"High":6125.0,"Low":6025.0,"Close":6075.0,"Change":-50.0,"Volume":134763200.0,"Value":816554920000.0,"Frequency":17286.0,"IndexIndividual":17345.2,"Offer":6075.0,"OfferVolume":135400.0,"Bid":6050.0,"BidVolume":47300.0,"ListedShares":122042299500.0,"TradebleShares":122042299500.0,"WeightForIndex":39458951953.0,"ForeignSell":73638900.0,"ForeignBuy":120850000.0,"DelistingDate":"","NonRegularVolume":15720537.0,"NonRegularValue":95408071050.0,"NonRegularFrequency":38.0,"persen":null,"percentage":null},{"No":104,"IDStockSummary":4114558,"Date":"2026-10-15T00:00:00","StockCode":"BBRI","StockName":"Bank Rakyat Indonesia (Persero) Tbk.","Remarks":"--MO1UQSPNU100G111------------","Previous":3090.0,"OpenPrice":3080.0,"FirstTrade":3080.0,"High":3110.0,"Low":3060.0,"Close":3100.0,"Change":10.0,"Volume":107210100.0,"Value":330799545000.0,"Frequency":16751.0,"IndexIndividual":3635.9,"Offer":3100.0,"OfferVolume":175700.0,"Bid":3090.0,"BidVolume":71000.0,"ListedShares":150043411587.0,"TradebleShares":150043411587.0,"WeightForIndex":63813462948.0,"ForeignSell":47516000.0,"ForeignBuy":72896800.0,"DelistingDate":"","NonRegularVolume":6266786.0,"NonRegularValue":19574409750.0,"NonRegularFrequency":10.0,"persen":null,"percentage":null},{"No":350,"IDStockSummary":4114804,"Date":"2026-10-15T00:00:00","StockCode":"GOTO","StockName":"GoTo Gojek Tokopedia Tbk.","Remarks":"--UO2H40544000I111----------N-","Previous":28.0,"OpenPrice":27.0,"FirstTrade":27.0,"High":27.0,"Low":26.0,"Close":27.0,"Change":-1.0,"Volume":5443007700.0,"Value":146026601200.0,"Frequency":15162.0,"IndexIndividual":8.0,"Offer":28.0,"OfferVolume":1445696200.0,"Bid":27.0,"BidVolume":374663700.0,"ListedShares":1140573267220.0,"TradebleShares":1140573267220.0,"WeightForIndex":799998089628.0,"ForeignSell":1724301600.0,"ForeignBuy":882307700.0,"DelistingDate":"","NonRegularVolume":43.0,"NonRegularValue":1204.0,"NonRegularFrequency":1.0,"persen":null,"percentage":null},{"No":877,"IDStockSummary":4115331,"Date":"2026-10-15T00:00:00","StockCode":"TLKM","StockName":"Telkom Indonesia (Persero) Tbk.","Remarks":"--MO1XDSWW0000J312------------","Previous":2250.0,"OpenPrice":2250.0,"FirstTrade":2260.0,"High":2260.0,"Low":2170.0,"Close":2230.0,"Change":-20.0,"Volume":150485300.0,"Value":333287991000.0,"Frequency":21026.0,"IndexIndividual":1171.9,"Offer":2230.0,"OfferVolume":1787000.0,"Bid":2220.0,"BidVolume":1084100.0,"ListedShares":99062216600.0,"TradebleShares":99062216600.0,"WeightForIndex":39902260846.0,"ForeignSell":105521800.0,"ForeignBuy":97385100.0,"DelistingDate":"","NonRegularVolume":1177287.0,"NonRegularValue":2606515710.0,"NonRegularFrequency":3.0,"persen":null,"percentage":null}]} diff --git a/crates/idx-core/tests/fixtures/idx_stocksummary_20261016.json b/crates/idx-core/tests/fixtures/idx_stocksummary_20261016.json new file mode 100644 index 0000000..93ae102 --- /dev/null +++ b/crates/idx-core/tests/fixtures/idx_stocksummary_20261016.json @@ -0,0 +1 @@ +{"draw":0,"recordsTotal":4,"recordsFiltered":4,"data":[{"No":98,"IDStockSummary":4115515,"Date":"2026-10-16T00:00:00","StockCode":"BBCA","StockName":"Bank Central Asia Tbk.","Remarks":"--SO1UQNCNU100G111------------","Previous":6075.0,"OpenPrice":5175.0,"FirstTrade":5225.0,"High":5825.0,"Low":5175.0,"Close":5175.0,"Change":-900.0,"Volume":1241098200.0,"Value":6474934315000.0,"Frequency":185072.0,"IndexIndividual":14775.6,"Offer":5175.0,"OfferVolume":183825200.0,"Bid":0.0,"BidVolume":0.0,"ListedShares":122042299500.0,"TradebleShares":122042299500.0,"WeightForIndex":39458951953.0,"ForeignSell":757227900.0,"ForeignBuy":309392800.0,"DelistingDate":"","NonRegularVolume":16501628.0,"NonRegularValue":100183151475.0,"NonRegularFrequency":15.0,"persen":null,"percentage":null},{"No":104,"IDStockSummary":4115521,"Date":"2026-10-16T00:00:00","StockCode":"BBRI","StockName":"Bank Rakyat Indonesia (Persero) Tbk.","Remarks":"--MO1UQSPNU100G111------------","Previous":3100.0,"OpenPrice":2640.0,"FirstTrade":2730.0,"High":2890.0,"Low":2640.0,"Close":2640.0,"Change":-460.0,"Volume":1193916400.0,"Value":3234746482000.0,"Frequency":219483.0,"IndexIndividual":3096.3,"Offer":2640.0,"OfferVolume":20839200.0,"Bid":0.0,"BidVolume":0.0,"ListedShares":150043411587.0,"TradebleShares":150043411587.0,"WeightForIndex":63813462948.0,"ForeignSell":687811000.0,"ForeignBuy":262656800.0,"DelistingDate":"","NonRegularVolume":23371386.0,"NonRegularValue":62017203290.0,"NonRegularFrequency":12.0,"persen":null,"percentage":null},{"No":350,"IDStockSummary":4115767,"Date":"2026-10-16T00:00:00","StockCode":"GOTO","StockName":"GoTo Gojek Tokopedia Tbk.","Remarks":"--UO2H40544000I111----------N-","Previous":27.0,"OpenPrice":23.0,"FirstTrade":23.0,"High":27.0,"Low":23.0,"Close":25.0,"Change":-2.0,"Volume":42657972100.0,"Value":1026997626200.0,"Frequency":61645.0,"IndexIndividual":7.4,"Offer":25.0,"OfferVolume":186996300.0,"Bid":24.0,"BidVolume":362311000.0,"ListedShares":1140573267220.0,"TradebleShares":1140573267220.0,"WeightForIndex":799998089628.0,"ForeignSell":10011994300.0,"ForeignBuy":21910169500.0,"DelistingDate":"","NonRegularVolume":49.0,"NonRegularValue":1127.0,"NonRegularFrequency":1.0,"persen":null,"percentage":null},{"No":877,"IDStockSummary":4116294,"Date":"2026-10-16T00:00:00","StockCode":"TLKM","StockName":"Telkom Indonesia (Persero) Tbk.","Remarks":"--MO1XDSWW0000J312------------","Previous":2230.0,"OpenPrice":1900.0,"FirstTrade":1970.0,"High":1985.0,"Low":1900.0,"Close":1900.0,"Change":-330.0,"Volume":963614200.0,"Value":1855341292500.0,"Frequency":68042.0,"IndexIndividual":998.5,"Offer":1900.0,"OfferVolume":860400.0,"Bid":0.0,"BidVolume":0.0,"ListedShares":99062216600.0,"TradebleShares":99062216600.0,"WeightForIndex":39902260846.0,"ForeignSell":727289800.0,"ForeignBuy":679004400.0,"DelistingDate":"","NonRegularVolume":165000429.0,"NonRegularValue":323596857244.0,"NonRegularFrequency":10.0,"persen":null,"percentage":null}]} diff --git a/crates/idx-core/tests/fixtures/idx_stocksummary_20261017.json b/crates/idx-core/tests/fixtures/idx_stocksummary_20261017.json new file mode 100644 index 0000000..a51cec4 --- /dev/null +++ b/crates/idx-core/tests/fixtures/idx_stocksummary_20261017.json @@ -0,0 +1 @@ +{"draw":0,"recordsTotal":0,"recordsFiltered":0,"data":[]} diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 2975031..35cc91d 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -157,6 +157,21 @@ pub struct BandarParams { pub top: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct FlowParams { + /// IDX ticker, e.g. "BBCA". + pub symbol: String, + /// Latest sessions (default 10, at most 60). Omit when giving from/to. + #[serde(default)] + pub days: Option, + /// First session of a range, YYYY-MM-DD (at most 92 days). + #[serde(default)] + pub from: Option, + /// Last session of the range, YYYY-MM-DD (default today). + #[serde(default)] + pub to: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct ShareholdersParams { /// IDX ticker, e.g. "BBCA". diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index dfbcd46..0d52312 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -11,6 +11,7 @@ use idx_core::broker::types::BrokerRange; use idx_core::config::CoreConfig; use idx_core::error::IdxError; use idx_core::financial::{EarningsFilter, StatementFilter}; +use idx_core::flow::types::FlowRange; use idx_core::investor::{HolderSource, Investor}; use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion}; use idx_core::ownership::types::ChangeRow; @@ -25,8 +26,8 @@ use tokio::sync::Semaphore; use crate::params::{ AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams, - CompanySection, EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, - SymbolsParams, SyncParams, + CompanySection, EntityParams, FlowParams, HistoryParams, ScreenParams, SearchParams, + ShareholdersParams, SymbolsParams, SyncParams, }; const SYNC_TOOL: &str = "ownership_sync"; @@ -239,6 +240,28 @@ gross figures overstate their direction. `method` names the rules applied. `inve .await } + #[tool( + description = "Foreign investors' buying and selling of one IDX ticker, session by \ +session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in \ +shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), \ +`change_bps`, and `non_regular_bps` (share of value traded outside the regular board). Totals \ +cover the whole range; `foreign_share_bps` is foreign investors' share of trading. End of day." + )] + async fn get_foreign_flow(&self, params: Parameters) -> ToolResult { + let FlowParams { + symbol, + days, + from, + to, + } = params.0; + self.live(move |idx| { + let flow = idx.flow(); + let range = FlowRange::from_args(days, from.as_deref(), to.as_deref(), flow.today())?; + flow.foreign(&symbol, range, CachePolicy::Default) + }) + .await + } + #[tool( description = "Company data for one IDX ticker: profile, financial statements, \ earnings, analyst sentiment, insights, and news. News items carry `relevance`: \ diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 44abf49..176c6e8 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -235,6 +235,49 @@ }, "name": "get_company" }, + { + "description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), `change_bps`, and `non_regular_bps` (share of value traded outside the regular board). Totals cover the whole range; `foreign_share_bps` is foreign investors' share of trading. End of day.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "days": { + "default": null, + "description": "Latest sessions (default 10, at most 60). Omit when giving from/to.", + "format": "uint", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "from": { + "default": null, + "description": "First session of a range, YYYY-MM-DD (at most 92 days).", + "type": [ + "string", + "null" + ] + }, + "symbol": { + "description": "IDX ticker, e.g. \"BBCA\".", + "type": "string" + }, + "to": { + "default": null, + "description": "Last session of the range, YYYY-MM-DD (default today).", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "symbol" + ], + "type": "object" + }, + "name": "get_foreign_flow" + }, { "description": "Every IDX holding (above 1%) of one investor, matched by name.", "inputSchema": { diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index ef89a07..af0fb70 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -9,12 +9,13 @@ use idx_core::config::CoreConfig; use idx_core::investor::{ImportSource, Investor}; use serde_json::{Value, json}; -const TOOLS: [&str; 13] = [ +const TOOLS: [&str; 14] = [ "analyze_bandar", "analyze_stock", "compare_stocks", "get_broker_summary", "get_company", + "get_foreign_flow", "get_investor_holdings", "get_ownership_changes", "get_price_history", @@ -275,6 +276,14 @@ fn live_tools_wrap_data_with_freshness() { assert_eq!(bandar["failures"][0]["symbol"], "ZZZZ.JK"); assert_eq!(bandar["failures"][0]["code"], "SYMBOLNOTFOUND"); + let flow = client.ok( + "get_foreign_flow", + json!({"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16"}), + ); + assert_fetched(&flow); + assert_eq!(flow["source"], "idx"); + assert_eq!(flow["data"]["days"].as_array().unwrap().len(), 5); + let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]})); assert_eq!(compared["reports"].as_array().unwrap().len(), 2); assert_eq!(compared["failures"], json!([])); @@ -311,6 +320,11 @@ fn errors_carry_cli_codes() { ), ("analyze_bandar", json!({"symbols": ["BBCA"], "top": 4})), ("analyze_bandar", json!({"symbols": []})), + ("get_foreign_flow", json!({"symbol": "BBCA", "days": 61})), + ( + "get_foreign_flow", + json!({"symbol": "BBCA", "days": 3, "from": "2026-10-12"}), + ), ] { let (is_error, body) = client.call(tool, arguments); assert!(is_error, "{tool}: {body}"); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c148c02..c1948f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -53,6 +53,8 @@ command parses arguments, makes one idx-core call, and renders the result. - **Yahoo Finance** — default/auto history source. Reliable OHLCV data via `/v8/finance/chart/`. - **IPOT (Indo Premier)** — broker summaries, whichever provider is selected (`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries). +- **IDX (idx.co.id)** — the daily trading summary behind foreign flow + (`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow). ### MSN Endpoints - `https://assets.msn.com/service/`: quotes, charts, equities, earnings, @@ -109,6 +111,22 @@ WIB as the fallback. fzn0x/idx. A tier with a missing figure has no reading; missing values are never summed as zero. A change to the rules gets a new method name. +### Foreign Flow +- `GET https://www.idx.co.id/primary/TradingSummary/GetStockSummary` + `?length=9999&start=0&date=YYYYMMDD` answers one JSON row per stock for the + session: prices, volume, value, `ForeignBuy`/`ForeignSell` (shares) and + non-regular volume and value. A day without trading has no rows; rows dated + another day are dropped. `api/idx/parse.rs` parses it purely. +- Cloudflare rejects most TLS fingerprints (in October 2026 only + `curl_safari184` passed), so the request uses curl-impersonate's profile + fallback with a 30 s limit. A challenge page fails that profile, not the + parse. +- Each session is cached whole (every stock) by date: 30 days once past, + 5 minutes for today. `Flow::foreign` walks weekdays without requesting + weekends; offline, `--days` skips uncached sessions while a `--from/--to` + range fails on one. `foreign_net_value` is net shares at the session's + average price (value over volume), an estimate. + ### Capability Gating ``` MarketDataProvider = QuoteProvider + FundamentalsProvider @@ -118,6 +136,7 @@ Factory functions: default_provider(kind) → Box history_provider(kind, mode, verbose) → Result<(ProviderKind, Box)> broker_provider() → Box + trading_summary_provider() → Box ``` ## Ownership Module (SQLite-backed) diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 5601eb5..ce9f4bd 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -54,7 +54,9 @@ nix develop --command scripts/live-smoke.sh --compare ./old/bin/idx # A/B again scripts/live-smoke.sh --dry-run ``` -- `stocks`: every `stocks` command for one ticker, plus `compare BUMI,ADRO,AIMS`, +- `stocks`: every `stocks` command for one ticker (`broker`, `bandar` and + `flow` on the new binary only, so an older `--compare` build that predates + them is fine), plus `compare BUMI,ADRO,AIMS`, whose MSN key ratios contain non-finite values. - `ownership`: `discover`, then imports the newest supported report into a temp DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many diff --git a/flake.nix b/flake.nix index 8644232..b3b9b45 100644 --- a/flake.nix +++ b/flake.nix @@ -288,7 +288,7 @@ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' sleep 2 } | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl - jq -se 'map(select(.id == 2))[0].result.tools | length == 13' out.jsonl + jq -se 'map(select(.id == 2))[0].result.tools | length == 14' out.jsonl touch "$out" ''; # The website's generated pages match the CLI help and the MCP diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 2aefa78..48b16ce 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -123,6 +123,12 @@ contract() { echo '(.highlights | type == "array") and (.risks | type == "array")' ;; "stocks news "*) echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;; + "stocks broker "*) + echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;; + "stocks bandar "*) + echo 'type == "array" and length > 0 and all(.[]; (.method | type == "string") and (.tiers | length == 4) and (.imbalance_bps | type == "number"))' ;; + "stocks flow "*) + echo '(.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy | type == "number") and (.foreign_sell | type == "number")) and (.foreign_net | type == "number")' ;; "ownership discover"*) echo 'type == "array" and length > 0 and (.[0].pdf_url | type == "string")' ;; "ownership releases") @@ -194,6 +200,12 @@ run_case() { echo " PASS" } +# A command the --compare binary may predate runs on the new binary only. +run_new_case() { + local COMPARE_BIN="" + run_case "$@" +} + if (( ! DRY_RUN )); then command -v jq >/dev/null 2>&1 || { echo "jq is required; run inside nix develop" >&2; exit 1; } mkdir -p "$LOG_DIR" @@ -217,6 +229,10 @@ if selected stocks; then run_case compare "stocks compare $SYMBOL,BBRI" # BUMI/ADRO/AIMS return non-finite MSN key ratios (Infinity/NaN). run_case compare-nonfinite "stocks compare BUMI,ADRO,AIMS" + # Broker summaries (IPOT) and foreign flow (IDX, via curl-impersonate). + run_new_case broker "stocks broker $SYMBOL" + run_new_case bandar "stocks bandar $SYMBOL,BBRI,TLKM" + run_new_case flow "stocks flow $SYMBOL --days 3" fi if selected ownership; then diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 65290e3..95216bb 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -68,6 +68,7 @@ Commands: fundamental Run full fundamental analysis on a stock broker Which brokers bought and sold a stock bandar Bandarmology: is buying more concentrated than selling + flow Foreign buying and selling of a stock, session by session profile Get company profile financials Get financial statements earnings Get earnings report @@ -429,6 +430,57 @@ Examples: idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar - ``` +### `idx stocks flow` + +```text +Foreign buying and selling of a stock, session by session, from IDX's daily trading summary: foreign buy, sell and net volume, the net value at the session's average price, and the share of value traded outside the regular board. End of day. Each session is one request to idx.co.id, which needs curl-impersonate. + +Usage: idx stocks flow [OPTIONS] + +Arguments: + + Single ticker symbol (e.g. BBCA) + +Options: + --days + Latest sessions to show (default 10, at most 60) + + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --from + First session of a range, YYYY-MM-DD (at most 92 days) + + --no-color + Disable colored output + + -q, --quiet + Suppress non-essential output + + --to + Last session of the range, YYYY-MM-DD. Default: today + + -v, --verbose... + Increase verbosity (-v, -vv) + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stocks flow BBCA + idx stocks flow BBCA --days 20 + idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16 + idx -o json stocks flow BBCA | jq '.foreign_net' +``` + ### `idx stocks profile` ```text diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index 163d34e..03828c5 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -47,6 +47,24 @@ codes during trading. Buyers and sellers are ranked separately on gross lots, so a broker can appear on both sides. The source rounds figures of a million and more to one decimal, and the output says so. +## Foreign flow + +```bash +idx stocks flow BBCA +idx stocks flow BBCA --days 20 +idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16 +``` + +What foreign investors bought and sold, session by session: buy, sell and +net volume in shares, the net value at each session's average price (an +estimate), and the share of value traded outside the regular board. The +footer gives the net over the range and foreign investors' share of trading. +Without dates it shows the latest 10 sessions (`--days`, at most 60). + +The data is IDX's own daily trading summary. Each session is one request to +idx.co.id, cached afterwards for every stock, and needs +[curl-impersonate](/start/install/) to pass the site's bot protection. + ## Bandarmology ```bash @@ -108,7 +126,7 @@ idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compa ## Providers MSN is the default for quotes, company data and screening; Yahoo serves -price history, and Indo Premier (IPOT) serves broker summaries whichever -provider is selected. Switch with `IDX_PROVIDER=msn|yahoo` and +price history. Indo Premier (IPOT) serves broker summaries and IDX's own site +serves foreign flow, whichever provider is selected. Switch with `IDX_PROVIDER=msn|yahoo` and `IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the matching [config keys](/start/config/). diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 471bd8e..215939b 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -63,6 +63,17 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys | `sections` | array of `profile` / `financials` / `earnings` / `sentiment` / `insights` / `news` | no | Sections to fetch (default: all). | | `symbol` | string | yes | IDX ticker, e.g. "BBCA". | +## `get_foreign_flow` + +Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), `change_bps`, and `non_regular_bps` (share of value traded outside the regular board). Totals cover the whole range; `foreign_share_bps` is foreign investors' share of trading. End of day. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `days` | integer | no | Latest sessions (default 10, at most 60). Omit when giving from/to. | +| `from` | string | no | First session of a range, YYYY-MM-DD (at most 92 days). | +| `symbol` | string | yes | IDX ticker, e.g. "BBCA". | +| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). | + ## `get_investor_holdings` Every IDX holding (above 1%) of one investor, matched by name. diff --git a/web/src/content/docs/start/install.mdx b/web/src/content/docs/start/install.mdx index e8da2b4..01ce32f 100644 --- a/web/src/content/docs/start/install.mdx +++ b/web/src/content/docs/start/install.mdx @@ -74,12 +74,13 @@ idx version ## Helper tools Everyday use needs nothing else: stock data with the default MSN provider, -price history, `ownership sync` and every local ownership query. A few -commands call external tools: +price history, broker summaries and bandarmology, `ownership sync` and every +local ownership query. A few commands call external tools: | Tool | Needed for | | --- | --- | | `curl_chrome*` from [curl-impersonate](https://github.com/lexiforest/curl-impersonate), or `IDX_CURL_IMPERSONATE_BIN` | `ownership discover` and `ownership import --url` (IDX sits behind Cloudflare), and Yahoo fundamentals with `IDX_PROVIDER=yahoo` | +| the full curl-impersonate set, with its `curl_safari*` and `curl_firefox*` profiles | `stocks flow`: IDX's Cloudflare rules pass only some browser fingerprints, and idx tries each installed profile | | `mutool` from MuPDF | importing legacy PDF ownership reports | ## The MCP server From 83783684cb2f78dfb2cf40d12ae6b24288bbf30c Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 05:41:55 +0000 Subject: [PATCH 08/32] fix(core): non-regular share is of all trading IDX's Value is regular-board turnover, so NonRegularValue / Value gave non-regular over regular (BBRI 2026-10-14: 50.91%) instead of the share of all trading (33.74%). non_regular_bps now divides by regular plus non-regular value, with a regression test on that fixture. The docs now say volume, value and the foreign figures are the regular board: IPOT's regular total matches Value, and in a week of every stock no foreign figure exceeded regular volume, even with non-regular volume above twice it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-cli/src/output/table.rs | 5 +++-- .../tests/golden/stocks-flow-latest.json.golden | 2 +- .../golden/stocks-flow-latest.table.golden | 6 +++--- .../tests/golden/stocks-flow.json.golden | 2 +- .../tests/golden/stocks-flow.jsonl.golden | 2 +- .../tests/golden/stocks-flow.table.golden | 8 ++++---- crates/idx-core/src/flow/mod.rs | 17 +++++++++++++++-- crates/idx-core/src/flow/types.rs | 17 ++++++++++------- crates/idx-mcp/src/server.rs | 5 +++-- crates/idx-mcp/tests/golden/tools.json | 2 +- docs/ARCHITECTURE.md | 6 +++++- web/src/content/docs/cli/stocks.md | 3 ++- web/src/content/docs/mcp/tools.md | 2 +- 13 files changed, 50 insertions(+), 27 deletions(-) diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index c79ec5f..641d9c6 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -1254,8 +1254,9 @@ pub fn print_foreign_flow(flow: &ForeignFlow) -> Result<(), IdxError> { } println!("{summary}."); println!( - "Volumes are shares. Net value is estimated at each session's average price; \ -NON-REG is the share of value traded outside the regular board (negotiated and cash)." + "Volume, value and foreign figures are the regular board, volumes in shares. Net value \ +is estimated at each session's average price; NON-REG is the share of all value traded \ +outside the regular board (negotiated and cash)." ); Ok(()) } diff --git a/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden b/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden index 850c9af..9711422 100644 --- a/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden +++ b/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"TLKM.JK","from":"2026-10-14","to":"2026-10-16","days":[{"date":"2026-10-14","close":2250,"change_bps":45,"volume":87159900,"value":197725738000,"foreign_buy":63030200,"foreign_sell":60811200,"foreign_net":2219000,"foreign_net_value":5033890729,"non_regular_bps":1085},{"date":"2026-10-15","close":2230,"change_bps":-89,"volume":150485300,"value":333287991000,"foreign_buy":97385100,"foreign_sell":105521800,"foreign_net":-8136700,"foreign_net_value":-18020792704,"non_regular_bps":78},{"date":"2026-10-16","close":1900,"change_bps":-1480,"volume":963614200,"value":1855341292500,"foreign_buy":679004400,"foreign_sell":727289800,"foreign_net":-48285400,"foreign_net_value":-92968634589,"non_regular_bps":1744}],"foreign_net":-54203100,"foreign_net_value":-105955536564,"foreign_share_bps":7213} +{"symbol":"TLKM.JK","from":"2026-10-14","to":"2026-10-16","days":[{"date":"2026-10-14","close":2250,"change_bps":45,"volume":87159900,"value":197725738000,"foreign_buy":63030200,"foreign_sell":60811200,"foreign_net":2219000,"foreign_net_value":5033890729,"non_regular_bps":979},{"date":"2026-10-15","close":2230,"change_bps":-89,"volume":150485300,"value":333287991000,"foreign_buy":97385100,"foreign_sell":105521800,"foreign_net":-8136700,"foreign_net_value":-18020792704,"non_regular_bps":78},{"date":"2026-10-16","close":1900,"change_bps":-1480,"volume":963614200,"value":1855341292500,"foreign_buy":679004400,"foreign_sell":727289800,"foreign_net":-48285400,"foreign_net_value":-92968634589,"non_regular_bps":1485}],"foreign_net":-54203100,"foreign_net_value":-105955536564,"foreign_share_bps":7213} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-latest.table.golden b/crates/idx-cli/tests/golden/stocks-flow-latest.table.golden index d931858..d9e7bf6 100644 --- a/crates/idx-cli/tests/golden/stocks-flow-latest.table.golden +++ b/crates/idx-cli/tests/golden/stocks-flow-latest.table.golden @@ -4,12 +4,12 @@ Foreign flow for TLKM.JK, 2026-10-14 to 2026-10-16 (3 sessions) ┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐ │ DATE ┆ CLOSE ┆ CHG% ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ NET VALUE ┆ NON-REG │ ╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡ -│ 2026-10-14 ┆ 2,250 ┆ +0.5% ┆ 197.7B ┆ 63.0M ┆ 60.8M ┆ +2.2M ┆ +5.0B ┆ 10.8% │ +│ 2026-10-14 ┆ 2,250 ┆ +0.5% ┆ 197.7B ┆ 63.0M ┆ 60.8M ┆ +2.2M ┆ +5.0B ┆ 9.8% │ ├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ │ 2026-10-15 ┆ 2,230 ┆ -0.9% ┆ 333.3B ┆ 97.4M ┆ 105.5M ┆ -8.1M ┆ -18.0B ┆ 0.8% │ ├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ -│ 2026-10-16 ┆ 1,900 ┆ -14.8% ┆ 1.9T ┆ 679.0M ┆ 727.3M ┆ -48.3M ┆ -93.0B ┆ 17.4% │ +│ 2026-10-16 ┆ 1,900 ┆ -14.8% ┆ 1.9T ┆ 679.0M ┆ 727.3M ┆ -48.3M ┆ -93.0B ┆ 14.8% │ └────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘ Net foreign: -54.2M shares, about -106.0B IDR; foreign investors made up 72.1% of trading. -Volumes are shares. Net value is estimated at each session's average price; NON-REG is the share of value traded outside the regular board (negotiated and cash). +Volume, value and foreign figures are the regular board, volumes in shares. Net value is estimated at each session's average price; NON-REG is the share of all value traded outside the regular board (negotiated and cash). --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.json.golden b/crates/idx-cli/tests/golden/stocks-flow.json.golden index 7714bb2..3a4105e 100644 --- a/crates/idx-cli/tests/golden/stocks-flow.json.golden +++ b/crates/idx-cli/tests/golden/stocks-flow.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_net_value":-56178999154,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_net_value":29800279088,"non_regular_bps":162},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_net_value":216356236186,"non_regular_bps":3178},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_net_value":286060704877,"non_regular_bps":1168},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_net_value":-2336400823441,"non_regular_bps":155}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050} +{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_net_value":-56178999154,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_net_value":29800279088,"non_regular_bps":159},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_net_value":216356236186,"non_regular_bps":2412},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_net_value":286060704877,"non_regular_bps":1046},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_net_value":-2336400823441,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden b/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden index 7714bb2..3a4105e 100644 --- a/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden +++ b/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_net_value":-56178999154,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_net_value":29800279088,"non_regular_bps":162},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_net_value":216356236186,"non_regular_bps":3178},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_net_value":286060704877,"non_regular_bps":1168},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_net_value":-2336400823441,"non_regular_bps":155}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050} +{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_net_value":-56178999154,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_net_value":29800279088,"non_regular_bps":159},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_net_value":216356236186,"non_regular_bps":2412},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_net_value":286060704877,"non_regular_bps":1046},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_net_value":-2336400823441,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.table.golden b/crates/idx-cli/tests/golden/stocks-flow.table.golden index 55bed56..0736ef1 100644 --- a/crates/idx-cli/tests/golden/stocks-flow.table.golden +++ b/crates/idx-cli/tests/golden/stocks-flow.table.golden @@ -8,12 +8,12 @@ Foreign flow for BBCA.JK, 2026-10-12 to 2026-10-16 (5 sessions) ├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ │ 2026-10-13 ┆ 5,950 ┆ +0.0% ┆ 726.6B ┆ 88.8M ┆ 83.7M ┆ +5.0M ┆ +29.8B ┆ 1.6% │ ├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ -│ 2026-10-14 ┆ 6,125 ┆ +2.9% ┆ 828.9B ┆ 119.9M ┆ 84.3M ┆ +35.6M ┆ +216.4B ┆ 31.8% │ +│ 2026-10-14 ┆ 6,125 ┆ +2.9% ┆ 828.9B ┆ 119.9M ┆ 84.3M ┆ +35.6M ┆ +216.4B ┆ 24.1% │ ├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ -│ 2026-10-15 ┆ 6,075 ┆ -0.8% ┆ 816.6B ┆ 120.8M ┆ 73.6M ┆ +47.2M ┆ +286.1B ┆ 11.7% │ +│ 2026-10-15 ┆ 6,075 ┆ -0.8% ┆ 816.6B ┆ 120.8M ┆ 73.6M ┆ +47.2M ┆ +286.1B ┆ 10.5% │ ├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ -│ 2026-10-16 ┆ 5,175 ┆ -14.8% ┆ 6.5T ┆ 309.4M ┆ 757.2M ┆ -447.8M ┆ -2.3T ┆ 1.6% │ +│ 2026-10-16 ┆ 5,175 ┆ -14.8% ┆ 6.5T ┆ 309.4M ┆ 757.2M ┆ -447.8M ┆ -2.3T ┆ 1.5% │ └────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘ Net foreign: -369.4M shares, about -1.9T IDR; foreign investors made up 50.5% of trading. -Volumes are shares. Net value is estimated at each session's average price; NON-REG is the share of value traded outside the regular board (negotiated and cash). +Volume, value and foreign figures are the regular board, volumes in shares. Net value is estimated at each session's average price; NON-REG is the share of all value traded outside the regular board (negotiated and cash). --- stderr diff --git a/crates/idx-core/src/flow/mod.rs b/crates/idx-core/src/flow/mod.rs index dfb9dc5..cbfc52e 100644 --- a/crates/idx-core/src/flow/mod.rs +++ b/crates/idx-core/src/flow/mod.rs @@ -184,10 +184,11 @@ fn flow_day(row: &StockDay) -> ForeignFlowDay { foreign_sell: row.foreign_sell, foreign_net, foreign_net_value, + // `value` is the regular board only; the share is of all trading. non_regular_bps: row .non_regular_value .zip(row.value) - .and_then(|(part, whole)| bps(part, whole)), + .and_then(|(part, regular)| bps(part, part.checked_add(regular)?)), } } @@ -255,7 +256,8 @@ mod tests { ); // 400 shares at 1,100 (value over volume). assert_eq!(first.foreign_net_value, Some(440_000)); - assert_eq!(first.non_regular_bps, Some(1000)); + // 110,000 of 1,100,000 regular plus 110,000 non-regular. + assert_eq!(first.non_regular_bps, Some(909)); assert_eq!(flow.days[1].change_bps, Some(-500)); assert_eq!( (flow.foreign_net, flow.foreign_net_value), @@ -278,6 +280,17 @@ mod tests { assert!(foreign_flow("TEST.JK", &[]).is_none()); } + #[test] + fn test_non_regular_share_is_of_all_trading() { + let raw = include_str!("../../tests/fixtures/idx_stocksummary_20261014.json"); + let date = NaiveDate::from_ymd_opt(2026, 10, 14).unwrap(); + let rows = crate::api::idx::parse_stock_summary(raw, date).unwrap(); + let bbri: Vec = rows.into_iter().filter(|row| row.code == "BBRI").collect(); + let flow = foreign_flow("BBRI.JK", &bbri).unwrap(); + // 172.3B non-regular against 338.5B regular: 33.74%, not 50.91%. + assert_eq!(flow.days[0].non_regular_bps, Some(3374)); + } + #[test] fn test_check_range_bounds() { let day = |d| NaiveDate::from_ymd_opt(2026, 10, d).unwrap(); diff --git a/crates/idx-core/src/flow/types.rs b/crates/idx-core/src/flow/types.rs index 376d0dc..1e01423 100644 --- a/crates/idx-core/src/flow/types.rs +++ b/crates/idx-core/src/flow/types.rs @@ -10,7 +10,10 @@ use crate::error::IdxError; pub const DEFAULT_SESSIONS: usize = 10; /// One stock's trading summary for one session, as IDX publishes it. -/// Prices are whole IDR, volumes are shares. +/// Prices are whole IDR, volumes are shares. Volume, value and the foreign +/// figures cover the regular board only (across a week of every stock, no +/// foreign figure exceeded regular volume, even where non-regular volume was +/// more than twice it); the non-regular figures cover the rest. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StockDay { /// Bare IDX code, e.g. `BBCA`. @@ -21,7 +24,7 @@ pub struct StockDay { pub low: Option, pub close: Option, pub volume: Option, - /// Value traded, whole IDR, all boards. + /// Value traded on the regular board, whole IDR. pub value: Option, pub frequency: Option, /// Shares bought by foreign investors. @@ -80,11 +83,11 @@ pub struct ForeignFlowDay { pub close: Option, /// Close against the previous close, basis points. pub change_bps: Option, - /// Shares traded. + /// Shares traded on the regular board. pub volume: Option, - /// Value traded, whole IDR. + /// Value traded on the regular board, whole IDR. pub value: Option, - /// Shares foreign investors bought. + /// Shares foreign investors bought (regular board). pub foreign_buy: Option, /// Shares foreign investors sold. pub foreign_sell: Option, @@ -94,7 +97,7 @@ pub struct ForeignFlowDay { /// whole IDR: an estimate. pub foreign_net_value: Option, /// Value traded outside the regular board (negotiated and cash), basis - /// points of the session's value. + /// points of all value traded (regular plus non-regular). pub non_regular_bps: Option, } @@ -112,6 +115,6 @@ pub struct ForeignFlow { /// Sum of the sessions' estimated net values, whole IDR. pub foreign_net_value: Option, /// Foreign buys and sells over twice the volume: foreign investors' - /// share of trading, basis points. + /// share of regular-board trading, basis points. pub foreign_share_bps: Option, } diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 0d52312..493e63a 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -244,8 +244,9 @@ gross figures overstate their direction. `method` names the rules applied. `inve description = "Foreign investors' buying and selling of one IDX ticker, session by \ session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in \ shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), \ -`change_bps`, and `non_regular_bps` (share of value traded outside the regular board). Totals \ -cover the whole range; `foreign_share_bps` is foreign investors' share of trading. End of day." +`change_bps`, and `non_regular_bps` (share of all value traded outside the regular board). \ +Volume, value and foreign figures are the regular board. Totals cover the whole range; \ +`foreign_share_bps` is foreign investors' share of regular-board trading. End of day." )] async fn get_foreign_flow(&self, params: Parameters) -> ToolResult { let FlowParams { diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 176c6e8..65be648 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -236,7 +236,7 @@ "name": "get_company" }, { - "description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), `change_bps`, and `non_regular_bps` (share of value traded outside the regular board). Totals cover the whole range; `foreign_share_bps` is foreign investors' share of trading. End of day.", + "description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), `change_bps`, and `non_regular_bps` (share of all value traded outside the regular board). Volume, value and foreign figures are the regular board. Totals cover the whole range; `foreign_share_bps` is foreign investors' share of regular-board trading. End of day.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1948f7..43d9435 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -115,7 +115,11 @@ WIB as the fallback. - `GET https://www.idx.co.id/primary/TradingSummary/GetStockSummary` `?length=9999&start=0&date=YYYYMMDD` answers one JSON row per stock for the session: prices, volume, value, `ForeignBuy`/`ForeignSell` (shares) and - non-regular volume and value. A day without trading has no rows; rows dated + non-regular volume and value. `Volume`, `Value` and the foreign figures are + the regular board only: IPOT's regular-board total for BBCA on 2026-10-16 + matches `Value`, and in a week of every stock no foreign figure exceeded + regular volume. `non_regular_bps` is therefore over regular plus + non-regular value. A day without trading has no rows; rows dated another day are dropped. `api/idx/parse.rs` parses it purely. - Cloudflare rejects most TLS fingerprints (in October 2026 only `curl_safari184` passed), so the request uses curl-impersonate's profile diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index 03828c5..e5fab6a 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -57,7 +57,8 @@ idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16 What foreign investors bought and sold, session by session: buy, sell and net volume in shares, the net value at each session's average price (an -estimate), and the share of value traded outside the regular board. The +estimate), and the share of all value traded outside the regular board. +Volume, value and the foreign figures are IDX's regular-board figures. The footer gives the net over the range and foreign investors' share of trading. Without dates it shows the latest 10 sessions (`--days`, at most 60). diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 215939b..110371a 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -65,7 +65,7 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys ## `get_foreign_flow` -Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), `change_bps`, and `non_regular_bps` (share of value traded outside the regular board). Totals cover the whole range; `foreign_share_bps` is foreign investors' share of trading. End of day. +Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), `change_bps`, and `non_regular_bps` (share of all value traded outside the regular board). Volume, value and foreign figures are the regular board. Totals cover the whole range; `foreign_share_bps` is foreign investors' share of regular-board trading. End of day. | Input | Type | Required | Description | | --- | --- | --- | --- | From 5f6431915779f3eff2c431a0a86f938861798577 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 06:08:02 +0000 Subject: [PATCH 09/32] feat: bandar and broker totals for one investor side IPOT's investor filter splits each broker row by client domicile, but its footer then gives only that side's buying (BBCA 2026-10-16: foreign 1.7 T, domestic 4.9 T of 6.6 T). The foreign net gives the selling on all boards: foreign 1.7 + 2.3 = 4.0 T (IDX: about 3.95 T regular), domestic 4.9 - 2.3 = 2.6 T. - BrokerSummary gains buy_value and sell_value: the total for all investors, the footer and the derived selling for one side (selling is null with a board filter, where the net covers other boards). - Bandar weighs the top N buyers' share of the value bought against the top N sellers' share of the value sold, so `--investor foreign|domestic` reads that side alone; for all investors the readings are unchanged. One side on one board is refused, as there is no selling to weigh. - Tables print what the side bought and sold; fixtures for foreign and domestic on all boards were recorded from IPOT. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-cli/src/cli/stocks.rs | 3 +- crates/idx-cli/src/output/table.rs | 90 +++++++-- crates/idx-cli/tests/cli.rs | 80 +++++++- .../golden/stocks-bandar-screen.json.golden | 2 +- .../golden/stocks-bandar-screen.jsonl.golden | 6 +- .../golden/stocks-bandar-screen.table.golden | 2 +- .../tests/golden/stocks-bandar.json.golden | 2 +- .../tests/golden/stocks-bandar.table.golden | 2 +- .../stocks-broker-no-trades.json.golden | 2 +- .../golden/stocks-broker-range.json.golden | 2 +- .../tests/golden/stocks-broker.json.golden | 2 +- crates/idx-core/src/analysis/bandar.rs | 73 ++++++- crates/idx-core/src/analysis/mod.rs | 15 +- crates/idx-core/src/broker/mod.rs | 96 ++++++++-- crates/idx-core/src/broker/types.rs | 13 +- ...kersummary_bbca_20261016_domestic_all.html | 181 ++++++++++++++++++ ...okersummary_bbca_20261016_foreign_all.html | 181 ++++++++++++++++++ crates/idx-mcp/src/server.rs | 12 +- crates/idx-mcp/tests/golden/tools.json | 4 +- docs/ARCHITECTURE.md | 16 +- web/src/content/docs/cli/reference.md | 2 +- web/src/content/docs/cli/stocks.md | 13 +- web/src/content/docs/mcp/tools.md | 4 +- 23 files changed, 717 insertions(+), 86 deletions(-) create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_domestic_all.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_all.html diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index 046734a..9667c2d 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -207,7 +207,8 @@ does not show broker codes during trading. Without dates, the latest session wit long_about = "Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value \ against the same number of sellers', as a share of value traded, with a signal from big \ accumulation to big distribution. Several symbols give a screen ranked by the headline tier \ -(at most 100, one request each). Counts all investors (`--investor` must be `all`). A \ +(at most 100, one request each). `--investor foreign|domestic` weighs that side's buyers \ +against its buying and its sellers against its selling (needs `--board all`). A \ heuristic over end-of-day broker codes, not advice.", after_help = "Examples:\n idx stocks bandar BBCA\n idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3\n idx stocks bandar BBCA,BBRI,BMRI,TLKM\n idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -" )] diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 641d9c6..4e742e9 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -952,6 +952,42 @@ pub(crate) fn broker_scope( scope } +/// What one investor side bought and sold, when a summary covers one side. +fn side_totals_line( + investor: BrokerInvestor, + board: BrokerBoard, + buy_value: Option, + sell_value: Option, + foreign_net: Option, +) -> Option { + let side = match investor { + BrokerInvestor::All => return None, + BrokerInvestor::Foreign => "Foreign", + BrokerInvestor::Domestic => "Domestic", + }; + let buy = format_compact_option(buy_value); + Some(match sell_value { + Some(sell) => { + let net = foreign_net + .map(|net| match investor { + BrokerInvestor::Domestic => -net, + _ => net, + }) + .map(|net| format!(" (net {:+})", format_compact(net))) + .unwrap_or_default(); + format!( + "{side} investors bought {buy} IDR and sold {} IDR{net}.", + format_compact(sell) + ) + } + None => format!( + "{side} investors bought {buy} IDR on the {} board; the source gives one side's \ +selling only across all boards.", + board.as_str() + ), + }) +} + pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> { println!( "{}", @@ -995,8 +1031,14 @@ pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> { totals.push_str(&format!("; foreign net {:+}", format_compact(net))); } println!("{totals}."); - } else if summary.investor != BrokerInvestor::All { - println!("No totals: for one investor side the source totals only that side's buying."); + } else if let Some(line) = side_totals_line( + summary.investor, + summary.board, + summary.buy_value, + summary.sell_value, + summary.foreign_net_value, + ) { + println!("{line}"); } for (kind, label) in [ (BrokerKind::Foreign, "Foreign brokers"), @@ -1049,9 +1091,9 @@ fn format_bandar_signal(signal: Option, no_color: bool) -> String fn bandar_method_note(top: u8) -> String { format!( - "Method {BANDAR_METHOD}: the top {top} buyers' value minus the top {top} sellers' value, \ -as a share of value traded; {}% or more reads accumulation, {}% or more big (negative: \ -distribution). Brokers trade for many clients, so this is a heuristic, not advice.", + "Method {BANDAR_METHOD}: the top {top} buyers' share of the value bought minus the top \ +{top} sellers' share of the value sold; {}% or more reads accumulation, {}% or more big \ +(negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice.", ACCUMULATION_BPS / 100, BIG_BPS / 100 ) @@ -1067,7 +1109,11 @@ pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), ) .bold() ); - if report.total_value.is_none_or(|value| value <= 0) { + if report + .buy_value + .zip(report.sell_value) + .is_none_or(|(buy, sell)| buy <= 0 && sell <= 0) + { println!("No trades."); return Ok(()); } @@ -1105,18 +1151,28 @@ pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), ]); } println!("{table}"); - let mut totals = format!("Total {} IDR", format_compact_option(report.total_value)); - if let Some(average) = report.average_price { - totals.push_str(&format!(", average {}", format_idr(average))); + if let Some(line) = side_totals_line( + report.investor, + report.board, + report.buy_value, + report.sell_value, + report.foreign_net_value, + ) { + println!("{line}"); + } else { + let mut totals = format!("Total {} IDR", format_compact_option(report.total_value)); + if let Some(average) = report.average_price { + totals.push_str(&format!(", average {}", format_idr(average))); + } + if let Some(net) = report.foreign_net_value { + totals.push_str(&format!( + "; foreign net {:+} ({} of value)", + format_compact(net), + format_bps_pct(report.foreign_net_bps) + )); + } + println!("{totals}."); } - if let Some(net) = report.foreign_net_value { - totals.push_str(&format!( - "; foreign net {:+} ({} of value)", - format_compact(net), - format_bps_pct(report.foreign_net_bps) - )); - } - println!("{totals}."); if !report.both_sides.is_empty() { println!( "On both sides: {}; their gross figures overstate which way they traded.", diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 7da8bb6..1175eaf 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2499,10 +2499,12 @@ fn broker_summary_json_names_brokers_and_keeps_source_totals() { "total_value", "total_lots", "average_price", + "sell_value", "foreign_net_value", ] { assert!(filtered[field].is_null(), "{field}: {filtered}"); } + assert_eq!(filtered["buy_value"], 1_600_000_000_000_i64); let table = run_success_stdout(mock_bin("broker-filtered-table").args([ "stocks", "broker", @@ -2515,7 +2517,27 @@ fn broker_summary_json_names_brokers_and_keeps_source_totals() { "regular", ])); assert!(!table.contains("Total "), "{table}"); - assert!(table.contains("No totals"), "{table}"); + assert!( + table.contains("Foreign investors bought 1.6T IDR on the regular board"), + "{table}" + ); + + // On all boards, the foreign net gives foreign selling. + let foreign = run_success_json(mock_bin("broker-foreign").args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + ])); + assert!(foreign["total_value"].is_null()); + assert_eq!(foreign["buy_value"], 1_700_000_000_000_i64); + assert_eq!(foreign["sell_value"], 4_000_000_000_000_i64); + assert_eq!(foreign["foreign_net_value"], -2_300_000_000_000_i64); } #[test] @@ -2717,7 +2739,15 @@ fn bandar_errors_follow_the_output_contract() { "INVALIDINPUT", ), ( - vec!["BBCA".to_string(), "--investor".into(), "foreign".into()], + vec![ + "BBCA".to_string(), + "--date".into(), + "2026-10-16".into(), + "--investor".into(), + "foreign".into(), + "--board".into(), + "regular".into(), + ], "INVALIDINPUT", ), ] { @@ -2857,3 +2887,49 @@ fn foreign_flow_errors_follow_the_output_contract() { .args(["-o", "json", "stocks", "flow", "BBCA"]); run_error_json(&mut failing, "PROVIDERUNAVAILABLE"); } + +#[test] +fn bandar_reads_one_investor_side_against_its_own_trading() { + let reports = run_success_json(mock_bin("bandar-foreign").args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + ])); + let report = &reports[0]; + assert_eq!(report["investor"], "foreign"); + assert_eq!(report["buy_value"], 1_700_000_000_000_i64); + assert_eq!(report["sell_value"], 4_000_000_000_000_i64); + let top5 = &report["tiers"][2]; + // Shares of foreign buying and of foreign selling, each within 100%. + let (buy, sell) = ( + top5["buy_share_bps"].as_i64().unwrap(), + top5["sell_share_bps"].as_i64().unwrap(), + ); + assert!( + (0..=10_000).contains(&buy) && (0..=10_000).contains(&sell), + "{top5}" + ); + assert!((top5["imbalance_bps"].as_i64().unwrap() - (buy - sell)).abs() <= 1); + assert!(report["signal"].is_string()); + + let table = run_success_stdout(mock_bin("bandar-foreign-table").args([ + "--no-color", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + ])); + assert!( + table.contains("Foreign investors bought 1.7T IDR and sold 4.0T IDR (net -2.3T)."), + "{table}" + ); +} diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden index 4ff0e74..c875813 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1748,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1654,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true},{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1993,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1732,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true},{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +[{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1748,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1654,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"buy_value":3300000000000,"sell_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true},{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1993,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1732,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"buy_value":2200000000000,"sell_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true},{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden index bb5a3cc..0cceb2c 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden @@ -1,6 +1,6 @@ exit: 0 --- stdout -{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1748,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1654,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true} -{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1993,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1732,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true} -{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true} +{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1748,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1654,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"buy_value":3300000000000,"sell_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true} +{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1993,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1732,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"buy_value":2200000000000,"sell_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true} +{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden index 3ac8d90..33a2a13 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden @@ -9,5 +9,5 @@ exit: 0 ├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ BBCA.JK ┆ 2026-10-16 ┆ big distribution ┆ -29.1% ┆ 30.1% ┆ 59.2% ┆ 6.6T ┆ -2.3T │ └─────────┴────────────┴──────────────────┴─────────────┴───────────┴────────────┴───────┴─────────────┘ -Method top_concentration_v1: the top 3 buyers' value minus the top 3 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. +Method top_concentration_v1: the top 3 buyers' share of the value bought minus the top 3 sellers' share of the value sold; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar.json.golden b/crates/idx-cli/tests/golden/stocks-bandar.json.golden index 5d6bad6..bda32bb 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar.json.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -[{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":5,"signal":"big_distribution","imbalance_bps":-2425,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +[{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":5,"signal":"big_distribution","imbalance_bps":-2425,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2913,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2425,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar.table.golden b/crates/idx-cli/tests/golden/stocks-bandar.table.golden index 7ea52f6..953b403 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar.table.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar.table.golden @@ -15,6 +15,6 @@ Signal (top 5): big distribution, -24.2% of value └─────┴────────┴──────┴───────┴────────┴─────────┴──────────┴──────────────────┘ Total 6.6T IDR, average 5,228; foreign net -2.3T (-34.8% of value). On both sides: AK, CC, MG, XL, YU, ZP; their gross figures overstate which way they traded. -Method top_concentration_v1: the top 5 buyers' value minus the top 5 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. +Method top_concentration_v1: the top 5 buyers' share of the value bought minus the top 5 sellers' share of the value sold; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. Note: the source rounds figures of a million and more to one decimal. --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden b/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden index aa27fb8..457e28f 100644 --- a/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden +++ b/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","from":"2026-10-17","to":"2026-10-17","investor":"all","board":"all","buyers":[],"sellers":[],"total_value":0,"total_lots":0,"average_price":null,"foreign_net_value":0,"rounded":false} +{"symbol":"BBCA.JK","from":"2026-10-17","to":"2026-10-17","investor":"all","board":"all","buyers":[],"sellers":[],"total_value":0,"total_lots":0,"average_price":null,"buy_value":0,"sell_value":0,"foreign_net_value":0,"rounded":false} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker-range.json.golden b/crates/idx-cli/tests/golden/stocks-broker-range.json.golden index 0b4fb84..4ef1b15 100644 --- a/crates/idx-cli/tests/golden/stocks-broker-range.json.golden +++ b/crates/idx-cli/tests/golden/stocks-broker-range.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":963000000000,"average_price":5573},{"rank":2,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":842000000000,"average_price":5192},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":900900000000,"average_price":5780},{"rank":4,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":781700000000,"average_price":5626},{"rank":5,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1400000,"value":737900000000,"average_price":5408},{"rank":6,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1300000,"value":687200000000,"average_price":5313},{"rank":7,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1100000,"value":643900000000,"average_price":5771},{"rank":8,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":973921,"value":519400000000,"average_price":5333},{"rank":9,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":919655,"value":487100000000,"average_price":5296},{"rank":10,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":646162,"value":341200000000,"average_price":5280}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":5100000,"value":2700000000000,"average_price":5336},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1800000,"value":928500000000,"average_price":5295},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":942100000000,"average_price":5756},{"rank":4,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":800500000000,"average_price":5239},{"rank":5,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":786500000000,"average_price":5785},{"rank":6,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":852704,"value":457700000000,"average_price":5368},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":830940,"value":466700000000,"average_price":5617},{"rank":8,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":709658,"value":393300000000,"average_price":5542},{"rank":9,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":621425,"value":363300000000,"average_price":5847},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":441903,"value":242600000000,"average_price":5490}],"total_value":9900000000000,"total_lots":18100000,"average_price":5471,"foreign_net_value":-4100000000000,"rounded":true} +{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":963000000000,"average_price":5573},{"rank":2,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":842000000000,"average_price":5192},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":900900000000,"average_price":5780},{"rank":4,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":781700000000,"average_price":5626},{"rank":5,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1400000,"value":737900000000,"average_price":5408},{"rank":6,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1300000,"value":687200000000,"average_price":5313},{"rank":7,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1100000,"value":643900000000,"average_price":5771},{"rank":8,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":973921,"value":519400000000,"average_price":5333},{"rank":9,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":919655,"value":487100000000,"average_price":5296},{"rank":10,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":646162,"value":341200000000,"average_price":5280}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":5100000,"value":2700000000000,"average_price":5336},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1800000,"value":928500000000,"average_price":5295},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":942100000000,"average_price":5756},{"rank":4,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":800500000000,"average_price":5239},{"rank":5,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":786500000000,"average_price":5785},{"rank":6,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":852704,"value":457700000000,"average_price":5368},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":830940,"value":466700000000,"average_price":5617},{"rank":8,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":709658,"value":393300000000,"average_price":5542},{"rank":9,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":621425,"value":363300000000,"average_price":5847},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":441903,"value":242600000000,"average_price":5490}],"total_value":9900000000000,"total_lots":18100000,"average_price":5471,"buy_value":9900000000000,"sell_value":9900000000000,"foreign_net_value":-4100000000000,"rounded":true} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker.json.golden b/crates/idx-cli/tests/golden/stocks-broker.json.golden index 8aade15..858fb29 100644 --- a/crates/idx-cli/tests/golden/stocks-broker.json.golden +++ b/crates/idx-cli/tests/golden/stocks-broker.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":841200000000,"average_price":5191},{"rank":2,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1100000,"value":600000000000,"average_price":5229},{"rank":3,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1000000,"value":543500000000,"average_price":5227},{"rank":4,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1000000,"value":522400000000,"average_price":5198},{"rank":5,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":828163,"value":432400000000,"average_price":5221},{"rank":6,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":827340,"value":431700000000,"average_price":5218},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":687603,"value":356600000000,"average_price":5186},{"rank":8,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":592667,"value":309200000000,"average_price":5218},{"rank":9,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":441000,"value":230200000000,"average_price":5220},{"rank":10,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":398474,"value":209900000000,"average_price":5268}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":4300000,"value":2200000000000,"average_price":5211},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":908200000000,"average_price":5282},{"rank":3,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":799400000000,"average_price":5238},{"rank":4,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":687067,"value":357400000000,"average_price":5202},{"rank":5,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":529223,"value":275400000000,"average_price":5203},{"rank":6,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":444696,"value":233500000000,"average_price":5251},{"rank":7,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":419266,"value":218800000000,"average_price":5219},{"rank":8,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":418986,"value":218000000000,"average_price":5202},{"rank":9,"code":"NI","name":"BNI SEKURITAS","kind":"state_owned","lots":357177,"value":186100000000,"average_price":5212},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":296269,"value":155000000000,"average_price":5232}],"total_value":6600000000000,"total_lots":12600000,"average_price":5228,"foreign_net_value":-2300000000000,"rounded":true} +{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":841200000000,"average_price":5191},{"rank":2,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1100000,"value":600000000000,"average_price":5229},{"rank":3,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1000000,"value":543500000000,"average_price":5227},{"rank":4,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1000000,"value":522400000000,"average_price":5198},{"rank":5,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":828163,"value":432400000000,"average_price":5221},{"rank":6,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":827340,"value":431700000000,"average_price":5218},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":687603,"value":356600000000,"average_price":5186},{"rank":8,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":592667,"value":309200000000,"average_price":5218},{"rank":9,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":441000,"value":230200000000,"average_price":5220},{"rank":10,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":398474,"value":209900000000,"average_price":5268}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":4300000,"value":2200000000000,"average_price":5211},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":908200000000,"average_price":5282},{"rank":3,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":799400000000,"average_price":5238},{"rank":4,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":687067,"value":357400000000,"average_price":5202},{"rank":5,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":529223,"value":275400000000,"average_price":5203},{"rank":6,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":444696,"value":233500000000,"average_price":5251},{"rank":7,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":419266,"value":218800000000,"average_price":5219},{"rank":8,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":418986,"value":218000000000,"average_price":5202},{"rank":9,"code":"NI","name":"BNI SEKURITAS","kind":"state_owned","lots":357177,"value":186100000000,"average_price":5212},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":296269,"value":155000000000,"average_price":5232}],"total_value":6600000000000,"total_lots":12600000,"average_price":5228,"buy_value":6600000000000,"sell_value":6600000000000,"foreign_net_value":-2300000000000,"rounded":true} --- stderr diff --git a/crates/idx-core/src/analysis/bandar.rs b/crates/idx-core/src/analysis/bandar.rs index 8fc1fda..e793ab1 100644 --- a/crates/idx-core/src/analysis/bandar.rs +++ b/crates/idx-core/src/analysis/bandar.rs @@ -2,10 +2,13 @@ //! brokers than selling? No network; see docs/ARCHITECTURE.md. //! //! The summary lists the top 10 buyers and the top 10 sellers by gross lots. -//! For N in 1, 3, 5 and 10 the top N buyers' value is set against the top N -//! sellers' value, as a share of the value traded: +//! For N in 1, 3, 5 and 10 the top N buyers' share of the value bought is set +//! against the top N sellers' share of the value sold. For all investors both +//! are the value traded, so this is the top N's buy value less their sell +//! value as a share of it; for one investor side (foreign or domestic), each +//! is that side's own buying or selling. //! -//! | top-N buy - sell value, % of value | signal | +//! | top-N buy share - sell share, % | signal | //! |------------------------------------|--------------------| //! | >= +15 | `big_accumulation` | //! | +5 to +15 | `accumulation` | @@ -79,11 +82,12 @@ pub struct BandarTier { pub sell_value: Option, /// `buy_value - sell_value`, whole IDR. pub imbalance_value: Option, + /// `buy_share_bps - sell_share_bps`, computed exactly: for all investors, /// `imbalance_value` in basis points of the value traded. pub imbalance_bps: Option, - /// The top N buyers' share of the value traded, basis points. + /// The top N buyers' share of the value bought, basis points. pub buy_share_bps: Option, - /// The top N sellers' share of the value traded, basis points. + /// The top N sellers' share of the value sold, basis points. pub sell_share_bps: Option, /// Volume-weighted average price the top N buyers paid, whole IDR. pub buy_average: Option, @@ -109,8 +113,12 @@ pub struct BandarReport { /// The headline tier's imbalance, basis points of value traded. pub imbalance_bps: Option, pub tiers: Vec, - /// Value traded over the range, whole IDR. + /// Value traded over the range, whole IDR; `None` for one investor side. pub total_value: Option, + /// Value the selected investors bought, whole IDR. + pub buy_value: Option, + /// Value the selected investors sold, whole IDR. + pub sell_value: Option, /// Average traded price, whole IDR. pub average_price: Option, /// Foreign net value, whole IDR; `None` for a filtered summary. @@ -175,15 +183,28 @@ pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier { let imbalance_value = buy_value .zip(sell_value) .and_then(|(buy, sell)| buy.checked_sub(sell)); - let imbalance_bps = imbalance_value.and_then(|value| bps(value, summary.total_value)); + let imbalance_bps = match (buy_value, sell_value, summary.buy_value, summary.sell_value) { + (Some(buy), Some(sell), Some(bought), Some(sold)) if bought > 0 && sold > 0 => { + // buy/bought - sell/sold over one denominator, so equal totals + // give exactly (buy - sell) / total. + let (buy, sell, bought, sold) = ( + i128::from(buy), + i128::from(sell), + i128::from(bought), + i128::from(sold), + ); + i64::try_from((buy * sold - sell * bought) * 10_000 / (bought * sold)).ok() + } + _ => None, + }; BandarTier { top, buy_value, sell_value, imbalance_value, imbalance_bps, - buy_share_bps: buy_value.and_then(|value| bps(value, summary.total_value)), - sell_share_bps: sell_value.and_then(|value| bps(value, summary.total_value)), + buy_share_bps: buy_value.and_then(|value| bps(value, summary.buy_value)), + sell_share_bps: sell_value.and_then(|value| bps(value, summary.sell_value)), buy_average: vwap(buyers), sell_average: vwap(sellers), signal: imbalance_bps.map(BandarSignal::from_imbalance_bps), @@ -223,6 +244,8 @@ pub fn analyze_bandar(summary: &BrokerSummary, top: u8) -> BandarReport { imbalance_bps: headline.imbalance_bps, tiers, total_value: summary.total_value, + buy_value: summary.buy_value, + sell_value: summary.sell_value, average_price: summary.average_price, foreign_net_value: summary.foreign_net_value, foreign_net_bps: summary @@ -262,6 +285,8 @@ mod tests { total_value: Some(total), total_lots: None, average_price: None, + buy_value: Some(total), + sell_value: Some(total), foreign_net_value: Some(-total / 10), rounded: false, } @@ -359,6 +384,36 @@ mod tests { ); } + #[test] + fn test_bandar_tier_weighs_one_side_against_its_own_buying_and_selling() { + // Foreign investors bought 1,000 and sold 4,000: the top buyer took + // half of the buying, the top seller a quarter of the selling. + let mut s = summary( + vec![flow("AA", Some(500), 100)], + vec![flow("BB", Some(1_000), 100)], + 0, + ); + s.investor = BrokerInvestor::Foreign; + s.total_value = None; + s.buy_value = Some(1_000); + s.sell_value = Some(4_000); + s.foreign_net_value = Some(-3_000); + let report = analyze_bandar(&s, 1); + let tier = &report.tiers[0]; + assert_eq!( + (tier.buy_share_bps, tier.sell_share_bps), + (Some(5000), Some(2500)) + ); + assert_eq!(tier.imbalance_bps, Some(2500)); + assert_eq!(tier.imbalance_value, Some(-500)); + assert_eq!(report.signal, Some(BandarSignal::BigAccumulation)); + assert_eq!(report.foreign_net_bps, None, "no total to weigh it against"); + + // Without the selling total (one side on one board) there is no reading. + s.sell_value = None; + assert_eq!(analyze_bandar(&s, 1).signal, None); + } + #[test] fn test_analyze_bandar_never_counts_a_missing_figure_as_zero() { let s = summary( diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index 0304678..b0741f2 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -31,14 +31,15 @@ pub struct Comparison { pub failures: Vec<(String, IdxError)>, } -/// Readings weigh brokers against the value traded, which the source does -/// not total for one investor side. -fn check_investor(investor: BrokerInvestor) -> Result<(), IdxError> { - if investor == BrokerInvestor::All { +/// One investor side is weighed against its own selling, which the source +/// gives only across all boards. +fn check_scope(investor: BrokerInvestor, board: BrokerBoard) -> Result<(), IdxError> { + if investor == BrokerInvestor::All || board == BrokerBoard::All { Ok(()) } else { Err(IdxError::InvalidInput(format!( - "bandar needs --investor all: the broker summary has no total for {} investors alone", + "bandar for {} investors needs --board all: the broker summary gives one side's \ +selling only across all boards", investor.as_str() ))) } @@ -70,7 +71,7 @@ impl Analysis<'_> { policy: CachePolicy, ) -> Result, IdxError> { let top = check_top(query.top)?; - check_investor(query.investor)?; + check_scope(query.investor, query.board)?; let summary = self.idx .broker() @@ -87,7 +88,7 @@ impl Analysis<'_> { policy: CachePolicy, ) -> Result { check_top(query.top)?; - check_investor(query.investor)?; + check_scope(query.investor, query.board)?; let mut reports = Vec::new(); let mut failures = Vec::new(); for symbol in symbols { diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs index 07aae3d..7ecc7e2 100644 --- a/crates/idx-core/src/broker/mod.rs +++ b/crates/idx-core/src/broker/mod.rs @@ -155,13 +155,27 @@ impl Broker<'_> { } } -/// The public summary of one parsed page. With one investor side selected, -/// the page's footer covers only part of the trades the lists show (foreign -/// buying, not foreign selling), so no totals are given; `F. NVal` ignores -/// both filters, so the foreign net goes with any filter. +/// The public summary of one parsed page. +/// +/// With one investor side selected, the page's footer (`T. Val`) covers only +/// that side's buying, so it is the side's `buy_value`, not a total of the +/// trades the lists show. `F. NVal`, the foreign net, ignores both filters: +/// on all boards it gives the side's selling (foreign buying less the foreign +/// net; domestic buying plus it), and with a board filter it is dropped. fn summary_from_raw(symbol: &str, query: &BrokerQuery, raw: BrokerSummaryRaw) -> BrokerSummary { let one_side = query.investor != BrokerInvestor::All; - let filtered = one_side || query.board != BrokerBoard::All; + let all_boards = query.board == BrokerBoard::All; + let foreign_net = raw.foreign_net_value.filter(|_| all_boards); + let buy_value = raw.total_value; + let sell_value = match query.investor { + BrokerInvestor::All => raw.total_value, + BrokerInvestor::Foreign => buy_value + .zip(foreign_net) + .and_then(|(buy, net)| buy.checked_sub(net)), + BrokerInvestor::Domestic => buy_value + .zip(foreign_net) + .and_then(|(buy, net)| buy.checked_add(net)), + }; let mut summary = BrokerSummary { symbol: symbol.to_string(), from: raw.from.unwrap_or(query.from), @@ -173,7 +187,9 @@ fn summary_from_raw(symbol: &str, query: &BrokerQuery, raw: BrokerSummaryRaw) -> total_value: raw.total_value.filter(|_| !one_side), total_lots: raw.total_lots.filter(|_| !one_side), average_price: raw.average_price.filter(|_| !one_side), - foreign_net_value: raw.foreign_net_value.filter(|_| !filtered), + buy_value, + sell_value, + foreign_net_value: foreign_net, rounded: raw.rounded, }; for flow in summary.buyers.iter_mut().chain(summary.sellers.iter_mut()) { @@ -228,7 +244,7 @@ mod tests { } #[test] - fn test_summary_from_raw_drops_totals_that_do_not_cover_the_lists() { + fn test_summary_from_raw_gives_each_side_its_own_totals() { let day = date(2026, 10, 16); let read = |name: &str| { let html = std::fs::read_to_string(format!("tests/fixtures/{name}")).unwrap(); @@ -243,26 +259,68 @@ mod tests { assert_eq!(all.foreign_net_value, Some(-2_300_000_000_000)); assert_eq!(all.sellers[0].name.as_deref(), Some("MANDIRI SEKURITAS")); - // Foreign, regular board: the footer's 1.6 T is less than the 2.0 T - // its top seller alone sold. - let foreign = BrokerQuery { - investor: BrokerInvestor::Foreign, - board: BrokerBoard::Regular, - ..query(day, day) + assert_eq!( + (all.buy_value, all.sell_value), + (all.total_value, all.total_value) + ); + + // Foreign on all boards: the footer's 1.7 T is foreign buying; with + // the -2.3 T foreign net, foreign selling is 4.0 T (IDX: about + // 3.95 T). Domestic buying 4.9 T makes up the rest of 6.6 T, and its + // selling is 2.6 T. + let side = |investor, board, name: &str| { + let query = BrokerQuery { + investor, + board, + ..query(day, day) + }; + summary_from_raw("BBCA.JK", &query, read(name)) }; + let foreign = side( + BrokerInvestor::Foreign, + BrokerBoard::All, + "ipot_brokersummary_bbca_20261016_foreign_all.html", + ); + assert_eq!( + (foreign.buy_value, foreign.sell_value), + (Some(1_700_000_000_000), Some(4_000_000_000_000)) + ); + assert_eq!(foreign.total_value, None); + assert_eq!(foreign.foreign_net_value, Some(-2_300_000_000_000)); + let domestic = side( + BrokerInvestor::Domestic, + BrokerBoard::All, + "ipot_brokersummary_bbca_20261016_domestic_all.html", + ); + assert_eq!( + (domestic.buy_value, domestic.sell_value), + (Some(4_900_000_000_000), Some(2_600_000_000_000)) + ); + + // Foreign, regular board: the footer's 1.6 T is regular foreign + // buying, less than the 2.0 T its top seller alone sold, and the net + // covers all boards, so selling cannot be derived. let raw = read("ipot_brokersummary_bbca_20261016_foreign_regular.html"); assert!(raw.total_value < raw.sellers[0].value); - let filtered = summary_from_raw("BBCA.JK", &foreign, raw); + let regular = side( + BrokerInvestor::Foreign, + BrokerBoard::Regular, + "ipot_brokersummary_bbca_20261016_foreign_regular.html", + ); assert_eq!( ( - filtered.total_value, - filtered.total_lots, - filtered.average_price + regular.total_value, + regular.total_lots, + regular.average_price ), (None, None, None) ); - assert_eq!(filtered.foreign_net_value, None); - assert_eq!(filtered.sellers[0].value, Some(2_000_000_000_000)); + assert_eq!( + (regular.buy_value, regular.sell_value), + (Some(1_600_000_000_000), None) + ); + assert_eq!(regular.foreign_net_value, None); + assert_eq!(regular.sellers[0].value, Some(2_000_000_000_000)); } #[test] diff --git a/crates/idx-core/src/broker/types.rs b/crates/idx-core/src/broker/types.rs index 5f8b313..8c19f6e 100644 --- a/crates/idx-core/src/broker/types.rs +++ b/crates/idx-core/src/broker/types.rs @@ -215,9 +215,18 @@ pub struct BrokerSummary { /// Average traded price, whole IDR; `None` when nothing traded, or as /// for `total_value`. pub average_price: Option, + /// Value the selected investors bought over the range on the selected + /// board, whole IDR: `total_value` for all investors, and for one side + /// the source's footer, which then covers that side's buying. + pub buy_value: Option, + /// Value the selected investors sold, whole IDR: `total_value` for all + /// investors; for one side on all boards, its buying less its net + /// (foreign net, or its negative for domestic investors). `None` for one + /// side on one board, where the source gives no net to derive it from. + pub sell_value: Option, /// Foreign buy value minus foreign sell value over the range, whole IDR, - /// across all boards. The source does not apply the investor or board - /// filter to it, so it is `None` for a filtered summary. + /// across all boards. The source does not apply the board filter to it, + /// so it is `None` for a summary of one board. pub foreign_net_value: Option, /// True when the source abbreviated at least one figure (`1.6 M` lots, /// `841.2 B` IDR), so those figures carry only one decimal of precision. diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_domestic_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_domestic_all.html new file mode 100644 index 0000000..c12918e --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_domestic_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
MG1.6 M841.2 B5,191 1MG1.5 M799.4 B5,238
XL1.1 M595.0 B5,229 2BB577,558 300.5 B5,203
CC841,738 439.2 B5,217 3CC448,606 233.8 B5,211
YP824,562 430.3 B5,218 4NI356,533 185.8 B5,212
SQ824,521 430.5 B5,221 5XL296,269 155.0 B5,232
PD587,559 306.6 B5,217 6YP264,188 138.5 B5,242
CP347,229 181.1 B5,216 7CP188,644 98.9 B5,245
XC336,997 176.1 B5,226 8YU166,603 86.6 B5,201
KK302,943 157.9 B5,211 9BQ142,119 74.7 B5,254
BQ245,981 128.6 B5,228 10KK133,633 70.0 B5,236
+
+ T. Val : 4.9 T + F. NVal : -2.3 T + T.Lot : 9.3 M + Avg : 5,216 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_all.html new file mode 100644 index 0000000..7f6adea --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ1.0 M521.3 B5,198 1CC3.9 M2.0 T5,211
YU575,965 298.4 B5,181 2RX1.7 M908.2 B5,282
BK369,446 196.0 B5,304 3ZP474,808 247.1 B5,203
AK366,209 193.1 B5,274 4AK444,447 233.4 B5,251
ZP287,065 150.0 B5,226 5BK419,266 218.8 B5,219
CC198,140 104.4 B5,267 6YU252,383 131.3 B5,203
RX162,162 97.1 B5,987 7DX252,100 130.5 B5,175
BB123,966 64.8 B5,225 8KZ115,380 60.1 B5,212
TP70,621 36.6 B5,189 9BB109,509 56.9 B5,200
DX15,000 7.8 B5,181 10TP34,490 17.9 B5,195
+
+ T. Val : 1.7 T + F. NVal : -2.3 T + T.Lot : 3.2 M + Avg : 5,263 +
+ +
+
+ + + diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 493e63a..f858838 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -175,8 +175,11 @@ for one or more IDX tickers." by lots over a session or date range, each with lots (1 lot = 100 shares), value and average \ price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can \ appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the \ -broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means \ -figures of a million and more carry one decimal of precision." +broker's own status (local, foreign, state_owned), not its clients'. With `investor` foreign \ +or domestic, each row is that broker's foreign or domestic clients' trades, `total_value` is \ +null, and `buy_value`/`sell_value` give what that side bought and sold (`sell_value` needs \ +`board` all). `rounded` = true means figures of a million and more carry one decimal of \ +precision." )] async fn get_broker_summary(&self, params: Parameters) -> ToolResult { let BrokerParams { symbol, scope } = params.0; @@ -206,8 +209,9 @@ traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) throu (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come \ strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over \ end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose \ -gross figures overstate their direction. `method` names the rules applied. `investor` must be \ -`all`: the summary has no total for one investor side." +gross figures overstate their direction. `method` names the rules applied. With `investor` \ +foreign or domestic, shares are of that side's own buying (`buy_value`) and selling \ +(`sell_value`); that needs `board` all." )] async fn analyze_bandar(&self, params: Parameters) -> ToolResult { let BandarParams { diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 65be648..84d48b4 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -1,6 +1,6 @@ [ { - "description": "Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. `investor` must be `all`: the summary has no total for one investor side.", + "description": "Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. With `investor` foreign or domestic, shares are of that side's own buying (`buy_value`) and selling (`sell_value`); that needs `board` all.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -134,7 +134,7 @@ "name": "compare_stocks" }, { - "description": "Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means figures of a million and more carry one decimal of precision.", + "description": "Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. With `investor` foreign or domestic, each row is that broker's foreign or domestic clients' trades, `total_value` is null, and `buy_value`/`sell_value` give what that side bought and sold (`sell_value` needs `board` all). `rounded` = true means figures of a million and more carry one decimal of precision.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 43d9435..e5b2a72 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,10 +95,13 @@ WIB as the fallback. sides), plus range totals. `api/ipot/parse.rs` parses it purely. - Figures of a million and more arrive abbreviated (`1.6 M`); they are scaled in decimal and the summary's `rounded` flag says so. `F. NVal` ignores the - investor and board filters, so `foreign_net_value` is null for a filtered - summary. With `fd=F` or `fd=D` the footer covers only that side's buying - (BBCA 2026-10-16 foreign: `T. Val` 1.6 T while seller CC alone sold - 2.0 T), so totals are null for an investor-filtered summary. + investor and board filters, so `foreign_net_value` is null for a summary of + one board. With `fd=F` or `fd=D` the footer covers only that + side's buying (BBCA 2026-10-16: foreign 1.7 T and domestic 4.9 T of 6.6 T), + so `total_value` is null and the footer is `buy_value`. On all boards the + side's `sell_value` is its buying less the foreign net (foreign: 1.7 + 2.3 + = 4.0 T, against IDX's 3.95 T regular) or plus it (domestic); with a board + filter it is null. - An unknown code is a 200 with `Invalid stock code!` (`SYMBOLNOTFOUND`). The server also answers spurious nginx 404s for seconds at a time, so the client retries 404 and 5xx with backoff. A range without trades is an empty summary. @@ -106,8 +109,9 @@ WIB as the fallback. ended before today are cached for 7 days, today's for 5 minutes. Broker names come from `broker/codes.tsv` (IDX `GetBrokerCodeList`). - Bandarmology (`analysis/bandar.rs`, method `top_concentration_v1`) is pure - over one summary: for the top 1/3/5/10 per side, buy value minus sell value - in basis points of value traded, with thresholds ±500/±1500 bps taken from + over one summary: for the top 1/3/5/10 per side, the buyers' share of the + value bought minus the sellers' share of the value sold (for all investors, + buy value minus sell value in basis points of value traded), with thresholds ±500/±1500 bps taken from fzn0x/idx. A tier with a missing figure has no reading; missing values are never summed as zero. A change to the rules gets a new method name. diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 95216bb..524eb97 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -356,7 +356,7 @@ Examples: ### `idx stocks bandar` ```text -Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value against the same number of sellers', as a share of value traded, with a signal from big accumulation to big distribution. Several symbols give a screen ranked by the headline tier (at most 100, one request each). Counts all investors (`--investor` must be `all`). A heuristic over end-of-day broker codes, not advice. +Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value against the same number of sellers', as a share of value traded, with a signal from big accumulation to big distribution. Several symbols give a screen ranked by the headline tier (at most 100, one request each). `--investor foreign|domestic` weighs that side's buyers against its buying and its sellers against its selling (needs `--board all`). A heuristic over end-of-day broker codes, not advice. Usage: idx stocks bandar [OPTIONS] ... diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index e5fab6a..a09a9e5 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -39,8 +39,10 @@ idx stocks broker BBCA --from 2026-10-12 --to 2026-10-16 --board regular Which brokers bought and sold a stock: the top 10 buyers and the top 10 sellers by lots, each with lots, value and average price, plus totals and the net foreign value. Without dates it shows the latest session with trades. -`--investor foreign|domestic` counts only one side's clients, and -`--board regular` leaves out negotiated block trades. +`--investor foreign|domestic` counts only one side's clients (each broker row +is then that broker's foreign or domestic clients' trades, and the footer +gives what that side bought and sold), and `--board regular` leaves out +negotiated block trades. The data comes from Indo Premier (IPOT) and is end of day: IDX hides broker codes during trading. Buyers and sellers are ranked separately on gross @@ -87,8 +89,11 @@ traded. The headline tier (`--top`, default 5) gives the signal: | -15% or less | big distribution | Several symbols give a screen ranked by that figure (at most 100, one request -each), and it takes the same date and board flags as `broker` (all investors -only: the summary has no total for one side). It is a +each), and it takes the same date and filter flags as `broker`. With +`--investor foreign` (or `domestic`) it reads that side alone: the top buyers' +share of foreign buying against the top sellers' share of foreign selling. +That needs `--board all`, since IPOT gives one side's selling only across all +boards. It is a heuristic: a broker trades for many clients, and a broker on both sides of the summary is listed, since its gross figures overstate which way it traded. The JSON names the rules in `method` (`top_concentration_v1`). diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 110371a..f6a0f9c 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -12,7 +12,7 @@ message}` with `isError` set. ## `analyze_bandar` -Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. `investor` must be `all`: the summary has no total for one investor side. +Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. With `investor` foreign or domestic, shares are of that side's own buying (`buy_value`) and selling (`sell_value`); that needs `board` all. | Input | Type | Required | Description | | --- | --- | --- | --- | @@ -43,7 +43,7 @@ Compare fundamental reports (growth, valuation, risk) across IDX tickers. Ticker ## `get_broker_summary` -Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means figures of a million and more carry one decimal of precision. +Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. With `investor` foreign or domestic, each row is that broker's foreign or domestic clients' trades, `total_value` is null, and `buy_value`/`sell_value` give what that side bought and sold (`sell_value` needs `board` all). `rounded` = true means figures of a million and more carry one decimal of precision. | Input | Type | Required | Description | | --- | --- | --- | --- | From b137dbf5408f6b3307dfd8782469b3ac2c617574 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 06:40:15 +0000 Subject: [PATCH 10/32] fix(core): version the broker summary cache bucket The cache stores normalized summaries, so one written before buy_value and sell_value existed read back without them: bandar gave null readings (the table said "No trades.") for a week, and an investor-filtered entry lacked its totals. Summaries now cache under ipot-broker-v2, so an old-shape entry is never served; offline it is a cache miss, online it is fetched again. A CLI test seeds an old entry and checks both, in JSON and table output. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-cli/tests/cli.rs | 66 +++++++++++++++++++++++++++++++ crates/idx-core/src/broker/mod.rs | 9 ++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 1175eaf..5028e9f 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2933,3 +2933,69 @@ fn bandar_reads_one_investor_side_against_its_own_trading() { "{table}" ); } + +#[test] +fn bandar_never_reads_a_broker_summary_cached_in_the_old_shape() { + // A summary cached before buy_value/sell_value existed: fresh for a + // week, with trades, but no denominators for a reading. + let root = test_env_dir("bandar-legacy-cache"); + let bucket = root.join("cache").join("idx").join("ipot-broker"); + fs::create_dir_all(&bucket).unwrap(); + let flow = |code: &str, value: i64| { + serde_json::json!({"rank": 1, "code": code, "name": null, "kind": null, + "lots": value / 100, "value": value, "average_price": 1}) + }; + let legacy = serde_json::json!({ + "fetched_at": chrono::Utc::now().to_rfc3339(), + "ttl_secs": 7 * 24 * 3600, + "schema_version": 1, + "data": { + "symbol": "BBCA.JK", "from": "2026-10-16", "to": "2026-10-16", + "investor": "all", "board": "all", + "buyers": [flow("AA", 60_000)], "sellers": [flow("BB", 40_000)], + "total_value": 100_000, "total_lots": 1_000, "average_price": 100, + "foreign_net_value": null, "rounded": false + } + }); + fs::write( + bucket.join("BBCA-2026-10-16-2026-10-16-all-all.json"), + legacy.to_string(), + ) + .unwrap(); + + // Offline, the old entry is not served as a complete summary. + run_error_json( + mock_bin_with_root(&root).args([ + "-o", + "json", + "--offline", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + ]), + "CACHEMISS", + ); + // Online, the summary is fetched again and read in full. + let reports = run_success_json(mock_bin_with_root(&root).args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + ])); + assert_eq!(reports[0]["imbalance_bps"], -2425); + let table = run_success_stdout(mock_bin_with_root(&root).args([ + "--no-color", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + ])); + assert!(!table.contains("No trades."), "{table}"); + assert!(table.contains("big distribution"), "{table}"); +} diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs index 7ecc7e2..b53addf 100644 --- a/crates/idx-core/src/broker/mod.rs +++ b/crates/idx-core/src/broker/mod.rs @@ -15,6 +15,11 @@ use crate::error::IdxError; use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerRange, BrokerSummary}; const SOURCE: &str = "ipot"; +/// Cache bucket for normalized summaries, versioned with their shape: a +/// summary cached before `buy_value`/`sell_value` existed would read back +/// without them (and an investor-filtered one without its totals), so a new +/// shape gets a new bucket rather than serving such entries. +const CACHE_BUCKET: &str = "ipot-broker-v2"; /// Longest range one summary may cover. pub const MAX_RANGE_DAYS: i64 = 366; /// Calendar days searched back for the latest session with trades. @@ -98,7 +103,7 @@ impl Broker<'_> { cached_fetch( &self.idx.cache, CacheSpec { - bucket: "ipot-broker", + bucket: CACHE_BUCKET, key: &key, ttl_secs: if query.to < today { TTL_CLOSED_SECS @@ -146,7 +151,7 @@ impl Broker<'_> { } if policy == CachePolicy::Offline { return Err(IdxError::CacheMiss(format!( - "ipot-broker/{resolved} (no cached session in the last {LATEST_LOOKBACK_DAYS} days)" + "{CACHE_BUCKET}/{resolved} (no cached session in the last {LATEST_LOOKBACK_DAYS} days)" ))); } Err(IdxError::NoMarketData(format!( From 8b507d78e2e92153fa28222f36c99ba3b6896f19 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 06:19:02 +0000 Subject: [PATCH 11/32] feat: foreign flow from IPOT by default (--source ipot|idx) `idx stocks flow` now reads IPOT by default, which needs nothing installed: per session, the all-investor broker summary gives the value traded and the foreign one gives foreign buying (its footer), the foreign net and selling (buying less the net), across all boards, through the broker summary cache. IPOT's foreign net matched IDX's estimate within 0.3 B on BBCA 2026-10-12 to 16. `--source idx` (and `source` on get_foreign_flow) keeps IDX's trading summary: exact share counts, close and the non-regular share, behind curl-impersonate and Cloudflare. - ForeignFlowDay gains foreign_buy_value, foreign_sell_value and foreign_share_bps for both sources (estimated at the session average for IDX); ForeignFlow gains source, the value totals and rounded. - The table lays out each source's figures; the live smoke runs both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- ROADMAP.md | 5 +- crates/idx-cli/src/cli/stocks.rs | 20 +- crates/idx-cli/src/output/table.rs | 65 +++- crates/idx-cli/tests/cli.rs | 67 +++- crates/idx-cli/tests/golden/cases.toml | 9 +- .../tests/golden/completions-bash.golden | 6 +- .../golden/stocks-flow-idx-latest.json.golden | 4 + ...en => stocks-flow-idx-latest.table.golden} | 2 +- .../tests/golden/stocks-flow-idx.json.golden | 4 + .../tests/golden/stocks-flow-idx.jsonl.golden | 4 + .../tests/golden/stocks-flow-idx.table.golden | 19 ++ .../golden/stocks-flow-latest.json.golden | 4 - .../tests/golden/stocks-flow.json.golden | 2 +- .../tests/golden/stocks-flow.jsonl.golden | 2 +- .../tests/golden/stocks-flow.table.golden | 33 +- crates/idx-core/src/broker/mod.rs | 6 +- crates/idx-core/src/flow/mod.rs | 306 ++++++++++++++---- crates/idx-core/src/flow/types.rs | 92 +++++- .../ipot_brokersummary_bbca_20261012.html | 181 +++++++++++ ...okersummary_bbca_20261012_foreign_all.html | 181 +++++++++++ .../ipot_brokersummary_bbca_20261013.html | 181 +++++++++++ ...okersummary_bbca_20261013_foreign_all.html | 181 +++++++++++ .../ipot_brokersummary_bbca_20261014.html | 181 +++++++++++ ...okersummary_bbca_20261014_foreign_all.html | 181 +++++++++++ .../ipot_brokersummary_bbca_20261015.html | 181 +++++++++++ ...okersummary_bbca_20261015_foreign_all.html | 181 +++++++++++ crates/idx-mcp/src/params.rs | 11 + crates/idx-mcp/src/server.rs | 15 +- crates/idx-mcp/tests/golden/tools.json | 11 +- crates/idx-mcp/tests/mcp.rs | 16 +- docs/ARCHITECTURE.md | 12 +- docs/SMOKE.md | 4 +- scripts/live-smoke.sh | 7 +- web/src/content/docs/cli/reference.md | 14 +- web/src/content/docs/cli/stocks.md | 25 +- web/src/content/docs/mcp/tools.md | 3 +- web/src/content/docs/start/install.mdx | 5 +- 37 files changed, 2079 insertions(+), 142 deletions(-) create mode 100644 crates/idx-cli/tests/golden/stocks-flow-idx-latest.json.golden rename crates/idx-cli/tests/golden/{stocks-flow-latest.table.golden => stocks-flow-idx-latest.table.golden} (97%) create mode 100644 crates/idx-cli/tests/golden/stocks-flow-idx.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-flow-idx.jsonl.golden create mode 100644 crates/idx-cli/tests/golden/stocks-flow-idx.table.golden delete mode 100644 crates/idx-cli/tests/golden/stocks-flow-latest.json.golden create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_foreign_all.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013_foreign_all.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014_foreign_all.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015.html create mode 100644 crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015_foreign_all.html diff --git a/ROADMAP.md b/ROADMAP.md index 2194c72..56cf036 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,8 +36,9 @@ history lives in git. - `stocks broker` and `stocks bandar` show only IPOT's top 10 buyers and sellers, ranked by gross lots, with figures of a million and more rounded to one decimal. -- `stocks flow` depends on IDX's Cloudflare passing a curl-impersonate - profile (only `curl_safari184` in October 2026). +- `stocks flow --source idx` depends on IDX's Cloudflare passing a + curl-impersonate profile (only `curl_safari184` in October 2026) and on the + network; the default IPOT source rounds to one decimal. - Yahoo can return `429` from datacenter IPs; requests retry with backoff but can still fail. - `stocks technical` reports the SMA200 trend as unavailable when fewer than diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index 9667c2d..d27e576 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -15,7 +15,7 @@ use idx_core::api::types::{Interval, Period}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use idx_core::error::IdxError; use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind}; -use idx_core::flow::types::FlowRange; +use idx_core::flow::types::{FlowRange, FlowSource}; use idx_core::market::ScreenQuery; use idx_core::{CachePolicy, Idx, runtime}; @@ -224,11 +224,13 @@ heuristic over end-of-day broker codes, not advice.", }, #[command( about = "Foreign buying and selling of a stock, session by session", - long_about = "Foreign buying and selling of a stock, session by session, from IDX's daily \ -trading summary: foreign buy, sell and net volume, the net value at the session's average \ -price, and the share of value traded outside the regular board. End of day. Each session is \ -one request to idx.co.id, which needs curl-impersonate.", - after_help = "Examples:\n idx stocks flow BBCA\n idx stocks flow BBCA --days 20\n idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16\n idx -o json stocks flow BBCA | jq '.foreign_net'" + long_about = "Foreign buying and selling of a stock, session by session, end of day. By \ +default from Indo Premier (IPOT): foreign buy, sell and net value across all boards and \ +foreign investors' share of trading, two plain requests per session. `--source idx` reads \ +IDX's own trading summary instead: exact foreign share counts, the close and the share \ +traded outside the regular board, one request per session to idx.co.id, which needs \ +curl-impersonate and a network IDX's Cloudflare lets through.", + after_help = "Examples:\n idx stocks flow BBCA\n idx stocks flow BBCA --days 20\n idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16\n idx stocks flow BBCA --source idx\n idx -o json stocks flow BBCA | jq '.foreign_net_value'" )] Flow { /// Single ticker symbol (e.g. BBCA). @@ -242,6 +244,9 @@ one request to idx.co.id, which needs curl-impersonate.", /// Last session of the range, YYYY-MM-DD. Default: today. #[arg(long, requires = "from")] to: Option, + /// Where the figures come from. + #[arg(long, value_enum, default_value_t = FlowSource::Ipot)] + source: FlowSource, }, #[command(about = "Get company profile")] Profile { symbol: String }, @@ -389,10 +394,11 @@ pub fn handle( days, from, to, + source, } => { let range = FlowRange::from_args(*days, from.as_deref(), to.as_deref(), idx.flow().today())?; - let flow = idx.flow().foreign(symbol, range, policy)?; + let flow = idx.flow().foreign(symbol, range, *source, policy)?; render_foreign_flow(&flow.data, format) } StocksSubcommand::Profile { symbol } => { diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 4e742e9..42b9afd 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -16,7 +16,7 @@ use idx_core::api::types::{ }; use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary}; use idx_core::error::IdxError; -use idx_core::flow::types::ForeignFlow; +use idx_core::flow::types::{FlowSource, ForeignFlow}; pub fn format_idr(value: i64) -> String { let sign = if value.is_negative() { "-" } else { "" }; @@ -1262,12 +1262,70 @@ pub fn print_foreign_flow(flow: &ForeignFlow) -> Result<(), IdxError> { println!( "{}", format!( - "Foreign flow for {}, {span} ({sessions} session{})", + "Foreign flow for {}, {span} ({sessions} session{}, {})", terminal_safe(&flow.symbol), - if sessions == 1 { "" } else { "s" } + if sessions == 1 { "" } else { "s" }, + match flow.source { + FlowSource::Ipot => "IPOT", + FlowSource::Idx => "IDX", + } ) .bold() ); + match flow.source { + FlowSource::Ipot => print_ipot_flow(flow), + FlowSource::Idx => print_idx_flow(flow), + } + if flow.rounded { + println!("Note: the source rounds figures of a million and more to one decimal."); + } + Ok(()) +} + +fn print_ipot_flow(flow: &ForeignFlow) { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "DATE", + "VALUE", + "FOREIGN BUY", + "FOREIGN SELL", + "FOREIGN NET", + "FOREIGN %", + ]); + for day in &flow.days { + table.add_row(vec![ + cell(day.date), + cell(format_compact_option(day.value)), + cell(format_compact_option(day.foreign_buy_value)), + cell(format_compact_option(day.foreign_sell_value)), + cell(format_signed_compact(day.foreign_net_value)), + cell(format_bps_share(day.foreign_share_bps)), + ]); + } + println!("{table}"); + let mut summary = format!( + "Foreign investors bought {} IDR and sold {} IDR: net {}", + format_compact_option(flow.foreign_buy_value), + format_compact_option(flow.foreign_sell_value), + format_signed_compact(flow.foreign_net_value) + ); + if let Some(share) = flow.foreign_share_bps { + summary.push_str(&format!( + "; they made up {} of trading", + format_bps_share(Some(share)) + )); + } + println!("{summary}."); + println!( + "Values in IDR across all boards. For exact share counts, the close and the non-regular \ +share, use --source idx (needs curl-impersonate)." + ); +} + +fn print_idx_flow(flow: &ForeignFlow) { let mut table = Table::new(); table .load_preset(UTF8_FULL) @@ -1314,7 +1372,6 @@ pub fn print_foreign_flow(flow: &ForeignFlow) -> Result<(), IdxError> { is estimated at each session's average price; NON-REG is the share of all value traded \ outside the regular board (negotiated and cash)." ); - Ok(()) } #[cfg(test)] diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 5028e9f..7cddf9d 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2758,11 +2758,13 @@ fn bandar_errors_follow_the_output_contract() { } #[test] -fn foreign_flow_reads_sessions_oldest_first() { +fn foreign_flow_from_idx_reads_sessions_oldest_first() { let flow = run_success_json( mock_bin("flow-json") .env("IDX_MOCK_TODAY", "2026-10-18") - .args(["-o", "json", "stocks", "flow", "BBCA", "--days", "3"]), + .args([ + "-o", "json", "stocks", "flow", "BBCA", "--days", "3", "--source", "idx", + ]), ); assert_eq!(flow["symbol"], "BBCA.JK"); let dates: Vec<&str> = flow["days"] @@ -2787,6 +2789,8 @@ fn foreign_flow_reads_sessions_oldest_first() { "json", "stocks", "flow", + "--source", + "idx", "BBCA", "--from", "2026-10-12", @@ -2805,7 +2809,7 @@ fn foreign_flow_reads_sessions_oldest_first() { } #[test] -fn foreign_flow_offline_uses_cached_sessions_only() { +fn foreign_flow_from_idx_offline_uses_cached_sessions_only() { let root = test_env_dir("flow-offline"); run_success_json( mock_bin_with_root(&root) @@ -2815,6 +2819,8 @@ fn foreign_flow_offline_uses_cached_sessions_only() { "json", "stocks", "flow", + "--source", + "idx", "BBCA", "--from", "2026-10-15", @@ -2832,6 +2838,8 @@ fn foreign_flow_offline_uses_cached_sessions_only() { "--offline", "stocks", "flow", + "--source", + "idx", "TLKM", "--days", "5", @@ -2848,6 +2856,8 @@ fn foreign_flow_offline_uses_cached_sessions_only() { "--offline", "stocks", "flow", + "--source", + "idx", "TLKM", "--from", "2026-10-12", @@ -2858,6 +2868,52 @@ fn foreign_flow_offline_uses_cached_sessions_only() { ); } +#[test] +fn foreign_flow_from_ipot_by_default_gives_values_across_all_boards() { + let flow = run_success_json( + mock_bin("flow-ipot") + .env("IDX_MOCK_TODAY", "2026-10-18") + .args([ + "-o", + "json", + "stocks", + "flow", + "BBCA", + "--from", + "2026-10-12", + "--to", + "2026-10-17", + ]), + ); + assert_eq!(flow["source"], "ipot"); + assert_eq!(flow["rounded"], true); + let days = flow["days"].as_array().unwrap(); + assert_eq!(days.len(), 5, "the Saturday had no trading"); + let last = &days[4]; + assert_eq!(last["date"], "2026-10-16"); + assert_eq!(last["value"], 6_600_000_000_000_i64); + assert_eq!(last["foreign_buy_value"], 1_700_000_000_000_i64); + assert_eq!(last["foreign_sell_value"], 4_000_000_000_000_i64); + assert_eq!(last["foreign_net_value"], -2_300_000_000_000_i64); + for field in ["close", "foreign_buy", "foreign_net", "non_regular_bps"] { + assert!(last[field].is_null(), "{field}"); + } + let net: i64 = days + .iter() + .map(|day| day["foreign_net_value"].as_i64().unwrap()) + .sum(); + assert_eq!(flow["foreign_net_value"], net); + assert!(flow["foreign_net"].is_null()); + + let table = run_success_stdout( + mock_bin("flow-ipot-table") + .env("IDX_MOCK_TODAY", "2026-10-18") + .args(["stocks", "flow", "BBCA", "--days", "1"]), + ); + assert!(table.contains("(1 session, IPOT)"), "{table}"); + assert!(table.contains("FOREIGN %"), "{table}"); +} + #[test] fn foreign_flow_errors_follow_the_output_contract() { for (args, code) in [ @@ -2873,6 +2929,11 @@ fn foreign_flow_errors_follow_the_output_contract() { vec!["BBCA", "--from", "2026-06-01", "--to", "2026-10-16"], "INVALIDINPUT", ), + (vec!["BBCA", "--source", "stockbit"], "INVALIDINPUT"), + ( + vec!["ZZZZ", "--days", "2", "--source", "idx"], + "SYMBOLNOTFOUND", + ), ] { let mut cmd = mock_bin("flow-errors"); cmd.env("IDX_MOCK_TODAY", "2026-10-18") diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 2e83d91..2cb5311 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -273,8 +273,13 @@ formats = ["table", "json", "jsonl"] args = ["stocks", "flow", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"] [[case]] -name = "stocks-flow-latest" -args = ["stocks", "flow", "TLKM", "--days", "3"] +name = "stocks-flow-idx" +formats = ["table", "json", "jsonl"] +args = ["stocks", "flow", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16", "--source", "idx"] + +[[case]] +name = "stocks-flow-idx-latest" +args = ["stocks", "flow", "TLKM", "--days", "3", "--source", "idx"] env = { IDX_MOCK_TODAY = "2026-10-18" } [[case]] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index f6630c2..aff5968 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -2442,7 +2442,7 @@ _idx() { return 0 ;; idx__stocks__flow) - opts="-o -q -v -h --days --from --to --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --days --from --to --source --output --no-color --quiet --verbose --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2460,6 +2460,10 @@ _idx() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --source) + COMPREPLY=($(compgen -W "ipot idx" -- "${cur}")) + return 0 + ;; --output) COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 diff --git a/crates/idx-cli/tests/golden/stocks-flow-idx-latest.json.golden b/crates/idx-cli/tests/golden/stocks-flow-idx-latest.json.golden new file mode 100644 index 0000000..b600c20 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-idx-latest.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"TLKM.JK","source":"idx","from":"2026-10-14","to":"2026-10-16","days":[{"date":"2026-10-14","close":2250,"change_bps":45,"volume":87159900,"value":197725738000,"foreign_buy":63030200,"foreign_sell":60811200,"foreign_net":2219000,"foreign_buy_value":142986543253,"foreign_sell_value":137952652523,"foreign_net_value":5033890729,"foreign_share_bps":7104,"non_regular_bps":979},{"date":"2026-10-15","close":2230,"change_bps":-89,"volume":150485300,"value":333287991000,"foreign_buy":97385100,"foreign_sell":105521800,"foreign_net":-8136700,"foreign_buy_value":215684085637,"foreign_sell_value":233704878341,"foreign_net_value":-18020792704,"foreign_share_bps":6742,"non_regular_bps":78},{"date":"2026-10-16","close":1900,"change_bps":-1480,"volume":963614200,"value":1855341292500,"foreign_buy":679004400,"foreign_sell":727289800,"foreign_net":-48285400,"foreign_buy_value":1307354023123,"foreign_sell_value":1400322657713,"foreign_net_value":-92968634589,"foreign_share_bps":7297,"non_regular_bps":1485}],"foreign_net":-54203100,"foreign_buy_value":1666024652013,"foreign_sell_value":1771980188577,"foreign_net_value":-105955536564,"foreign_share_bps":7213,"rounded":false} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-latest.table.golden b/crates/idx-cli/tests/golden/stocks-flow-idx-latest.table.golden similarity index 97% rename from crates/idx-cli/tests/golden/stocks-flow-latest.table.golden rename to crates/idx-cli/tests/golden/stocks-flow-idx-latest.table.golden index d9e7bf6..8dad2c5 100644 --- a/crates/idx-cli/tests/golden/stocks-flow-latest.table.golden +++ b/crates/idx-cli/tests/golden/stocks-flow-idx-latest.table.golden @@ -1,6 +1,6 @@ exit: 0 --- stdout -Foreign flow for TLKM.JK, 2026-10-14 to 2026-10-16 (3 sessions) +Foreign flow for TLKM.JK, 2026-10-14 to 2026-10-16 (3 sessions, IDX) ┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐ │ DATE ┆ CLOSE ┆ CHG% ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ NET VALUE ┆ NON-REG │ ╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡ diff --git a/crates/idx-cli/tests/golden/stocks-flow-idx.json.golden b/crates/idx-cli/tests/golden/stocks-flow-idx.json.golden new file mode 100644 index 0000000..f75365d --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-idx.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","source":"idx","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_buy_value":308312483427,"foreign_sell_value":364491482581,"foreign_net_value":-56178999154,"foreign_share_bps":5736,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_buy_value":525596708899,"foreign_sell_value":495796429810,"foreign_net_value":29800279088,"foreign_share_bps":7029,"non_regular_bps":159},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_buy_value":728974613876,"foreign_sell_value":512618377690,"foreign_net_value":216356236186,"foreign_share_bps":7490,"non_regular_bps":2412},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_buy_value":732252292035,"foreign_sell_value":446191587157,"foreign_net_value":286060704877,"foreign_share_bps":7216,"non_regular_bps":1046},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_buy_value":1614133400188,"foreign_sell_value":3950534223629,"foreign_net_value":-2336400823441,"foreign_share_bps":4297,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_buy_value":3909269498425,"foreign_sell_value":5769632100867,"foreign_net_value":-1860362602444,"foreign_share_bps":5050,"rounded":false} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-idx.jsonl.golden b/crates/idx-cli/tests/golden/stocks-flow-idx.jsonl.golden new file mode 100644 index 0000000..f75365d --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-idx.jsonl.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","source":"idx","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_buy_value":308312483427,"foreign_sell_value":364491482581,"foreign_net_value":-56178999154,"foreign_share_bps":5736,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_buy_value":525596708899,"foreign_sell_value":495796429810,"foreign_net_value":29800279088,"foreign_share_bps":7029,"non_regular_bps":159},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_buy_value":728974613876,"foreign_sell_value":512618377690,"foreign_net_value":216356236186,"foreign_share_bps":7490,"non_regular_bps":2412},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_buy_value":732252292035,"foreign_sell_value":446191587157,"foreign_net_value":286060704877,"foreign_share_bps":7216,"non_regular_bps":1046},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_buy_value":1614133400188,"foreign_sell_value":3950534223629,"foreign_net_value":-2336400823441,"foreign_share_bps":4297,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_buy_value":3909269498425,"foreign_sell_value":5769632100867,"foreign_net_value":-1860362602444,"foreign_share_bps":5050,"rounded":false} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-idx.table.golden b/crates/idx-cli/tests/golden/stocks-flow-idx.table.golden new file mode 100644 index 0000000..32fa111 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-flow-idx.table.golden @@ -0,0 +1,19 @@ +exit: 0 +--- stdout +Foreign flow for BBCA.JK, 2026-10-12 to 2026-10-16 (5 sessions, IDX) +┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐ +│ DATE ┆ CLOSE ┆ CHG% ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ NET VALUE ┆ NON-REG │ +╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡ +│ 2026-10-12 ┆ 5,950 ┆ -1.6% ┆ 586.5B ┆ 51.5M ┆ 60.9M ┆ -9.4M ┆ -56.2B ┆ 0.1% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-13 ┆ 5,950 ┆ +0.0% ┆ 726.6B ┆ 88.8M ┆ 83.7M ┆ +5.0M ┆ +29.8B ┆ 1.6% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-14 ┆ 6,125 ┆ +2.9% ┆ 828.9B ┆ 119.9M ┆ 84.3M ┆ +35.6M ┆ +216.4B ┆ 24.1% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ 6,075 ┆ -0.8% ┆ 816.6B ┆ 120.8M ┆ 73.6M ┆ +47.2M ┆ +286.1B ┆ 10.5% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-16 ┆ 5,175 ┆ -14.8% ┆ 6.5T ┆ 309.4M ┆ 757.2M ┆ -447.8M ┆ -2.3T ┆ 1.5% │ +└────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘ +Net foreign: -369.4M shares, about -1.9T IDR; foreign investors made up 50.5% of trading. +Volume, value and foreign figures are the regular board, volumes in shares. Net value is estimated at each session's average price; NON-REG is the share of all value traded outside the regular board (negotiated and cash). +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden b/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden deleted file mode 100644 index 9711422..0000000 --- a/crates/idx-cli/tests/golden/stocks-flow-latest.json.golden +++ /dev/null @@ -1,4 +0,0 @@ -exit: 0 ---- stdout -{"symbol":"TLKM.JK","from":"2026-10-14","to":"2026-10-16","days":[{"date":"2026-10-14","close":2250,"change_bps":45,"volume":87159900,"value":197725738000,"foreign_buy":63030200,"foreign_sell":60811200,"foreign_net":2219000,"foreign_net_value":5033890729,"non_regular_bps":979},{"date":"2026-10-15","close":2230,"change_bps":-89,"volume":150485300,"value":333287991000,"foreign_buy":97385100,"foreign_sell":105521800,"foreign_net":-8136700,"foreign_net_value":-18020792704,"non_regular_bps":78},{"date":"2026-10-16","close":1900,"change_bps":-1480,"volume":963614200,"value":1855341292500,"foreign_buy":679004400,"foreign_sell":727289800,"foreign_net":-48285400,"foreign_net_value":-92968634589,"non_regular_bps":1485}],"foreign_net":-54203100,"foreign_net_value":-105955536564,"foreign_share_bps":7213} ---- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.json.golden b/crates/idx-cli/tests/golden/stocks-flow.json.golden index 3a4105e..29e9d90 100644 --- a/crates/idx-cli/tests/golden/stocks-flow.json.golden +++ b/crates/idx-cli/tests/golden/stocks-flow.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_net_value":-56178999154,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_net_value":29800279088,"non_regular_bps":159},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_net_value":216356236186,"non_regular_bps":2412},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_net_value":286060704877,"non_regular_bps":1046},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_net_value":-2336400823441,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050} +{"symbol":"BBCA.JK","source":"ipot","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":null,"change_bps":null,"volume":98041500,"value":587100000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":308700000000,"foreign_sell_value":364600000000,"foreign_net_value":-55900000000,"foreign_share_bps":5734,"non_regular_bps":null},{"date":"2026-10-13","close":null,"change_bps":null,"volume":120000000,"value":738200000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":537300000000,"foreign_sell_value":507200000000,"foreign_net_value":30100000000,"foreign_share_bps":7075,"non_regular_bps":null},{"date":"2026-10-14","close":null,"change_bps":null,"volume":180000000,"value":1100000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":991800000000,"foreign_sell_value":775200000000,"foreign_net_value":216600000000,"foreign_share_bps":8032,"non_regular_bps":null},{"date":"2026-10-15","close":null,"change_bps":null,"volume":150000000,"value":912000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":826900000000,"foreign_sell_value":540800000000,"foreign_net_value":286100000000,"foreign_share_bps":7498,"non_regular_bps":null},{"date":"2026-10-16","close":null,"change_bps":null,"volume":1260000000,"value":6600000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":1700000000000,"foreign_sell_value":4000000000000,"foreign_net_value":-2300000000000,"foreign_share_bps":4318,"non_regular_bps":null}],"foreign_net":null,"foreign_buy_value":4364700000000,"foreign_sell_value":6187800000000,"foreign_net_value":-1823100000000,"foreign_share_bps":5310,"rounded":true} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden b/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden index 3a4105e..29e9d90 100644 --- a/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden +++ b/crates/idx-cli/tests/golden/stocks-flow.jsonl.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":5950,"change_bps":-165,"volume":97965100,"value":586491892500,"foreign_buy":51499200,"foreign_sell":60883100,"foreign_net":-9383900,"foreign_net_value":-56178999154,"non_regular_bps":14},{"date":"2026-10-13","close":5950,"change_bps":0,"volume":122716900,"value":726575115000,"foreign_buy":88772100,"foreign_sell":83738900,"foreign_net":5033200,"foreign_net_value":29800279088,"non_regular_bps":159},{"date":"2026-10-14","close":6125,"change_bps":294,"volume":136303100,"value":828862772500,"foreign_buy":119876900,"foreign_sell":84298000,"foreign_net":35578900,"foreign_net_value":216356236186,"non_regular_bps":2412},{"date":"2026-10-15","close":6075,"change_bps":-82,"volume":134763200,"value":816554920000,"foreign_buy":120850000,"foreign_sell":73638900,"foreign_net":47211100,"foreign_net_value":286060704877,"non_regular_bps":1046},{"date":"2026-10-16","close":5175,"change_bps":-1481,"volume":1241098200,"value":6474934315000,"foreign_buy":309392800,"foreign_sell":757227900,"foreign_net":-447835100,"foreign_net_value":-2336400823441,"non_regular_bps":152}],"foreign_net":-369395800,"foreign_net_value":-1860362602444,"foreign_share_bps":5050} +{"symbol":"BBCA.JK","source":"ipot","from":"2026-10-12","to":"2026-10-16","days":[{"date":"2026-10-12","close":null,"change_bps":null,"volume":98041500,"value":587100000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":308700000000,"foreign_sell_value":364600000000,"foreign_net_value":-55900000000,"foreign_share_bps":5734,"non_regular_bps":null},{"date":"2026-10-13","close":null,"change_bps":null,"volume":120000000,"value":738200000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":537300000000,"foreign_sell_value":507200000000,"foreign_net_value":30100000000,"foreign_share_bps":7075,"non_regular_bps":null},{"date":"2026-10-14","close":null,"change_bps":null,"volume":180000000,"value":1100000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":991800000000,"foreign_sell_value":775200000000,"foreign_net_value":216600000000,"foreign_share_bps":8032,"non_regular_bps":null},{"date":"2026-10-15","close":null,"change_bps":null,"volume":150000000,"value":912000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":826900000000,"foreign_sell_value":540800000000,"foreign_net_value":286100000000,"foreign_share_bps":7498,"non_regular_bps":null},{"date":"2026-10-16","close":null,"change_bps":null,"volume":1260000000,"value":6600000000000,"foreign_buy":null,"foreign_sell":null,"foreign_net":null,"foreign_buy_value":1700000000000,"foreign_sell_value":4000000000000,"foreign_net_value":-2300000000000,"foreign_share_bps":4318,"non_regular_bps":null}],"foreign_net":null,"foreign_buy_value":4364700000000,"foreign_sell_value":6187800000000,"foreign_net_value":-1823100000000,"foreign_share_bps":5310,"rounded":true} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-flow.table.golden b/crates/idx-cli/tests/golden/stocks-flow.table.golden index 0736ef1..066a27a 100644 --- a/crates/idx-cli/tests/golden/stocks-flow.table.golden +++ b/crates/idx-cli/tests/golden/stocks-flow.table.golden @@ -1,19 +1,20 @@ exit: 0 --- stdout -Foreign flow for BBCA.JK, 2026-10-12 to 2026-10-16 (5 sessions) -┌────────────┬───────┬────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┬─────────┐ -│ DATE ┆ CLOSE ┆ CHG% ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ NET VALUE ┆ NON-REG │ -╞════════════╪═══════╪════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╪═════════╡ -│ 2026-10-12 ┆ 5,950 ┆ -1.6% ┆ 586.5B ┆ 51.5M ┆ 60.9M ┆ -9.4M ┆ -56.2B ┆ 0.1% │ -├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ -│ 2026-10-13 ┆ 5,950 ┆ +0.0% ┆ 726.6B ┆ 88.8M ┆ 83.7M ┆ +5.0M ┆ +29.8B ┆ 1.6% │ -├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ -│ 2026-10-14 ┆ 6,125 ┆ +2.9% ┆ 828.9B ┆ 119.9M ┆ 84.3M ┆ +35.6M ┆ +216.4B ┆ 24.1% │ -├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ -│ 2026-10-15 ┆ 6,075 ┆ -0.8% ┆ 816.6B ┆ 120.8M ┆ 73.6M ┆ +47.2M ┆ +286.1B ┆ 10.5% │ -├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤ -│ 2026-10-16 ┆ 5,175 ┆ -14.8% ┆ 6.5T ┆ 309.4M ┆ 757.2M ┆ -447.8M ┆ -2.3T ┆ 1.5% │ -└────────────┴───────┴────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┴─────────┘ -Net foreign: -369.4M shares, about -1.9T IDR; foreign investors made up 50.5% of trading. -Volume, value and foreign figures are the regular board, volumes in shares. Net value is estimated at each session's average price; NON-REG is the share of all value traded outside the regular board (negotiated and cash). +Foreign flow for BBCA.JK, 2026-10-12 to 2026-10-16 (5 sessions, IPOT) +┌────────────┬────────┬─────────────┬──────────────┬─────────────┬───────────┐ +│ DATE ┆ VALUE ┆ FOREIGN BUY ┆ FOREIGN SELL ┆ FOREIGN NET ┆ FOREIGN % │ +╞════════════╪════════╪═════════════╪══════════════╪═════════════╪═══════════╡ +│ 2026-10-12 ┆ 587.1B ┆ 308.7B ┆ 364.6B ┆ -55.9B ┆ 57.3% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-13 ┆ 738.2B ┆ 537.3B ┆ 507.2B ┆ +30.1B ┆ 70.8% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-14 ┆ 1.1T ┆ 991.8B ┆ 775.2B ┆ +216.6B ┆ 80.3% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ 912.0B ┆ 826.9B ┆ 540.8B ┆ +286.1B ┆ 75.0% │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-16 ┆ 6.6T ┆ 1.7T ┆ 4.0T ┆ -2.3T ┆ 43.2% │ +└────────────┴────────┴─────────────┴──────────────┴─────────────┴───────────┘ +Foreign investors bought 4.4T IDR and sold 6.2T IDR: net -1.8T; they made up 53.1% of trading. +Values in IDR across all boards. For exact share counts, the close and the non-regular share, use --source idx (needs curl-impersonate). +Note: the source rounds figures of a million and more to one decimal. --- stderr diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs index b53addf..7392266 100644 --- a/crates/idx-core/src/broker/mod.rs +++ b/crates/idx-core/src/broker/mod.rs @@ -167,7 +167,11 @@ impl Broker<'_> { /// trades the lists show. `F. NVal`, the foreign net, ignores both filters: /// on all boards it gives the side's selling (foreign buying less the foreign /// net; domestic buying plus it), and with a board filter it is dropped. -fn summary_from_raw(symbol: &str, query: &BrokerQuery, raw: BrokerSummaryRaw) -> BrokerSummary { +pub(crate) fn summary_from_raw( + symbol: &str, + query: &BrokerQuery, + raw: BrokerSummaryRaw, +) -> BrokerSummary { let one_side = query.investor != BrokerInvestor::All; let all_boards = query.board == BrokerBoard::All; let foreign_net = raw.foreign_net_value.filter(|_| all_boards); diff --git a/crates/idx-core/src/flow/mod.rs b/crates/idx-core/src/flow/mod.rs index cbfc52e..eec2d31 100644 --- a/crates/idx-core/src/flow/mod.rs +++ b/crates/idx-core/src/flow/mod.rs @@ -1,18 +1,19 @@ //! Foreign flow: what foreign investors bought and sold of a stock, session -//! by session, from IDX's daily trading summary (end of day). One request -//! covers every stock for one session and is cached by date. +//! by session (end of day). IPOT (the default) needs two plain requests per +//! session; IDX's trading summary covers every stock in one request per +//! session, cached by date, but needs curl-impersonate. pub mod types; use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday}; use crate::api::{today_wib, trading_summary_provider}; +use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerSummary}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::error::IdxError; -use types::{FlowRange, ForeignFlow, ForeignFlowDay, StockDay}; +use types::{FlowRange, FlowSource, ForeignFlow, ForeignFlowDay, StockDay}; -const SOURCE: &str = "idx"; -/// Most sessions `FlowRange::Latest` reads, one request each. +/// Most sessions `FlowRange::Latest` reads. pub const MAX_SESSIONS: usize = 60; /// Longest `FlowRange::Dates` range, calendar days. pub const MAX_RANGE_DAYS: i64 = 92; @@ -24,17 +25,36 @@ pub struct Flow<'a> { pub(crate) idx: &'a Idx, } +/// What one weekday held for the stock. +enum Day { + /// The market (or, for IPOT, the stock) did not trade. + NoSession, + /// The market traded without the stock (a suspension). + Absent, + Traded { + day: Box, + rounded: bool, + }, +} + +struct Fetch { + day: Day, + fetched_at: DateTime, + stale: bool, +} + impl Flow<'_> { /// Today in WIB. pub fn today(&self) -> NaiveDate { today_wib() } - /// Foreign buying and selling of `symbol` over `range`. + /// Foreign buying and selling of `symbol` over `range`, from `source`. pub fn foreign( &self, symbol: &str, range: FlowRange, + source: FlowSource, policy: CachePolicy, ) -> Result, IdxError> { let (resolved, code) = self.idx.idx_code(symbol, "foreign flow")?; @@ -63,8 +83,9 @@ impl Flow<'_> { } }; - let mut rows = Vec::new(); + let mut days = Vec::new(); let mut sessions = 0; + let mut rounded = false; let mut fetched_at: Option> = None; let mut stale = false; for date in dates { @@ -74,39 +95,117 @@ impl Flow<'_> { if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) { continue; } - let day = match self.session(date, today, policy) { - Ok(day) => day, + let fetch = match source { + FlowSource::Ipot => self.ipot_day(&resolved, date, policy), + FlowSource::Idx => self.idx_day(&code, date, today, policy), + }; + let fetch = match fetch { + Ok(fetch) => fetch, Err(IdxError::CacheMiss(_)) if skip_misses => continue, Err(err) => return Err(err), }; - if day.data.is_empty() { - continue; + match fetch.day { + Day::NoSession => continue, + Day::Absent => {} + Day::Traded { + day, + rounded: day_rounded, + } => { + days.push(*day); + rounded |= day_rounded; + } } sessions += 1; - fetched_at = Some(fetched_at.map_or(day.fetched_at, |at| at.min(day.fetched_at))); - stale |= day.stale; - // A stock missing from a session did not trade (suspension). - if let Some(row) = day.data.into_iter().find(|row| row.code == code) { - rows.push(row); - } + fetched_at = Some(fetched_at.map_or(fetch.fetched_at, |at| at.min(fetch.fetched_at))); + stale |= fetch.stale; } if sessions == 0 { return Err(if policy == CachePolicy::Offline { - IdxError::CacheMiss(format!("idx-stocksummary/{resolved} (no cached session)")) + IdxError::CacheMiss(format!( + "{}-flow/{resolved} (no cached session)", + source.as_str() + )) } else { - IdxError::NoMarketData(format!("{resolved}: no IDX sessions in range")) + IdxError::NoMarketData(format!("{resolved}: no sessions in range")) }); } - rows.reverse(); - let flow = foreign_flow(&resolved, &rows).ok_or(IdxError::SymbolNotFound(code))?; + days.reverse(); + let flow = + foreign_flow(&resolved, source, days, rounded).ok_or(IdxError::SymbolNotFound(code))?; Ok(Fetched { data: flow, - source: SOURCE, + source: source.as_str(), fetched_at: fetched_at.unwrap_or_else(Utc::now), stale, }) } + /// One session from IPOT: the all-investor summary for the value traded, + /// the foreign one for foreign buying, selling and net. Both go through + /// the broker summary cache. + fn ipot_day( + &self, + resolved: &str, + date: NaiveDate, + policy: CachePolicy, + ) -> Result { + let broker = self.idx.broker(); + let all_query = BrokerQuery { + from: date, + to: date, + investor: BrokerInvestor::All, + board: BrokerBoard::All, + }; + let all = broker.summary(resolved, &all_query, policy)?; + if all.data.is_empty() { + return Ok(Fetch { + day: Day::NoSession, + fetched_at: all.fetched_at, + stale: all.stale, + }); + } + let foreign_query = BrokerQuery { + investor: BrokerInvestor::Foreign, + ..all_query + }; + let foreign = broker.summary(resolved, &foreign_query, policy)?; + Ok(Fetch { + day: Day::Traded { + day: Box::new(ipot_flow_day(date, &all.data, &foreign.data)), + rounded: all.data.rounded || foreign.data.rounded, + }, + fetched_at: all.fetched_at.min(foreign.fetched_at), + stale: all.stale || foreign.stale, + }) + } + + /// One session from IDX's trading summary of every stock. + fn idx_day( + &self, + code: &str, + date: NaiveDate, + today: NaiveDate, + policy: CachePolicy, + ) -> Result { + let session = self.session(date, today, policy)?; + let day = if session.data.is_empty() { + Day::NoSession + } else { + match session.data.iter().find(|row| row.code == code) { + Some(row) => Day::Traded { + day: Box::new(idx_flow_day(row)), + rounded: false, + }, + None => Day::Absent, + } + }; + Ok(Fetch { + day, + fetched_at: session.fetched_at, + stale: session.stale, + }) + } + /// Every stock's summary for one session; empty without trading. fn session( &self, @@ -127,7 +226,7 @@ impl Flow<'_> { TTL_TODAY_SECS }, subject: &key, - source: SOURCE, + source: FlowSource::Idx.as_str(), }, policy, || provider.stock_summary(date), @@ -160,17 +259,54 @@ fn bps(part: i64, whole: i64) -> Option { (whole > 0).then(|| (part as f64 * 10_000.0 / whole as f64).round() as i64) } -fn flow_day(row: &StockDay) -> ForeignFlowDay { +/// `shares` at the session's average price (value over volume), whole IDR. +fn at_average(shares: Option, value: Option, volume: Option) -> Option { + match (shares, value, volume) { + (Some(shares), Some(value), Some(volume)) if volume > 0 => { + i64::try_from(i128::from(shares) * i128::from(value) / i128::from(volume)).ok() + } + _ => None, + } +} + +/// Foreign buying plus selling over twice `total`. +fn share(buy: Option, sell: Option, total: Option) -> Option { + let traded = buy?.checked_add(sell?)?; + bps(traded, total?.checked_mul(2)?) +} + +/// One IPOT session: the value traded from the all-investor summary, the +/// foreign figures from the foreign one (`buy_value` is its footer, +/// `sell_value` that less the foreign net). +pub fn ipot_flow_day( + date: NaiveDate, + all: &BrokerSummary, + foreign: &BrokerSummary, +) -> ForeignFlowDay { + let foreign_net_value = foreign.foreign_net_value.or(all.foreign_net_value); + ForeignFlowDay { + date, + close: None, + change_bps: None, + volume: all.total_lots.and_then(|lots| lots.checked_mul(100)), + value: all.total_value, + foreign_buy: None, + foreign_sell: None, + foreign_net: None, + foreign_buy_value: foreign.buy_value, + foreign_sell_value: foreign.sell_value, + foreign_net_value, + foreign_share_bps: share(foreign.buy_value, foreign.sell_value, all.total_value), + non_regular_bps: None, + } +} + +/// One IDX session for one stock. +pub fn idx_flow_day(row: &StockDay) -> ForeignFlowDay { let foreign_net = row .foreign_buy .zip(row.foreign_sell) .map(|(buy, sell)| buy - sell); - let foreign_net_value = match (foreign_net, row.value, row.volume) { - (Some(net), Some(value), Some(volume)) if volume > 0 => { - i64::try_from(i128::from(net) * i128::from(value) / i128::from(volume)).ok() - } - _ => None, - }; ForeignFlowDay { date: row.date, close: row.close, @@ -183,7 +319,10 @@ fn flow_day(row: &StockDay) -> ForeignFlowDay { foreign_buy: row.foreign_buy, foreign_sell: row.foreign_sell, foreign_net, - foreign_net_value, + foreign_buy_value: at_average(row.foreign_buy, row.value, row.volume), + foreign_sell_value: at_average(row.foreign_sell, row.value, row.volume), + foreign_net_value: at_average(foreign_net, row.value, row.volume), + foreign_share_bps: share(row.foreign_buy, row.foreign_sell, row.volume), // `value` is the regular board only; the share is of all trading. non_regular_bps: row .non_regular_value @@ -197,25 +336,38 @@ fn sum(days: &[ForeignFlowDay], field: fn(&ForeignFlowDay) -> Option) -> Op .try_fold(0_i64, |total, day| total.checked_add(field(day)?)) } -/// The flow over `rows` (one stock, oldest first); `None` without rows. -pub fn foreign_flow(symbol: &str, rows: &[StockDay]) -> Option { - let (first, last) = (rows.first()?, rows.last()?); - let days: Vec = rows.iter().map(flow_day).collect(); - let foreign_share_bps = match ( - sum(&days, |day| day.foreign_buy), - sum(&days, |day| day.foreign_sell), - sum(&days, |day| day.volume), - ) { - (Some(buy), Some(sell), Some(volume)) => bps(buy + sell, 2 * volume), - _ => None, +/// The flow over `days` (one stock, oldest first); `None` without days. +pub fn foreign_flow( + symbol: &str, + source: FlowSource, + days: Vec, + rounded: bool, +) -> Option { + let (from, to) = (days.first()?.date, days.last()?.date); + // IDX has exact shares; IPOT has values. + let foreign_share_bps = match source { + FlowSource::Idx => share( + sum(&days, |day| day.foreign_buy), + sum(&days, |day| day.foreign_sell), + sum(&days, |day| day.volume), + ), + FlowSource::Ipot => share( + sum(&days, |day| day.foreign_buy_value), + sum(&days, |day| day.foreign_sell_value), + sum(&days, |day| day.value), + ), }; Some(ForeignFlow { symbol: symbol.to_string(), - from: first.date, - to: last.date, + source, + from, + to, foreign_net: sum(&days, |day| day.foreign_net), + foreign_buy_value: sum(&days, |day| day.foreign_buy_value), + foreign_sell_value: sum(&days, |day| day.foreign_sell_value), foreign_net_value: sum(&days, |day| day.foreign_net_value), foreign_share_bps, + rounded, days, }) } @@ -242,13 +394,15 @@ mod tests { } } + fn idx_flow(rows: &[StockDay]) -> Option { + let days = rows.iter().map(idx_flow_day).collect(); + foreign_flow("TEST.JK", FlowSource::Idx, days, false) + } + #[test] fn test_foreign_flow_sums_sessions_and_estimates_value() { - let flow = foreign_flow( - "TEST.JK", - &[row(15, 1100, 600, Some(200)), row(16, 950, 100, Some(300))], - ) - .unwrap(); + let flow = + idx_flow(&[row(15, 1100, 600, Some(200)), row(16, 950, 100, Some(300))]).unwrap(); let first = &flow.days[0]; assert_eq!( (first.foreign_net, first.change_bps), @@ -256,6 +410,11 @@ mod tests { ); // 400 shares at 1,100 (value over volume). assert_eq!(first.foreign_net_value, Some(440_000)); + assert_eq!( + (first.foreign_buy_value, first.foreign_sell_value), + (Some(660_000), Some(220_000)) + ); + assert_eq!(first.foreign_share_bps, Some(4000)); // 110,000 of 1,100,000 regular plus 110,000 non-regular. assert_eq!(first.non_regular_bps, Some(909)); assert_eq!(flow.days[1].change_bps, Some(-500)); @@ -270,14 +429,10 @@ mod tests { #[test] fn test_foreign_flow_never_counts_a_missing_figure_as_zero() { - let flow = foreign_flow( - "TEST.JK", - &[row(15, 1100, 600, Some(200)), row(16, 950, 100, None)], - ) - .unwrap(); + let flow = idx_flow(&[row(15, 1100, 600, Some(200)), row(16, 950, 100, None)]).unwrap(); assert_eq!(flow.days[1].foreign_net, None); assert_eq!((flow.foreign_net, flow.foreign_share_bps), (None, None)); - assert!(foreign_flow("TEST.JK", &[]).is_none()); + assert!(idx_flow(&[]).is_none()); } #[test] @@ -286,11 +441,50 @@ mod tests { let date = NaiveDate::from_ymd_opt(2026, 10, 14).unwrap(); let rows = crate::api::idx::parse_stock_summary(raw, date).unwrap(); let bbri: Vec = rows.into_iter().filter(|row| row.code == "BBRI").collect(); - let flow = foreign_flow("BBRI.JK", &bbri).unwrap(); + let flow = idx_flow(&bbri).unwrap(); // 172.3B non-regular against 338.5B regular: 33.74%, not 50.91%. assert_eq!(flow.days[0].non_regular_bps, Some(3374)); } + #[test] + fn test_ipot_flow_day_reads_foreign_buying_and_selling_from_the_summaries() { + let day = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(); + let summary = |investor, name: &str| { + let html = std::fs::read_to_string(format!("tests/fixtures/{name}")).unwrap(); + let raw = crate::api::ipot::summary_from_html("BBCA", &html).unwrap(); + let query = BrokerQuery { + from: day, + to: day, + investor, + board: BrokerBoard::All, + }; + crate::broker::summary_from_raw("BBCA.JK", &query, raw) + }; + let all = summary(BrokerInvestor::All, "ipot_brokersummary_bbca_20261016.html"); + let foreign = summary( + BrokerInvestor::Foreign, + "ipot_brokersummary_bbca_20261016_foreign_all.html", + ); + let flow = ipot_flow_day(day, &all, &foreign); + assert_eq!(flow.value, Some(6_600_000_000_000)); + assert_eq!(flow.volume, Some(1_260_000_000)); + assert_eq!( + ( + flow.foreign_buy_value, + flow.foreign_sell_value, + flow.foreign_net_value + ), + ( + Some(1_700_000_000_000), + Some(4_000_000_000_000), + Some(-2_300_000_000_000) + ) + ); + // (1.7 T + 4.0 T) / (2 * 6.6 T). + assert_eq!(flow.foreign_share_bps, Some(4318)); + assert_eq!((flow.close, flow.foreign_net), (None, None)); + } + #[test] fn test_check_range_bounds() { let day = |d| NaiveDate::from_ymd_opt(2026, 10, d).unwrap(); diff --git a/crates/idx-core/src/flow/types.rs b/crates/idx-core/src/flow/types.rs index 1e01423..28fdc45 100644 --- a/crates/idx-core/src/flow/types.rs +++ b/crates/idx-core/src/flow/types.rs @@ -1,5 +1,7 @@ //! Foreign flow types: what foreign investors bought and sold of a stock, -//! session by session (IDX trading summary, end of day). +//! session by session (end of day), from IPOT or IDX's trading summary. + +use std::str::FromStr; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; @@ -9,6 +11,47 @@ use crate::error::IdxError; /// Sessions shown when a frontend gives neither days nor dates. pub const DEFAULT_SESSIONS: usize = 10; +/// Where a foreign flow comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "snake_case")] +pub enum FlowSource { + /// Indo Premier's broker summary: foreign buy, sell and net value across + /// all boards, two plain requests per session. Figures of a million and + /// more are rounded to one decimal. + #[default] + Ipot, + /// IDX's own trading summary: exact foreign share counts, close and the + /// non-regular share, regular board. Needs curl-impersonate and a + /// network IDX's Cloudflare lets through. + Idx, +} + +impl FlowSource { + pub const ALL: [Self; 2] = [Self::Ipot, Self::Idx]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Ipot => "ipot", + Self::Idx => "idx", + } + } +} + +impl FromStr for FlowSource { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "ipot" => Ok(Self::Ipot), + "idx" => Ok(Self::Idx), + _ => Err(IdxError::InvalidInput(format!( + "invalid source '{value}'; use ipot or idx" + ))), + } + } +} + /// One stock's trading summary for one session, as IDX publishes it. /// Prices are whole IDR, volumes are shares. Volume, value and the foreign /// figures cover the regular board only (across a week of every stock, no @@ -77,27 +120,41 @@ impl FlowRange { } } +/// One session. Which figures are present depends on the source: IPOT gives +/// values across all boards, IDX gives exact shares on the regular board (and +/// values estimated from them) with the close and the non-regular share. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ForeignFlowDay { pub date: NaiveDate, + /// Close, whole IDR (IDX only). pub close: Option, - /// Close against the previous close, basis points. + /// Close against the previous close, basis points (IDX only). pub change_bps: Option, - /// Shares traded on the regular board. + /// Shares traded: the regular board (IDX), or all boards from lots + /// (IPOT). pub volume: Option, - /// Value traded on the regular board, whole IDR. + /// Value traded, whole IDR: the regular board (IDX) or all boards (IPOT). pub value: Option, - /// Shares foreign investors bought (regular board). + /// Shares foreign investors bought (IDX only). pub foreign_buy: Option, - /// Shares foreign investors sold. + /// Shares foreign investors sold (IDX only). pub foreign_sell: Option, - /// `foreign_buy - foreign_sell`, shares. + /// `foreign_buy - foreign_sell`, shares (IDX only). pub foreign_net: Option, - /// `foreign_net` at the session's average price (value over volume), - /// whole IDR: an estimate. + /// Value foreign investors bought, whole IDR: as published (IPOT), or + /// estimated at the session's average price (IDX). + pub foreign_buy_value: Option, + /// Value foreign investors sold, whole IDR: their buying less their net + /// (IPOT), or estimated at the session's average price (IDX). + pub foreign_sell_value: Option, + /// Foreign buy value minus sell value, whole IDR: as published (IPOT), or + /// net shares at the session's average price (IDX, an estimate). pub foreign_net_value: Option, + /// Foreign buying and selling over twice the session's trading: foreign + /// investors' share of it, basis points. + pub foreign_share_bps: Option, /// Value traded outside the regular board (negotiated and cash), basis - /// points of all value traded (regular plus non-regular). + /// points of all value traded (IDX only). pub non_regular_bps: Option, } @@ -105,16 +162,23 @@ pub struct ForeignFlowDay { pub struct ForeignFlow { /// Resolved ticker, e.g. `BBCA.JK`. pub symbol: String, + pub source: FlowSource, /// First and last session covered. pub from: NaiveDate, pub to: NaiveDate, /// Sessions the stock traded, oldest first. pub days: Vec, - /// Net shares over the sessions; `None` when a session lacks a figure. + /// Net shares over the sessions (IDX only); `None` when a session lacks + /// a figure. pub foreign_net: Option, - /// Sum of the sessions' estimated net values, whole IDR. + /// Foreign buy value over the sessions, whole IDR. + pub foreign_buy_value: Option, + /// Foreign sell value over the sessions, whole IDR. + pub foreign_sell_value: Option, + /// Foreign net value over the sessions, whole IDR. pub foreign_net_value: Option, - /// Foreign buys and sells over twice the volume: foreign investors' - /// share of regular-board trading, basis points. + /// Foreign investors' share of trading over the sessions, basis points. pub foreign_share_bps: Option, + /// The source rounded figures of a million and more to one decimal. + pub rounded: bool, } diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012.html new file mode 100644 index 0000000..542bf59 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
ZP319,263 191.3 B5,993 1CC248,149 148.6 B5,988
CC91,178 54.7 B5,998 2KZ169,689 101.8 B6,000
RX82,364 49.3 B5,991 3ZP142,270 85.1 B5,980
SQ70,119 41.9 B5,973 4AK109,896 65.7 B5,978
XL66,085 39.5 B5,982 5YU51,291 30.7 B5,977
BB46,535 27.9 B5,989 6BK35,962 21.5 B5,988
YP31,891 19.1 B5,983 7XL33,257 19.9 B5,987
XC31,403 18.8 B5,983 8CP29,928 17.8 B5,962
AK24,560 14.7 B5,967 9PD29,063 17.5 B6,023
BK20,056 12.0 B5,970 10SQ23,464 14.1 B6,002
+
+ T. Val : 587.1 B + F. NVal : -55.9 B + T.Lot : 980,415 + Avg : 5,988 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_foreign_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_foreign_all.html new file mode 100644 index 0000000..fe1ce60 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261012_foreign_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
ZP316,144 189.5 B5,993 1CC201,030 120.4 B5,992
RX82,364 49.3 B5,991 2ZP137,297 82.1 B5,981
BB25,000 15.0 B5,989 3KZ123,872 74.4 B6,007
AK24,560 14.7 B5,967 4AK89,396 53.5 B5,980
BK20,056 12.0 B5,970 5BK25,685 15.4 B5,985
CC19,945 12.1 B6,069 6RX20,942 12.5 B5,950
KZ8,553 5.2 B6,075 7YU7,476 4.5 B5,989
BQ5,000 3.0 B5,950 8HD1,084 647.7 M5,975
AG4,315 2.6 B5,951 9BB782 465.3 M5,950
YU3,957 2.4 B5,979 10AG696 417.3 M5,995
+
+ T. Val : 308.7 B + F. NVal : -55.9 B + T.Lot : 514,995 + Avg : 5,994 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013.html new file mode 100644 index 0000000..bf6e224 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
ZP353,553 209.7 B5,931 1ZP362,463 215.0 B5,933
CC123,885 73.3 B5,919 2CC170,087 100.6 B5,914
BK119,389 70.6 B5,915 3KZ131,973 78.2 B5,922
TP114,823 68.1 B5,931 4AG113,249 67.1 B5,923
RX90,525 53.7 B5,931 5AK99,863 59.0 B5,904
AK84,572 50.1 B5,920 6BK87,628 51.8 B5,915
YU54,296 32.2 B5,928 7YU58,039 34.4 B5,926
XL44,062 26.0 B5,907 8XL31,516 18.6 B5,905
SQ38,109 22.5 B5,901 9SQ21,672 12.8 B5,917
YP22,957 13.5 B5,898 10BQ19,658 11.7 B5,937
+
+ T. Val : 738.2 B + F. NVal : 30.1 B + T.Lot : 1.2 M + Avg : 5,921 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013_foreign_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013_foreign_all.html new file mode 100644 index 0000000..f46eb58 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261013_foreign_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
ZP347,552 206.1 B5,931 1ZP361,884 214.7 B5,933
BK119,389 70.6 B5,915 2CC134,566 79.6 B5,918
TP112,063 66.5 B5,932 3AG113,249 67.1 B5,923
RX90,525 53.7 B5,931 4AK99,863 59.0 B5,904
AK84,572 50.1 B5,920 5YU57,345 34.0 B5,927
CC82,117 48.7 B5,925 6BK49,256 29.1 B5,918
YU48,065 28.5 B5,932 7DP12,747 7.5 B5,917
HD12,342 7.3 B5,905 8KZ11,973 7.1 B5,924
KZ3,683 2.2 B5,929 9HD8,420 5.0 B5,925
EP3,000 1.8 B5,925 10BQ5,000 3.0 B5,925
+
+ T. Val : 537.3 B + F. NVal : 30.1 B + T.Lot : 906,459 + Avg : 5,927 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014.html new file mode 100644 index 0000000..8785c56 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ554,786 338.9 B6,109 1AK525,906 319.8 B6,081
AK485,138 294.7 B6,075 2ZP377,446 230.0 B6,094
YU187,056 113.8 B6,085 3KZ164,627 100.4 B6,096
ZP160,092 97.1 B6,062 4SQ103,273 62.8 B6,082
RX79,012 48.0 B6,071 5BK98,516 59.8 B6,070
BK70,651 43.1 B6,096 6YU73,085 44.6 B6,098
BB65,000 39.5 B6,073 7YP51,697 31.4 B6,075
BQ31,865 19.2 B6,027 8CC48,169 29.3 B6,082
CC30,758 18.7 B6,066 9XL46,536 28.3 B6,074
YP27,270 16.6 B6,085 10BQ39,660 24.1 B6,070
+
+ T. Val : 1.1 T + F. NVal : 216.6 B + T.Lot : 1.8 M + Avg : 6,084 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014_foreign_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014_foreign_all.html new file mode 100644 index 0000000..57c9f36 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261014_foreign_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ554,012 338.5 B6,109 1AK525,906 319.8 B6,081
AK485,138 294.7 B6,075 2ZP368,595 224.6 B6,094
YU187,042 113.8 B6,085 3KZ163,864 99.9 B6,096
ZP156,660 95.0 B6,063 4BK98,516 59.8 B6,070
RX79,012 48.0 B6,071 5YU59,747 36.5 B6,103
BK70,651 43.1 B6,096 6BB20,374 12.4 B6,074
BB65,000 39.5 B6,073 7HD17,554 10.6 B6,056
CC17,593 10.7 B6,067 8CC12,729 7.8 B6,094
HD13,632 8.2 B6,050 9EP3,000 1.8 B6,075
DP474 288.2 M6,080 10IU1,200 729.0 M6,075
+
+ T. Val : 991.8 B + F. NVal : 216.6 B + T.Lot : 1.6 M + Avg : 6,087 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015.html new file mode 100644 index 0000000..7e51a42 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
YU447,791 271.5 B6,064 1CC335,078 203.1 B6,062
ZP284,818 172.7 B6,062 2YU229,539 139.1 B6,061
DX194,491 117.8 B6,059 3ZP225,482 136.6 B6,059
KZ138,619 84.0 B6,058 4AK179,064 108.5 B6,059
AK123,061 74.5 B6,057 5BB143,526 86.9 B6,053
CC78,820 47.7 B6,050 6BK68,285 41.3 B6,049
RX72,951 44.2 B6,055 7SQ63,839 38.6 B6,050
BK36,383 22.1 B6,066 8CP49,275 29.9 B6,059
SQ26,718 16.1 B6,039 9XL34,325 20.8 B6,068
XL24,529 14.8 B6,052 10PD27,152 16.4 B6,053
+
+ T. Val : 912.0 B + F. NVal : 286.1 B + T.Lot : 1.5 M + Avg : 6,060 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015_foreign_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015_foreign_all.html new file mode 100644 index 0000000..2ddb65b --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261015_foreign_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
YU445,640 270.2 B6,064 1YU224,007 135.8 B6,061
ZP283,676 172.0 B6,062 2ZP197,862 119.9 B6,058
DX192,771 116.8 B6,059 3CC185,436 112.5 B6,066
KZ138,619 84.0 B6,058 4BB118,526 71.7 B6,053
AK122,365 74.1 B6,057 5AK109,064 66.1 B6,064
RX72,951 44.2 B6,055 6KZ25,617 15.5 B6,068
CC65,609 39.7 B6,051 7BK15,460 9.3 B6,044
BK36,383 22.1 B6,066 8RX11,033 6.7 B6,069
HD6,018 3.6 B6,053 9HD4,283 2.6 B6,058
AI200 120.5 M6,025 10YP550 332.9 M6,052
+
+ T. Val : 826.9 B + F. NVal : 286.1 B + T.Lot : 1.4 M + Avg : 6,061 +
+ +
+
+ + + diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 35cc91d..5e3c4a1 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -3,6 +3,7 @@ use idx_core::api::types::{Interval, Period}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor}; +use idx_core::flow::types::FlowSource; use idx_core::market::{ScreenFilter, ScreenRegion}; use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::Deserialize; @@ -170,6 +171,12 @@ pub struct FlowParams { /// Last session of the range, YYYY-MM-DD (default today). #[serde(default)] pub to: Option, + /// Where the figures come from (default "ipot"): "ipot" gives foreign buy, sell and net + /// value across all boards; "idx" gives exact share counts, the close and the non-regular + /// share, but needs curl-impersonate and may be blocked (HTTP 403). + #[serde(default)] + #[schemars(schema_with = "flow_source_schema")] + pub source: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -244,3 +251,7 @@ fn investor_schema(_: &mut SchemaGenerator) -> Schema { fn board_schema(_: &mut SchemaGenerator) -> Schema { string_enum(BrokerBoard::ALL.map(BrokerBoard::as_str).to_vec()) } + +fn flow_source_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(FlowSource::ALL.map(FlowSource::as_str).to_vec()) +} diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index f858838..9127c85 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -246,11 +246,12 @@ foreign or domestic, shares are of that side's own buying (`buy_value`) and sell #[tool( description = "Foreign investors' buying and selling of one IDX ticker, session by \ -session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in \ -shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), \ -`change_bps`, and `non_regular_bps` (share of all value traded outside the regular board). \ -Volume, value and foreign figures are the regular board. Totals cover the whole range; \ -`foreign_share_bps` is foreign investors' share of regular-board trading. End of day." +session (oldest first), with range totals; end of day. With `source` \"ipot\" (default): \ +`foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, \ +`value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and \ +`non_regular_bps` are null, and `rounded` = true means one decimal of precision. With \ +\"idx\": exact foreign share counts on the regular board, values estimated at the session's \ +average price, the close, `change_bps` and `non_regular_bps`." )] async fn get_foreign_flow(&self, params: Parameters) -> ToolResult { let FlowParams { @@ -258,11 +259,13 @@ Volume, value and foreign figures are the regular board. Totals cover the whole days, from, to, + source, } = params.0; self.live(move |idx| { let flow = idx.flow(); let range = FlowRange::from_args(days, from.as_deref(), to.as_deref(), flow.today())?; - flow.foreign(&symbol, range, CachePolicy::Default) + let source = source.as_deref().unwrap_or("ipot").parse()?; + flow.foreign(&symbol, range, source, CachePolicy::Default) }) .await } diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 84d48b4..25cb886 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -236,7 +236,7 @@ "name": "get_company" }, { - "description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), `change_bps`, and `non_regular_bps` (share of all value traded outside the regular board). Volume, value and foreign figures are the regular board. Totals cover the whole range; `foreign_share_bps` is foreign investors' share of regular-board trading. End of day.", + "description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` \"ipot\" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With \"idx\": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -258,6 +258,15 @@ "null" ] }, + "source": { + "default": null, + "description": "Where the figures come from (default \"ipot\"): \"ipot\" gives foreign buy, sell and net\nvalue across all boards; \"idx\" gives exact share counts, the close and the non-regular\nshare, but needs curl-impersonate and may be blocked (HTTP 403).", + "enum": [ + "ipot", + "idx" + ], + "type": "string" + }, "symbol": { "description": "IDX ticker, e.g. \"BBCA\".", "type": "string" diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index af0fb70..928b160 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -281,8 +281,18 @@ fn live_tools_wrap_data_with_freshness() { json!({"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16"}), ); assert_fetched(&flow); - assert_eq!(flow["source"], "idx"); + assert_eq!(flow["source"], "ipot"); assert_eq!(flow["data"]["days"].as_array().unwrap().len(), 5); + assert_eq!( + flow["data"]["days"][4]["foreign_sell_value"], + 4_000_000_000_000_i64 + ); + let idx_flow = client.ok( + "get_foreign_flow", + json!({"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16", "source": "idx"}), + ); + assert_eq!(idx_flow["source"], "idx"); + assert_eq!(idx_flow["data"]["days"][4]["close"], 5175); let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]})); assert_eq!(compared["reports"].as_array().unwrap().len(), 2); @@ -321,6 +331,10 @@ fn errors_carry_cli_codes() { ("analyze_bandar", json!({"symbols": ["BBCA"], "top": 4})), ("analyze_bandar", json!({"symbols": []})), ("get_foreign_flow", json!({"symbol": "BBCA", "days": 61})), + ( + "get_foreign_flow", + json!({"symbol": "BBCA", "source": "rti"}), + ), ( "get_foreign_flow", json!({"symbol": "BBCA", "days": 3, "from": "2026-10-12"}), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e5b2a72..17e408f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -53,8 +53,8 @@ command parses arguments, makes one idx-core call, and renders the result. - **Yahoo Finance** — default/auto history source. Reliable OHLCV data via `/v8/finance/chart/`. - **IPOT (Indo Premier)** — broker summaries, whichever provider is selected (`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries). -- **IDX (idx.co.id)** — the daily trading summary behind foreign flow - (`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow). +- **IDX (idx.co.id)** — the daily trading summary behind `stocks flow + --source idx` (`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow). ### MSN Endpoints - `https://assets.msn.com/service/`: quotes, charts, equities, earnings, @@ -116,7 +116,13 @@ WIB as the fallback. never summed as zero. A change to the rules gets a new method name. ### Foreign Flow -- `GET https://www.idx.co.id/primary/TradingSummary/GetStockSummary` +- The default source is IPOT: per session, the all-investor broker summary + gives the value traded and the foreign one (`fd=F`) gives foreign buying + (its footer), the foreign net, and selling (buying less the net), all + boards, through the broker summary cache. IPOT's foreign net matched IDX's + estimate within 0.3 B on BBCA 2026-10-12 to 16. An empty all-investor + summary counts as no session. +- `--source idx`: `GET https://www.idx.co.id/primary/TradingSummary/GetStockSummary` `?length=9999&start=0&date=YYYYMMDD` answers one JSON row per stock for the session: prices, volume, value, `ForeignBuy`/`ForeignSell` (shares) and non-regular volume and value. `Volume`, `Value` and the foreign figures are diff --git a/docs/SMOKE.md b/docs/SMOKE.md index ce9f4bd..5f56439 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -54,8 +54,8 @@ nix develop --command scripts/live-smoke.sh --compare ./old/bin/idx # A/B again scripts/live-smoke.sh --dry-run ``` -- `stocks`: every `stocks` command for one ticker (`broker`, `bandar` and - `flow` on the new binary only, so an older `--compare` build that predates +- `stocks`: every `stocks` command for one ticker (`broker`, `bandar`, + `flow` and `flow --source idx` on the new binary only, so an older `--compare` build that predates them is fine), plus `compare BUMI,ADRO,AIMS`, whose MSN key ratios contain non-finite values. - `ownership`: `discover`, then imports the newest supported report into a temp diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 48b16ce..98dbfb9 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -127,8 +127,10 @@ contract() { echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;; "stocks bandar "*) echo 'type == "array" and length > 0 and all(.[]; (.method | type == "string") and (.tiers | length == 4) and (.imbalance_bps | type == "number"))' ;; + "stocks flow "*"--source idx"*) + echo '(.source == "idx") and (.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy | type == "number") and (.foreign_sell | type == "number")) and (.foreign_net | type == "number")' ;; "stocks flow "*) - echo '(.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy | type == "number") and (.foreign_sell | type == "number")) and (.foreign_net | type == "number")' ;; + echo '(.source == "ipot") and (.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy_value | type == "number") and (.foreign_sell_value | type == "number")) and (.foreign_net_value | type == "number")' ;; "ownership discover"*) echo 'type == "array" and length > 0 and (.[0].pdf_url | type == "string")' ;; "ownership releases") @@ -229,10 +231,11 @@ if selected stocks; then run_case compare "stocks compare $SYMBOL,BBRI" # BUMI/ADRO/AIMS return non-finite MSN key ratios (Infinity/NaN). run_case compare-nonfinite "stocks compare BUMI,ADRO,AIMS" - # Broker summaries (IPOT) and foreign flow (IDX, via curl-impersonate). + # Broker summaries and foreign flow (IPOT; --source idx via curl-impersonate). run_new_case broker "stocks broker $SYMBOL" run_new_case bandar "stocks bandar $SYMBOL,BBRI,TLKM" run_new_case flow "stocks flow $SYMBOL --days 3" + run_new_case flow-idx "stocks flow $SYMBOL --days 3 --source idx" fi if selected ownership; then diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 524eb97..5a89111 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -433,7 +433,7 @@ Examples: ### `idx stocks flow` ```text -Foreign buying and selling of a stock, session by session, from IDX's daily trading summary: foreign buy, sell and net volume, the net value at the session's average price, and the share of value traded outside the regular board. End of day. Each session is one request to idx.co.id, which needs curl-impersonate. +Foreign buying and selling of a stock, session by session, end of day. By default from Indo Premier (IPOT): foreign buy, sell and net value across all boards and foreign investors' share of trading, two plain requests per session. `--source idx` reads IDX's own trading summary instead: exact foreign share counts, the close and the share traded outside the regular board, one request per session to idx.co.id, which needs curl-impersonate and a network IDX's Cloudflare lets through. Usage: idx stocks flow [OPTIONS] @@ -462,6 +462,15 @@ Options: --to Last session of the range, YYYY-MM-DD. Default: today + --source + Where the figures come from + + Possible values: + - ipot: Indo Premier's broker summary: foreign buy, sell and net value across all boards, two plain requests per session. Figures of a million and more are rounded to one decimal + - idx: IDX's own trading summary: exact foreign share counts, close and the non-regular share, regular board. Needs curl-impersonate and a network IDX's Cloudflare lets through + + [default: ipot] + -v, --verbose... Increase verbosity (-v, -vv) @@ -478,7 +487,8 @@ Examples: idx stocks flow BBCA idx stocks flow BBCA --days 20 idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16 - idx -o json stocks flow BBCA | jq '.foreign_net' + idx stocks flow BBCA --source idx + idx -o json stocks flow BBCA | jq '.foreign_net_value' ``` ### `idx stocks profile` diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index a09a9e5..30049c8 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -55,18 +55,25 @@ million and more to one decimal, and the output says so. idx stocks flow BBCA idx stocks flow BBCA --days 20 idx stocks flow BBCA --from 2026-10-01 --to 2026-10-16 +idx stocks flow BBCA --source idx ``` -What foreign investors bought and sold, session by session: buy, sell and -net volume in shares, the net value at each session's average price (an -estimate), and the share of all value traded outside the regular board. -Volume, value and the foreign figures are IDX's regular-board figures. The -footer gives the net over the range and foreign investors' share of trading. -Without dates it shows the latest 10 sessions (`--days`, at most 60). +What foreign investors bought and sold, session by session, with the totals +over the range. Without dates it shows the latest 10 sessions (`--days`, at +most 60). -The data is IDX's own daily trading summary. Each session is one request to -idx.co.id, cached afterwards for every stock, and needs -[curl-impersonate](/start/install/) to pass the site's bot protection. +By default the figures come from Indo Premier (IPOT), like `broker`: foreign +buy, sell and net value across all boards and foreign investors' share of +trading. It needs nothing installed, takes two requests per session, and +rounds figures of a million and more to one decimal. + +`--source idx` reads IDX's own daily trading summary instead: exact foreign +share counts, the close and day change, and the share of value traded +outside the regular board. Those figures are the regular board, and values +are estimated at each session's average price. Each session is one request +to idx.co.id, cached for every stock, and needs +[curl-impersonate](/start/install/) and a network IDX's Cloudflare lets +through; even then it can answer 403. ## Bandarmology diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index f6a0f9c..7287341 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -65,12 +65,13 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys ## `get_foreign_flow` -Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), from IDX's daily trading summary: foreign buy, sell and net volume in shares, `foreign_net_value` (net shares at the session's average price, an estimate, IDR), `change_bps`, and `non_regular_bps` (share of all value traded outside the regular board). Volume, value and foreign figures are the regular board. Totals cover the whole range; `foreign_share_bps` is foreign investors' share of regular-board trading. End of day. +Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` "ipot" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With "idx": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`. | Input | Type | Required | Description | | --- | --- | --- | --- | | `days` | integer | no | Latest sessions (default 10, at most 60). Omit when giving from/to. | | `from` | string | no | First session of a range, YYYY-MM-DD (at most 92 days). | +| `source` | `ipot` / `idx` | no | Where the figures come from (default "ipot"): "ipot" gives foreign buy, sell and net value across all boards; "idx" gives exact share counts, the close and the non-regular share, but needs curl-impersonate and may be blocked (HTTP 403). | | `symbol` | string | yes | IDX ticker, e.g. "BBCA". | | `to` | string | no | Last session of the range, YYYY-MM-DD (default today). | diff --git a/web/src/content/docs/start/install.mdx b/web/src/content/docs/start/install.mdx index 01ce32f..8a98b41 100644 --- a/web/src/content/docs/start/install.mdx +++ b/web/src/content/docs/start/install.mdx @@ -74,13 +74,14 @@ idx version ## Helper tools Everyday use needs nothing else: stock data with the default MSN provider, -price history, broker summaries and bandarmology, `ownership sync` and every +price history, broker summaries, bandarmology and foreign flow, `ownership sync` +and every local ownership query. A few commands call external tools: | Tool | Needed for | | --- | --- | | `curl_chrome*` from [curl-impersonate](https://github.com/lexiforest/curl-impersonate), or `IDX_CURL_IMPERSONATE_BIN` | `ownership discover` and `ownership import --url` (IDX sits behind Cloudflare), and Yahoo fundamentals with `IDX_PROVIDER=yahoo` | -| the full curl-impersonate set, with its `curl_safari*` and `curl_firefox*` profiles | `stocks flow`: IDX's Cloudflare rules pass only some browser fingerprints, and idx tries each installed profile | +| the full curl-impersonate set, with its `curl_safari*` and `curl_firefox*` profiles | `stocks flow --source idx`: IDX's Cloudflare rules pass only some browser fingerprints, and idx tries each installed profile (the default source, IPOT, needs nothing) | | `mutool` from MuPDF | importing legacy PDF ownership reports | ## The MCP server From d82f7c6be9ee611b712364176db0ca8fb1b4a54c Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 11:48:00 +0000 Subject: [PATCH 12/32] fix(core): reject broker and trading pages that would misreport a session From the 0.4.0 release review (Codex): - IPOT pages with a short row, a rank out of order or a broker after a blank row are parse errors; skipping a row shifted later brokers up a rank and could flip a bandar reading (-2425 bps to -66 bps). - A tbody end before its start is a parse error, not a panic. - An IPOT page that echoes other dates than requested is rejected before it is cached, so one session's figures never pass as another's. - An IDX trading summary with rows for other days only is an error, not an empty session cached for 30 days. - Empty sessions count toward a flow's fetched_at and stale. - The bandar imbalance uses checked i128 arithmetic: past i128 there is no reading instead of a wrapped one. - The bandar table prints "No trades." only when no broker traded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-cli/src/output/table.rs | 7 ++-- crates/idx-core/src/analysis/bandar.rs | 20 +++++++++- crates/idx-core/src/api/idx/parse.rs | 23 ++++++++---- crates/idx-core/src/api/ipot/parse.rs | 51 ++++++++++++++++++++++++-- crates/idx-core/src/broker/mod.rs | 50 ++++++++++++++++++++++++- crates/idx-core/src/flow/mod.rs | 5 ++- 6 files changed, 137 insertions(+), 19 deletions(-) diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 42b9afd..39ca8cc 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -1109,10 +1109,11 @@ pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), ) .bold() ); + // Brokers on either side mean trades, even when a total is unavailable. if report - .buy_value - .zip(report.sell_value) - .is_none_or(|(buy, sell)| buy <= 0 && sell <= 0) + .tiers + .iter() + .all(|tier| tier.buy_value == Some(0) && tier.sell_value == Some(0)) { println!("No trades."); return Ok(()); diff --git a/crates/idx-core/src/analysis/bandar.rs b/crates/idx-core/src/analysis/bandar.rs index e793ab1..10439f2 100644 --- a/crates/idx-core/src/analysis/bandar.rs +++ b/crates/idx-core/src/analysis/bandar.rs @@ -193,7 +193,13 @@ pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier { i128::from(bought), i128::from(sold), ); - i64::try_from((buy * sold - sell * bought) * 10_000 / (bought * sold)).ok() + // Checked: past i128 there is no reading, never a wrapped one. + buy.checked_mul(sold) + .zip(sell.checked_mul(bought)) + .and_then(|(buys, sells)| buys.checked_sub(sells)) + .and_then(|diff| diff.checked_mul(10_000)) + .zip(bought.checked_mul(sold)) + .and_then(|(diff, denominator)| i64::try_from(diff / denominator).ok()) } _ => None, }; @@ -440,6 +446,18 @@ mod tests { assert_eq!(report.tiers[2].buy_value, Some(0)); } + #[test] + fn test_bandar_tier_past_i128_has_no_reading_instead_of_a_wrapped_one() { + let reading = |total: i64| { + let buyers = vec![flow("AA", Some(total / 10 * 6), 1)]; + let sellers = vec![flow("BB", Some(total / 10 * 4), 1)]; + bandar_tier(&summary(buyers, sellers, total), 1).imbalance_bps + }; + // A quadrillion rupiah, far past any session, is still exact. + assert_eq!(reading(1_000_000_000_000_000), Some(2000)); + assert_eq!(reading(1_000_000_000_000_000_000), None); + } + #[test] fn test_check_top_allows_only_report_tiers() { assert_eq!(check_top(5).unwrap(), 5); diff --git a/crates/idx-core/src/api/idx/parse.rs b/crates/idx-core/src/api/idx/parse.rs index cfcd6e4..21f9005 100644 --- a/crates/idx-core/src/api/idx/parse.rs +++ b/crates/idx-core/src/api/idx/parse.rs @@ -38,11 +38,13 @@ fn whole(value: Option) -> Option { /// The rows of one session. A day without trading (weekend, holiday) is /// empty; rows dated other than `date` are dropped, so another session's -/// figures never pass as this one's. +/// figures never pass as this one's. Rows for other days only are an error, +/// not an empty session, so they are never cached as one. pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result, IdxError> { let response: StockSummaryResponse = serde_json::from_str(raw) .map_err(|e| IdxError::ParseError(format!("idx stock summary: {e}")))?; - Ok(response + let answered = response.data.len(); + let rows: Vec = response .data .into_iter() .filter(|row| { @@ -66,7 +68,13 @@ pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result, non_regular_volume: whole(row.non_regular_volume), non_regular_value: whole(row.non_regular_value), }) - .collect()) + .collect(); + if rows.is_empty() && answered > 0 { + return Err(IdxError::ParseError(format!( + "idx stock summary: asked for {date}, got {answered} rows for other days" + ))); + } + Ok(rows) } #[cfg(test)] @@ -101,11 +109,10 @@ mod tests { .unwrap() .is_empty() ); - assert!( - parse_stock_summary(DAY, date(2026, 10, 15)) - .unwrap() - .is_empty() - ); + assert!(matches!( + parse_stock_summary(DAY, date(2026, 10, 15)), + Err(IdxError::ParseError(_)) + )); assert!(matches!( parse_stock_summary("Just a moment...", date(2026, 10, 16)), Err(IdxError::ParseError(_)) diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs index 9dd822a..c996ff5 100644 --- a/crates/idx-core/src/api/ipot/parse.rs +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -151,7 +151,11 @@ pub fn parse_broker_summary(html: &str) -> Result { if html.to_ascii_lowercase().contains("invalid stock code") { return Ok(IpotPage::Invalid); } - let (Some(body_start), Some(body_end)) = (html.find(""), html.find("")) else { + let Some((body_start, body_end)) = html.find("").and_then(|start| { + html[start..] + .find("") + .map(|len| (start, start + len)) + }) else { return Err(IdxError::ParseError( "ipot broker summary: no table in the page".to_string(), )); @@ -168,17 +172,37 @@ pub fn parse_broker_summary(html: &str) -> Result { value }) }; + // A skipped row would shift every later broker up a rank, so a row of + // another shape, a rank out of order, or a broker after a side's blank + // rows rejects the page. + let (mut buyers_ended, mut sellers_ended) = (false, false); for (index, row) in html[body_start..body_end].split("").enumerate() { let row = cells(row); - if row.len() < 9 { + if row.is_empty() { continue; } - let rank = row[4].text.parse::().unwrap_or(index as u32 + 1); - for (side, at) in [(&mut raw.buyers, 0), (&mut raw.sellers, 5)] { + let rank = index as u32 + 1; + if row.len() != 9 || row[4].text.parse::().ok() != Some(rank) { + return Err(IdxError::ParseError(format!( + "ipot broker summary: unexpected row {rank} ({} cells, rank {:?})", + row.len(), + row.get(4).map(|cell| cell.text.as_str()) + ))); + } + for (side, at, ended) in [ + (&mut raw.buyers, 0, &mut buyers_ended), + (&mut raw.sellers, 5, &mut sellers_ended), + ] { let code = row[at].text.to_ascii_uppercase(); if code.is_empty() { + *ended = true; continue; } + if *ended { + return Err(IdxError::ParseError(format!( + "ipot broker summary: broker {code:?} after a blank row" + ))); + } if code.len() != 2 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) { return Err(IdxError::ParseError(format!( "ipot broker summary: unexpected broker code {code:?}" @@ -314,4 +338,23 @@ mod tests { Err(IdxError::ParseError(_)) )); } + + #[test] + fn test_parse_broker_summary_rejects_rows_that_would_shift_ranks() { + let first_cell = "MG"; + let short_row = DAY.replacen(first_cell, "", 1); + let skipped_rank = DAY.replacen( + "1", + "2", + 1, + ); + let gap = DAY.replacen(">XL<", "><", 1); + for page in [short_row, skipped_rank, gap, "".to_string()] { + assert!( + matches!(parse_broker_summary(&page), Err(IdxError::ParseError(_))), + "{}", + &page[..page.len().min(80)] + ); + } + } } diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs index 7392266..f90bc56 100644 --- a/crates/idx-core/src/broker/mod.rs +++ b/crates/idx-core/src/broker/mod.rs @@ -114,7 +114,11 @@ impl Broker<'_> { source: SOURCE, }, policy, - || provider.broker_summary(&code, query), + || { + provider + .broker_summary(&code, query) + .and_then(|raw| check_echoed_dates(query, raw)) + }, |raw| Ok(summary_from_raw(&resolved, query, raw)), ) } @@ -207,6 +211,25 @@ pub(crate) fn summary_from_raw( summary } +/// The page echoes the dates it covers; another range's figures must not be +/// cached or reported as the requested one's. +fn check_echoed_dates( + query: &BrokerQuery, + raw: BrokerSummaryRaw, +) -> Result { + if raw.from == Some(query.from) && raw.to == Some(query.to) { + return Ok(raw); + } + let echoed = |date: Option| date.map_or("none".to_string(), |date| date.to_string()); + Err(IdxError::ParseError(format!( + "ipot broker summary: asked for {} to {}, the page covers {} to {}", + query.from, + query.to, + echoed(raw.from), + echoed(raw.to) + ))) +} + fn check_range(query: &BrokerQuery, today: NaiveDate) -> Result<(), IdxError> { if query.from > query.to { return Err(IdxError::InvalidInput(format!( @@ -381,4 +404,29 @@ mod tests { ); } } + + #[test] + fn test_check_echoed_dates_rejects_another_sessions_page() { + let friday = date(2026, 10, 16); + let page = || BrokerSummaryRaw { + from: Some(friday), + to: Some(friday), + ..BrokerSummaryRaw::default() + }; + assert!(check_echoed_dates(&query(friday, friday), page()).is_ok()); + let thursday = date(2026, 10, 15); + for (asked, raw) in [ + (query(thursday, thursday), page()), + (query(thursday, friday), page()), + (query(friday, friday), BrokerSummaryRaw::default()), + ] { + assert!( + matches!( + check_echoed_dates(&asked, raw), + Err(IdxError::ParseError(_)) + ), + "{asked:?}" + ); + } + } } diff --git a/crates/idx-core/src/flow/mod.rs b/crates/idx-core/src/flow/mod.rs index eec2d31..ff31688 100644 --- a/crates/idx-core/src/flow/mod.rs +++ b/crates/idx-core/src/flow/mod.rs @@ -104,6 +104,9 @@ impl Flow<'_> { Err(IdxError::CacheMiss(_)) if skip_misses => continue, Err(err) => return Err(err), }; + // Every consulted session counts toward freshness, empty ones too. + fetched_at = Some(fetched_at.map_or(fetch.fetched_at, |at| at.min(fetch.fetched_at))); + stale |= fetch.stale; match fetch.day { Day::NoSession => continue, Day::Absent => {} @@ -116,8 +119,6 @@ impl Flow<'_> { } } sessions += 1; - fetched_at = Some(fetched_at.map_or(fetch.fetched_at, |at| at.min(fetch.fetched_at))); - stale |= fetch.stale; } if sessions == 0 { return Err(if policy == CachePolicy::Offline { From 6f211a398f4a55cadfa9ff070cf1510e661b840e Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 11:48:00 +0000 Subject: [PATCH 13/32] fix(mcp): analyze_bandar lists the tiers top accepts The schema said uint8 0-255; only 1, 3, 5 and 10 are valid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-mcp/src/params.rs | 6 ++++++ crates/idx-mcp/tests/golden/tools.json | 14 +++++++------- web/src/content/docs/mcp/tools.md | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 5e3c4a1..88ee860 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -1,6 +1,7 @@ //! Tool inputs. String enums are validated by idx-core's `FromStr`, so the //! accepted values and error messages match the CLI; the schemas list them. +use idx_core::analysis::bandar::TIERS; use idx_core::api::types::{Interval, Period}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor}; use idx_core::flow::types::FlowSource; @@ -155,6 +156,7 @@ pub struct BandarParams { pub scope: BrokerScope, /// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5). #[serde(default)] + #[schemars(schema_with = "top_schema")] pub top: Option, } @@ -255,3 +257,7 @@ fn board_schema(_: &mut SchemaGenerator) -> Schema { fn flow_source_schema(_: &mut SchemaGenerator) -> Schema { string_enum(FlowSource::ALL.map(FlowSource::as_str).to_vec()) } + +fn top_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "type": "integer", "enum": TIERS }) +} diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 25cb886..ad343a7 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -59,13 +59,13 @@ "top": { "default": null, "description": "Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5).", - "format": "uint8", - "maximum": 255, - "minimum": 0, - "type": [ - "integer", - "null" - ] + "enum": [ + 1, + 3, + 5, + 10 + ], + "type": "integer" } }, "required": [ diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 7287341..0380cc9 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -22,7 +22,7 @@ Bandarmology for one or more IDX tickers from the broker summary: for the top 1, | `investor` | `all` / `foreign` / `domestic` | no | Whose trades to count, by investor domicile (default "all"). | | `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. | | `to` | string | no | Last session of the range, YYYY-MM-DD (default today). | -| `top` | integer | no | Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5). | +| `top` | `1` / `3` / `5` / `10` | no | Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5). | ## `analyze_stock` From 2ae025be5dd86b3aa1d3e0c8ca766182f17700e1 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 11:48:00 +0000 Subject: [PATCH 14/32] test(agents): broker, bandar and foreign flow cases Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- tests/agents/cases.json | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/agents/cases.json b/tests/agents/cases.json index c31184b..55df201 100644 --- a/tests/agents/cases.json +++ b/tests/agents/cases.json @@ -62,5 +62,35 @@ "prompt": "Search the KSEI ownership database for investors named Salim.", "expect_tools": [{"name": "search_investors", "args": {"query": "Salim"}}], "expect_text": [] + }, + { + "id": "broker", + "prompt": "Which brokers bought and sold the most BBCA on 2026-10-16?", + "expect_tools": [{"name": "get_broker_summary", "args": {"symbol": "BBCA", "date": "2026-10-16"}}], + "expect_text": ["\\bMG\\b"], + "live": { + "prompt": "Which brokers bought and sold the most BBCA in the latest session?", + "expect_tools": [{"name": "get_broker_summary", "args": {"symbol": "BBCA"}}] + } + }, + { + "id": "bandar", + "prompt": "Were the big brokers (bandar) accumulating or distributing BBCA on 2026-10-16?", + "expect_tools": [{"name": "analyze_bandar", "args": {"symbols": ["BBCA"], "date": "2026-10-16"}}], + "expect_text": ["(?i)distribut"], + "live": { + "prompt": "Are the big brokers (bandar) accumulating or distributing BBCA lately?", + "expect_tools": [{"name": "analyze_bandar", "args": {"symbols": ["BBCA"]}}] + } + }, + { + "id": "flow", + "prompt": "What was the net foreign flow in BBCA from 2026-10-12 to 2026-10-16?", + "expect_tools": [{"name": "get_foreign_flow", "args": {"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16"}}], + "expect_text": ["(?i)foreign"], + "live": { + "prompt": "What has the net foreign flow in BBCA been over the last 10 sessions?", + "expect_tools": [{"name": "get_foreign_flow", "args": {"symbol": "BBCA"}}] + } } ] From 106590d1513cff3b155f51eda727919792e54321 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 11:56:25 +0000 Subject: [PATCH 15/32] fix(core): a row with no parsed cells rejects the page too Every piece of the table body that holds a row must parse as nine cells; ranks count the rows read, not the pieces. A rank-1 row without its cell ends was skipped and ranks 2-10 still matched (Codex re-review). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-core/src/api/ipot/parse.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs index c996ff5..74763e9 100644 --- a/crates/idx-core/src/api/ipot/parse.rs +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -176,12 +176,14 @@ pub fn parse_broker_summary(html: &str) -> Result { // another shape, a rank out of order, or a broker after a side's blank // rows rejects the page. let (mut buyers_ended, mut sellers_ended) = (false, false); - for (index, row) in html[body_start..body_end].split("").enumerate() { - let row = cells(row); - if row.is_empty() { + let mut rank = 0; + for piece in html[body_start..body_end].split("") { + // Only the space after the last row has no row in it. + if !piece.contains("().ok() != Some(rank) { return Err(IdxError::ParseError(format!( "ipot broker summary: unexpected row {rank} ({} cells, rank {:?})", @@ -349,7 +351,19 @@ mod tests { 1, ); let gap = DAY.replacen(">XL<", "><", 1); - for page in [short_row, skipped_rank, gap, "".to_string()] { + // Rank 1 without its cell ends parses to no cells at all. + let body = DAY.find("").unwrap(); + let first_row_end = body + DAY[body..].find("").unwrap(); + let unclosed = DAY[..body].to_string() + + &DAY[body..first_row_end].replace("", "") + + &DAY[first_row_end..]; + for page in [ + short_row, + skipped_rank, + gap, + unclosed, + "".to_string(), + ] { assert!( matches!(parse_broker_summary(&page), Err(IdxError::ParseError(_))), "{}", From 30afcc109db47288b7ccaacd407a03440b8d44ac Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 12:05:13 +0000 Subject: [PATCH 16/32] fix(core): read the IPOT table body as whole rows only The body (one tbody, tags matched case-insensitively) must be whole rows: each one holding nine opened and nine closed cells, ranked in order, with nothing else between rows. A row with missing close tags no longer merges into its neighbour, and an uppercase row is no longer skipped (Codex re-check). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-core/src/api/ipot/parse.rs | 61 +++++++++++++++++++++------ 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs index 74763e9..1b97ef1 100644 --- a/crates/idx-core/src/api/ipot/parse.rs +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -151,13 +151,17 @@ pub fn parse_broker_summary(html: &str) -> Result { if html.to_ascii_lowercase().contains("invalid stock code") { return Ok(IpotPage::Invalid); } - let Some((body_start, body_end)) = html.find("").and_then(|start| { - html[start..] + // Tags are matched case-insensitively on a lowercased copy; ASCII + // lowercasing keeps byte offsets, so slices of it index `html` too. + let lower = html.to_ascii_lowercase(); + let body = lower.find("").and_then(|start| { + lower[start..] .find("") - .map(|len| (start, start + len)) - }) else { + .map(|len| (start + "".len(), start + len)) + }); + let Some((body_start, body_end)) = body.filter(|_| lower.matches(" Result { value }) }; - // A skipped row would shift every later broker up a rank, so a row of - // another shape, a rank out of order, or a broker after a side's blank - // rows rejects the page. + // A skipped or merged row would shift brokers between ranks, so the body + // must be whole rows only, each one `` of nine closed cells, ranked + // in order, and a broker after a side's blank rows rejects the page. let (mut buyers_ended, mut sellers_ended) = (false, false); let mut rank = 0; - for piece in html[body_start..body_end].split("") { - // Only the space after the last row has no row in it. - if !piece.contains("") + .map_or(rest.len(), |end| end + "".len()); + let piece = &rest[..end]; + let original = &html[at..at + end]; + at += end; rank += 1; - if row.len() != 9 || row[4].text.parse::().ok() != Some(rank) { + let whole_row = piece.trim_start().starts_with("") + && piece.matches("").count() == 9; + let row = cells(original); + if !whole_row || row.len() != 9 || row[4].text.parse::().ok() != Some(rank) { return Err(IdxError::ParseError(format!( "ipot broker summary: unexpected row {rank} ({} cells, rank {:?})", row.len(), @@ -357,11 +373,28 @@ mod tests { let unclosed = DAY[..body].to_string() + &DAY[body..first_row_end].replace("", "") + &DAY[first_row_end..]; + // Rank 9 left open and rank 10 without cell ends: rank 10 would + // vanish into rank 9. + let rows: Vec = DAY[body..] + .match_indices("") + .map(|(at, _)| body + at) + .collect(); + let merged = DAY[..rows[8]].to_string() + + &DAY[rows[8] + "".len()..rows[9]].replace("", "") + + &DAY[rows[9]..]; + // An extra row in uppercase tags before rank 5. + let rank5 = DAY[..rows[4]].rfind("").unwrap(); + let extra_row = DAY[rank5..rows[4] + "".len()] + .replace("", "") + .replace("".to_string(), ] { assert!( From 186d9557d65517a4d1f24c11c98347bbe3d2e580 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 18 Oct 2026 12:15:40 +0000 Subject: [PATCH 17/32] fix(core): the IPOT table body holds plain row markup only One tbody start and end in the page, in any case, and inside it only tr, td and span tags with no '<' before a tag closes. A stray no longer cuts the table short, and cells inside a comment or an attribute are no longer read as brokers (Codex re-check). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-core/src/api/ipot/parse.rs | 40 +++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/idx-core/src/api/ipot/parse.rs b/crates/idx-core/src/api/ipot/parse.rs index 1b97ef1..516d4ae 100644 --- a/crates/idx-core/src/api/ipot/parse.rs +++ b/crates/idx-core/src/api/ipot/parse.rs @@ -131,6 +131,25 @@ fn broker_kind(class: Option<&str>) -> Option { } } +/// Whether `body` (lowercased) holds only `tr`, `td` and `span` tags, each +/// closed before the next `<`: no comments, other elements, or markup +/// inside attributes that the row checks could miscount. +fn plain_markup(body: &str) -> bool { + body.match_indices('<').all(|(at, _)| { + let tag = &body[at + 1..]; + let Some(end) = tag.find('>') else { + return false; + }; + let inner = &tag[..end]; + let name: String = inner + .trim_start_matches('/') + .chars() + .take_while(char::is_ascii_alphanumeric) + .collect(); + !inner.contains('<') && matches!(name.as_str(), "tr" | "td" | "span") + }) +} + /// The `MM/DD/YYYY` value of the input with this id. fn input_date(html: &str, id: &str) -> Option { let at = html.find(&format!("id=\"{id}\""))?; @@ -159,9 +178,12 @@ pub fn parse_broker_summary(html: &str) -> Result { .find("") .map(|len| (start + "".len(), start + len)) }); - let Some((body_start, body_end)) = body.filter(|_| lower.matches("", "") .replace("