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::config::{HistoryProviderKind, IdxConfig};
use crate::output::{ use crate::output::{
render_compare, render_earnings, render_financials, render_fundamental, render_growth, render_broker_summary, render_compare, render_earnings, render_financials, render_fundamental,
render_history, render_insights, render_news, render_profile, render_quotes, render_risk, render_growth, render_history, render_insights, render_news, render_profile, render_quotes,
render_screener, render_sentiment, render_technical, render_valuation, render_risk, render_screener, render_sentiment, render_technical, render_valuation,
}; };
use idx_core::api::types::{Interval, Period}; use idx_core::api::types::{Interval, Period};
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
use idx_core::error::IdxError; use idx_core::error::IdxError;
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind}; use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
use idx_core::market::ScreenQuery; 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)] #[derive(Debug, Args)]
#[command(about = "Stock data and analysis")] #[command(about = "Stock data and analysis")]
pub struct StocksCmd { pub struct StocksCmd {
@ -150,6 +182,19 @@ pub enum StocksSubcommand {
/// Single ticker symbol (e.g. BBCA). /// Single ticker symbol (e.g. BBCA).
symbol: String, 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")] #[command(about = "Get company profile")]
Profile { symbol: String }, Profile { symbol: String },
#[command( #[command(
@ -256,6 +301,16 @@ pub fn handle(
let report = idx.analysis().fundamental(symbol, policy)?; let report = idx.analysis().fundamental(symbol, policy)?;
render_fundamental(&report.data, format, no_color) 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 } => { StocksSubcommand::Profile { symbol } => {
let profile = idx.financial().profile(symbol, policy)?; let profile = idx.financial().profile(symbol, policy)?;
render_profile(&profile.data, format) render_profile(&profile.data, format)

View file

@ -16,6 +16,7 @@ use idx_core::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote,
SentimentData, SentimentData,
}; };
use idx_core::broker::types::BrokerSummary;
use idx_core::error::IdxError; use idx_core::error::IdxError;
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file). /// 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( pub fn render_history(
symbol: &str, symbol: &str,
history: &[Ohlc], history: &[Ohlc],

View file

@ -11,6 +11,7 @@ use idx_core::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc,
Quote, SentimentData, Quote, SentimentData,
}; };
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
use idx_core::error::IdxError; use idx_core::error::IdxError;
pub fn format_idr(value: i64) -> String { pub fn format_idr(value: i64) -> String {
@ -893,6 +894,126 @@ fn truncate_url(url: &str) -> String {
truncate_chars(url, 72) 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)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{

View file

@ -2434,3 +2434,154 @@ fn msn_screen_rejects_invalid_totals_without_populating_cache() {
fs::remove_dir_all(root).unwrap(); 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"] args = ["stocks", "fundamental", "BBCA"]
env = { IDX_PROVIDER = "yahoo" } 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 --------------------------------------------------------------------- # --- errors ---------------------------------------------------------------------
[[case]] [[case]]

View file

@ -174,6 +174,9 @@ _idx() {
idx__help__ownership__resolve,unresolved) idx__help__ownership__resolve,unresolved)
cmd="idx__help__ownership__resolve__unresolved" cmd="idx__help__ownership__resolve__unresolved"
;; ;;
idx__help__stocks,broker)
cmd="idx__help__stocks__broker"
;;
idx__help__stocks,compare) idx__help__stocks,compare)
cmd="idx__help__stocks__compare" cmd="idx__help__stocks__compare"
;; ;;
@ -336,6 +339,9 @@ _idx() {
idx__ownership__resolve__help,unresolved) idx__ownership__resolve__help,unresolved)
cmd="idx__ownership__resolve__help__unresolved" cmd="idx__ownership__resolve__help__unresolved"
;; ;;
idx__stocks,broker)
cmd="idx__stocks__broker"
;;
idx__stocks,compare) idx__stocks,compare)
cmd="idx__stocks__compare" cmd="idx__stocks__compare"
;; ;;
@ -384,6 +390,9 @@ _idx() {
idx__stocks,valuation) idx__stocks,valuation)
cmd="idx__stocks__valuation" cmd="idx__stocks__valuation"
;; ;;
idx__stocks__help,broker)
cmd="idx__stocks__help__broker"
;;
idx__stocks__help,compare) idx__stocks__help,compare)
cmd="idx__stocks__help__compare" cmd="idx__stocks__help__compare"
;; ;;
@ -1191,7 +1200,7 @@ _idx() {
return 0 return 0
;; ;;
idx__help__stocks) 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 if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
@ -1204,6 +1213,20 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 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) idx__help__stocks__compare)
opts="" opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
@ -2193,7 +2216,7 @@ _idx() {
return 0 return 0
;; ;;
idx__stocks) 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 if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
@ -2214,6 +2237,48 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 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) idx__stocks__compare)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOLS>..." opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOLS>..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
@ -2329,7 +2394,7 @@ _idx() {
return 0 return 0
;; ;;
idx__stocks__help) 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 if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
@ -2342,6 +2407,20 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 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) idx__stocks__help__compare)
opts="" opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then 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 valuation Run valuation analysis on a stock
risk Run risk analysis on a stock risk Run risk analysis on a stock
fundamental Run full fundamental analysis on a stock fundamental Run full fundamental analysis on a stock
broker Which brokers bought and sold a stock
profile Get company profile profile Get company profile
financials Get financial statements financials Get financial statements
earnings Get earnings report 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::api::{broker_provider, today_wib};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::error::IdxError; use crate::error::IdxError;
use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerSummary}; use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerRange, BrokerSummary};
const SOURCE: &str = "ipot"; const SOURCE: &str = "ipot";
/// Longest range one summary may cover. /// Longest range one summary may cover.
@ -47,6 +47,35 @@ pub struct Broker<'a> {
} }
impl Broker<'_> { 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 /// The broker summary for `symbol` over `query`'s range. An empty
/// summary means no trades (weekend, holiday, suspension). /// summary means no trades (weekend, holiday, suspension).
pub fn summary( pub fn summary(
@ -247,6 +276,38 @@ mod tests {
assert_eq!(filtered.sellers[0].value, Some(2_000_000_000_000)); 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] #[test]
fn test_check_range_bounds() { fn test_check_range_bounds() {
let today = date(2026, 10, 18); let today = date(2026, 10, 18);

View file

@ -1,8 +1,12 @@
//! Broker summary types: which IDX brokers bought and sold a stock. //! Broker summary types: which IDX brokers bought and sold a stock.
use std::str::FromStr;
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::IdxError;
/// Whose trades a broker summary counts. /// Whose trades a broker summary counts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
@ -18,6 +22,8 @@ pub enum BrokerInvestor {
} }
impl BrokerInvestor { impl BrokerInvestor {
pub const ALL: [Self; 3] = [Self::All, Self::Foreign, Self::Domestic];
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::All => "all", 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. /// Which market board's trades a broker summary counts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
@ -44,6 +65,8 @@ pub enum BrokerBoard {
} }
impl BrokerBoard { impl BrokerBoard {
pub const ALL: [Self; 4] = [Self::All, Self::Regular, Self::Cash, Self::Negotiated];
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::All => "all", 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 /// The source's classification of a securities house. This is the broker's
/// own status, not the nationality of its clients. /// own status, not the nationality of its clients.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[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). /// The trading days a broker summary covers, both ends included (WIB dates).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrokerQuery { pub struct BrokerQuery {

View file

@ -2,6 +2,7 @@
//! accepted values and error messages match the CLI; the schemas list them. //! accepted values and error messages match the CLI; the schemas list them.
use idx_core::api::types::{Interval, Period}; use idx_core::api::types::{Interval, Period};
use idx_core::broker::types::{BrokerBoard, BrokerInvestor};
use idx_core::market::{ScreenFilter, ScreenRegion}; use idx_core::market::{ScreenFilter, ScreenRegion};
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::Deserialize; use serde::Deserialize;
@ -114,6 +115,30 @@ pub struct AnalyzeParams {
pub kinds: Option<Vec<AnalysisKind>>, 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)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct ShareholdersParams { pub struct ShareholdersParams {
/// IDX ticker, e.g. "BBCA". /// IDX ticker, e.g. "BBCA".
@ -178,3 +203,11 @@ fn filter_schema(_: &mut SchemaGenerator) -> Schema {
fn region_schema(_: &mut SchemaGenerator) -> Schema { fn region_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(ScreenRegion::ALL.map(ScreenRegion::as_str).to_vec()) 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 std::collections::BTreeMap;
use idx_core::analysis::fundamental::FundamentalReport; use idx_core::analysis::fundamental::FundamentalReport;
use idx_core::broker::types::BrokerRange;
use idx_core::config::CoreConfig; use idx_core::config::CoreConfig;
use idx_core::error::IdxError; use idx_core::error::IdxError;
use idx_core::financial::{EarningsFilter, StatementFilter}; use idx_core::financial::{EarningsFilter, StatementFilter};
@ -21,8 +22,9 @@ use serde_json::{Map, Value, json};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use crate::params::{ use crate::params::{
AnalysisKind, AnalyzeParams, ChangesParams, CompanyParams, CompanySection, EntityParams, AnalysisKind, AnalyzeParams, BrokerParams, ChangesParams, CompanyParams, CompanySection,
HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams, EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams,
SyncParams,
}; };
const SYNC_TOOL: &str = "ownership_sync"; const SYNC_TOOL: &str = "ownership_sync";
@ -165,6 +167,42 @@ for one or more IDX tickers."
.await .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( #[tool(
description = "Company data for one IDX ticker: profile, financial statements, \ description = "Company data for one IDX ticker: profile, financial statements, \
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \ earnings, analyst sentiment, insights, and news. News items carry `relevance`: \

View file

@ -57,6 +57,68 @@
}, },
"name": "compare_stocks" "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.", "description": "Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. News items carry `relevance`: `company` (about this ticker) first, then `market` (general market news). Sections that fail are listed under `errors`; the call fails only if every section fails.",
"inputSchema": { "inputSchema": {

View file

@ -9,9 +9,10 @@ use idx_core::config::CoreConfig;
use idx_core::investor::{ImportSource, Investor}; use idx_core::investor::{ImportSource, Investor};
use serde_json::{Value, json}; use serde_json::{Value, json};
const TOOLS: [&str; 11] = [ const TOOLS: [&str; 12] = [
"analyze_stock", "analyze_stock",
"compare_stocks", "compare_stocks",
"get_broker_summary",
"get_company", "get_company",
"get_investor_holdings", "get_investor_holdings",
"get_ownership_changes", "get_ownership_changes",
@ -247,6 +248,16 @@ fn live_tools_wrap_data_with_freshness() {
assert_fetched(&analysis[kind]); 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"]})); let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]}));
assert_eq!(compared["reports"].as_array().unwrap().len(), 2); assert_eq!(compared["reports"].as_array().unwrap().len(), 2);
assert_eq!(compared["failures"], json!([])); assert_eq!(compared["failures"], json!([]));
@ -273,6 +284,14 @@ fn errors_carry_cli_codes() {
("compare_stocks", json!({"symbols": []})), ("compare_stocks", json!({"symbols": []})),
("get_company", json!({"symbol": "BBCA", "sections": []})), ("get_company", json!({"symbol": "BBCA", "sections": []})),
("analyze_stock", json!({"symbol": "BBCA", "kinds": []})), ("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); let (is_error, body) = client.call(tool, arguments);
assert!(is_error, "{tool}: {body}"); assert!(is_error, "{tool}: {body}");

View file

@ -288,7 +288,7 @@
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
sleep 2 sleep 2
} | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl } | 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" touch "$out"
''; '';
# The website's generated pages match the CLI help and the MCP # The website's generated pages match the CLI help and the MCP

View file

@ -66,6 +66,7 @@ Commands:
valuation Run valuation analysis on a stock valuation Run valuation analysis on a stock
risk Run risk analysis on a stock risk Run risk analysis on a stock
fundamental Run full fundamental analysis on a stock fundamental Run full fundamental analysis on a stock
broker Which brokers bought and sold a stock
profile Get company profile profile Get company profile
financials Get financial statements financials Get financial statements
earnings Get earnings report earnings Get earnings report
@ -278,6 +279,78 @@ Examples:
idx -o json stocks fundamental BBCA 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] <SYMBOL>
Arguments:
<SYMBOL>
Single ticker symbol (e.g. BBCA)
Options:
--date <DATE>
One session, YYYY-MM-DD. Default: the latest session with trades
-o, --output <OUTPUT>
Output format [default: auto = table on a terminal, json when piped]
[possible values: auto, table, json, jsonl]
--from <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 <TO>
Last session of the range, YYYY-MM-DD. Default: today
--investor <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 <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` ### `idx stocks profile`
```text ```text

View file

@ -1,6 +1,6 @@
--- ---
title: Stocks 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 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 | | `insights` | AI insights from the provider |
| `news` | recent news, this company's first (`ABOUT` = `company`), then general market news (`market`) | | `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 ## Analysis
| Command | What it computes | | Command | What it computes |
@ -62,6 +81,7 @@ idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compa
## Providers ## Providers
MSN is the default for quotes, company data and screening; Yahoo serves 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 `IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the matching
[config keys](/start/config/). [config keys](/start/config/).

View file

@ -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. | | `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` ## `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. 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.