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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-18 04:38:58 +00:00
commit 860d4daf77
25 changed files with 939 additions and 13 deletions

View file

@ -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<EarningsFilterArgs> 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<String>,
/// First session of a range, YYYY-MM-DD (at most 366 days).
#[arg(long)]
from: Option<String>,
/// Last session of the range, YYYY-MM-DD. Default: today.
#[arg(long, requires = "from")]
to: Option<String>,
/// 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, IdxError> {
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)

View file

@ -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],

View file

@ -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<i64>) -> 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::{

View file

@ -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");
}

View file

@ -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]]

View file

@ -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 <SYMBOL>"
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 <SYMBOLS>..."
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

View file

@ -0,0 +1,4 @@
exit: 3
--- stdout
--- stderr
{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: ZZZZ"}

View file

@ -0,0 +1,4 @@
exit: 3
--- stdout
--- stderr
Error: symbol not found: ZZZZ

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
exit: 0
--- stdout
Broker summary for BBCA.JK, 2026-10-17
No trades.
--- stderr

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<Fetched<BrokerSummary>, 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);

View file

@ -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<Self, Self::Err> {
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<Self, Self::Err> {
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<Self, IdxError> {
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 {

View file

@ -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<Vec<AnalysisKind>>,
}
#[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<String>,
/// First session of a range, YYYY-MM-DD (at most 366 days).
#[serde(default)]
pub from: Option<String>,
/// Last session of the range, YYYY-MM-DD (default today).
#[serde(default)]
pub to: Option<String>,
/// Whose trades to count, by investor domicile (default "all").
#[serde(default)]
#[schemars(schema_with = "investor_schema")]
pub investor: Option<String>,
/// Which board's trades to count (default "all"); "regular" leaves out
/// negotiated block trades.
#[serde(default)]
#[schemars(schema_with = "board_schema")]
pub board: Option<String>,
}
#[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())
}

View file

@ -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<BrokerParams>) -> 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`: \

View file

@ -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": {

View file

@ -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}");