feat: bandarmology (idx stocks bandar, analyze_bandar)

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

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

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

View file

@ -4,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<BrokerParams>) -> 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<BandarParams>) -> ToolResult {
let BandarParams {
scope,
symbols,
top,
} = params.0;
let symbols = match checked_symbols(symbols) {
Ok(symbols) => symbols,
Err(err) => return err,
};
self.live(move |idx| {
let query = BandarQuery {
range: BrokerRange::from_args(
scope.date.as_deref(),
scope.from.as_deref(),
scope.to.as_deref(),
idx.broker().today(),
)?,
investor: scope.investor.as_deref().unwrap_or("all").parse()?,
board: scope.board.as_deref().unwrap_or("all").parse()?,
top: top.unwrap_or(DEFAULT_TOP),
};
let screen = idx
.analysis()
.bandar_screen(&symbols, &query, CachePolicy::Default)?;
let reports: &[Fetched<BandarReport>] = &screen.reports;
Ok(json!({ "reports": reports, "failures": failures_json(&screen.failures) }))
})
.await
}
#[tool(
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<Value> = comparison
.failures
.iter()
.map(|(symbol, err)| {
let mut body = error_body(err);
body.insert("symbol".into(), json!(symbol));
Value::Object(body)
})
.collect();
let reports: &[Fetched<FundamentalReport>] = &comparison.reports;
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<Value> {
failures
.iter()
.map(|(symbol, err)| {
let mut body = error_body(err);
body.insert("symbol".into(), json!(symbol));
Value::Object(body)
})
.collect()
}
fn input_error(message: String) -> ToolResult {
let err = IdxError::InvalidInput(message);
Ok(CallToolResult::structured_error(Value::Object(error_body(