feat: bandarmology (idx stocks bandar, analyze_bandar)

`idx stocks bandar SYMBOL...` reads the broker summary: for the top 1, 3,
5 and 10 brokers per side, buy value minus sell value in basis points of
value traded, with a signal from big accumulation to big distribution on
the headline tier (--top, default 5). Several symbols give a screen ranked
by that figure (at most 100, one request each), which pipes from
`stocks screen`. The MCP server exposes it as analyze_bandar (20 symbols).

- analysis/bandar.rs is pure and versioned (`top_concentration_v1`):
  integer basis points, no figure missing counted as zero, buy/sell VWAP
  from exact averages, and the brokers on both sides listed because their
  gross figures overstate their direction.
- Thresholds (±5%/±15%) follow fzn0x/idx (MIT); docs call them heuristics.
- Invalid input fails a screen at once; other errors fail one symbol and
  are warned about on stderr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-18 04:55:25 +00:00
commit 970da26a0e
25 changed files with 1588 additions and 37 deletions

View file

@ -4,10 +4,13 @@ use clap::{Args, Subcommand};
use crate::config::{HistoryProviderKind, IdxConfig}; use crate::config::{HistoryProviderKind, IdxConfig};
use crate::output::{ use crate::output::{
render_broker_summary, render_compare, render_earnings, render_financials, render_fundamental, render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
render_growth, render_history, render_insights, render_news, render_profile, render_quotes, render_fundamental, render_growth, render_history, render_insights, render_news,
render_risk, render_screener, render_sentiment, render_technical, render_valuation, render_profile, render_quotes, render_risk, render_screener, render_sentiment,
render_technical, render_valuation,
}; };
use idx_core::analysis::BandarQuery;
use idx_core::analysis::bandar::DEFAULT_TOP;
use idx_core::api::types::{Interval, Period}; use idx_core::api::types::{Interval, Period};
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
use idx_core::error::IdxError; use idx_core::error::IdxError;
@ -17,6 +20,9 @@ use idx_core::{CachePolicy, Idx, runtime};
/// Splits comma-separated symbol arguments; `-` reads symbols from stdin, /// Splits comma-separated symbol arguments; `-` reads symbols from stdin,
/// separated by whitespace or commas. /// separated by whitespace or commas.
/// Most symbols one `bandar` screen fetches, one request each.
const MAX_BANDAR_SYMBOLS: usize = 100;
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> { fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
let mut symbols = Vec::new(); let mut symbols = Vec::new();
for arg in args { for arg in args {
@ -195,6 +201,24 @@ does not show broker codes during trading. Without dates, the latest session wit
#[command(flatten)] #[command(flatten)]
scope: BrokerScopeArgs, scope: BrokerScopeArgs,
}, },
#[command(
about = "Bandarmology: is buying more concentrated than selling",
long_about = "Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value \
against the same number of sellers', as a share of value traded, with a signal from big \
accumulation to big distribution. Several symbols give a screen ranked by the headline tier \
(at most 100, one request each). A heuristic over end-of-day broker codes, not advice.",
after_help = "Examples:\n idx stocks bandar BBCA\n idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3\n idx stocks bandar BBCA,BBRI,BMRI,TLKM\n idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -"
)]
Bandar {
/// One or more symbols, comma-separated or space-separated; `-` reads them from stdin.
#[arg(required = true)]
symbols: Vec<String>,
#[command(flatten)]
scope: BrokerScopeArgs,
/// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side.
#[arg(long, default_value_t = DEFAULT_TOP)]
top: u8,
},
#[command(about = "Get company profile")] #[command(about = "Get company profile")]
Profile { symbol: String }, Profile { symbol: String },
#[command( #[command(
@ -311,6 +335,31 @@ pub fn handle(
)?; )?;
render_broker_summary(&summary.data, format) render_broker_summary(&summary.data, format)
} }
StocksSubcommand::Bandar {
symbols,
scope,
top,
} => {
let symbols = expand_symbols(symbols)?;
if symbols.len() > MAX_BANDAR_SYMBOLS {
return Err(IdxError::InvalidInput(format!(
"at most {MAX_BANDAR_SYMBOLS} symbols per bandar screen (got {})",
symbols.len()
)));
}
let query = BandarQuery {
range: scope.range(&idx)?,
investor: scope.investor,
board: scope.board,
top: *top,
};
let screen = idx.analysis().bandar_screen(&symbols, &query, policy)?;
for (resolved, err) in screen.failures {
runtime::warn(format!("failed to read brokers for {resolved}: {err}"));
}
let reports: Vec<_> = screen.reports.into_iter().map(|r| r.data).collect();
render_bandar(&reports, symbols.len() > 1, format, no_color)
}
StocksSubcommand::Profile { symbol } => { 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

@ -8,6 +8,7 @@ use std::io::IsTerminal;
use clap::ValueEnum; use clap::ValueEnum;
use serde::Serialize; use serde::Serialize;
use idx_core::analysis::bandar::BandarReport;
use idx_core::analysis::fundamental::{ use idx_core::analysis::fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport, FundamentalReport, GrowthReport, RiskReport, ValuationReport,
}; };
@ -84,6 +85,21 @@ pub fn render_broker_summary(
} }
} }
/// One symbol gets the detailed table, several the ranked screen (even when
/// only one of them succeeded); JSON is always the array.
pub fn render_bandar(
reports: &[BandarReport],
screen: bool,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match (format, reports) {
(OutputFormat::Table, [report]) if !screen => table::print_bandar_report(report, no_color),
(OutputFormat::Table, _) => table::print_bandar_screen(reports, no_color),
(OutputFormat::Json | OutputFormat::Jsonl, _) => json::print_json(reports),
}
}
pub fn render_history( pub fn render_history(
symbol: &str, symbol: &str,
history: &[Ohlc], history: &[Ohlc],

View file

@ -2,6 +2,9 @@ use crate::output::style::Paint;
use crate::output::text::{cell, painted, terminal_safe, truncate_chars}; use crate::output::text::{cell, painted, terminal_safe, truncate_chars};
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL}; use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
use idx_core::analysis::bandar::{
ACCUMULATION_BPS, BIG_BPS, BandarReport, BandarSignal, METHOD as BANDAR_METHOD,
};
use idx_core::analysis::fundamental::{ use idx_core::analysis::fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport, FundamentalReport, GrowthReport, RiskReport, ValuationReport,
}; };
@ -1018,6 +1021,171 @@ pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> {
Ok(()) Ok(())
} }
fn format_bps_pct(bps: Option<i64>) -> String {
bps.map(|bps| format!("{:+.1}%", bps as f64 / 100.0))
.unwrap_or_else(|| "-".to_string())
}
fn format_bps_share(bps: Option<i64>) -> String {
bps.map(|bps| format!("{:.1}%", bps as f64 / 100.0))
.unwrap_or_else(|| "-".to_string())
}
fn format_bandar_signal(signal: Option<BandarSignal>, no_color: bool) -> String {
let Some(signal) = signal else {
return "-".to_string();
};
let label = signal.as_str().replace('_', " ");
if no_color {
return label;
}
match signal {
BandarSignal::BigAccumulation | BandarSignal::Accumulation => label.green(),
BandarSignal::BigDistribution | BandarSignal::Distribution => label.red(),
BandarSignal::Neutral => label,
}
}
fn bandar_method_note(top: u8) -> String {
format!(
"Method {BANDAR_METHOD}: the top {top} buyers' value minus the top {top} sellers' value, \
as a share of value traded; {}% or more reads accumulation, {}% or more big (negative: \
distribution). Brokers trade for many clients, so this is a heuristic, not advice.",
ACCUMULATION_BPS / 100,
BIG_BPS / 100
)
}
pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), IdxError> {
println!(
"{}",
format!(
"Bandar reading for {}, {}",
terminal_safe(&report.symbol),
broker_scope(report.from, report.to, report.investor, report.board)
)
.bold()
);
if report.total_value.is_none_or(|value| value <= 0) {
println!("No trades.");
return Ok(());
}
println!(
"Signal (top {}): {}, {} of value",
report.top,
format_bandar_signal(report.signal, no_color),
format_bps_pct(report.imbalance_bps)
);
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
"TOP", "BUY", "SELL", "NET", "NET %", "BUY AVG", "SELL AVG", "SIGNAL",
]);
for tier in &report.tiers {
table.add_row(vec![
cell(tier.top),
cell(format_compact_option(tier.buy_value)),
cell(format_compact_option(tier.sell_value)),
cell(format_compact_option(tier.imbalance_value)),
cell(format_bps_pct(tier.imbalance_bps)),
cell(
tier.buy_average
.map(format_idr)
.unwrap_or_else(|| "-".to_string()),
),
cell(
tier.sell_average
.map(format_idr)
.unwrap_or_else(|| "-".to_string()),
),
painted(format_bandar_signal(tier.signal, no_color)),
]);
}
println!("{table}");
let mut totals = format!("Total {} IDR", format_compact_option(report.total_value));
if let Some(average) = report.average_price {
totals.push_str(&format!(", average {}", format_idr(average)));
}
if let Some(net) = report.foreign_net_value {
totals.push_str(&format!(
"; foreign net {:+} ({} of value)",
format_compact(net),
format_bps_pct(report.foreign_net_bps)
));
}
println!("{totals}.");
if !report.both_sides.is_empty() {
println!(
"On both sides: {}; their gross figures overstate which way they traded.",
terminal_safe(&report.both_sides.join(", "))
);
}
println!("{}", bandar_method_note(report.top));
if report.rounded {
println!("Note: the source rounds figures of a million and more to one decimal.");
}
Ok(())
}
pub fn print_bandar_screen(reports: &[BandarReport], no_color: bool) -> Result<(), IdxError> {
let top = reports.first().map(|report| report.top).unwrap_or_default();
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
"SYMBOL".to_string(),
"DATES".to_string(),
"SIGNAL".to_string(),
format!("TOP {top} NET %"),
"BUY SHARE".to_string(),
"SELL SHARE".to_string(),
"VALUE".to_string(),
"FOREIGN NET".to_string(),
]);
for report in reports {
let headline = report.tiers.iter().find(|tier| tier.top == report.top);
table.add_row(vec![
cell(&report.symbol),
cell(broker_scope(
report.from,
report.to,
BrokerInvestor::All,
BrokerBoard::All,
)),
painted(format_bandar_signal(report.signal, no_color)),
cell(format_bps_pct(report.imbalance_bps)),
cell(format_bps_share(
headline.and_then(|tier| tier.buy_share_bps),
)),
cell(format_bps_share(
headline.and_then(|tier| tier.sell_share_bps),
)),
cell(format_compact_option(report.total_value)),
cell(
report
.foreign_net_value
.map(|net| format!("{:+}", format_compact(net)))
.unwrap_or_else(|| "-".to_string()),
),
]);
}
println!("{table}");
if let Some(first) = reports.first()
&& (first.investor, first.board) != (BrokerInvestor::All, BrokerBoard::All)
{
println!(
"Counting {} investors on the {} board.",
first.investor.as_str(),
first.board.as_str()
);
}
println!("{}", bandar_method_note(top));
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{

View file

@ -2605,3 +2605,120 @@ fn broker_summary_errors_follow_the_output_contract() {
]); ]);
run_error_json(&mut failing, "PROVIDERUNAVAILABLE"); run_error_json(&mut failing, "PROVIDERUNAVAILABLE");
} }
#[test]
fn bandar_reads_the_broker_summary_tiers() {
let reports = run_success_json(mock_bin("bandar-json").args([
"-o",
"json",
"stocks",
"bandar",
"BBCA",
"--date",
"2026-10-16",
]));
let report = &reports[0];
assert_eq!(reports.as_array().unwrap().len(), 1);
assert_eq!(report["symbol"], "BBCA.JK");
assert_eq!(report["method"], "top_concentration_v1");
let tiers: Vec<i64> = report["tiers"]
.as_array()
.unwrap()
.iter()
.map(|tier| tier["top"].as_i64().unwrap())
.collect();
assert_eq!(tiers, [1, 3, 5, 10]);
// Top 5 bought 2,939.5B and sold 4,540.4B of 6.6T traded.
let top5 = &report["tiers"][2];
assert_eq!(top5["buy_value"], 2_939_500_000_000_i64);
assert_eq!(top5["sell_value"], 4_540_400_000_000_i64);
assert_eq!(top5["imbalance_bps"], -2426);
assert_eq!(report["top"], 5);
assert_eq!(report["imbalance_bps"], -2426);
assert_eq!(report["signal"], "big_distribution");
assert_eq!(
report["both_sides"],
serde_json::json!(["AK", "CC", "MG", "XL", "YU", "ZP"])
);
}
#[test]
fn bandar_screen_ranks_symbols_and_reports_failures_on_stderr() {
let output = mock_bin("bandar-screen")
.args([
"-o",
"json",
"stocks",
"bandar",
"BBCA,ZZZZ",
"TLKM",
"BBRI",
"--date",
"2026-10-16",
"--top",
"3",
])
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
let reports: Value = serde_json::from_slice(&output.stdout).unwrap();
let ranked: Vec<(&str, i64)> = reports
.as_array()
.unwrap()
.iter()
.map(|r| {
(
r["symbol"].as_str().unwrap(),
r["imbalance_bps"].as_i64().unwrap(),
)
})
.collect();
assert_eq!(ranked.len(), 3);
assert!(
ranked.windows(2).all(|pair| pair[0].1 >= pair[1].1),
"{ranked:?}"
);
assert!(reports.as_array().unwrap().iter().all(|r| r["top"] == 3));
let warning: Value = serde_json::from_slice(&output.stderr).expect("one JSON warning line");
assert_eq!(warning["level"], "warning");
assert!(warning["message"].as_str().unwrap().contains("ZZZZ.JK"));
// A one-symbol screen that loses a symbol still prints the screen table.
let table = run_success_stdout(mock_bin("bandar-screen-table").args([
"--no-color",
"stocks",
"bandar",
"BBCA,ZZZZ",
"--date",
"2026-10-16",
]));
assert!(table.contains("TOP 5 NET %"), "{table}");
}
#[test]
fn bandar_errors_follow_the_output_contract() {
let many: Vec<String> = (0..101).map(|i| format!("S{i}")).collect();
for (args, code) in [
(
vec!["BBCA".to_string(), "--top".into(), "4".into()],
"INVALIDINPUT",
),
(vec![many.join(",")], "INVALIDINPUT"),
(
vec!["ZZZZ".to_string(), "--date".into(), "2026-10-16".into()],
"SYMBOLNOTFOUND",
),
(
vec![
"BBCA,BBRI".to_string(),
"--date".into(),
"2999-01-01".into(),
],
"INVALIDINPUT",
),
] {
let mut cmd = mock_bin("bandar-errors");
cmd.args(["-o", "json", "stocks", "bandar"]).args(&args);
run_error_json(&mut cmd, code);
}
}

