mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
7c8151a3a6
commit
860d4daf77
25 changed files with 939 additions and 13 deletions
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`: \
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue