feat: bandarmology (idx stocks bandar, analyze_bandar)

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

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

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

View file

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