View file

@ -258,6 +258,15 @@ args = ["stocks", "broker", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"
name = "stocks-broker-no-trades" name = "stocks-broker-no-trades"
args = ["stocks", "broker", "BBCA", "--date", "2026-10-17"] args = ["stocks", "broker", "BBCA", "--date", "2026-10-17"]
[[case]]
name = "stocks-bandar"
args = ["stocks", "bandar", "BBCA", "--date", "2026-10-16"]
[[case]]
name = "stocks-bandar-screen"
formats = ["table", "json", "jsonl"]
args = ["stocks", "bandar", "BBCA,BBRI,TLKM", "--date", "2026-10-16", "--top", "3"]
[[case]] [[case]]
name = "error-broker-unknown-symbol" name = "error-broker-unknown-symbol"
args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"] args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"]

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,bandar)
cmd="idx__help__stocks__bandar"
;;
idx__help__stocks,broker) idx__help__stocks,broker)
cmd="idx__help__stocks__broker" cmd="idx__help__stocks__broker"
;; ;;
@ -339,6 +342,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,bandar)
cmd="idx__stocks__bandar"
;;
idx__stocks,broker) idx__stocks,broker)
cmd="idx__stocks__broker" cmd="idx__stocks__broker"
;; ;;
@ -390,6 +396,9 @@ _idx() {
idx__stocks,valuation) idx__stocks,valuation)
cmd="idx__stocks__valuation" cmd="idx__stocks__valuation"
;; ;;
idx__stocks__help,bandar)
cmd="idx__stocks__help__bandar"
;;
idx__stocks__help,broker) idx__stocks__help,broker)
cmd="idx__stocks__help__broker" cmd="idx__stocks__help__broker"
;; ;;
@ -1200,7 +1209,7 @@ _idx() {
return 0 return 0
;; ;;
idx__help__stocks) idx__help__stocks)
opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare" opts="quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
@ -1213,6 +1222,20 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
idx__help__stocks__bandar)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__help__stocks__broker) idx__help__stocks__broker)
opts="" opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
@ -2216,7 +2239,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 broker profile financials earnings sentiment insights news screen compare help" opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
@ -2237,6 +2260,52 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
idx__stocks__bandar)
opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOLS>..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--date)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--from)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--to)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--investor)
COMPREPLY=($(compgen -W "all foreign domestic" -- "${cur}"))
return 0
;;
--board)
COMPREPLY=($(compgen -W "all regular cash negotiated" -- "${cur}"))
return 0
;;
--top)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--output)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
-o)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stocks__broker) idx__stocks__broker)
opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>" 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 if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
@ -2394,7 +2463,7 @@ _idx() {
return 0 return 0
;; ;;
idx__stocks__help) idx__stocks__help)
opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare help" opts="quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
@ -2407,6 +2476,20 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
idx__stocks__help__bandar)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stocks__help__broker) idx__stocks__help__broker)
opts="" opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then

View file

@ -13,6 +13,7 @@ Commands:
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 broker Which brokers bought and sold a stock
bandar Bandarmology: is buying more concentrated than selling
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":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1749,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1503,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1749,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1655,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1322,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3334,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true},{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1994,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1546,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1994,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1733,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-560,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true},{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2914,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}]
--- stderr

View file

@ -0,0 +1,6 @@
exit: 0
--- stdout
{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1749,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1503,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1749,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1655,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1322,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3334,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true}
{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1994,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1546,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1994,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1733,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-560,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true}
{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2914,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}
--- stderr

View file

@ -0,0 +1,13 @@
exit: 0
--- stdout
┌─────────┬────────────┬──────────────────┬─────────────┬───────────┬────────────┬───────┬─────────────┐
│ SYMBOL ┆ DATES ┆ SIGNAL ┆ TOP 3 NET % ┆ BUY SHARE ┆ SELL SHARE ┆ VALUE ┆ FOREIGN NET │
╞═════════╪════════════╪══════════════════╪═════════════╪═══════════╪════════════╪═══════╪═════════════╡
│ BBRI.JK ┆ 2026-10-16 ┆ big distribution ┆ -17.5% ┆ 38.3% ┆ 55.7% ┆ 3.3T ┆ -1.1T │
├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ TLKM.JK ┆ 2026-10-16 ┆ big distribution ┆ -19.9% ┆ 43.1% ┆ 63.1% ┆ 2.2T ┆ -90.2B │
├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ BBCA.JK ┆ 2026-10-16 ┆ big distribution ┆ -29.1% ┆ 30.1% ┆ 59.2% ┆ 6.6T ┆ -2.3T │
└─────────┴────────────┴──────────────────┴─────────────┴───────────┴────────────┴───────┴─────────────┘
Method top_concentration_v1: the top 3 buyers' value minus the top 3 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice.
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":5,"signal":"big_distribution","imbalance_bps":-2426,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}]
--- stderr

View file

@ -0,0 +1,20 @@
exit: 0
--- stdout
Bandar reading for BBCA.JK, 2026-10-16
Signal (top 5): big distribution, -24.3% of value
┌─────┬────────┬──────┬───────┬────────┬─────────┬──────────┬──────────────────┐
│ TOP ┆ BUY ┆ SELL ┆ NET ┆ NET % ┆ BUY AVG ┆ SELL AVG ┆ SIGNAL │
╞═════╪════════╪══════╪═══════╪════════╪═════════╪══════════╪══════════════════╡
│ 1 ┆ 841.2B ┆ 2.2T ┆ -1.4T ┆ -20.6% ┆ 5,191 ┆ 5,211 ┆ big distribution │
├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 3 ┆ 2.0T ┆ 3.9T ┆ -1.9T ┆ -29.1% ┆ 5,212 ┆ 5,233 ┆ big distribution │
├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 5 ┆ 2.9T ┆ 4.5T ┆ -1.6T ┆ -24.3% ┆ 5,211 ┆ 5,229 ┆ big distribution │
├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 10 ┆ 4.5T ┆ 5.6T ┆ -1.1T ┆ -16.3% ┆ 5,213 ┆ 5,228 ┆ big distribution │
└─────┴────────┴──────┴───────┴────────┴─────────┴──────────┴──────────────────┘
Total 6.6T IDR, average 5,228; foreign net -2.3T (-34.9% of value).
On both sides: AK, CC, MG, XL, YU, ZP; their gross figures overstate which way they traded.
Method top_concentration_v1: the top 5 buyers' value minus the top 5 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice.
Note: the source rounds figures of a million and more to one decimal.
--- stderr

View file

@ -0,0 +1,360 @@
//! Bandarmology over a broker summary: is buying more concentrated in a few
//! brokers than selling? No network; see docs/ARCHITECTURE.md.
//!
//! The summary lists the top 10 buyers and the top 10 sellers by gross lots.
//! For N in 1, 3, 5 and 10 the top N buyers' value is set against the top N
//! sellers' value, as a share of the value traded:
//!
//! | top-N buy - sell value, % of value | signal |
//! |------------------------------------|--------------------|
//! | >= +15 | `big_accumulation` |
//! | +5 to +15 | `accumulation` |
//! | within +-5 | `neutral` |
//! | -15 to -5 | `distribution` |
//! | <= -15 | `big_distribution` |
//!
//! The thresholds follow fzn0x/idx (MIT), which set them against the 40 most
//! traded IDX stocks in October 2026. They are heuristics: a broker code is
//! not an investor, and gross figures overstate a broker that both bought
//! and sold (listed in `both_sides`).
use serde::{Deserialize, Serialize};
use crate::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerSummary};
use crate::error::IdxError;
use chrono::NaiveDate;
/// Names the rules below; bumped when they change.
pub const METHOD: &str = "top_concentration_v1";
/// Tiers every report carries.
pub const TIERS: [u8; 4] = [1, 3, 5, 10];
/// Tier behind the headline signal unless asked otherwise.
pub const DEFAULT_TOP: u8 = 5;
/// Imbalance, in basis points of value traded, from which a tier reads
/// accumulation (or, negative, distribution).
pub const ACCUMULATION_BPS: i64 = 500;
/// Imbalance from which the reading is big.
pub const BIG_BPS: i64 = 1500;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BandarSignal {
BigAccumulation,
Accumulation,
Neutral,
Distribution,
BigDistribution,
}
impl BandarSignal {
pub fn as_str(self) -> &'static str {
match self {
Self::BigAccumulation => "big_accumulation",
Self::Accumulation => "accumulation",
Self::Neutral => "neutral",
Self::Distribution => "distribution",
Self::BigDistribution => "big_distribution",
}
}
pub fn from_imbalance_bps(bps: i64) -> Self {
match bps {
b if b >= BIG_BPS => Self::BigAccumulation,
b if b >= ACCUMULATION_BPS => Self::Accumulation,
b if b <= -BIG_BPS => Self::BigDistribution,
b if b <= -ACCUMULATION_BPS => Self::Distribution,
_ => Self::Neutral,
}
}
}
/// The top N buyers against the top N sellers. Every figure is `None` when
/// a broker in the tier lacks it, or when nothing traded.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BandarTier {
pub top: u8,
/// Value the top N buyers bought, whole IDR.
pub buy_value: Option<i64>,
/// Value the top N sellers sold, whole IDR.
pub sell_value: Option<i64>,
/// `buy_value - sell_value`, whole IDR.
pub imbalance_value: Option<i64>,
/// `imbalance_value` in basis points of the value traded.
pub imbalance_bps: Option<i64>,
/// The top N buyers' share of the value traded, basis points.
pub buy_share_bps: Option<i64>,
/// The top N sellers' share of the value traded, basis points.
pub sell_share_bps: Option<i64>,
/// Volume-weighted average price the top N buyers paid, whole IDR.
pub buy_average: Option<i64>,
/// Volume-weighted average price the top N sellers got, whole IDR.
pub sell_average: Option<i64>,
pub signal: Option<BandarSignal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BandarReport {
pub symbol: String,
pub from: NaiveDate,
pub to: NaiveDate,
pub investor: BrokerInvestor,
pub board: BrokerBoard,
/// The rules applied, e.g. `top_concentration_v1`.
pub method: String,
/// Tier behind the headline `signal`.
pub top: u8,
/// The headline tier's signal; `None` when nothing traded or a figure
/// is missing.
pub signal: Option<BandarSignal>,
/// The headline tier's imbalance, basis points of value traded.
pub imbalance_bps: Option<i64>,
pub tiers: Vec<BandarTier>,
/// Value traded over the range, whole IDR.
pub total_value: Option<i64>,
/// Average traded price, whole IDR.
pub average_price: Option<i64>,
/// Foreign net value, whole IDR; `None` for a filtered summary.
pub foreign_net_value: Option<i64>,
/// `foreign_net_value` in basis points of the value traded.
pub foreign_net_bps: Option<i64>,
/// Brokers among the top 10 on both sides, whose gross figures overstate
/// which way they traded.
pub both_sides: Vec<String>,
/// The source rounded figures of a million and more to one decimal.
pub rounded: bool,
}
/// The headline tier must be one the report carries.
pub fn check_top(top: u8) -> Result<u8, IdxError> {
if TIERS.contains(&top) {
Ok(top)
} else {
Err(IdxError::InvalidInput(format!(
"top must be 1, 3, 5 or 10 (got {top})"
)))
}
}
/// `part` in basis points of `whole`; `None` without a positive whole.
fn bps(part: i64, whole: Option<i64>) -> Option<i64> {
let whole = whole.filter(|whole| *whole > 0)?;
i64::try_from((i128::from(part) * 10_000).div_euclid(i128::from(whole))).ok()
}
/// Sum of `field` over `flows`; `None` when any flow lacks it.
fn sum(flows: &[BrokerFlow], field: fn(&BrokerFlow) -> Option<i64>) -> Option<i64> {
flows
.iter()
.try_fold(0_i64, |total, flow| total.checked_add(field(flow)?))
}
/// Value over shares, the shares taken from each broker's value and its
/// exact published average (lots of a million and more are rounded,
/// averages never are).
fn vwap(flows: &[BrokerFlow]) -> Option<i64> {
let (mut value, mut shares) = (0.0_f64, 0.0_f64);
for flow in flows {
let (flow_value, average) = (flow.value?, flow.average_price?);
if average <= 0 {
return None;
}
value += flow_value as f64;
shares += flow_value as f64 / average as f64;
}
(shares > 0.0).then(|| (value / shares).round() as i64)
}
pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier {
let n = usize::from(top);
let buyers = &summary.buyers[..n.min(summary.buyers.len())];
let sellers = &summary.sellers[..n.min(summary.sellers.len())];
let buy_value = sum(buyers, |flow| flow.value);
let sell_value = sum(sellers, |flow| flow.value);
let imbalance_value = buy_value
.zip(sell_value)
.and_then(|(buy, sell)| buy.checked_sub(sell));
let imbalance_bps = imbalance_value.and_then(|value| bps(value, summary.total_value));
BandarTier {
top,
buy_value,
sell_value,
imbalance_value,
imbalance_bps,
buy_share_bps: buy_value.and_then(|value| bps(value, summary.total_value)),
sell_share_bps: sell_value.and_then(|value| bps(value, summary.total_value)),
buy_average: vwap(buyers),
sell_average: vwap(sellers),
signal: imbalance_bps.map(BandarSignal::from_imbalance_bps),
}
}
/// The full reading of one summary, headlined by the top `top` tier (one of
/// [`TIERS`]; see [`check_top`]).
pub fn analyze_bandar(summary: &BrokerSummary, top: u8) -> BandarReport {
let tiers: Vec<BandarTier> = TIERS.iter().map(|n| bandar_tier(summary, *n)).collect();
let headline = tiers
.iter()
.find(|tier| tier.top == top)
.cloned()
.unwrap_or_else(|| bandar_tier(summary, top));
let mut both_sides: Vec<String> = summary
.buyers
.iter()
.filter(|buyer| {
summary
.sellers
.iter()
.any(|seller| seller.code == buyer.code)
})
.map(|buyer| buyer.code.clone())
.collect();
both_sides.sort_unstable();
BandarReport {
symbol: summary.symbol.clone(),
from: summary.from,
to: summary.to,
investor: summary.investor,
board: summary.board,
method: METHOD.to_string(),
top,
signal: headline.signal,
imbalance_bps: headline.imbalance_bps,
tiers,
total_value: summary.total_value,
average_price: summary.average_price,
foreign_net_value: summary.foreign_net_value,
foreign_net_bps: summary
.foreign_net_value
.and_then(|value| bps(value, summary.total_value)),
both_sides,
rounded: summary.rounded,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn flow(code: &str, value: Option<i64>, average: i64) -> BrokerFlow {
BrokerFlow {
rank: 0,
code: code.to_string(),
name: None,
kind: None,
lots: value.map(|value| value / average / 100),
value,
average_price: Some(average),
}
}
fn summary(buyers: Vec<BrokerFlow>, sellers: Vec<BrokerFlow>, total: i64) -> BrokerSummary {
let day = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap();
BrokerSummary {
symbol: "TEST.JK".to_string(),
from: day,
to: day,
investor: BrokerInvestor::All,
board: BrokerBoard::All,
buyers,
sellers,
total_value: Some(total),
total_lots: None,
average_price: None,
foreign_net_value: Some(-total / 10),
rounded: false,
}
}
#[test]
fn test_bandar_signal_thresholds_are_inclusive() {
use BandarSignal::*;
for (bps, signal) in [
(1500, BigAccumulation),
(1499, Accumulation),
(500, Accumulation),
(499, Neutral),
(-499, Neutral),
(-500, Distribution),
(-1499, Distribution),
(-1500, BigDistribution),
] {
assert_eq!(BandarSignal::from_imbalance_bps(bps), signal, "{bps}");
}
}
#[test]
fn test_analyze_bandar_weighs_top_buyers_against_top_sellers() {
let s = summary(
vec![
flow("AA", Some(400), 100),
flow("BB", Some(200), 100),
flow("CC", Some(100), 50),
],
vec![
flow("DD", Some(300), 110),
flow("AA", Some(100), 110),
flow("EE", Some(50), 110),
],
1000,
);
let report = analyze_bandar(&s, 3);
let top1 = &report.tiers[0];
assert_eq!((top1.top, top1.imbalance_value), (1, Some(100)));
assert_eq!(top1.imbalance_bps, Some(1000));
assert_eq!(top1.signal, Some(BandarSignal::Accumulation));
let top3 = &report.tiers[1];
assert_eq!((top3.buy_value, top3.sell_value), (Some(700), Some(450)));
assert_eq!(
(top3.buy_share_bps, top3.sell_share_bps),
(Some(7000), Some(4500))
);
assert_eq!(top3.imbalance_bps, Some(2500));
// 700 IDR over 4 + 2 + 2 shares.
assert_eq!(top3.buy_average, Some(88));
assert_eq!(top3.sell_average, Some(110));
// Short lists: every broker on a side is listed, so the sums hold.
assert_eq!(report.tiers[3].imbalance_value, Some(250));
assert_eq!(
(report.top, report.signal),
(3, Some(BandarSignal::BigAccumulation))
);
assert_eq!(report.imbalance_bps, Some(2500));
assert_eq!(report.foreign_net_bps, Some(-1000));
assert_eq!(report.both_sides, vec!["AA".to_string()]);
assert_eq!(report.method, METHOD);
}
#[test]
fn test_analyze_bandar_never_counts_a_missing_figure_as_zero() {
let s = summary(
vec![flow("AA", Some(400), 100), flow("BB", None, 100)],
vec![flow("DD", Some(300), 100)],
1000,
);
let report = analyze_bandar(&s, 3);
assert_eq!(report.tiers[0].imbalance_bps, Some(1000));
let top3 = &report.tiers[1];
assert_eq!(
(top3.buy_value, top3.imbalance_bps, top3.signal),
(None, None, None)
);
assert_eq!(top3.buy_average, None);
assert_eq!(report.signal, None);
}
#[test]
fn test_analyze_bandar_without_trades_has_no_signal() {
let report = analyze_bandar(&summary(vec![], vec![], 0), DEFAULT_TOP);
assert!(report.tiers.iter().all(|tier| tier.signal.is_none()));
assert_eq!((report.signal, report.imbalance_bps), (None, None));
assert_eq!(report.tiers[2].buy_value, Some(0));
}
#[test]
fn test_check_top_allows_only_report_tiers() {
assert_eq!(check_top(5).unwrap(), 5);
assert!(matches!(check_top(4), Err(IdxError::InvalidInput(_))));
}
}

View file

@ -1,3 +1,4 @@
pub mod bandar;
pub mod fundamental; pub mod fundamental;
pub mod report; pub mod report;
pub mod signals; pub mod signals;
@ -8,9 +9,11 @@ use serde::de::DeserializeOwned;
use crate::api::history_provider; use crate::api::history_provider;
use crate::api::types::{Fundamentals, Interval, Period}; use crate::api::types::{Fundamentals, Interval, Period};
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::error::IdxError; use crate::error::IdxError;
use crate::market::HistorySource; use crate::market::HistorySource;
use bandar::{BandarReport, analyze_bandar, check_top};
use fundamental::{ use fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental, FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental,
analyze_growth, analyze_risk, analyze_valuation, analyze_growth, analyze_risk, analyze_valuation,
@ -28,7 +31,69 @@ pub struct Comparison {
pub failures: Vec<(String, IdxError)>, pub failures: Vec<(String, IdxError)>,
} }
/// Bandar readings for several symbols, strongest accumulation first;
/// symbols that failed are listed with their error.
pub struct BandarScreen {
pub reports: Vec<Fetched<BandarReport>>,
pub failures: Vec<(String, IdxError)>,
}
/// The sessions and trades a bandar reading covers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BandarQuery {
pub range: BrokerRange,
pub investor: BrokerInvestor,
pub board: BrokerBoard,
/// Headline tier, one of `bandar::TIERS`.
pub top: u8,
}
impl Analysis<'_> { impl Analysis<'_> {
/// Bandarmology over the broker summary `query` selects.
pub fn bandar(
&self,
symbol: &str,
query: &BandarQuery,
policy: CachePolicy,
) -> Result<Fetched<BandarReport>, IdxError> {
let top = check_top(query.top)?;
let summary =
self.idx
.broker()
.fetch(symbol, query.range, query.investor, query.board, policy)?;
Ok(summary.map(|summary| analyze_bandar(&summary, top)))
}
/// [`Self::bandar`] for each symbol, ranked by the headline imbalance.
/// Invalid input fails the whole screen; other errors fail one symbol.
pub fn bandar_screen<S: AsRef<str>>(
&self,
symbols: &[S],
query: &BandarQuery,
policy: CachePolicy,
) -> Result<BandarScreen, IdxError> {
check_top(query.top)?;
let mut reports = Vec::new();
let mut failures = Vec::new();
for symbol in symbols {
let resolved = self.idx.resolve(symbol.as_ref())?;
match self.bandar(&resolved, query, policy) {
Ok(report) => reports.push(report),
Err(err @ IdxError::InvalidInput(_)) => return Err(err),
Err(err) => failures.push((resolved, err)),
}
}
if reports.is_empty() {
return Err(failures
.pop()
.map(|(_, err)| err)
.unwrap_or_else(|| IdxError::InvalidInput("no symbols to screen".to_string())));
}
// Strongest accumulation first; readings without an imbalance last.
reports.sort_by_key(|report| std::cmp::Reverse(report.data.imbalance_bps));
Ok(BandarScreen { reports, failures })
}
/// Technical analysis over one year of daily history from `source` /// Technical analysis over one year of daily history from `source`
/// (see `Market::history_source`). /// (see `Market::history_source`).
pub fn technical( pub fn technical(

View file

@ -0,0 +1,181 @@
<div id="modContentBrokerSum"></div>
<!--DATA BROKER SUMMARY-->
<div class="pa5">
<div class="row mincol-3">
<div class="col col-xs-5 col-sm-3">
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
</div>
<div class="col col-xs-5 col-sm-3">
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
</div>
<div class="mt15 visible-xs">&nbsp;</div>
<div class="col col-xs-5 col-sm-3">
<select class="form-control input-xs" id="FDBrokerSummary">
<option value="all">All</option>
<option value="F" >Foreign</option>
<option value="D" >Domestic</option>
</select>
</div>
<div class="col col-xs-5 col-sm-2">
<select class="form-control input-xs" id="boardBrokerSummary">
<option value="all">All Trade</option>
<option value="RG" >Regular</option>
<option value="TN" >Tunai</option>
<option value="NG" >Nego</option>
</select>
</div>
<div class="col col-xs-2 col-sm-1">
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
</div>
</div>
</div>
<div class="table-responsive">
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
<thead>
<tr>
<th align="center">Buyer</th>
<th align="right">B.Lot</th>
<th align="right">B.Val</th>
<th align="right">B.Avg</th>
<th align="center">#</th>
<th align="center">Seller</th>
<th align="right">S.Lot</th>
<th align="right">S.Val</th>
<th align="right">S.Avg</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center"><span class="text-bumn">CC</span></td>
<td align="right"><span class="text-up">1.9 M</span></td>
<td align="right"><span class="text-up">504.3 B</span></td>
<td align="right"><span class="text-up">2,709 </span></td>
<td align="center">1</td>
<td align="center"><span class="text-foreign">AK</span></td>
<td align="right"><span class="text-down">3.7 M</span></td>
<td align="right"><span class="text-down">1.0 T</span></td>
<td align="right"><span class="text-down">2,716 </span></td>
</tr>
<tr>
<td align="center"><span class="text-local">XL</span></td>
<td align="right"><span class="text-up">1.6 M</span></td>
<td align="right"><span class="text-up">436.0 B</span></td>
<td align="right"><span class="text-up">2,720 </span></td>
<td align="center">2</td>
<td align="center"><span class="text-foreign">KZ</span></td>
<td align="right"><span class="text-down">1.8 M</span></td>
<td align="right"><span class="text-down">481.4 B</span></td>
<td align="right"><span class="text-down">2,672 </span></td>
</tr>
<tr>
<td align="center"><span class="text-foreign">AK</span></td>
<td align="right"><span class="text-up">1.2 M</span></td>
<td align="right"><span class="text-up">322.4 B</span></td>
<td align="right"><span class="text-up">2,719 </span></td>
<td align="center">3</td>
<td align="center"><span class="text-bumn">CC</span></td>
<td align="right"><span class="text-down">1.3 M</span></td>
<td align="right"><span class="text-down">358.2 B</span></td>
<td align="right"><span class="text-down">2,717 </span></td>
</tr>
<tr>
<td align="center"><span class="text-foreign">YP</span></td>
<td align="right"><span class="text-up">1.0 M</span></td>
<td align="right"><span class="text-up">281.3 B</span></td>
<td align="right"><span class="text-up">2,694 </span></td>
<td align="center">4</td>
<td align="center"><span class="text-foreign">BK</span></td>
<td align="right"><span class="text-down">933,299 </span></td>
<td align="right"><span class="text-down">253.5 B</span></td>
<td align="right"><span class="text-down">2,716 </span></td>
</tr>
<tr>
<td align="center"><span class="text-local">SQ</span></td>
<td align="right"><span class="text-up">712,722 </span></td>
<td align="right"><span class="text-up">193.1 B</span></td>
<td align="right"><span class="text-up">2,709 </span></td>
<td align="center">5</td>
<td align="center"><span class="text-local">XL</span></td>
<td align="right"><span class="text-down">697,845 </span></td>
<td align="right"><span class="text-down">189.9 B</span></td>
<td align="right"><span class="text-down">2,721 </span></td>
</tr>
<tr>
<td align="center"><span class="text-bumn">OD</span></td>
<td align="right"><span class="text-up">518,938 </span></td>
<td align="right"><span class="text-up">140.6 B</span></td>
<td align="right"><span class="text-up">2,709 </span></td>
<td align="center">6</td>
<td align="center"><span class="text-foreign">ZP</span></td>
<td align="right"><span class="text-down">477,399 </span></td>
<td align="right"><span class="text-down">129.4 B</span></td>
<td align="right"><span class="text-down">2,711 </span></td>
</tr>
<tr>
<td align="center"><span class="text-bumn">NI</span></td>
<td align="right"><span class="text-up">496,800 </span></td>
<td align="right"><span class="text-up">133.9 B</span></td>
<td align="right"><span class="text-up">2,695 </span></td>
<td align="center">7</td>
<td align="center"><span class="text-foreign">YU</span></td>
<td align="right"><span class="text-down">447,840 </span></td>
<td align="right"><span class="text-down">120.9 B</span></td>
<td align="right"><span class="text-down">2,700 </span></td>
</tr>
<tr>
<td align="center"><span class="text-foreign">YU</span></td>
<td align="right"><span class="text-up">420,264 </span></td>
<td align="right"><span class="text-up">112.3 B</span></td>
<td align="right"><span class="text-up">2,672 </span></td>
<td align="center">8</td>
<td align="center"><span class="text-foreign">YP</span></td>
<td align="right"><span class="text-down">431,663 </span></td>
<td align="right"><span class="text-down">117.6 B</span></td>
<td align="right"><span class="text-down">2,725 </span></td>
</tr>
<tr>
<td align="center"><span class="text-local">PD</span></td>
<td align="right"><span class="text-up">403,468 </span></td>
<td align="right"><span class="text-up">109.5 B</span></td>
<td align="right"><span class="text-up">2,713 </span></td>
<td align="center">9</td>
<td align="center"><span class="text-bumn">NI</span></td>
<td align="right"><span class="text-down">234,641 </span></td>
<td align="right"><span class="text-down">63.1 B</span></td>
<td align="right"><span class="text-down">2,690 </span></td>
</tr>
<tr>
<td align="center"><span class="text-local">XC</span></td>
<td align="right"><span class="text-up">381,871 </span></td>
<td align="right"><span class="text-up">103.7 B</span></td>
<td align="right"><span class="text-up">2,717 </span></td>
<td align="center">10</td>
<td align="center"><span class="text-local">XA</span></td>
<td align="right"><span class="text-down">217,888 </span></td>
<td align="right"><span class="text-down">59.1 B</span></td>
<td align="right"><span class="text-down">2,714 </span></td>
</tr>
</tbody>
<tfoot>
<tr class="bdr-t">
<th colspan="9" align="center">
<div class="text-center pt20 valign-middle" style="height:50px;">
<span class="fsize12 ml5 mr5">T. Val : 3.3 T</span>
<span class="fsize12 ml5 mr5">F. NVal : -1.1 T</span>
<span class="fsize12 ml5 mr5">T.Lot : 12.2 M</span>
<span class="fsize12 ml5 mr5">Avg : 2,708 </span>
</div>
</th>
</tr>
</tfoot>
</table>
</div>
<!-- =======================
======SCRIPT MODULE=========
============================-->
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>

View file

@ -0,0 +1,181 @@
<div id="modContentBrokerSum"></div>
<!--DATA BROKER SUMMARY-->
<div class="pa5">
<div class="row mincol-3">
<div class="col col-xs-5 col-sm-3">
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
</div>
<div class="col col-xs-5 col-sm-3">
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
</div>
<div class="mt15 visible-xs">&nbsp;</div>
<div class="col col-xs-5 col-sm-3">
<select class="form-control input-xs" id="FDBrokerSummary">
<option value="all">All</option>
<option value="F" >Foreign</option>
<option value="D" >Domestic</option>
</select>
</div>
<div class="col col-xs-5 col-sm-2">
<select class="form-control input-xs" id="boardBrokerSummary">
<option value="all">All Trade</option>
<option value="RG" >Regular</option>
<option value="TN" >Tunai</option>
<option value="NG" >Nego</option>
</select>
</div>
<div class="col col-xs-2 col-sm-1">
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
</div>
</div>
</div>
<div class="table-responsive">
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
<thead>
<tr>
<th align="center">Buyer</th>
<th align="right">B.Lot</th>
<th align="right">B.Val</th>
<th align="right">B.Avg</th>
<th align="center">#</th>
<th align="center">Seller</th>
<th align="right">S.Lot</th>
<th align="right">S.Val</th>
<th align="right">S.Avg</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center"><span class="text-foreign">KZ</span></td>
<td align="right"><span class="text-up">1.9 M</span></td>
<td align="right"><span class="text-up">381.6 B</span></td>
<td align="right"><span class="text-up">1,960 </span></td>
<td align="center">1</td>
<td align="center"><span class="text-foreign">KZ</span></td>
<td align="right"><span class="text-down">3.7 M</span></td>
<td align="right"><span class="text-down">721.6 B</span></td>
<td align="right"><span class="text-down">1,933 </span></td>
</tr>
<tr>
<td align="center"><span class="text-bumn">CC</span></td>
<td align="right"><span class="text-up">1.5 M</span></td>
<td align="right"><span class="text-up">292.8 B</span></td>
<td align="right"><span class="text-up">1,932 </span></td>
<td align="center">2</td>
<td align="center"><span class="text-bumn">CC</span></td>
<td align="right"><span class="text-down">1.8 M</span></td>
<td align="right"><span class="text-down">353.9 B</span></td>
<td align="right"><span class="text-down">1,933 </span></td>
</tr>
<tr>
<td align="center"><span class="text-foreign">BK</span></td>
<td align="right"><span class="text-up">1.4 M</span></td>
<td align="right"><span class="text-up">274.7 B</span></td>
<td align="right"><span class="text-up">1,917 </span></td>
<td align="center">3</td>
<td align="center"><span class="text-foreign">BK</span></td>
<td align="right"><span class="text-down">1.6 M</span></td>
<td align="right"><span class="text-down">312.2 B</span></td>
<td align="right"><span class="text-down">1,926 </span></td>
</tr>
<tr>
<td align="center"><span class="text-foreign">RX</span></td>
<td align="right"><span class="text-up">1.0 M</span></td>
<td align="right"><span class="text-up">200.0 B</span></td>
<td align="right"><span class="text-up">1,933 </span></td>
<td align="center">4</td>
<td align="center"><span class="text-foreign">ZP</span></td>
<td align="right"><span class="text-down">1.0 M</span></td>
<td align="right"><span class="text-down">198.1 B</span></td>
<td align="right"><span class="text-down">1,926 </span></td>
</tr>
<tr>
<td align="center"><span class="text-foreign">ZP</span></td>
<td align="right"><span class="text-up">992,588 </span></td>
<td align="right"><span class="text-up">192.3 B</span></td>
<td align="right"><span class="text-up">1,937 </span></td>
<td align="center">5</td>
<td align="center"><span class="text-foreign">AK</span></td>
<td align="right"><span class="text-down">704,361 </span></td>
<td align="right"><span class="text-down">136.7 B</span></td>
<td align="right"><span class="text-down">1,941 </span></td>
</tr>
<tr>
<td align="center"><span class="text-foreign">YU</span></td>
<td align="right"><span class="text-up">944,581 </span></td>
<td align="right"><span class="text-up">180.4 B</span></td>
<td align="right"><span class="text-up">1,910 </span></td>
<td align="center">6</td>
<td align="center"><span class="text-local">MG</span></td>
<td align="right"><span class="text-down">529,257 </span></td>
<td align="right"><span class="text-down">101.4 B</span></td>
<td align="right"><span class="text-down">1,916 </span></td>
</tr>
<tr>
<td align="center"><span class="text-foreign">AK</span></td>
<td align="right"><span class="text-up">715,278 </span></td>
<td align="right"><span class="text-up">138.2 B</span></td>
<td align="right"><span class="text-up">1,932 </span></td>
<td align="center">7</td>
<td align="center"><span class="text-foreign">YU</span></td>
<td align="right"><span class="text-down">324,973 </span></td>
<td align="right"><span class="text-down">62.5 B</span></td>
<td align="right"><span class="text-down">1,924 </span></td>
</tr>
<tr>
<td align="center"><span class="text-local">MG</span></td>
<td align="right"><span class="text-up">528,650 </span></td>
<td align="right"><span class="text-up">100.6 B</span></td>
<td align="right"><span class="text-up">1,903 </span></td>
<td align="center">8</td>
<td align="center"><span class="text-bumn">NI</span></td>
<td align="right"><span class="text-down">208,165 </span></td>
<td align="right"><span class="text-down">39.8 B</span></td>
<td align="right"><span class="text-down">1,911 </span></td>
</tr>
<tr>
<td align="center"><span class="text-local">XL</span></td>
<td align="right"><span class="text-up">331,760 </span></td>
<td align="right"><span class="text-up">64.1 B</span></td>
<td align="right"><span class="text-up">1,931 </span></td>
<td align="center">9</td>
<td align="center"><span class="text-local">XL</span></td>
<td align="right"><span class="text-down">173,939 </span></td>
<td align="right"><span class="text-down">33.7 B</span></td>
<td align="right"><span class="text-down">1,937 </span></td>
</tr>
<tr>
<td align="center"><span class="text-foreign">YP</span></td>
<td align="right"><span class="text-up">234,664 </span></td>
<td align="right"><span class="text-up">44.9 B</span></td>
<td align="right"><span class="text-up">1,913 </span></td>
<td align="center">10</td>
<td align="center"><span class="text-foreign">YP</span></td>
<td align="right"><span class="text-down">168,848 </span></td>
<td align="right"><span class="text-down">32.7 B</span></td>
<td align="right"><span class="text-down">1,938 </span></td>
</tr>
</tbody>
<tfoot>
<tr class="bdr-t">
<th colspan="9" align="center">
<div class="text-center pt20 valign-middle" style="height:50px;">
<span class="fsize12 ml5 mr5">T. Val : 2.2 T</span>
<span class="fsize12 ml5 mr5">F. NVal : -90.2 B</span>
<span class="fsize12 ml5 mr5">T.Lot : 11.3 M</span>
<span class="fsize12 ml5 mr5">Avg : 1,931 </span>
</div>
</th>
</tr>
</tfoot>
</table>
</div>
<!-- =======================
======SCRIPT MODULE=========
============================-->
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>

View file

@ -115,10 +115,9 @@ pub struct AnalyzeParams {
pub kinds: Option<Vec<AnalysisKind>>, pub kinds: Option<Vec<AnalysisKind>>,
} }
/// Which sessions and trades a broker tool covers.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct BrokerParams { pub struct BrokerScope {
/// IDX ticker, e.g. "BBCA".
pub symbol: String,
/// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. /// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.
#[serde(default)] #[serde(default)]
pub date: Option<String>, pub date: Option<String>,
@ -139,6 +138,25 @@ pub struct BrokerParams {
pub board: Option<String>, pub board: Option<String>,
} }
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BrokerParams {
/// IDX ticker, e.g. "BBCA".
pub symbol: String,
#[serde(flatten)]
pub scope: BrokerScope,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BandarParams {
/// IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20.
pub symbols: Vec<String>,
#[serde(flatten)]
pub scope: BrokerScope,
/// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5).
#[serde(default)]
pub top: Option<u8>,
}
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct ShareholdersParams { pub struct ShareholdersParams {
/// IDX ticker, e.g. "BBCA". /// IDX ticker, e.g. "BBCA".

View file

@ -4,6 +4,8 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use idx_core::analysis::BandarQuery;
use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP};
use idx_core::analysis::fundamental::FundamentalReport; use idx_core::analysis::fundamental::FundamentalReport;
use idx_core::broker::types::BrokerRange; use idx_core::broker::types::BrokerRange;
use idx_core::config::CoreConfig; use idx_core::config::CoreConfig;
@ -22,9 +24,9 @@ use serde_json::{Map, Value, json};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use crate::params::{ use crate::params::{
AnalysisKind, AnalyzeParams, BrokerParams, ChangesParams, CompanyParams, CompanySection, AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, CompanySection, EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams,
SyncParams, SymbolsParams, SyncParams,
}; };
const SYNC_TOOL: &str = "ownership_sync"; const SYNC_TOOL: &str = "ownership_sync";
@ -176,33 +178,66 @@ broker's own status (local, foreign, state_owned), not its clients'. `rounded` =
figures of a million and more carry one decimal of precision." figures of a million and more carry one decimal of precision."
)] )]
async fn get_broker_summary(&self, params: Parameters<BrokerParams>) -> ToolResult { async fn get_broker_summary(&self, params: Parameters<BrokerParams>) -> ToolResult {
let BrokerParams { let BrokerParams { symbol, scope } = params.0;
symbol,
date,
from,
to,
investor,
board,
} = params.0;
self.live(move |idx| { self.live(move |idx| {
let broker = idx.broker(); let broker = idx.broker();
let range = BrokerRange::from_args( let range = BrokerRange::from_args(
date.as_deref(), scope.date.as_deref(),
from.as_deref(), scope.from.as_deref(),
to.as_deref(), scope.to.as_deref(),
broker.today(), broker.today(),
)?; )?;
broker.fetch( broker.fetch(
&symbol, &symbol,
range, range,
investor.as_deref().unwrap_or("all").parse()?, scope.investor.as_deref().unwrap_or("all").parse()?,
board.as_deref().unwrap_or("all").parse()?, scope.board.as_deref().unwrap_or("all").parse()?,
CachePolicy::Default, CachePolicy::Default,
) )
}) })
.await .await
} }
#[tool(
description = "Bandarmology for one or more IDX tickers from the broker summary: for \
the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value \
traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral \
(within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come \
strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over \
end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose \
gross figures overstate their direction. `method` names the rules applied."
)]
async fn analyze_bandar(&self, params: Parameters<BandarParams>) -> ToolResult {
let BandarParams {
scope,
symbols,
top,
} = params.0;
let symbols = match checked_symbols(symbols) {
Ok(symbols) => symbols,
Err(err) => return err,
};
self.live(move |idx| {
let query = BandarQuery {
range: BrokerRange::from_args(
scope.date.as_deref(),
scope.from.as_deref(),
scope.to.as_deref(),
idx.broker().today(),
)?,
investor: scope.investor.as_deref().unwrap_or("all").parse()?,
board: scope.board.as_deref().unwrap_or("all").parse()?,
top: top.unwrap_or(DEFAULT_TOP),
};
let screen = idx
.analysis()
.bandar_screen(&symbols, &query, CachePolicy::Default)?;
let reports: &[Fetched<BandarReport>] = &screen.reports;
Ok(json!({ "reports": reports, "failures": failures_json(&screen.failures) }))
})
.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`: \
@ -294,17 +329,8 @@ tickers. Tickers that fail are listed under `failures`."
}; };
self.live(move |idx| { self.live(move |idx| {
let comparison = idx.analysis().compare(&symbols, CachePolicy::Default)?; let comparison = idx.analysis().compare(&symbols, CachePolicy::Default)?;
let failures: Vec<Value> = comparison
.failures
.iter()
.map(|(symbol, err)| {
let mut body = error_body(err);
body.insert("symbol".into(), json!(symbol));
Value::Object(body)
})
.collect();
let reports: &[Fetched<FundamentalReport>] = &comparison.reports; let reports: &[Fetched<FundamentalReport>] = &comparison.reports;
Ok(json!({ "reports": reports, "failures": failures })) Ok(json!({ "reports": reports, "failures": failures_json(&comparison.failures) }))
}) })
.await .await
} }
@ -480,6 +506,18 @@ where
}) })
} }
/// Per-symbol failures as error bodies carrying their symbol.
fn failures_json(failures: &[(String, IdxError)]) -> Vec<Value> {
failures
.iter()
.map(|(symbol, err)| {
let mut body = error_body(err);
body.insert("symbol".into(), json!(symbol));
Value::Object(body)
})
.collect()
}
fn input_error(message: String) -> ToolResult { fn input_error(message: String) -> ToolResult {
let err = IdxError::InvalidInput(message); let err = IdxError::InvalidInput(message);
Ok(CallToolResult::structured_error(Value::Object(error_body( Ok(CallToolResult::structured_error(Value::Object(error_body(

View file

@ -1,4 +1,80 @@
[ [
{
"description": "Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"board": {
"default": null,
"description": "Which board's trades to count (default \"all\"); \"regular\" leaves out\nnegotiated block trades.",
"enum": [
"all",
"regular",
"cash",
"negotiated"
],
"type": "string"
},
"date": {
"default": null,
"description": "One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.",
"type": [
"string",
"null"
]
},
"from": {
"default": null,
"description": "First session of a range, YYYY-MM-DD (at most 366 days).",
"type": [
"string",
"null"
]
},
"investor": {
"default": null,
"description": "Whose trades to count, by investor domicile (default \"all\").",
"enum": [
"all",
"foreign",
"domestic"
],
"type": "string"
},
"symbols": {
"description": "IDX tickers, e.g. [\"BBCA\", \"BBRI\"]; the `.JK` suffix is optional. At most 20.",
"items": {
"type": "string"
},
"type": "array"
},
"to": {
"default": null,
"description": "Last session of the range, YYYY-MM-DD (default today).",
"type": [
"string",
"null"
]
},
"top": {
"default": null,
"description": "Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5).",
"format": "uint8",
"maximum": 255,
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"required": [
"symbols"
],
"type": "object"
},
"name": "analyze_bandar"
},
{ {
"description": "Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails.", "description": "Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails.",
"inputSchema": { "inputSchema": {

View file

@ -9,7 +9,8 @@ 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; 12] = [ const TOOLS: [&str; 13] = [
"analyze_bandar",
"analyze_stock", "analyze_stock",
"compare_stocks", "compare_stocks",
"get_broker_summary", "get_broker_summary",
@ -258,6 +259,22 @@ fn live_tools_wrap_data_with_freshness() {
assert_eq!(brokers["data"]["sellers"][0]["name"], "MANDIRI SEKURITAS"); assert_eq!(brokers["data"]["sellers"][0]["name"], "MANDIRI SEKURITAS");
assert_eq!(brokers["data"]["foreign_net_value"], -2_300_000_000_000_i64); assert_eq!(brokers["data"]["foreign_net_value"], -2_300_000_000_000_i64);
let bandar = client.ok(
"analyze_bandar",
json!({"symbols": ["BBCA", "BBRI", "ZZZZ"], "date": "2026-10-16", "top": 3}),
);
let reports = bandar["reports"].as_array().unwrap();
assert_eq!(reports.len(), 2);
assert_fetched(&reports[0]);
assert_eq!(reports[0]["data"]["top"], 3);
assert_eq!(reports[0]["data"]["method"], "top_concentration_v1");
assert!(
reports[0]["data"]["imbalance_bps"].as_i64()
>= reports[1]["data"]["imbalance_bps"].as_i64()
);
assert_eq!(bandar["failures"][0]["symbol"], "ZZZZ.JK");
assert_eq!(bandar["failures"][0]["code"], "SYMBOLNOTFOUND");
let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]})); 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!([]));
@ -292,6 +309,8 @@ fn errors_carry_cli_codes() {
"get_broker_summary", "get_broker_summary",
json!({"symbol": "BBCA", "date": "2026-10-16", "board": "dark"}), json!({"symbol": "BBCA", "date": "2026-10-16", "board": "dark"}),
), ),
("analyze_bandar", json!({"symbols": ["BBCA"], "top": 4})),
("analyze_bandar", json!({"symbols": []})),
] { ] {
let (is_error, body) = client.call(tool, arguments); let (is_error, body) = client.call(tool, arguments);
assert!(is_error, "{tool}: {body}"); assert!(is_error, "{tool}: {body}");

View file

@ -103,6 +103,11 @@ WIB as the fallback.
- Data is end of day: IDX hides broker codes during trading. Summaries that - Data is end of day: IDX hides broker codes during trading. Summaries that
ended before today are cached for 7 days, today's for 5 minutes. Broker ended before today are cached for 7 days, today's for 5 minutes. Broker
names come from `broker/codes.tsv` (IDX `GetBrokerCodeList`). names come from `broker/codes.tsv` (IDX `GetBrokerCodeList`).
- Bandarmology (`analysis/bandar.rs`, method `top_concentration_v1`) is pure
over one summary: for the top 1/3/5/10 per side, buy value minus sell value
in basis points of value traded, with thresholds ±500/±1500 bps taken from
fzn0x/idx. A tier with a missing figure has no reading; missing values are
never summed as zero. A change to the rules gets a new method name.
### Capability Gating ### Capability Gating
``` ```

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 == 12' out.jsonl jq -se 'map(select(.id == 2))[0].result.tools | length == 13' 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

@ -67,6 +67,7 @@ Commands:
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 broker Which brokers bought and sold a stock
bandar Bandarmology: is buying more concentrated than selling
profile Get company profile profile Get company profile
financials Get financial statements financials Get financial statements
earnings Get earnings report earnings Get earnings report
@ -351,6 +352,83 @@ Examples:
idx -o json stocks broker BBCA --investor foreign idx -o json stocks broker BBCA --investor foreign
``` ```
### `idx stocks bandar`
```text
Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value against the same number of sellers', as a share of value traded, with a signal from big accumulation to big distribution. Several symbols give a screen ranked by the headline tier (at most 100, one request each). A heuristic over end-of-day broker codes, not advice.
Usage: idx stocks bandar [OPTIONS] <SYMBOLS>...
Arguments:
<SYMBOLS>...
One or more symbols, comma-separated or space-separated; `-` reads them from stdin
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
--top <TOP>
Tier behind the headline signal: 1, 3, 5 or 10 brokers per side
[default: 5]
-h, --help
Print help (see a summary with '-h')
Examples:
idx stocks bandar BBCA
idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3
idx stocks bandar BBCA,BBRI,BMRI,TLKM
idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -
```
### `idx stocks profile` ### `idx stocks profile`
```text ```text

View file

@ -47,6 +47,32 @@ codes during trading. Buyers and sellers are ranked separately on gross
lots, so a broker can appear on both sides. The source rounds figures of a 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. million and more to one decimal, and the output says so.
## Bandarmology
```bash
idx stocks bandar BBCA
idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3
idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -
```
`bandar` reads the broker summary: for the top 1, 3, 5 and 10 brokers on each
side, the buyers' value minus the sellers' value as a share of the value
traded. The headline tier (`--top`, default 5) gives the signal:
| Top-N buy minus sell, % of value | Signal |
| --- | --- |
| +15% or more | big accumulation |
| +5% to +15% | accumulation |
| within ±5% | neutral |
| -15% to -5% | distribution |
| -15% or less | big distribution |
Several symbols give a screen ranked by that figure (at most 100, one request
each), and it takes the same date and filter flags as `broker`. It is a
heuristic: a broker trades for many clients, and a broker on both sides of
the summary is listed, since its gross figures overstate which way it traded.
The JSON names the rules in `method` (`top_concentration_v1`).
## Analysis ## Analysis
| Command | What it computes | | Command | What it computes |

View file

@ -10,6 +10,20 @@ starts with `--allow-sync`. Results that come from a live provider are wrapped
as `{data, source, fetched_at, stale}`; failures return `{error, code, as `{data, source, fetched_at, stale}`; failures return `{error, code,
message}` with `isError` set. message}` with `isError` set.
## `analyze_bandar`
Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied.
| Input | Type | Required | Description |
| --- | --- | --- | --- |
| `board` | `all` / `regular` / `cash` / `negotiated` | no | Which board's trades to count (default "all"); "regular" leaves out negotiated block trades. |
| `date` | string | no | One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. |
| `from` | string | no | First session of a range, YYYY-MM-DD (at most 366 days). |
| `investor` | `all` / `foreign` / `domestic` | no | Whose trades to count, by investor domicile (default "all"). |
| `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. |
| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). |
| `top` | integer | no | Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5). |
## `analyze_stock` ## `analyze_stock`
Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails. Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails.