diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index 046734a..9667c2d 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -207,7 +207,8 @@ does not show broker codes during trading. Without dates, the latest session wit 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). Counts all investors (`--investor` must be `all`). A \ +(at most 100, one request each). `--investor foreign|domestic` weighs that side's buyers \ +against its buying and its sellers against its selling (needs `--board all`). 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 -" )] diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 641d9c6..4e742e9 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -952,6 +952,42 @@ pub(crate) fn broker_scope( scope } +/// What one investor side bought and sold, when a summary covers one side. +fn side_totals_line( + investor: BrokerInvestor, + board: BrokerBoard, + buy_value: Option, + sell_value: Option, + foreign_net: Option, +) -> Option { + let side = match investor { + BrokerInvestor::All => return None, + BrokerInvestor::Foreign => "Foreign", + BrokerInvestor::Domestic => "Domestic", + }; + let buy = format_compact_option(buy_value); + Some(match sell_value { + Some(sell) => { + let net = foreign_net + .map(|net| match investor { + BrokerInvestor::Domestic => -net, + _ => net, + }) + .map(|net| format!(" (net {:+})", format_compact(net))) + .unwrap_or_default(); + format!( + "{side} investors bought {buy} IDR and sold {} IDR{net}.", + format_compact(sell) + ) + } + None => format!( + "{side} investors bought {buy} IDR on the {} board; the source gives one side's \ +selling only across all boards.", + board.as_str() + ), + }) +} + pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> { println!( "{}", @@ -995,8 +1031,14 @@ pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> { totals.push_str(&format!("; foreign net {:+}", format_compact(net))); } println!("{totals}."); - } else if summary.investor != BrokerInvestor::All { - println!("No totals: for one investor side the source totals only that side's buying."); + } else if let Some(line) = side_totals_line( + summary.investor, + summary.board, + summary.buy_value, + summary.sell_value, + summary.foreign_net_value, + ) { + println!("{line}"); } for (kind, label) in [ (BrokerKind::Foreign, "Foreign brokers"), @@ -1049,9 +1091,9 @@ fn format_bandar_signal(signal: Option, no_color: bool) -> String 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.", + "Method {BANDAR_METHOD}: the top {top} buyers' share of the value bought minus the top \ +{top} sellers' share of the value sold; {}% 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 ) @@ -1067,7 +1109,11 @@ pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), ) .bold() ); - if report.total_value.is_none_or(|value| value <= 0) { + if report + .buy_value + .zip(report.sell_value) + .is_none_or(|(buy, sell)| buy <= 0 && sell <= 0) + { println!("No trades."); return Ok(()); } @@ -1105,18 +1151,28 @@ pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), ]); } 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(line) = side_totals_line( + report.investor, + report.board, + report.buy_value, + report.sell_value, + report.foreign_net_value, + ) { + println!("{line}"); + } else { + 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 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.", diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 7da8bb6..1175eaf 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2499,10 +2499,12 @@ fn broker_summary_json_names_brokers_and_keeps_source_totals() { "total_value", "total_lots", "average_price", + "sell_value", "foreign_net_value", ] { assert!(filtered[field].is_null(), "{field}: {filtered}"); } + assert_eq!(filtered["buy_value"], 1_600_000_000_000_i64); let table = run_success_stdout(mock_bin("broker-filtered-table").args([ "stocks", "broker", @@ -2515,7 +2517,27 @@ fn broker_summary_json_names_brokers_and_keeps_source_totals() { "regular", ])); assert!(!table.contains("Total "), "{table}"); - assert!(table.contains("No totals"), "{table}"); + assert!( + table.contains("Foreign investors bought 1.6T IDR on the regular board"), + "{table}" + ); + + // On all boards, the foreign net gives foreign selling. + let foreign = run_success_json(mock_bin("broker-foreign").args([ + "-o", + "json", + "stocks", + "broker", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + ])); + assert!(foreign["total_value"].is_null()); + assert_eq!(foreign["buy_value"], 1_700_000_000_000_i64); + assert_eq!(foreign["sell_value"], 4_000_000_000_000_i64); + assert_eq!(foreign["foreign_net_value"], -2_300_000_000_000_i64); } #[test] @@ -2717,7 +2739,15 @@ fn bandar_errors_follow_the_output_contract() { "INVALIDINPUT", ), ( - vec!["BBCA".to_string(), "--investor".into(), "foreign".into()], + vec![ + "BBCA".to_string(), + "--date".into(), + "2026-10-16".into(), + "--investor".into(), + "foreign".into(), + "--board".into(), + "regular".into(), + ], "INVALIDINPUT", ), ] { @@ -2857,3 +2887,49 @@ fn foreign_flow_errors_follow_the_output_contract() { .args(["-o", "json", "stocks", "flow", "BBCA"]); run_error_json(&mut failing, "PROVIDERUNAVAILABLE"); } + +#[test] +fn bandar_reads_one_investor_side_against_its_own_trading() { + let reports = run_success_json(mock_bin("bandar-foreign").args([ + "-o", + "json", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + ])); + let report = &reports[0]; + assert_eq!(report["investor"], "foreign"); + assert_eq!(report["buy_value"], 1_700_000_000_000_i64); + assert_eq!(report["sell_value"], 4_000_000_000_000_i64); + let top5 = &report["tiers"][2]; + // Shares of foreign buying and of foreign selling, each within 100%. + let (buy, sell) = ( + top5["buy_share_bps"].as_i64().unwrap(), + top5["sell_share_bps"].as_i64().unwrap(), + ); + assert!( + (0..=10_000).contains(&buy) && (0..=10_000).contains(&sell), + "{top5}" + ); + assert!((top5["imbalance_bps"].as_i64().unwrap() - (buy - sell)).abs() <= 1); + assert!(report["signal"].is_string()); + + let table = run_success_stdout(mock_bin("bandar-foreign-table").args([ + "--no-color", + "stocks", + "bandar", + "BBCA", + "--date", + "2026-10-16", + "--investor", + "foreign", + ])); + assert!( + table.contains("Foreign investors bought 1.7T IDR and sold 4.0T IDR (net -2.3T)."), + "{table}" + ); +} diff --git a/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden index 4ff0e74..c875813 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.json.golden @@ -1,4 +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":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"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":-1748,"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":-1654,"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":-1321,"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":-3333,"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":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"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":-1993,"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":-1732,"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":-559,"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":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"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":-2913,"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":-2425,"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":-1628,"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":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +[{"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":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"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":-1748,"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":-1654,"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":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"buy_value":3300000000000,"sell_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"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":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"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":-1993,"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":-1732,"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":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"buy_value":2200000000000,"sell_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":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"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":-2913,"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":-2425,"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":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"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 index bb5a3cc..0cceb2c 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.jsonl.golden @@ -1,6 +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":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"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":-1748,"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":-1654,"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":-1321,"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":-3333,"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":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"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":-1993,"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":-1732,"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":-559,"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":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"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":-2913,"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":-2425,"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":-1628,"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":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true} +{"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":-1748,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1502,"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":-1748,"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":-1654,"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":-1321,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"buy_value":3300000000000,"sell_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3333,"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":-1993,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1545,"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":-1993,"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":-1732,"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":-559,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"buy_value":2200000000000,"sell_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":-2913,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"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":-2913,"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":-2425,"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":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"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 index 3ac8d90..33a2a13 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar-screen.table.golden @@ -9,5 +9,5 @@ exit: 0 ├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ 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. +Method top_concentration_v1: the top 3 buyers' share of the value bought minus the top 3 sellers' share of the value sold; 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 index 5d6bad6..bda32bb 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar.json.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar.json.golden @@ -1,4 +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":-2425,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"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":-2913,"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":-2425,"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":-1628,"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":-3484,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}] +[{"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":-2425,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2058,"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":-2913,"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":-2425,"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":-1628,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"buy_value":6600000000000,"sell_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3484,"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 index 7ea52f6..953b403 100644 --- a/crates/idx-cli/tests/golden/stocks-bandar.table.golden +++ b/crates/idx-cli/tests/golden/stocks-bandar.table.golden @@ -15,6 +15,6 @@ Signal (top 5): big distribution, -24.2% of value └─────┴────────┴──────┴───────┴────────┴─────────┴──────────┴──────────────────┘ Total 6.6T IDR, average 5,228; foreign net -2.3T (-34.8% 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. +Method top_concentration_v1: the top 5 buyers' share of the value bought minus the top 5 sellers' share of the value sold; 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-cli/tests/golden/stocks-broker-no-trades.json.golden b/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden index aa27fb8..457e28f 100644 --- a/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden +++ b/crates/idx-cli/tests/golden/stocks-broker-no-trades.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","from":"2026-10-17","to":"2026-10-17","investor":"all","board":"all","buyers":[],"sellers":[],"total_value":0,"total_lots":0,"average_price":null,"foreign_net_value":0,"rounded":false} +{"symbol":"BBCA.JK","from":"2026-10-17","to":"2026-10-17","investor":"all","board":"all","buyers":[],"sellers":[],"total_value":0,"total_lots":0,"average_price":null,"buy_value":0,"sell_value":0,"foreign_net_value":0,"rounded":false} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker-range.json.golden b/crates/idx-cli/tests/golden/stocks-broker-range.json.golden index 0b4fb84..4ef1b15 100644 --- a/crates/idx-cli/tests/golden/stocks-broker-range.json.golden +++ b/crates/idx-cli/tests/golden/stocks-broker-range.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":963000000000,"average_price":5573},{"rank":2,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":842000000000,"average_price":5192},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":900900000000,"average_price":5780},{"rank":4,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":781700000000,"average_price":5626},{"rank":5,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1400000,"value":737900000000,"average_price":5408},{"rank":6,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1300000,"value":687200000000,"average_price":5313},{"rank":7,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1100000,"value":643900000000,"average_price":5771},{"rank":8,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":973921,"value":519400000000,"average_price":5333},{"rank":9,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":919655,"value":487100000000,"average_price":5296},{"rank":10,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":646162,"value":341200000000,"average_price":5280}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":5100000,"value":2700000000000,"average_price":5336},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1800000,"value":928500000000,"average_price":5295},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":942100000000,"average_price":5756},{"rank":4,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":800500000000,"average_price":5239},{"rank":5,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":786500000000,"average_price":5785},{"rank":6,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":852704,"value":457700000000,"average_price":5368},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":830940,"value":466700000000,"average_price":5617},{"rank":8,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":709658,"value":393300000000,"average_price":5542},{"rank":9,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":621425,"value":363300000000,"average_price":5847},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":441903,"value":242600000000,"average_price":5490}],"total_value":9900000000000,"total_lots":18100000,"average_price":5471,"foreign_net_value":-4100000000000,"rounded":true} +{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":963000000000,"average_price":5573},{"rank":2,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":842000000000,"average_price":5192},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":900900000000,"average_price":5780},{"rank":4,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":781700000000,"average_price":5626},{"rank":5,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1400000,"value":737900000000,"average_price":5408},{"rank":6,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1300000,"value":687200000000,"average_price":5313},{"rank":7,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1100000,"value":643900000000,"average_price":5771},{"rank":8,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":973921,"value":519400000000,"average_price":5333},{"rank":9,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":919655,"value":487100000000,"average_price":5296},{"rank":10,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":646162,"value":341200000000,"average_price":5280}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":5100000,"value":2700000000000,"average_price":5336},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1800000,"value":928500000000,"average_price":5295},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":942100000000,"average_price":5756},{"rank":4,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":800500000000,"average_price":5239},{"rank":5,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":786500000000,"average_price":5785},{"rank":6,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":852704,"value":457700000000,"average_price":5368},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":830940,"value":466700000000,"average_price":5617},{"rank":8,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":709658,"value":393300000000,"average_price":5542},{"rank":9,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":621425,"value":363300000000,"average_price":5847},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":441903,"value":242600000000,"average_price":5490}],"total_value":9900000000000,"total_lots":18100000,"average_price":5471,"buy_value":9900000000000,"sell_value":9900000000000,"foreign_net_value":-4100000000000,"rounded":true} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-broker.json.golden b/crates/idx-cli/tests/golden/stocks-broker.json.golden index 8aade15..858fb29 100644 --- a/crates/idx-cli/tests/golden/stocks-broker.json.golden +++ b/crates/idx-cli/tests/golden/stocks-broker.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":841200000000,"average_price":5191},{"rank":2,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1100000,"value":600000000000,"average_price":5229},{"rank":3,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1000000,"value":543500000000,"average_price":5227},{"rank":4,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1000000,"value":522400000000,"average_price":5198},{"rank":5,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":828163,"value":432400000000,"average_price":5221},{"rank":6,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":827340,"value":431700000000,"average_price":5218},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":687603,"value":356600000000,"average_price":5186},{"rank":8,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":592667,"value":309200000000,"average_price":5218},{"rank":9,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":441000,"value":230200000000,"average_price":5220},{"rank":10,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":398474,"value":209900000000,"average_price":5268}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":4300000,"value":2200000000000,"average_price":5211},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":908200000000,"average_price":5282},{"rank":3,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":799400000000,"average_price":5238},{"rank":4,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":687067,"value":357400000000,"average_price":5202},{"rank":5,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":529223,"value":275400000000,"average_price":5203},{"rank":6,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":444696,"value":233500000000,"average_price":5251},{"rank":7,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":419266,"value":218800000000,"average_price":5219},{"rank":8,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":418986,"value":218000000000,"average_price":5202},{"rank":9,"code":"NI","name":"BNI SEKURITAS","kind":"state_owned","lots":357177,"value":186100000000,"average_price":5212},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":296269,"value":155000000000,"average_price":5232}],"total_value":6600000000000,"total_lots":12600000,"average_price":5228,"foreign_net_value":-2300000000000,"rounded":true} +{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":841200000000,"average_price":5191},{"rank":2,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1100000,"value":600000000000,"average_price":5229},{"rank":3,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1000000,"value":543500000000,"average_price":5227},{"rank":4,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1000000,"value":522400000000,"average_price":5198},{"rank":5,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":828163,"value":432400000000,"average_price":5221},{"rank":6,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":827340,"value":431700000000,"average_price":5218},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":687603,"value":356600000000,"average_price":5186},{"rank":8,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":592667,"value":309200000000,"average_price":5218},{"rank":9,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":441000,"value":230200000000,"average_price":5220},{"rank":10,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":398474,"value":209900000000,"average_price":5268}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":4300000,"value":2200000000000,"average_price":5211},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":908200000000,"average_price":5282},{"rank":3,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":799400000000,"average_price":5238},{"rank":4,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":687067,"value":357400000000,"average_price":5202},{"rank":5,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":529223,"value":275400000000,"average_price":5203},{"rank":6,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":444696,"value":233500000000,"average_price":5251},{"rank":7,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":419266,"value":218800000000,"average_price":5219},{"rank":8,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":418986,"value":218000000000,"average_price":5202},{"rank":9,"code":"NI","name":"BNI SEKURITAS","kind":"state_owned","lots":357177,"value":186100000000,"average_price":5212},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":296269,"value":155000000000,"average_price":5232}],"total_value":6600000000000,"total_lots":12600000,"average_price":5228,"buy_value":6600000000000,"sell_value":6600000000000,"foreign_net_value":-2300000000000,"rounded":true} --- stderr diff --git a/crates/idx-core/src/analysis/bandar.rs b/crates/idx-core/src/analysis/bandar.rs index 8fc1fda..e793ab1 100644 --- a/crates/idx-core/src/analysis/bandar.rs +++ b/crates/idx-core/src/analysis/bandar.rs @@ -2,10 +2,13 @@ //! 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: +//! For N in 1, 3, 5 and 10 the top N buyers' share of the value bought is set +//! against the top N sellers' share of the value sold. For all investors both +//! are the value traded, so this is the top N's buy value less their sell +//! value as a share of it; for one investor side (foreign or domestic), each +//! is that side's own buying or selling. //! -//! | top-N buy - sell value, % of value | signal | +//! | top-N buy share - sell share, % | signal | //! |------------------------------------|--------------------| //! | >= +15 | `big_accumulation` | //! | +5 to +15 | `accumulation` | @@ -79,11 +82,12 @@ pub struct BandarTier { pub sell_value: Option, /// `buy_value - sell_value`, whole IDR. pub imbalance_value: Option, + /// `buy_share_bps - sell_share_bps`, computed exactly: for all investors, /// `imbalance_value` in basis points of the value traded. pub imbalance_bps: Option, - /// The top N buyers' share of the value traded, basis points. + /// The top N buyers' share of the value bought, basis points. pub buy_share_bps: Option, - /// The top N sellers' share of the value traded, basis points. + /// The top N sellers' share of the value sold, basis points. pub sell_share_bps: Option, /// Volume-weighted average price the top N buyers paid, whole IDR. pub buy_average: Option, @@ -109,8 +113,12 @@ pub struct BandarReport { /// The headline tier's imbalance, basis points of value traded. pub imbalance_bps: Option, pub tiers: Vec, - /// Value traded over the range, whole IDR. + /// Value traded over the range, whole IDR; `None` for one investor side. pub total_value: Option, + /// Value the selected investors bought, whole IDR. + pub buy_value: Option, + /// Value the selected investors sold, whole IDR. + pub sell_value: Option, /// Average traded price, whole IDR. pub average_price: Option, /// Foreign net value, whole IDR; `None` for a filtered summary. @@ -175,15 +183,28 @@ pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier { 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)); + let imbalance_bps = match (buy_value, sell_value, summary.buy_value, summary.sell_value) { + (Some(buy), Some(sell), Some(bought), Some(sold)) if bought > 0 && sold > 0 => { + // buy/bought - sell/sold over one denominator, so equal totals + // give exactly (buy - sell) / total. + let (buy, sell, bought, sold) = ( + i128::from(buy), + i128::from(sell), + i128::from(bought), + i128::from(sold), + ); + i64::try_from((buy * sold - sell * bought) * 10_000 / (bought * sold)).ok() + } + _ => None, + }; 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_share_bps: buy_value.and_then(|value| bps(value, summary.buy_value)), + sell_share_bps: sell_value.and_then(|value| bps(value, summary.sell_value)), buy_average: vwap(buyers), sell_average: vwap(sellers), signal: imbalance_bps.map(BandarSignal::from_imbalance_bps), @@ -223,6 +244,8 @@ pub fn analyze_bandar(summary: &BrokerSummary, top: u8) -> BandarReport { imbalance_bps: headline.imbalance_bps, tiers, total_value: summary.total_value, + buy_value: summary.buy_value, + sell_value: summary.sell_value, average_price: summary.average_price, foreign_net_value: summary.foreign_net_value, foreign_net_bps: summary @@ -262,6 +285,8 @@ mod tests { total_value: Some(total), total_lots: None, average_price: None, + buy_value: Some(total), + sell_value: Some(total), foreign_net_value: Some(-total / 10), rounded: false, } @@ -359,6 +384,36 @@ mod tests { ); } + #[test] + fn test_bandar_tier_weighs_one_side_against_its_own_buying_and_selling() { + // Foreign investors bought 1,000 and sold 4,000: the top buyer took + // half of the buying, the top seller a quarter of the selling. + let mut s = summary( + vec![flow("AA", Some(500), 100)], + vec![flow("BB", Some(1_000), 100)], + 0, + ); + s.investor = BrokerInvestor::Foreign; + s.total_value = None; + s.buy_value = Some(1_000); + s.sell_value = Some(4_000); + s.foreign_net_value = Some(-3_000); + let report = analyze_bandar(&s, 1); + let tier = &report.tiers[0]; + assert_eq!( + (tier.buy_share_bps, tier.sell_share_bps), + (Some(5000), Some(2500)) + ); + assert_eq!(tier.imbalance_bps, Some(2500)); + assert_eq!(tier.imbalance_value, Some(-500)); + assert_eq!(report.signal, Some(BandarSignal::BigAccumulation)); + assert_eq!(report.foreign_net_bps, None, "no total to weigh it against"); + + // Without the selling total (one side on one board) there is no reading. + s.sell_value = None; + assert_eq!(analyze_bandar(&s, 1).signal, None); + } + #[test] fn test_analyze_bandar_never_counts_a_missing_figure_as_zero() { let s = summary( diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index 0304678..b0741f2 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -31,14 +31,15 @@ pub struct Comparison { pub failures: Vec<(String, IdxError)>, } -/// Readings weigh brokers against the value traded, which the source does -/// not total for one investor side. -fn check_investor(investor: BrokerInvestor) -> Result<(), IdxError> { - if investor == BrokerInvestor::All { +/// One investor side is weighed against its own selling, which the source +/// gives only across all boards. +fn check_scope(investor: BrokerInvestor, board: BrokerBoard) -> Result<(), IdxError> { + if investor == BrokerInvestor::All || board == BrokerBoard::All { Ok(()) } else { Err(IdxError::InvalidInput(format!( - "bandar needs --investor all: the broker summary has no total for {} investors alone", + "bandar for {} investors needs --board all: the broker summary gives one side's \ +selling only across all boards", investor.as_str() ))) } @@ -70,7 +71,7 @@ impl Analysis<'_> { policy: CachePolicy, ) -> Result, IdxError> { let top = check_top(query.top)?; - check_investor(query.investor)?; + check_scope(query.investor, query.board)?; let summary = self.idx .broker() @@ -87,7 +88,7 @@ impl Analysis<'_> { policy: CachePolicy, ) -> Result { check_top(query.top)?; - check_investor(query.investor)?; + check_scope(query.investor, query.board)?; let mut reports = Vec::new(); let mut failures = Vec::new(); for symbol in symbols { diff --git a/crates/idx-core/src/broker/mod.rs b/crates/idx-core/src/broker/mod.rs index 07aae3d..7ecc7e2 100644 --- a/crates/idx-core/src/broker/mod.rs +++ b/crates/idx-core/src/broker/mod.rs @@ -155,13 +155,27 @@ impl Broker<'_> { } } -/// The public summary of one parsed page. With one investor side selected, -/// the page's footer covers only part of the trades the lists show (foreign -/// buying, not foreign selling), so no totals are given; `F. NVal` ignores -/// both filters, so the foreign net goes with any filter. +/// The public summary of one parsed page. +/// +/// With one investor side selected, the page's footer (`T. Val`) covers only +/// that side's buying, so it is the side's `buy_value`, not a total of the +/// trades the lists show. `F. NVal`, the foreign net, ignores both filters: +/// on all boards it gives the side's selling (foreign buying less the foreign +/// net; domestic buying plus it), and with a board filter it is dropped. fn summary_from_raw(symbol: &str, query: &BrokerQuery, raw: BrokerSummaryRaw) -> BrokerSummary { let one_side = query.investor != BrokerInvestor::All; - let filtered = one_side || query.board != BrokerBoard::All; + let all_boards = query.board == BrokerBoard::All; + let foreign_net = raw.foreign_net_value.filter(|_| all_boards); + let buy_value = raw.total_value; + let sell_value = match query.investor { + BrokerInvestor::All => raw.total_value, + BrokerInvestor::Foreign => buy_value + .zip(foreign_net) + .and_then(|(buy, net)| buy.checked_sub(net)), + BrokerInvestor::Domestic => buy_value + .zip(foreign_net) + .and_then(|(buy, net)| buy.checked_add(net)), + }; let mut summary = BrokerSummary { symbol: symbol.to_string(), from: raw.from.unwrap_or(query.from), @@ -173,7 +187,9 @@ fn summary_from_raw(symbol: &str, query: &BrokerQuery, raw: BrokerSummaryRaw) -> total_value: raw.total_value.filter(|_| !one_side), total_lots: raw.total_lots.filter(|_| !one_side), average_price: raw.average_price.filter(|_| !one_side), - foreign_net_value: raw.foreign_net_value.filter(|_| !filtered), + buy_value, + sell_value, + foreign_net_value: foreign_net, rounded: raw.rounded, }; for flow in summary.buyers.iter_mut().chain(summary.sellers.iter_mut()) { @@ -228,7 +244,7 @@ mod tests { } #[test] - fn test_summary_from_raw_drops_totals_that_do_not_cover_the_lists() { + fn test_summary_from_raw_gives_each_side_its_own_totals() { let day = date(2026, 10, 16); let read = |name: &str| { let html = std::fs::read_to_string(format!("tests/fixtures/{name}")).unwrap(); @@ -243,26 +259,68 @@ mod tests { assert_eq!(all.foreign_net_value, Some(-2_300_000_000_000)); assert_eq!(all.sellers[0].name.as_deref(), Some("MANDIRI SEKURITAS")); - // Foreign, regular board: the footer's 1.6 T is less than the 2.0 T - // its top seller alone sold. - let foreign = BrokerQuery { - investor: BrokerInvestor::Foreign, - board: BrokerBoard::Regular, - ..query(day, day) + assert_eq!( + (all.buy_value, all.sell_value), + (all.total_value, all.total_value) + ); + + // Foreign on all boards: the footer's 1.7 T is foreign buying; with + // the -2.3 T foreign net, foreign selling is 4.0 T (IDX: about + // 3.95 T). Domestic buying 4.9 T makes up the rest of 6.6 T, and its + // selling is 2.6 T. + let side = |investor, board, name: &str| { + let query = BrokerQuery { + investor, + board, + ..query(day, day) + }; + summary_from_raw("BBCA.JK", &query, read(name)) }; + let foreign = side( + BrokerInvestor::Foreign, + BrokerBoard::All, + "ipot_brokersummary_bbca_20261016_foreign_all.html", + ); + assert_eq!( + (foreign.buy_value, foreign.sell_value), + (Some(1_700_000_000_000), Some(4_000_000_000_000)) + ); + assert_eq!(foreign.total_value, None); + assert_eq!(foreign.foreign_net_value, Some(-2_300_000_000_000)); + let domestic = side( + BrokerInvestor::Domestic, + BrokerBoard::All, + "ipot_brokersummary_bbca_20261016_domestic_all.html", + ); + assert_eq!( + (domestic.buy_value, domestic.sell_value), + (Some(4_900_000_000_000), Some(2_600_000_000_000)) + ); + + // Foreign, regular board: the footer's 1.6 T is regular foreign + // buying, less than the 2.0 T its top seller alone sold, and the net + // covers all boards, so selling cannot be derived. let raw = read("ipot_brokersummary_bbca_20261016_foreign_regular.html"); assert!(raw.total_value < raw.sellers[0].value); - let filtered = summary_from_raw("BBCA.JK", &foreign, raw); + let regular = side( + BrokerInvestor::Foreign, + BrokerBoard::Regular, + "ipot_brokersummary_bbca_20261016_foreign_regular.html", + ); assert_eq!( ( - filtered.total_value, - filtered.total_lots, - filtered.average_price + regular.total_value, + regular.total_lots, + regular.average_price ), (None, None, None) ); - assert_eq!(filtered.foreign_net_value, None); - assert_eq!(filtered.sellers[0].value, Some(2_000_000_000_000)); + assert_eq!( + (regular.buy_value, regular.sell_value), + (Some(1_600_000_000_000), None) + ); + assert_eq!(regular.foreign_net_value, None); + assert_eq!(regular.sellers[0].value, Some(2_000_000_000_000)); } #[test] diff --git a/crates/idx-core/src/broker/types.rs b/crates/idx-core/src/broker/types.rs index 5f8b313..8c19f6e 100644 --- a/crates/idx-core/src/broker/types.rs +++ b/crates/idx-core/src/broker/types.rs @@ -215,9 +215,18 @@ pub struct BrokerSummary { /// Average traded price, whole IDR; `None` when nothing traded, or as /// for `total_value`. pub average_price: Option, + /// Value the selected investors bought over the range on the selected + /// board, whole IDR: `total_value` for all investors, and for one side + /// the source's footer, which then covers that side's buying. + pub buy_value: Option, + /// Value the selected investors sold, whole IDR: `total_value` for all + /// investors; for one side on all boards, its buying less its net + /// (foreign net, or its negative for domestic investors). `None` for one + /// side on one board, where the source gives no net to derive it from. + pub sell_value: Option, /// Foreign buy value minus foreign sell value over the range, whole IDR, - /// across all boards. The source does not apply the investor or board - /// filter to it, so it is `None` for a filtered summary. + /// across all boards. The source does not apply the board filter to it, + /// so it is `None` for a summary of one board. pub foreign_net_value: Option, /// True when the source abbreviated at least one figure (`1.6 M` lots, /// `841.2 B` IDR), so those figures carry only one decimal of precision. diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_domestic_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_domestic_all.html new file mode 100644 index 0000000..c12918e --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_domestic_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
MG1.6 M841.2 B5,191 1MG1.5 M799.4 B5,238
XL1.1 M595.0 B5,229 2BB577,558 300.5 B5,203
CC841,738 439.2 B5,217 3CC448,606 233.8 B5,211
YP824,562 430.3 B5,218 4NI356,533 185.8 B5,212
SQ824,521 430.5 B5,221 5XL296,269 155.0 B5,232
PD587,559 306.6 B5,217 6YP264,188 138.5 B5,242
CP347,229 181.1 B5,216 7CP188,644 98.9 B5,245
XC336,997 176.1 B5,226 8YU166,603 86.6 B5,201
KK302,943 157.9 B5,211 9BQ142,119 74.7 B5,254
BQ245,981 128.6 B5,228 10KK133,633 70.0 B5,236
+
+ T. Val : 4.9 T + F. NVal : -2.3 T + T.Lot : 9.3 M + Avg : 5,216 +
+ +
+
+ + + diff --git a/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_all.html b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_all.html new file mode 100644 index 0000000..7f6adea --- /dev/null +++ b/crates/idx-core/tests/fixtures/ipot_brokersummary_bbca_20261016_foreign_all.html @@ -0,0 +1,181 @@ +
+ + +
+
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuyerB.LotB.ValB.Avg#SellerS.LotS.ValS.Avg
KZ1.0 M521.3 B5,198 1CC3.9 M2.0 T5,211
YU575,965 298.4 B5,181 2RX1.7 M908.2 B5,282
BK369,446 196.0 B5,304 3ZP474,808 247.1 B5,203
AK366,209 193.1 B5,274 4AK444,447 233.4 B5,251
ZP287,065 150.0 B5,226 5BK419,266 218.8 B5,219
CC198,140 104.4 B5,267 6YU252,383 131.3 B5,203
RX162,162 97.1 B5,987 7DX252,100 130.5 B5,175
BB123,966 64.8 B5,225 8KZ115,380 60.1 B5,212
TP70,621 36.6 B5,189 9BB109,509 56.9 B5,200
DX15,000 7.8 B5,181 10TP34,490 17.9 B5,195
+
+ T. Val : 1.7 T + F. NVal : -2.3 T + T.Lot : 3.2 M + Avg : 5,263 +
+ +
+
+ + + diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 493e63a..f858838 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -175,8 +175,11 @@ for one or more IDX tickers." by lots over a session or date range, each with lots (1 lot = 100 shares), value and average \ price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can \ appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the \ -broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means \ -figures of a million and more carry one decimal of precision." +broker's own status (local, foreign, state_owned), not its clients'. With `investor` foreign \ +or domestic, each row is that broker's foreign or domestic clients' trades, `total_value` is \ +null, and `buy_value`/`sell_value` give what that side bought and sold (`sell_value` needs \ +`board` all). `rounded` = true means figures of a million and more carry one decimal of \ +precision." )] async fn get_broker_summary(&self, params: Parameters) -> ToolResult { let BrokerParams { symbol, scope } = params.0; @@ -206,8 +209,9 @@ traded (`imbalance_bps`), with a `signal` from big_accumulation (>= +1500) throu (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. `investor` must be \ -`all`: the summary has no total for one investor side." +gross figures overstate their direction. `method` names the rules applied. With `investor` \ +foreign or domestic, shares are of that side's own buying (`buy_value`) and selling \ +(`sell_value`); that needs `board` all." )] async fn analyze_bandar(&self, params: Parameters) -> ToolResult { let BandarParams { diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 65be648..84d48b4 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -1,6 +1,6 @@ [ { - "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. `investor` must be `all`: the summary has no total for one investor side.", + "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. With `investor` foreign or domestic, shares are of that side's own buying (`buy_value`) and selling (`sell_value`); that needs `board` all.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -134,7 +134,7 @@ "name": "compare_stocks" }, { - "description": "Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means figures of a million and more carry one decimal of precision.", + "description": "Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. With `investor` foreign or domestic, each row is that broker's foreign or domestic clients' trades, `total_value` is null, and `buy_value`/`sell_value` give what that side bought and sold (`sell_value` needs `board` all). `rounded` = true means figures of a million and more carry one decimal of precision.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 43d9435..e5b2a72 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,10 +95,13 @@ WIB as the fallback. sides), plus range totals. `api/ipot/parse.rs` parses it purely. - Figures of a million and more arrive abbreviated (`1.6 M`); they are scaled in decimal and the summary's `rounded` flag says so. `F. NVal` ignores the - investor and board filters, so `foreign_net_value` is null for a filtered - summary. With `fd=F` or `fd=D` the footer covers only that side's buying - (BBCA 2026-10-16 foreign: `T. Val` 1.6 T while seller CC alone sold - 2.0 T), so totals are null for an investor-filtered summary. + investor and board filters, so `foreign_net_value` is null for a summary of + one board. With `fd=F` or `fd=D` the footer covers only that + side's buying (BBCA 2026-10-16: foreign 1.7 T and domestic 4.9 T of 6.6 T), + so `total_value` is null and the footer is `buy_value`. On all boards the + side's `sell_value` is its buying less the foreign net (foreign: 1.7 + 2.3 + = 4.0 T, against IDX's 3.95 T regular) or plus it (domestic); with a board + filter it is null. - An unknown code is a 200 with `Invalid stock code!` (`SYMBOLNOTFOUND`). The server also answers spurious nginx 404s for seconds at a time, so the client retries 404 and 5xx with backoff. A range without trades is an empty summary. @@ -106,8 +109,9 @@ WIB as the fallback. 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 + over one summary: for the top 1/3/5/10 per side, the buyers' share of the + value bought minus the sellers' share of the value sold (for all investors, + 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. diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 95216bb..524eb97 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -356,7 +356,7 @@ Examples: ### `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). Counts all investors (`--investor` must be `all`). A heuristic over end-of-day broker codes, not advice. +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). `--investor foreign|domestic` weighs that side's buyers against its buying and its sellers against its selling (needs `--board all`). A heuristic over end-of-day broker codes, not advice. Usage: idx stocks bandar [OPTIONS] ... diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index e5fab6a..a09a9e5 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -39,8 +39,10 @@ idx stocks broker BBCA --from 2026-10-12 --to 2026-10-16 --board regular Which brokers bought and sold a stock: the top 10 buyers and the top 10 sellers by lots, each with lots, value and average price, plus totals and the net foreign value. Without dates it shows the latest session with trades. -`--investor foreign|domestic` counts only one side's clients, and -`--board regular` leaves out negotiated block trades. +`--investor foreign|domestic` counts only one side's clients (each broker row +is then that broker's foreign or domestic clients' trades, and the footer +gives what that side bought and sold), and `--board regular` leaves out +negotiated block trades. The data comes from Indo Premier (IPOT) and is end of day: IDX hides broker codes during trading. Buyers and sellers are ranked separately on gross @@ -87,8 +89,11 @@ traded. The headline tier (`--top`, default 5) gives the signal: | -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 board flags as `broker` (all investors -only: the summary has no total for one side). It is a +each), and it takes the same date and filter flags as `broker`. With +`--investor foreign` (or `domestic`) it reads that side alone: the top buyers' +share of foreign buying against the top sellers' share of foreign selling. +That needs `--board all`, since IPOT gives one side's selling only across all +boards. 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`). diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 110371a..f6a0f9c 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -12,7 +12,7 @@ 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. `investor` must be `all`: the summary has no total for one investor side. +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. With `investor` foreign or domestic, shares are of that side's own buying (`buy_value`) and selling (`sell_value`); that needs `board` all. | Input | Type | Required | Description | | --- | --- | --- | --- | @@ -43,7 +43,7 @@ Compare fundamental reports (growth, valuation, risk) across IDX tickers. Ticker ## `get_broker_summary` -Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means figures of a million and more carry one decimal of precision. +Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. With `investor` foreign or domestic, each row is that broker's foreign or domestic clients' trades, `total_value` is null, and `buy_value`/`sell_value` give what that side bought and sold (`sell_value` needs `board` all). `rounded` = true means figures of a million and more carry one decimal of precision. | Input | Type | Required | Description | | --- | --- | --- | --- |