feat(cli,mcp): idx stocks broker and get_broker_summary

`idx stocks broker SYMBOL` shows the top 10 buyers and sellers of a stock
by lots for the latest session with trades, one `--date`, or a
`--from`/`--to` range, filtered by `--investor` and `--board`. The MCP
server exposes the same as get_broker_summary.

- Range and filter parsing lives in idx-core (BrokerRange::from_args,
  FromStr for the filters), so both frontends accept the same input.
- Without dates, the latest session is found by walking back over
  weekdays; offline, uncached days are skipped.
- Table output lists foreign and state-owned brokers, and says when the
  source rounded figures.

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:38:58 +00:00
commit 860d4daf77
25 changed files with 939 additions and 13 deletions

View file

@ -2,6 +2,7 @@
//! accepted values and error messages match the CLI; the schemas list them.
use idx_core::api::types::{Interval, Period};
use idx_core::broker::types::{BrokerBoard, BrokerInvestor};
use idx_core::market::{ScreenFilter, ScreenRegion};
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::Deserialize;
@ -114,6 +115,30 @@ pub struct AnalyzeParams {
pub kinds: Option<Vec<AnalysisKind>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BrokerParams {
/// IDX ticker, e.g. "BBCA".
pub symbol: String,
/// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.
#[serde(default)]
pub date: Option<String>,
/// First session of a range, YYYY-MM-DD (at most 366 days).
#[serde(default)]
pub from: Option<String>,
/// Last session of the range, YYYY-MM-DD (default today).
#[serde(default)]
pub to: Option<String>,
/// Whose trades to count, by investor domicile (default "all").
#[serde(default)]
#[schemars(schema_with = "investor_schema")]
pub investor: Option<String>,
/// Which board's trades to count (default "all"); "regular" leaves out
/// negotiated block trades.
#[serde(default)]
#[schemars(schema_with = "board_schema")]
pub board: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ShareholdersParams {
/// IDX ticker, e.g. "BBCA".
@ -178,3 +203,11 @@ fn filter_schema(_: &mut SchemaGenerator) -> Schema {
fn region_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(ScreenRegion::ALL.map(ScreenRegion::as_str).to_vec())
}
fn investor_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(BrokerInvestor::ALL.map(BrokerInvestor::as_str).to_vec())
}
fn board_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(BrokerBoard::ALL.map(BrokerBoard::as_str).to_vec())
}

View file

@ -5,6 +5,7 @@
use std::collections::BTreeMap;
use idx_core::analysis::fundamental::FundamentalReport;
use idx_core::broker::types::BrokerRange;
use idx_core::config::CoreConfig;
use idx_core::error::IdxError;
use idx_core::financial::{EarningsFilter, StatementFilter};
@ -21,8 +22,9 @@ use serde_json::{Map, Value, json};
use tokio::sync::Semaphore;
use crate::params::{
AnalysisKind, AnalyzeParams, ChangesParams, CompanyParams, CompanySection, EntityParams,
HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
AnalysisKind, AnalyzeParams, BrokerParams, ChangesParams, CompanyParams, CompanySection,
EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams,
SyncParams,
};
const SYNC_TOOL: &str = "ownership_sync";
@ -165,6 +167,42 @@ for one or more IDX tickers."
.await
}
#[tool(
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."
)]
async fn get_broker_summary(&self, params: Parameters<BrokerParams>) -> ToolResult {
let BrokerParams {
symbol,
date,
from,
to,
investor,
board,
} = params.0;
self.live(move |idx| {
let broker = idx.broker();
let range = BrokerRange::from_args(
date.as_deref(),
from.as_deref(),
to.as_deref(),
broker.today(),
)?;
broker.fetch(
&symbol,
range,
investor.as_deref().unwrap_or("all").parse()?,
board.as_deref().unwrap_or("all").parse()?,
CachePolicy::Default,
)
})
.await
}
#[tool(
description = "Company data for one IDX ticker: profile, financial statements, \
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \

View file

@ -57,6 +57,68 @@
},
"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.",
"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"
},
"symbol": {
"description": "IDX ticker, e.g. \"BBCA\".",
"type": "string"
},
"to": {
"default": null,
"description": "Last session of the range, YYYY-MM-DD (default today).",
"type": [
"string",
"null"
]
}
},
"required": [
"symbol"
],
"type": "object"
},
"name": "get_broker_summary"
},
{
"description": "Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. News items carry `relevance`: `company` (about this ticker) first, then `market` (general market news). Sections that fail are listed under `errors`; the call fails only if every section fails.",
"inputSchema": {

View file

@ -9,9 +9,10 @@ use idx_core::config::CoreConfig;
use idx_core::investor::{ImportSource, Investor};
use serde_json::{Value, json};
const TOOLS: [&str; 11] = [
const TOOLS: [&str; 12] = [
"analyze_stock",
"compare_stocks",
"get_broker_summary",
"get_company",
"get_investor_holdings",
"get_ownership_changes",
@ -247,6 +248,16 @@ fn live_tools_wrap_data_with_freshness() {
assert_fetched(&analysis[kind]);
}
let brokers = client.ok(
"get_broker_summary",
json!({"symbol": "BBCA", "date": "2026-10-16"}),
);
assert_fetched(&brokers);
assert_eq!(brokers["source"], "ipot");
assert_eq!(brokers["data"]["buyers"][0]["code"], "MG");
assert_eq!(brokers["data"]["sellers"][0]["name"], "MANDIRI SEKURITAS");
assert_eq!(brokers["data"]["foreign_net_value"], -2_300_000_000_000_i64);
let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]}));
assert_eq!(compared["reports"].as_array().unwrap().len(), 2);
assert_eq!(compared["failures"], json!([]));
@ -273,6 +284,14 @@ fn errors_carry_cli_codes() {
("compare_stocks", json!({"symbols": []})),
("get_company", json!({"symbol": "BBCA", "sections": []})),
("analyze_stock", json!({"symbol": "BBCA", "kinds": []})),
(
"get_broker_summary",
json!({"symbol": "BBCA", "date": "2026-10-16", "from": "2026-10-12"}),
),
(
"get_broker_summary",
json!({"symbol": "BBCA", "date": "2026-10-16", "board": "dark"}),
),
] {
let (is_error, body) = client.call(tool, arguments);
assert!(is_error, "{tool}: {body}");