mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
ae7a1e2b5c
commit
970da26a0e
25 changed files with 1588 additions and 37 deletions
|
|
@ -115,10 +115,9 @@ pub struct AnalyzeParams {
|
|||
pub kinds: Option<Vec<AnalysisKind>>,
|
||||
}
|
||||
|
||||
/// Which sessions and trades a broker tool covers.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BrokerParams {
|
||||
/// IDX ticker, e.g. "BBCA".
|
||||
pub symbol: String,
|
||||
pub struct BrokerScope {
|
||||
/// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.
|
||||
#[serde(default)]
|
||||
pub date: Option<String>,
|
||||
|
|
@ -139,6 +138,25 @@ pub struct BrokerParams {
|
|||
pub board: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BrokerParams {
|
||||
/// IDX ticker, e.g. "BBCA".
|
||||
pub symbol: String,
|
||||
#[serde(flatten)]
|
||||
pub scope: BrokerScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BandarParams {
|
||||
/// IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20.
|
||||
pub symbols: Vec<String>,
|
||||
#[serde(flatten)]
|
||||
pub scope: BrokerScope,
|
||||
/// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5).
|
||||
#[serde(default)]
|
||||
pub top: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ShareholdersParams {
|
||||
/// IDX ticker, e.g. "BBCA".
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,80 @@
|
|||
[
|
||||
{
|
||||
"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.",
|
||||
"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"
|
||||
},
|
||||
"symbols": {
|
||||
"description": "IDX tickers, e.g. [\"BBCA\", \"BBRI\"]; the `.JK` suffix is optional. At most 20.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"to": {
|
||||
"default": null,
|
||||
"description": "Last session of the range, YYYY-MM-DD (default today).",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"top": {
|
||||
"default": null,
|
||||
"description": "Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5).",
|
||||
"format": "uint8",
|
||||
"maximum": 255,
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"symbols"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "analyze_bandar"
|
||||
},
|
||||
{
|
||||
"description": "Analyze one IDX ticker: technical signals (trend, RSI, MACD, moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail are listed under `errors`; the call fails only if every analysis fails.",
|
||||
"inputSchema": {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue