diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index d97dda2..cf316ca 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -4,10 +4,13 @@ use clap::{Args, Subcommand}; use crate::config::{HistoryProviderKind, IdxConfig}; use crate::output::{ - render_broker_summary, render_compare, render_earnings, render_financials, render_fundamental, - render_growth, render_history, render_insights, render_news, render_profile, render_quotes, - render_risk, render_screener, render_sentiment, render_technical, render_valuation, + render_bandar, render_broker_summary, render_compare, render_earnings, render_financials, + render_fundamental, render_growth, render_history, render_insights, render_news, + render_profile, render_quotes, render_risk, render_screener, render_sentiment, + render_technical, render_valuation, }; +use idx_core::analysis::BandarQuery; +use idx_core::analysis::bandar::DEFAULT_TOP; use idx_core::api::types::{Interval, Period}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use idx_core::error::IdxError; @@ -17,6 +20,9 @@ use idx_core::{CachePolicy, Idx, runtime}; /// Splits comma-separated symbol arguments; `-` reads symbols from stdin, /// separated by whitespace or commas. +/// Most symbols one `bandar` screen fetches, one request each. +const MAX_BANDAR_SYMBOLS: usize = 100; + fn expand_symbols(args: &[String]) -> Result, IdxError> { let mut symbols = Vec::new(); for arg in args { @@ -195,6 +201,24 @@ does not show broker codes during trading. Without dates, the latest session wit #[command(flatten)] scope: BrokerScopeArgs, }, + #[command( + about = "Bandarmology: is buying more concentrated than selling", + long_about = "Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value \ +against the same number of sellers', as a share of value traded, with a signal from big \ +accumulation to big distribution. Several symbols give a screen ranked by the headline tier \ +(at most 100, one request each). A heuristic over end-of-day broker codes, not advice.", + after_help = "Examples:\n idx stocks bandar BBCA\n idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3\n idx stocks bandar BBCA,BBRI,BMRI,TLKM\n idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -" + )] + Bandar { + /// One or more symbols, comma-separated or space-separated; `-` reads them from stdin. + #[arg(required = true)] + symbols: Vec, + #[command(flatten)] + scope: BrokerScopeArgs, + /// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side. + #[arg(long, default_value_t = DEFAULT_TOP)] + top: u8, + }, #[command(about = "Get company profile")] Profile { symbol: String }, #[command( @@ -311,6 +335,31 @@ pub fn handle( )?; render_broker_summary(&summary.data, format) } + StocksSubcommand::Bandar { + symbols, + scope, + top, + } => { + let symbols = expand_symbols(symbols)?; + if symbols.len() > MAX_BANDAR_SYMBOLS { + return Err(IdxError::InvalidInput(format!( + "at most {MAX_BANDAR_SYMBOLS} symbols per bandar screen (got {})", + symbols.len() + ))); + } + let query = BandarQuery { + range: scope.range(&idx)?, + investor: scope.investor, + board: scope.board, + top: *top, + }; + let screen = idx.analysis().bandar_screen(&symbols, &query, policy)?; + for (resolved, err) in screen.failures { + runtime::warn(format!("failed to read brokers for {resolved}: {err}")); + } + let reports: Vec<_> = screen.reports.into_iter().map(|r| r.data).collect(); + render_bandar(&reports, symbols.len() > 1, format, no_color) + } StocksSubcommand::Profile { symbol } => { let profile = idx.financial().profile(symbol, policy)?; render_profile(&profile.data, format) diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index a9fda2e..aeb1e54 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -8,6 +8,7 @@ use std::io::IsTerminal; use clap::ValueEnum; use serde::Serialize; +use idx_core::analysis::bandar::BandarReport; use idx_core::analysis::fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, }; @@ -84,6 +85,21 @@ pub fn render_broker_summary( } } +/// One symbol gets the detailed table, several the ranked screen (even when +/// only one of them succeeded); JSON is always the array. +pub fn render_bandar( + reports: &[BandarReport], + screen: bool, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match (format, reports) { + (OutputFormat::Table, [report]) if !screen => table::print_bandar_report(report, no_color), + (OutputFormat::Table, _) => table::print_bandar_screen(reports, no_color), + (OutputFormat::Json | OutputFormat::Jsonl, _) => json::print_json(reports), + } +} + pub fn render_history( symbol: &str, history: &[Ohlc], diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 641d434..f05e4a9 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -2,6 +2,9 @@ use crate::output::style::Paint; use crate::output::text::{cell, painted, terminal_safe, truncate_chars}; use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL}; +use idx_core::analysis::bandar::{ + ACCUMULATION_BPS, BIG_BPS, BandarReport, BandarSignal, METHOD as BANDAR_METHOD, +}; use idx_core::analysis::fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, }; @@ -1018,6 +1021,171 @@ pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> { Ok(()) } +fn format_bps_pct(bps: Option) -> String { + bps.map(|bps| format!("{:+.1}%", bps as f64 / 100.0)) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_bps_share(bps: Option) -> String { + bps.map(|bps| format!("{:.1}%", bps as f64 / 100.0)) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_bandar_signal(signal: Option, no_color: bool) -> String { + let Some(signal) = signal else { + return "-".to_string(); + }; + let label = signal.as_str().replace('_', " "); + if no_color { + return label; + } + match signal { + BandarSignal::BigAccumulation | BandarSignal::Accumulation => label.green(), + BandarSignal::BigDistribution | BandarSignal::Distribution => label.red(), + BandarSignal::Neutral => label, + } +} + +fn bandar_method_note(top: u8) -> String { + format!( + "Method {BANDAR_METHOD}: the top {top} buyers' value minus the top {top} sellers' value, \ +as a share of value traded; {}% or more reads accumulation, {}% or more big (negative: \ +distribution). Brokers trade for many clients, so this is a heuristic, not advice.", + ACCUMULATION_BPS / 100, + BIG_BPS / 100 + ) +} + +pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), IdxError> { + println!( + "{}", + format!( + "Bandar reading for {}, {}", + terminal_safe(&report.symbol), + broker_scope(report.from, report.to, report.investor, report.board) + ) + .bold() + ); + if report.total_value.is_none_or(|value| value <= 0) { + println!("No trades."); + return Ok(()); + } + println!( + "Signal (top {}): {}, {} of value", + report.top, + format_bandar_signal(report.signal, no_color), + format_bps_pct(report.imbalance_bps) + ); + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "TOP", "BUY", "SELL", "NET", "NET %", "BUY AVG", "SELL AVG", "SIGNAL", + ]); + for tier in &report.tiers { + table.add_row(vec![ + cell(tier.top), + cell(format_compact_option(tier.buy_value)), + cell(format_compact_option(tier.sell_value)), + cell(format_compact_option(tier.imbalance_value)), + cell(format_bps_pct(tier.imbalance_bps)), + cell( + tier.buy_average + .map(format_idr) + .unwrap_or_else(|| "-".to_string()), + ), + cell( + tier.sell_average + .map(format_idr) + .unwrap_or_else(|| "-".to_string()), + ), + painted(format_bandar_signal(tier.signal, no_color)), + ]); + } + println!("{table}"); + let mut totals = format!("Total {} IDR", format_compact_option(report.total_value)); + if let Some(average) = report.average_price { + totals.push_str(&format!(", average {}", format_idr(average))); + } + if let Some(net) = report.foreign_net_value { + totals.push_str(&format!( + "; foreign net {:+} ({} of value)", + format_compact(net), + format_bps_pct(report.foreign_net_bps) + )); + } + println!("{totals}."); + if !report.both_sides.is_empty() { + println!( + "On both sides: {}; their gross figures overstate which way they traded.", + terminal_safe(&report.both_sides.join(", ")) + ); + } + println!("{}", bandar_method_note(report.top)); + if report.rounded { + println!("Note: the source rounds figures of a million and more to one decimal."); + } + Ok(()) +} + +pub fn print_bandar_screen(reports: &[BandarReport], no_color: bool) -> Result<(), IdxError> { + let top = reports.first().map(|report| report.top).unwrap_or_default(); + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "SYMBOL".to_string(), + "DATES".to_string(), + "SIGNAL".to_string(), + format!("TOP {top} NET %"), + "BUY SHARE".to_string(), + "SELL SHARE".to_string(), + "VALUE".to_string(), + "FOREIGN NET".to_string(), + ]); + for report in reports { + let headline = report.tiers.iter().find(|tier| tier.top == report.top); + table.add_row(vec![ + cell(&report.symbol), + cell(broker_scope( + report.from, + report.to, + BrokerInvestor::All, + BrokerBoard::All, + )), + painted(format_bandar_signal(report.signal, no_color)), + cell(format_bps_pct(report.imbalance_bps)), + cell(format_bps_share( + headline.and_then(|tier| tier.buy_share_bps), + )), + cell(format_bps_share( + headline.and_then(|tier| tier.sell_share_bps), + )), + cell(format_compact_option(report.total_value)), + cell( + report + .foreign_net_value + .map(|net| format!("{:+}", format_compact(net))) + .unwrap_or_else(|| "-".to_string()), + ), + ]); + } + println!("{table}"); + if let Some(first) = reports.first() + && (first.investor, first.board) != (BrokerInvestor::All, BrokerBoard::All) + { + println!( + "Counting {} investors on the {} board.", + first.investor.as_str(), + first.board.as_str() + ); + } + println!("{}", bandar_method_note(top)); + Ok(()) +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 453830c..bcee661 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2605,3 +2605,120 @@ fn broker_summary_errors_follow_the_output_contract() { ]); run_error_json(&mut failing, "PROVIDERUNAVAILABLE"); } + +#[test] +fn bandar_reads_the_broker_summary_tiers() { + let reports = run_success_json(mock_bin("bandar-json").args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + ])); + let report = &reports[0]; + assert_eq!(reports.as_array().unwrap().len(), 1); + assert_eq!(report["symbol"], "BBCA.JK"); + assert_eq!(report["method"], "top_concentration_v1"); + let tiers: Vec = report["tiers"] + .as_array() + .unwrap() + .iter() + .map(|tier| tier["top"].as_i64().unwrap()) + .collect(); + assert_eq!(tiers, [1, 3, 5, 10]); + // Top 5 bought 2,939.5B and sold 4,540.4B of 6.6T traded. + let top5 = &report["tiers"][2]; + assert_eq!(top5["buy_value"], 2_939_500_000_000_i64); + assert_eq!(top5["sell_value"], 4_540_400_000_000_i64); + assert_eq!(top5["imbalance_bps"], -2426); + assert_eq!(report["top"], 5); + assert_eq!(report["imbalance_bps"], -2426); + assert_eq!(report["signal"], "big_distribution"); + assert_eq!( + report["both_sides"], + serde_json::json!(["AK", "CC", "MG", "XL", "YU", "ZP"]) + ); +} + +#[test] +fn bandar_screen_ranks_symbols_and_reports_failures_on_stderr() { + let output = mock_bin("bandar-screen") + .args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA,ZZZZ", + "TLKM", + "BBRI", + "--date", + "2026-10-16", + "--top", + "3", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let reports: Value = serde_json::from_slice(&output.stdout).unwrap(); + let ranked: Vec<(&str, i64)> = reports + .as_array() + .unwrap() + .iter() + .map(|r| { + ( + r["symbol"].as_str().unwrap(), + r["imbalance_bps"].as_i64().unwrap(), + ) + }) + .collect(); + assert_eq!(ranked.len(), 3); + assert!( + ranked.windows(2).all(|pair| pair[0].1 >= pair[1].1), + "{ranked:?}" + ); + assert!(reports.as_array().unwrap().iter().all(|r| r["top"] == 3)); + let warning: Value = serde_json::from_slice(&output.stderr).expect("one JSON warning line"); + assert_eq!(warning["level"], "warning"); + assert!(warning["message"].as_str().unwrap().contains("ZZZZ.JK")); + + // A one-symbol screen that loses a symbol still prints the screen table. + let table = run_success_stdout(mock_bin("bandar-screen-table").args([ + "--no-color", + "stocks", + "bandar", + "BBCA,ZZZZ", + "--date", + "2026-10-16", + ])); + assert!(table.contains("TOP 5 NET %"), "{table}"); +} + +#[test] +fn bandar_errors_follow_the_output_contract() { + let many: Vec = (0..101).map(|i| format!("S{i}")).collect(); + for (args, code) in [ + ( + vec!["BBCA".to_string(), "--top".into(), "4".into()], + "INVALIDINPUT", + ), + (vec![many.join(",")], "INVALIDINPUT"), + ( + vec!["ZZZZ".to_string(), "--date".into(), "2026-10-16".into()], + "SYMBOLNOTFOUND", + ), + ( + vec![ + "BBCA,BBRI".to_string(), + "--date".into(), + "2999-01-01".into(), + ], + "INVALIDINPUT", + ), + ] { + let mut cmd = mock_bin("bandar-errors"); + cmd.args(["-o", "json", "stocks", "bandar"]).args(&args); + run_error_json(&mut cmd, code); + } +} diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 85a1a53..51cd001 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -258,6 +258,15 @@ args = ["stocks", "broker", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16" name = "stocks-broker-no-trades" args = ["stocks", "broker", "BBCA", "--date", "2026-10-17"] +[[case]] +name = "stocks-bandar" +args = ["stocks", "bandar", "BBCA", "--date", "2026-10-16"] + +[[case]] +name = "stocks-bandar-screen" +formats = ["table", "json", "jsonl"] +args = ["stocks", "bandar", "BBCA,BBRI,TLKM", "--date", "2026-10-16", "--top", "3"] + [[case]] name = "error-broker-unknown-symbol" args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index 55d5ca2..08222dd 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -174,6 +174,9 @@ _idx() { idx__help__ownership__resolve,unresolved) cmd="idx__help__ownership__resolve__unresolved" ;; + idx__help__stocks,bandar) + cmd="idx__help__stocks__bandar" + ;; idx__help__stocks,broker) cmd="idx__help__stocks__broker" ;; @@ -339,6 +342,9 @@ _idx() { idx__ownership__resolve__help,unresolved) cmd="idx__ownership__resolve__help__unresolved" ;; + idx__stocks,bandar) + cmd="idx__stocks__bandar" + ;; idx__stocks,broker) cmd="idx__stocks__broker" ;; @@ -390,6 +396,9 @@ _idx() { idx__stocks,valuation) cmd="idx__stocks__valuation" ;; + idx__stocks__help,bandar) + cmd="idx__stocks__help__bandar" + ;; idx__stocks__help,broker) cmd="idx__stocks__help__broker" ;; @@ -1200,7 +1209,7 @@ _idx() { return 0 ;; idx__help__stocks) - opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare" + opts="quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1213,6 +1222,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stocks__bandar) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks__broker) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2216,7 +2239,7 @@ _idx() { return 0 ;; idx__stocks) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare help" + opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2237,6 +2260,52 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__bandar) + opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --offline --no-cache --help ..." + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --date) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --investor) + COMPREPLY=($(compgen -W "all foreign domestic" -- "${cur}")) + return 0 + ;; + --board) + COMPREPLY=($(compgen -W "all regular cash negotiated" -- "${cur}")) + return 0 + ;; + --top) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__broker) opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2394,7 +2463,7 @@ _idx() { return 0 ;; idx__stocks__help) - opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare help" + opts="quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2407,6 +2476,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__bandar) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__help__broker) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/crates/idx-cli/tests/golden/help-stocks.golden b/crates/idx-cli/tests/golden/help-stocks.golden index 8fb9414..7bf301e 100644 --- a/crates/idx-cli/tests/golden/help-stocks.golden +++ b/crates/idx-cli/tests/golden/help-stocks.golden @@ -13,6 +13,7 @@ Commands: risk Run risk analysis on a stock fundamental Run full fundamental analysis on a stock broker Which brokers bought and sold a stock + bandar Bandarmology: is buying more concentrated than selling profile Get company profile financials Get financial statements earnings Get earnings report diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden new file mode 100644 index 0000000..d8f3bcf --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1749,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1503,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1749,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1655,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1322,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3334,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true},{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1994,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1546,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1994,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1733,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-560,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true},{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2914,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden new file mode 100644 index 0000000..16dc84e --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden @@ -0,0 +1,6 @@ +exit: 0 +--- stdout +{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1749,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1503,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1749,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1655,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1322,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3334,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true} +{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1994,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1546,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1994,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1733,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-560,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true} +{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2914,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden new file mode 100644 index 0000000..3ac8d90 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden @@ -0,0 +1,13 @@ +exit: 0 +--- stdout +┌─────────┬────────────┬──────────────────┬─────────────┬───────────┬────────────┬───────┬─────────────┐ +│ SYMBOL ┆ DATES ┆ SIGNAL ┆ TOP 3 NET % ┆ BUY SHARE ┆ SELL SHARE ┆ VALUE ┆ FOREIGN NET │ +╞═════════╪════════════╪══════════════════╪═════════════╪═══════════╪════════════╪═══════╪═════════════╡ +│ BBRI.JK ┆ 2026-10-16 ┆ big distribution ┆ -17.5% ┆ 38.3% ┆ 55.7% ┆ 3.3T ┆ -1.1T │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ TLKM.JK ┆ 2026-10-16 ┆ big distribution ┆ -19.9% ┆ 43.1% ┆ 63.1% ┆ 2.2T ┆ -90.2B │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ BBCA.JK ┆ 2026-10-16 ┆ big distribution ┆ -29.1% ┆ 30.1% ┆ 59.2% ┆ 6.6T ┆ -2.3T │ +└─────────┴────────────┴──────────────────┴─────────────┴───────────┴────────────┴───────┴─────────────┘ +Method top_concentration_v1: the top 3 buyers' value minus the top 3 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar.json.golden b/crates/idx-cli/tests/golden/stocks-bandar.json.golden new file mode 100644 index 0000000..ec8a432 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":5,"signal":"big_distribution","imbalance_bps":-2426,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-bandar.table.golden b/crates/idx-cli/tests/golden/stocks-bandar.table.golden new file mode 100644 index 0000000..ebc704f --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-bandar.table.golden @@ -0,0 +1,20 @@ +exit: 0 +--- stdout +Bandar reading for BBCA.JK, 2026-10-16 +Signal (top 5): big distribution, -24.3% of value +┌─────┬────────┬──────┬───────┬────────┬─────────┬──────────┬──────────────────┐ +│ TOP ┆ BUY ┆ SELL ┆ NET ┆ NET % ┆ BUY AVG ┆ SELL AVG ┆ SIGNAL │ +╞═════╪════════╪══════╪═══════╪════════╪═════════╪══════════╪══════════════════╡ +│ 1 ┆ 841.2B ┆ 2.2T ┆ -1.4T ┆ -20.6% ┆ 5,191 ┆ 5,211 ┆ big distribution │ +├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 3 ┆ 2.0T ┆ 3.9T ┆ -1.9T ┆ -29.1% ┆ 5,212 ┆ 5,233 ┆ big distribution │ +├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 5 ┆ 2.9T ┆ 4.5T ┆ -1.6T ┆ -24.3% ┆ 5,211 ┆ 5,229 ┆ big distribution │ +├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 10 ┆ 4.5T ┆ 5.6T ┆ -1.1T ┆ -16.3% ┆ 5,213 ┆ 5,228 ┆ big distribution │ +└─────┴────────┴──────┴───────┴────────┴─────────┴──────────┴──────────────────┘ +Total 6.6T IDR, average 5,228; foreign net -2.3T (-34.9% of value). +On both sides: AK, CC, MG, XL, YU, ZP; their gross figures overstate which way they traded. +Method top_concentration_v1: the top 5 buyers' value minus the top 5 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice. +Note: the source rounds figures of a million and more to one decimal. +--- stderr diff --git a/crates/idx-core/src/analysis/bandar.rs b/crates/idx-core/src/analysis/bandar.rs new file mode 100644 index 0000000..f800535 --- /dev/null +++ b/crates/idx-core/src/analysis/bandar.rs @@ -0,0 +1,360 @@ +//! Bandarmology over a broker summary: is buying more concentrated in a few +//! brokers than selling? No network; see docs/ARCHITECTURE.md. +//! +//! The summary lists the top 10 buyers and the top 10 sellers by gross lots. +//! For N in 1, 3, 5 and 10 the top N buyers' value is set against the top N +//! sellers' value, as a share of the value traded: +//! +//! | top-N buy - sell value, % of value | signal | +//! |------------------------------------|--------------------| +//! | >= +15 | `big_accumulation` | +//! | +5 to +15 | `accumulation` | +//! | within +-5 | `neutral` | +//! | -15 to -5 | `distribution` | +//! | <= -15 | `big_distribution` | +//! +//! The thresholds follow fzn0x/idx (MIT), which set them against the 40 most +//! traded IDX stocks in October 2026. They are heuristics: a broker code is +//! not an investor, and gross figures overstate a broker that both bought +//! and sold (listed in `both_sides`). + +use serde::{Deserialize, Serialize}; + +use crate::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerSummary}; +use crate::error::IdxError; +use chrono::NaiveDate; + +/// Names the rules below; bumped when they change. +pub const METHOD: &str = "top_concentration_v1"; +/// Tiers every report carries. +pub const TIERS: [u8; 4] = [1, 3, 5, 10]; +/// Tier behind the headline signal unless asked otherwise. +pub const DEFAULT_TOP: u8 = 5; +/// Imbalance, in basis points of value traded, from which a tier reads +/// accumulation (or, negative, distribution). +pub const ACCUMULATION_BPS: i64 = 500; +/// Imbalance from which the reading is big. +pub const BIG_BPS: i64 = 1500; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BandarSignal { + BigAccumulation, + Accumulation, + Neutral, + Distribution, + BigDistribution, +} + +impl BandarSignal { + pub fn as_str(self) -> &'static str { + match self { + Self::BigAccumulation => "big_accumulation", + Self::Accumulation => "accumulation", + Self::Neutral => "neutral", + Self::Distribution => "distribution", + Self::BigDistribution => "big_distribution", + } + } + + pub fn from_imbalance_bps(bps: i64) -> Self { + match bps { + b if b >= BIG_BPS => Self::BigAccumulation, + b if b >= ACCUMULATION_BPS => Self::Accumulation, + b if b <= -BIG_BPS => Self::BigDistribution, + b if b <= -ACCUMULATION_BPS => Self::Distribution, + _ => Self::Neutral, + } + } +} + +/// The top N buyers against the top N sellers. Every figure is `None` when +/// a broker in the tier lacks it, or when nothing traded. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BandarTier { + pub top: u8, + /// Value the top N buyers bought, whole IDR. + pub buy_value: Option, + /// Value the top N sellers sold, whole IDR. + pub sell_value: Option, + /// `buy_value - sell_value`, whole IDR. + pub imbalance_value: Option, + /// `imbalance_value` in basis points of the value traded. + pub imbalance_bps: Option, + /// The top N buyers' share of the value traded, basis points. + pub buy_share_bps: Option, + /// The top N sellers' share of the value traded, basis points. + pub sell_share_bps: Option, + /// Volume-weighted average price the top N buyers paid, whole IDR. + pub buy_average: Option, + /// Volume-weighted average price the top N sellers got, whole IDR. + pub sell_average: Option, + pub signal: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BandarReport { + pub symbol: String, + pub from: NaiveDate, + pub to: NaiveDate, + pub investor: BrokerInvestor, + pub board: BrokerBoard, + /// The rules applied, e.g. `top_concentration_v1`. + pub method: String, + /// Tier behind the headline `signal`. + pub top: u8, + /// The headline tier's signal; `None` when nothing traded or a figure + /// is missing. + pub signal: Option, + /// The headline tier's imbalance, basis points of value traded. + pub imbalance_bps: Option, + pub tiers: Vec, + /// Value traded over the range, whole IDR. + pub total_value: Option, + /// Average traded price, whole IDR. + pub average_price: Option, + /// Foreign net value, whole IDR; `None` for a filtered summary. + pub foreign_net_value: Option, + /// `foreign_net_value` in basis points of the value traded. + pub foreign_net_bps: Option, + /// Brokers among the top 10 on both sides, whose gross figures overstate + /// which way they traded. + pub both_sides: Vec, + /// The source rounded figures of a million and more to one decimal. + pub rounded: bool, +} + +/// The headline tier must be one the report carries. +pub fn check_top(top: u8) -> Result { + if TIERS.contains(&top) { + Ok(top) + } else { + Err(IdxError::InvalidInput(format!( + "top must be 1, 3, 5 or 10 (got {top})" + ))) + } +} + +/// `part` in basis points of `whole`; `None` without a positive whole. +fn bps(part: i64, whole: Option) -> Option { + let whole = whole.filter(|whole| *whole > 0)?; + i64::try_from((i128::from(part) * 10_000).div_euclid(i128::from(whole))).ok() +} + +/// Sum of `field` over `flows`; `None` when any flow lacks it. +fn sum(flows: &[BrokerFlow], field: fn(&BrokerFlow) -> Option) -> Option { + flows + .iter() + .try_fold(0_i64, |total, flow| total.checked_add(field(flow)?)) +} + +/// Value over shares, the shares taken from each broker's value and its +/// exact published average (lots of a million and more are rounded, +/// averages never are). +fn vwap(flows: &[BrokerFlow]) -> Option { + let (mut value, mut shares) = (0.0_f64, 0.0_f64); + for flow in flows { + let (flow_value, average) = (flow.value?, flow.average_price?); + if average <= 0 { + return None; + } + value += flow_value as f64; + shares += flow_value as f64 / average as f64; + } + (shares > 0.0).then(|| (value / shares).round() as i64) +} + +pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier { + let n = usize::from(top); + let buyers = &summary.buyers[..n.min(summary.buyers.len())]; + let sellers = &summary.sellers[..n.min(summary.sellers.len())]; + let buy_value = sum(buyers, |flow| flow.value); + let sell_value = sum(sellers, |flow| flow.value); + let imbalance_value = buy_value + .zip(sell_value) + .and_then(|(buy, sell)| buy.checked_sub(sell)); + let imbalance_bps = imbalance_value.and_then(|value| bps(value, summary.total_value)); + BandarTier { + top, + buy_value, + sell_value, + imbalance_value, + imbalance_bps, + buy_share_bps: buy_value.and_then(|value| bps(value, summary.total_value)), + sell_share_bps: sell_value.and_then(|value| bps(value, summary.total_value)), + buy_average: vwap(buyers), + sell_average: vwap(sellers), + signal: imbalance_bps.map(BandarSignal::from_imbalance_bps), + } +} + +/// The full reading of one summary, headlined by the top `top` tier (one of +/// [`TIERS`]; see [`check_top`]). +pub fn analyze_bandar(summary: &BrokerSummary, top: u8) -> BandarReport { + let tiers: Vec = TIERS.iter().map(|n| bandar_tier(summary, *n)).collect(); + let headline = tiers + .iter() + .find(|tier| tier.top == top) + .cloned() + .unwrap_or_else(|| bandar_tier(summary, top)); + let mut both_sides: Vec = summary + .buyers + .iter() + .filter(|buyer| { + summary + .sellers + .iter() + .any(|seller| seller.code == buyer.code) + }) + .map(|buyer| buyer.code.clone()) + .collect(); + both_sides.sort_unstable(); + BandarReport { + symbol: summary.symbol.clone(), + from: summary.from, + to: summary.to, + investor: summary.investor, + board: summary.board, + method: METHOD.to_string(), + top, + signal: headline.signal, + imbalance_bps: headline.imbalance_bps, + tiers, + total_value: summary.total_value, + average_price: summary.average_price, + foreign_net_value: summary.foreign_net_value, + foreign_net_bps: summary + .foreign_net_value + .and_then(|value| bps(value, summary.total_value)), + both_sides, + rounded: summary.rounded, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn flow(code: &str, value: Option, average: i64) -> BrokerFlow { + BrokerFlow { + rank: 0, + code: code.to_string(), + name: None, + kind: None, + lots: value.map(|value| value / average / 100), + value, + average_price: Some(average), + } + } + + fn summary(buyers: Vec, sellers: Vec, total: i64) -> BrokerSummary { + let day = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(); + BrokerSummary { + symbol: "TEST.JK".to_string(), + from: day, + to: day, + investor: BrokerInvestor::All, + board: BrokerBoard::All, + buyers, + sellers, + total_value: Some(total), + total_lots: None, + average_price: None, + foreign_net_value: Some(-total / 10), + rounded: false, + } + } + + #[test] + fn test_bandar_signal_thresholds_are_inclusive() { + use BandarSignal::*; + for (bps, signal) in [ + (1500, BigAccumulation), + (1499, Accumulation), + (500, Accumulation), + (499, Neutral), + (-499, Neutral), + (-500, Distribution), + (-1499, Distribution), + (-1500, BigDistribution), + ] { + assert_eq!(BandarSignal::from_imbalance_bps(bps), signal, "{bps}"); + } + } + + #[test] + fn test_analyze_bandar_weighs_top_buyers_against_top_sellers() { + let s = summary( + vec![ + flow("AA", Some(400), 100), + flow("BB", Some(200), 100), + flow("CC", Some(100), 50), + ], + vec![ + flow("DD", Some(300), 110), + flow("AA", Some(100), 110), + flow("EE", Some(50), 110), + ], + 1000, + ); + let report = analyze_bandar(&s, 3); + let top1 = &report.tiers[0]; + assert_eq!((top1.top, top1.imbalance_value), (1, Some(100))); + assert_eq!(top1.imbalance_bps, Some(1000)); + assert_eq!(top1.signal, Some(BandarSignal::Accumulation)); + + let top3 = &report.tiers[1]; + assert_eq!((top3.buy_value, top3.sell_value), (Some(700), Some(450))); + assert_eq!( + (top3.buy_share_bps, top3.sell_share_bps), + (Some(7000), Some(4500)) + ); + assert_eq!(top3.imbalance_bps, Some(2500)); + // 700 IDR over 4 + 2 + 2 shares. + assert_eq!(top3.buy_average, Some(88)); + assert_eq!(top3.sell_average, Some(110)); + + // Short lists: every broker on a side is listed, so the sums hold. + assert_eq!(report.tiers[3].imbalance_value, Some(250)); + assert_eq!( + (report.top, report.signal), + (3, Some(BandarSignal::BigAccumulation)) + ); + assert_eq!(report.imbalance_bps, Some(2500)); + assert_eq!(report.foreign_net_bps, Some(-1000)); + assert_eq!(report.both_sides, vec!["AA".to_string()]); + assert_eq!(report.method, METHOD); + } + + #[test] + fn test_analyze_bandar_never_counts_a_missing_figure_as_zero() { + let s = summary( + vec![flow("AA", Some(400), 100), flow("BB", None, 100)], + vec![flow("DD", Some(300), 100)], + 1000, + ); + let report = analyze_bandar(&s, 3); + assert_eq!(report.tiers[0].imbalance_bps, Some(1000)); + let top3 = &report.tiers[1]; + assert_eq!( + (top3.buy_value, top3.imbalance_bps, top3.signal), + (None, None, None) + ); + assert_eq!(top3.buy_average, None); + assert_eq!(report.signal, None); + } + + #[test] + fn test_analyze_bandar_without_trades_has_no_signal() { + let report = analyze_bandar(&summary(vec![], vec![], 0), DEFAULT_TOP); + assert!(report.tiers.iter().all(|tier| tier.signal.is_none())); + assert_eq!((report.signal, report.imbalance_bps), (None, None)); + assert_eq!(report.tiers[2].buy_value, Some(0)); + } + + #[test] + fn test_check_top_allows_only_report_tiers() { + assert_eq!(check_top(5).unwrap(), 5); + assert!(matches!(check_top(4), Err(IdxError::InvalidInput(_)))); + } +} diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index 87c3aea..63dfb46 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -1,3 +1,4 @@ +pub mod bandar; pub mod fundamental; pub mod report; pub mod signals; @@ -8,9 +9,11 @@ use serde::de::DeserializeOwned; use crate::api::history_provider; use crate::api::types::{Fundamentals, Interval, Period}; +use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::error::IdxError; use crate::market::HistorySource; +use bandar::{BandarReport, analyze_bandar, check_top}; use fundamental::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental, analyze_growth, analyze_risk, analyze_valuation, @@ -28,7 +31,69 @@ pub struct Comparison { pub failures: Vec<(String, IdxError)>, } +/// Bandar readings for several symbols, strongest accumulation first; +/// symbols that failed are listed with their error. +pub struct BandarScreen { + pub reports: Vec>, + pub failures: Vec<(String, IdxError)>, +} + +/// The sessions and trades a bandar reading covers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BandarQuery { + pub range: BrokerRange, + pub investor: BrokerInvestor, + pub board: BrokerBoard, + /// Headline tier, one of `bandar::TIERS`. + pub top: u8, +} + impl Analysis<'_> { + /// Bandarmology over the broker summary `query` selects. + pub fn bandar( + &self, + symbol: &str, + query: &BandarQuery, + policy: CachePolicy, + ) -> Result, IdxError> { + let top = check_top(query.top)?; + let summary = + self.idx + .broker() + .fetch(symbol, query.range, query.investor, query.board, policy)?; + Ok(summary.map(|summary| analyze_bandar(&summary, top))) + } + + /// [`Self::bandar`] for each symbol, ranked by the headline imbalance. + /// Invalid input fails the whole screen; other errors fail one symbol. + pub fn bandar_screen>( + &self, + symbols: &[S], + query: &BandarQuery, + policy: CachePolicy, + ) -> Result { + check_top(query.top)?; + let mut reports = Vec::new(); + let mut failures = Vec::new(); + for symbol in symbols { + let resolved = self.idx.resolve(symbol.as_ref())?; + match self.bandar(&resolved, query, policy) { + Ok(report) => reports.push(report), + Err(err @ IdxError::InvalidInput(_)) => return Err(err), + Err(err) => failures.push((resolved, err)), + } + } + if reports.is_empty() { + return Err(failures + .pop() + .map(|(_, err)| err) + .unwrap_or_else(|| IdxError::InvalidInput("no symbols to screen".to_string()))); + } + // Strongest accumulation first; readings without an imbalance last. + reports.sort_by_key(|report| std::cmp::Reverse(report.data.imbalance_bps)); + Ok(BandarScreen { reports, failures }) + } + /// Technical analysis over one year of daily history from `source` /// (see `Market::history_source`). pub fn technical( diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html new file mode 100644 index 0000000..024b547 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
CC1.9 M504.3 B2,709 1AK3.7 M1.0 T2,716
XL1.6 M436.0 B2,720 2KZ1.8 M481.4 B2,672
AK1.2 M322.4 B2,719 3CC1.3 M358.2 B2,717
YP1.0 M281.3 B2,694 4BK933,299 253.5 B2,716
SQ712,722 193.1 B2,709 5XL697,845 189.9 B2,721
OD518,938 140.6 B2,709 6ZP477,399 129.4 B2,711
NI496,800 133.9 B2,695 7YU447,840 120.9 B2,700
YU420,264 112.3 B2,672 8YP431,663 117.6 B2,725
PD403,468 109.5 B2,713 9NI234,641 63.1 B2,690
XC381,871 103.7 B2,717 10XA217,888 59.1 B2,714
+
+ T. Val : 3.3 T + F. NVal : -1.1 T + T.Lot : 12.2 M + Avg : 2,708 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html new file mode 100644 index 0000000..a0f62e7 --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ1.9 M381.6 B1,960 1KZ3.7 M721.6 B1,933
CC1.5 M292.8 B1,932 2CC1.8 M353.9 B1,933
BK1.4 M274.7 B1,917 3BK1.6 M312.2 B1,926
RX1.0 M200.0 B1,933 4ZP1.0 M198.1 B1,926
ZP992,588 192.3 B1,937 5AK704,361 136.7 B1,941
YU944,581 180.4 B1,910 6MG529,257 101.4 B1,916
AK715,278 138.2 B1,932 7YU324,973 62.5 B1,924
MG528,650 100.6 B1,903 8NI208,165 39.8 B1,911
XL331,760 64.1 B1,931 9XL173,939 33.7 B1,937
YP234,664 44.9 B1,913 10YP168,848 32.7 B1,938
+
+ T. Val : 2.2 T + F. NVal : -90.2 B + T.Lot : 11.3 M + Avg : 1,931 +
+ +
+
+ + + diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 90309da..2975031 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -115,10 +115,9 @@ pub struct AnalyzeParams { pub kinds: Option>, } +/// Which sessions and trades a broker tool covers. #[derive(Debug, Deserialize, JsonSchema)] -pub struct BrokerParams { - /// IDX ticker, e.g. "BBCA". - pub symbol: String, +pub struct BrokerScope { /// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. #[serde(default)] pub date: Option, @@ -139,6 +138,25 @@ pub struct BrokerParams { pub board: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct BrokerParams { + /// IDX ticker, e.g. "BBCA". + pub symbol: String, + #[serde(flatten)] + pub scope: BrokerScope, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct BandarParams { + /// IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. + pub symbols: Vec, + #[serde(flatten)] + pub scope: BrokerScope, + /// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5). + #[serde(default)] + pub top: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct ShareholdersParams { /// IDX ticker, e.g. "BBCA". diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 2386d64..e73bdbf 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -4,6 +4,8 @@ use std::collections::BTreeMap; +use idx_core::analysis::BandarQuery; +use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP}; use idx_core::analysis::fundamental::FundamentalReport; use idx_core::broker::types::BrokerRange; use idx_core::config::CoreConfig; @@ -22,9 +24,9 @@ use serde_json::{Map, Value, json}; use tokio::sync::Semaphore; use crate::params::{ - AnalysisKind, AnalyzeParams, BrokerParams, ChangesParams, CompanyParams, CompanySection, - EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, - SyncParams, + AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams, + CompanySection, EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, + SymbolsParams, SyncParams, }; const SYNC_TOOL: &str = "ownership_sync"; @@ -176,33 +178,66 @@ broker's own status (local, foreign, state_owned), not its clients'. `rounded` = figures of a million and more carry one decimal of precision." )] async fn get_broker_summary(&self, params: Parameters) -> ToolResult { - let BrokerParams { - symbol, - date, - from, - to, - investor, - board, - } = params.0; + let BrokerParams { symbol, scope } = params.0; self.live(move |idx| { let broker = idx.broker(); let range = BrokerRange::from_args( - date.as_deref(), - from.as_deref(), - to.as_deref(), + scope.date.as_deref(), + scope.from.as_deref(), + scope.to.as_deref(), broker.today(), )?; broker.fetch( &symbol, range, - investor.as_deref().unwrap_or("all").parse()?, - board.as_deref().unwrap_or("all").parse()?, + scope.investor.as_deref().unwrap_or("all").parse()?, + scope.board.as_deref().unwrap_or("all").parse()?, CachePolicy::Default, ) }) .await } + #[tool( + description = "Bandarmology for one or more IDX tickers from the broker summary: for \ +the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value \ +traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral \ +(within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come \ +strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over \ +end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose \ +gross figures overstate their direction. `method` names the rules applied." + )] + async fn analyze_bandar(&self, params: Parameters) -> ToolResult { + let BandarParams { + scope, + symbols, + top, + } = params.0; + let symbols = match checked_symbols(symbols) { + Ok(symbols) => symbols, + Err(err) => return err, + }; + self.live(move |idx| { + let query = BandarQuery { + range: BrokerRange::from_args( + scope.date.as_deref(), + scope.from.as_deref(), + scope.to.as_deref(), + idx.broker().today(), + )?, + investor: scope.investor.as_deref().unwrap_or("all").parse()?, + board: scope.board.as_deref().unwrap_or("all").parse()?, + top: top.unwrap_or(DEFAULT_TOP), + }; + let screen = idx + .analysis() + .bandar_screen(&symbols, &query, CachePolicy::Default)?; + let reports: &[Fetched] = &screen.reports; + Ok(json!({ "reports": reports, "failures": failures_json(&screen.failures) })) + }) + .await + } + #[tool( description = "Company data for one IDX ticker: profile, financial statements, \ earnings, analyst sentiment, insights, and news. News items carry `relevance`: \ @@ -294,17 +329,8 @@ tickers. Tickers that fail are listed under `failures`." }; self.live(move |idx| { let comparison = idx.analysis().compare(&symbols, CachePolicy::Default)?; - let failures: Vec = comparison - .failures - .iter() - .map(|(symbol, err)| { - let mut body = error_body(err); - body.insert("symbol".into(), json!(symbol)); - Value::Object(body) - }) - .collect(); let reports: &[Fetched] = &comparison.reports; - Ok(json!({ "reports": reports, "failures": failures })) + Ok(json!({ "reports": reports, "failures": failures_json(&comparison.failures) })) }) .await } @@ -480,6 +506,18 @@ where }) } +/// Per-symbol failures as error bodies carrying their symbol. +fn failures_json(failures: &[(String, IdxError)]) -> Vec { + failures + .iter() + .map(|(symbol, err)| { + let mut body = error_body(err); + body.insert("symbol".into(), json!(symbol)); + Value::Object(body) + }) + .collect() +} + fn input_error(message: String) -> ToolResult { let err = IdxError::InvalidInput(message); Ok(CallToolResult::structured_error(Value::Object(error_body( diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 1d0253c..938ba16 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -1,4 +1,80 @@ [ + { + "description": "Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "board": { + "default": null, + "description": "Which board's trades to count (default \"all\"); \"regular\" leaves out\nnegotiated block trades.", + "enum": [ + "all", + "regular", + "cash", + "negotiated" + ], + "type": "string" + }, + "date": { + "default": null, + "description": "One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.", + "type": [ + "string", + "null" + ] + }, + "from": { + "default": null, + "description": "First session of a range, YYYY-MM-DD (at most 366 days).", + "type": [ + "string", + "null" + ] + }, + "investor": { + "default": null, + "description": "Whose trades to count, by investor domicile (default \"all\").", + "enum": [ + "all", + "foreign", + "domestic" + ], + "type": "string" + }, + "symbols": { + "description": "IDX tickers, e.g. [\"BBCA\", \"BBRI\"]; the `.JK` suffix is optional. At most 20.", + "items": { + "type": "string" + }, + "type": "array" + }, + "to": { + "default": null, + "description": "Last session of the range, YYYY-MM-DD (default today).", + "type": [ + "string", + "null" + ] + }, + "top": { + "default": null, + "description": "Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5).", + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "symbols" + ], + "type": "object" + }, + "name": "analyze_bandar" + }, { "description": "Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails.", "inputSchema": { diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index 7e28d78..ef89a07 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -9,7 +9,8 @@ use idx_core::config::CoreConfig; use idx_core::investor::{ImportSource, Investor}; use serde_json::{Value, json}; -const TOOLS: [&str; 12] = [ +const TOOLS: [&str; 13] = [ + "analyze_bandar", "analyze_stock", "compare_stocks", "get_broker_summary", @@ -258,6 +259,22 @@ fn live_tools_wrap_data_with_freshness() { assert_eq!(brokers["data"]["sellers"][0]["name"], "MANDIRI SEKURITAS"); assert_eq!(brokers["data"]["foreign_net_value"], -2_300_000_000_000_i64); + let bandar = client.ok( + "analyze_bandar", + json!({"symbols": ["BBCA", "BBRI", "ZZZZ"], "date": "2026-10-16", "top": 3}), + ); + let reports = bandar["reports"].as_array().unwrap(); + assert_eq!(reports.len(), 2); + assert_fetched(&reports[0]); + assert_eq!(reports[0]["data"]["top"], 3); + assert_eq!(reports[0]["data"]["method"], "top_concentration_v1"); + assert!( + reports[0]["data"]["imbalance_bps"].as_i64() + >= reports[1]["data"]["imbalance_bps"].as_i64() + ); + assert_eq!(bandar["failures"][0]["symbol"], "ZZZZ.JK"); + assert_eq!(bandar["failures"][0]["code"], "SYMBOLNOTFOUND"); + let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]})); assert_eq!(compared["reports"].as_array().unwrap().len(), 2); assert_eq!(compared["failures"], json!([])); @@ -292,6 +309,8 @@ fn errors_carry_cli_codes() { "get_broker_summary", json!({"symbol": "BBCA", "date": "2026-10-16", "board": "dark"}), ), + ("analyze_bandar", json!({"symbols": ["BBCA"], "top": 4})), + ("analyze_bandar", json!({"symbols": []})), ] { let (is_error, body) = client.call(tool, arguments); assert!(is_error, "{tool}: {body}"); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9278f89..c148c02 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -103,6 +103,11 @@ WIB as the fallback. - Data is end of day: IDX hides broker codes during trading. Summaries that ended before today are cached for 7 days, today's for 5 minutes. Broker names come from `broker/codes.tsv` (IDX `GetBrokerCodeList`). +- Bandarmology (`analysis/bandar.rs`, method `top_concentration_v1`) is pure + over one summary: for the top 1/3/5/10 per side, buy value minus sell value + in basis points of value traded, with thresholds ±500/±1500 bps taken from + fzn0x/idx. A tier with a missing figure has no reading; missing values are + never summed as zero. A change to the rules gets a new method name. ### Capability Gating ``` diff --git a/flake.nix b/flake.nix index 9962c1d..8644232 100644 --- a/flake.nix +++ b/flake.nix @@ -288,7 +288,7 @@ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' sleep 2 } | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl - jq -se 'map(select(.id == 2))[0].result.tools | length == 12' out.jsonl + jq -se 'map(select(.id == 2))[0].result.tools | length == 13' out.jsonl touch "$out" ''; # The website's generated pages match the CLI help and the MCP diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 3cfe6c7..5ffd38a 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -67,6 +67,7 @@ Commands: risk Run risk analysis on a stock fundamental Run full fundamental analysis on a stock broker Which brokers bought and sold a stock + bandar Bandarmology: is buying more concentrated than selling profile Get company profile financials Get financial statements earnings Get earnings report @@ -351,6 +352,83 @@ Examples: idx -o json stocks broker BBCA --investor foreign ``` +### `idx stocks bandar` + +```text +Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value against the same number of sellers', as a share of value traded, with a signal from big accumulation to big distribution. Several symbols give a screen ranked by the headline tier (at most 100, one request each). A heuristic over end-of-day broker codes, not advice. + +Usage: idx stocks bandar [OPTIONS] ... + +Arguments: + ... + One or more symbols, comma-separated or space-separated; `-` reads them from stdin + +Options: + --date + One session, YYYY-MM-DD. Default: the latest session with trades + + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --from + First session of a range, YYYY-MM-DD (at most 366 days) + + --no-color + Disable colored output + + -q, --quiet + Suppress non-essential output + + --to + Last session of the range, YYYY-MM-DD. Default: today + + --investor + Whose trades to count + + Possible values: + - all: Every trade + - foreign: Trades by foreign investors only + - domestic: Trades by domestic investors only + + [default: all] + + -v, --verbose... + Increase verbosity (-v, -vv) + + --board + Which board's trades to count; `regular` leaves out negotiated block trades + + Possible values: + - all: Every board + - regular: Regular market (RG), the continuous order book + - cash: Cash market (TN) + - negotiated: Negotiated market (NG): pre-arranged block trades + + [default: all] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + --top + Tier behind the headline signal: 1, 3, 5 or 10 brokers per side + + [default: 5] + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stocks bandar BBCA + idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3 + idx stocks bandar BBCA,BBRI,BMRI,TLKM + idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar - +``` + ### `idx stocks profile` ```text diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index 61f4a8c..7bb02b7 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -47,6 +47,32 @@ codes during trading. Buyers and sellers are ranked separately on gross lots, so a broker can appear on both sides. The source rounds figures of a million and more to one decimal, and the output says so. +## Bandarmology + +```bash +idx stocks bandar BBCA +idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3 +idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar - +``` + +`bandar` reads the broker summary: for the top 1, 3, 5 and 10 brokers on each +side, the buyers' value minus the sellers' value as a share of the value +traded. The headline tier (`--top`, default 5) gives the signal: + +| Top-N buy minus sell, % of value | Signal | +| --- | --- | +| +15% or more | big accumulation | +| +5% to +15% | accumulation | +| within ±5% | neutral | +| -15% to -5% | distribution | +| -15% or less | big distribution | + +Several symbols give a screen ranked by that figure (at most 100, one request +each), and it takes the same date and filter flags as `broker`. It is a +heuristic: a broker trades for many clients, and a broker on both sides of +the summary is listed, since its gross figures overstate which way it traded. +The JSON names the rules in `method` (`top_concentration_v1`). + ## Analysis | Command | What it computes | diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 0c1825a..8664986 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -10,6 +10,20 @@ starts with `--allow-sync`. Results that come from a live provider are wrapped as `{data, source, fetched_at, stale}`; failures return `{error, code, message}` with `isError` set. +## `analyze_bandar` + +Bandarmology for one or more IDX tickers from the broker summary: for the top 1, 3, 5 and 10 brokers per side, buy value minus sell value as basis points of value traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) through neutral (within +-500) to big_distribution (<= -1500), headlined by `top` (default 5). Reports come strongest accumulation first; tickers that fail are listed under `failures`. A heuristic over end-of-day broker codes: brokers trade for many clients, and `both_sides` lists brokers whose gross figures overstate their direction. `method` names the rules applied. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `board` | `all` / `regular` / `cash` / `negotiated` | no | Which board's trades to count (default "all"); "regular" leaves out negotiated block trades. | +| `date` | string | no | One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. | +| `from` | string | no | First session of a range, YYYY-MM-DD (at most 366 days). | +| `investor` | `all` / `foreign` / `domestic` | no | Whose trades to count, by investor domicile (default "all"). | +| `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. | +| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). | +| `top` | integer | no | Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5). | + ## `analyze_stock` Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails.