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.