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
|
|
@ -4,10 +4,13 @@ use clap::{Args, Subcommand};
|
||||||
|
|
||||||
use crate::config::{HistoryProviderKind, IdxConfig};
|
use crate::config::{HistoryProviderKind, IdxConfig};
|
||||||
use crate::output::{
|
use crate::output::{
|
||||||
render_broker_summary, render_compare, render_earnings, render_financials, render_fundamental,
|
render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
|
||||||
render_growth, render_history, render_insights, render_news, render_profile, render_quotes,
|
render_fundamental, render_growth, render_history, render_insights, render_news,
|
||||||
render_risk, render_screener, render_sentiment, render_technical, render_valuation,
|
render_profile, render_quotes, render_risk, render_screener, render_sentiment,
|
||||||
|
render_technical, render_valuation,
|
||||||
};
|
};
|
||||||
|
use idx_core::analysis::BandarQuery;
|
||||||
|
use idx_core::analysis::bandar::DEFAULT_TOP;
|
||||||
use idx_core::api::types::{Interval, Period};
|
use idx_core::api::types::{Interval, Period};
|
||||||
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
|
|
@ -17,6 +20,9 @@ use idx_core::{CachePolicy, Idx, runtime};
|
||||||
|
|
||||||
/// Splits comma-separated symbol arguments; `-` reads symbols from stdin,
|
/// Splits comma-separated symbol arguments; `-` reads symbols from stdin,
|
||||||
/// separated by whitespace or commas.
|
/// separated by whitespace or commas.
|
||||||
|
/// Most symbols one `bandar` screen fetches, one request each.
|
||||||
|
const MAX_BANDAR_SYMBOLS: usize = 100;
|
||||||
|
|
||||||
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
|
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
|
||||||
let mut symbols = Vec::new();
|
let mut symbols = Vec::new();
|
||||||
for arg in args {
|
for arg in args {
|
||||||
|
|
@ -195,6 +201,24 @@ does not show broker codes during trading. Without dates, the latest session wit
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
scope: BrokerScopeArgs,
|
scope: BrokerScopeArgs,
|
||||||
},
|
},
|
||||||
|
#[command(
|
||||||
|
about = "Bandarmology: is buying more concentrated than selling",
|
||||||
|
long_about = "Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value \
|
||||||
|
against the same number of sellers', as a share of value traded, with a signal from big \
|
||||||
|
accumulation to big distribution. Several symbols give a screen ranked by the headline tier \
|
||||||
|
(at most 100, one request each). A heuristic over end-of-day broker codes, not advice.",
|
||||||
|
after_help = "Examples:\n idx stocks bandar BBCA\n idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3\n idx stocks bandar BBCA,BBRI,BMRI,TLKM\n idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -"
|
||||||
|
)]
|
||||||
|
Bandar {
|
||||||
|
/// One or more symbols, comma-separated or space-separated; `-` reads them from stdin.
|
||||||
|
#[arg(required = true)]
|
||||||
|
symbols: Vec<String>,
|
||||||
|
#[command(flatten)]
|
||||||
|
scope: BrokerScopeArgs,
|
||||||
|
/// Tier behind the headline signal: 1, 3, 5 or 10 brokers per side.
|
||||||
|
#[arg(long, default_value_t = DEFAULT_TOP)]
|
||||||
|
top: u8,
|
||||||
|
},
|
||||||
#[command(about = "Get company profile")]
|
#[command(about = "Get company profile")]
|
||||||
Profile { symbol: String },
|
Profile { symbol: String },
|
||||||
#[command(
|
#[command(
|
||||||
|
|
@ -311,6 +335,31 @@ pub fn handle(
|
||||||
)?;
|
)?;
|
||||||
render_broker_summary(&summary.data, format)
|
render_broker_summary(&summary.data, format)
|
||||||
}
|
}
|
||||||
|
StocksSubcommand::Bandar {
|
||||||
|
symbols,
|
||||||
|
scope,
|
||||||
|
top,
|
||||||
|
} => {
|
||||||
|
let symbols = expand_symbols(symbols)?;
|
||||||
|
if symbols.len() > MAX_BANDAR_SYMBOLS {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"at most {MAX_BANDAR_SYMBOLS} symbols per bandar screen (got {})",
|
||||||
|
symbols.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let query = BandarQuery {
|
||||||
|
range: scope.range(&idx)?,
|
||||||
|
investor: scope.investor,
|
||||||
|
board: scope.board,
|
||||||
|
top: *top,
|
||||||
|
};
|
||||||
|
let screen = idx.analysis().bandar_screen(&symbols, &query, policy)?;
|
||||||
|
for (resolved, err) in screen.failures {
|
||||||
|
runtime::warn(format!("failed to read brokers for {resolved}: {err}"));
|
||||||
|
}
|
||||||
|
let reports: Vec<_> = screen.reports.into_iter().map(|r| r.data).collect();
|
||||||
|
render_bandar(&reports, symbols.len() > 1, format, no_color)
|
||||||
|
}
|
||||||
StocksSubcommand::Profile { symbol } => {
|
StocksSubcommand::Profile { symbol } => {
|
||||||
let profile = idx.financial().profile(symbol, policy)?;
|
let profile = idx.financial().profile(symbol, policy)?;
|
||||||
render_profile(&profile.data, format)
|
render_profile(&profile.data, format)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use std::io::IsTerminal;
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use idx_core::analysis::bandar::BandarReport;
|
||||||
use idx_core::analysis::fundamental::{
|
use idx_core::analysis::fundamental::{
|
||||||
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
|
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
|
||||||
};
|
};
|
||||||
|
|
@ -84,6 +85,21 @@ pub fn render_broker_summary(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One symbol gets the detailed table, several the ranked screen (even when
|
||||||
|
/// only one of them succeeded); JSON is always the array.
|
||||||
|
pub fn render_bandar(
|
||||||
|
reports: &[BandarReport],
|
||||||
|
screen: bool,
|
||||||
|
format: &OutputFormat,
|
||||||
|
no_color: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
match (format, reports) {
|
||||||
|
(OutputFormat::Table, [report]) if !screen => table::print_bandar_report(report, no_color),
|
||||||
|
(OutputFormat::Table, _) => table::print_bandar_screen(reports, no_color),
|
||||||
|
(OutputFormat::Json | OutputFormat::Jsonl, _) => json::print_json(reports),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_history(
|
pub fn render_history(
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
history: &[Ohlc],
|
history: &[Ohlc],
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ use crate::output::style::Paint;
|
||||||
use crate::output::text::{cell, painted, terminal_safe, truncate_chars};
|
use crate::output::text::{cell, painted, terminal_safe, truncate_chars};
|
||||||
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
|
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
|
||||||
|
|
||||||
|
use idx_core::analysis::bandar::{
|
||||||
|
ACCUMULATION_BPS, BIG_BPS, BandarReport, BandarSignal, METHOD as BANDAR_METHOD,
|
||||||
|
};
|
||||||
use idx_core::analysis::fundamental::{
|
use idx_core::analysis::fundamental::{
|
||||||
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
|
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
|
||||||
};
|
};
|
||||||
|
|
@ -1018,6 +1021,171 @@ pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_bps_pct(bps: Option<i64>) -> String {
|
||||||
|
bps.map(|bps| format!("{:+.1}%", bps as f64 / 100.0))
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_bps_share(bps: Option<i64>) -> String {
|
||||||
|
bps.map(|bps| format!("{:.1}%", bps as f64 / 100.0))
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_bandar_signal(signal: Option<BandarSignal>, no_color: bool) -> String {
|
||||||
|
let Some(signal) = signal else {
|
||||||
|
return "-".to_string();
|
||||||
|
};
|
||||||
|
let label = signal.as_str().replace('_', " ");
|
||||||
|
if no_color {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
match signal {
|
||||||
|
BandarSignal::BigAccumulation | BandarSignal::Accumulation => label.green(),
|
||||||
|
BandarSignal::BigDistribution | BandarSignal::Distribution => label.red(),
|
||||||
|
BandarSignal::Neutral => label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bandar_method_note(top: u8) -> String {
|
||||||
|
format!(
|
||||||
|
"Method {BANDAR_METHOD}: the top {top} buyers' value minus the top {top} sellers' value, \
|
||||||
|
as a share of value traded; {}% or more reads accumulation, {}% or more big (negative: \
|
||||||
|
distribution). Brokers trade for many clients, so this is a heuristic, not advice.",
|
||||||
|
ACCUMULATION_BPS / 100,
|
||||||
|
BIG_BPS / 100
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_bandar_report(report: &BandarReport, no_color: bool) -> Result<(), IdxError> {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(
|
||||||
|
"Bandar reading for {}, {}",
|
||||||
|
terminal_safe(&report.symbol),
|
||||||
|
broker_scope(report.from, report.to, report.investor, report.board)
|
||||||
|
)
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
if report.total_value.is_none_or(|value| value <= 0) {
|
||||||
|
println!("No trades.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"Signal (top {}): {}, {} of value",
|
||||||
|
report.top,
|
||||||
|
format_bandar_signal(report.signal, no_color),
|
||||||
|
format_bps_pct(report.imbalance_bps)
|
||||||
|
);
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec![
|
||||||
|
"TOP", "BUY", "SELL", "NET", "NET %", "BUY AVG", "SELL AVG", "SIGNAL",
|
||||||
|
]);
|
||||||
|
for tier in &report.tiers {
|
||||||
|
table.add_row(vec![
|
||||||
|
cell(tier.top),
|
||||||
|
cell(format_compact_option(tier.buy_value)),
|
||||||
|
cell(format_compact_option(tier.sell_value)),
|
||||||
|
cell(format_compact_option(tier.imbalance_value)),
|
||||||
|
cell(format_bps_pct(tier.imbalance_bps)),
|
||||||
|
cell(
|
||||||
|
tier.buy_average
|
||||||
|
.map(format_idr)
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
|
cell(
|
||||||
|
tier.sell_average
|
||||||
|
.map(format_idr)
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
|
painted(format_bandar_signal(tier.signal, no_color)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
let mut totals = format!("Total {} IDR", format_compact_option(report.total_value));
|
||||||
|
if let Some(average) = report.average_price {
|
||||||
|
totals.push_str(&format!(", average {}", format_idr(average)));
|
||||||
|
}
|
||||||
|
if let Some(net) = report.foreign_net_value {
|
||||||
|
totals.push_str(&format!(
|
||||||
|
"; foreign net {:+} ({} of value)",
|
||||||
|
format_compact(net),
|
||||||
|
format_bps_pct(report.foreign_net_bps)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
println!("{totals}.");
|
||||||
|
if !report.both_sides.is_empty() {
|
||||||
|
println!(
|
||||||
|
"On both sides: {}; their gross figures overstate which way they traded.",
|
||||||
|
terminal_safe(&report.both_sides.join(", "))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("{}", bandar_method_note(report.top));
|
||||||
|
if report.rounded {
|
||||||
|
println!("Note: the source rounds figures of a million and more to one decimal.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_bandar_screen(reports: &[BandarReport], no_color: bool) -> Result<(), IdxError> {
|
||||||
|
let top = reports.first().map(|report| report.top).unwrap_or_default();
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec![
|
||||||
|
"SYMBOL".to_string(),
|
||||||
|
"DATES".to_string(),
|
||||||
|
"SIGNAL".to_string(),
|
||||||
|
format!("TOP {top} NET %"),
|
||||||
|
"BUY SHARE".to_string(),
|
||||||
|
"SELL SHARE".to_string(),
|
||||||
|
"VALUE".to_string(),
|
||||||
|
"FOREIGN NET".to_string(),
|
||||||
|
]);
|
||||||
|
for report in reports {
|
||||||
|
let headline = report.tiers.iter().find(|tier| tier.top == report.top);
|
||||||
|
table.add_row(vec![
|
||||||
|
cell(&report.symbol),
|
||||||
|
cell(broker_scope(
|
||||||
|
report.from,
|
||||||
|
report.to,
|
||||||
|
BrokerInvestor::All,
|
||||||
|
BrokerBoard::All,
|
||||||
|
)),
|
||||||
|
painted(format_bandar_signal(report.signal, no_color)),
|
||||||
|
cell(format_bps_pct(report.imbalance_bps)),
|
||||||
|
cell(format_bps_share(
|
||||||
|
headline.and_then(|tier| tier.buy_share_bps),
|
||||||
|
)),
|
||||||
|
cell(format_bps_share(
|
||||||
|
headline.and_then(|tier| tier.sell_share_bps),
|
||||||
|
)),
|
||||||
|
cell(format_compact_option(report.total_value)),
|
||||||
|
cell(
|
||||||
|
report
|
||||||
|
.foreign_net_value
|
||||||
|
.map(|net| format!("{:+}", format_compact(net)))
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
if let Some(first) = reports.first()
|
||||||
|
&& (first.investor, first.board) != (BrokerInvestor::All, BrokerBoard::All)
|
||||||
|
{
|
||||||
|
println!(
|
||||||
|
"Counting {} investors on the {} board.",
|
||||||
|
first.investor.as_str(),
|
||||||
|
first.board.as_str()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("{}", bandar_method_note(top));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
|
|
|
||||||
|
|
@ -2605,3 +2605,120 @@ fn broker_summary_errors_follow_the_output_contract() {
|
||||||
]);
|
]);
|
||||||
run_error_json(&mut failing, "PROVIDERUNAVAILABLE");
|
run_error_json(&mut failing, "PROVIDERUNAVAILABLE");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bandar_reads_the_broker_summary_tiers() {
|
||||||
|
let reports = run_success_json(mock_bin("bandar-json").args([
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
"stocks",
|
||||||
|
"bandar",
|
||||||
|
"BBCA",
|
||||||
|
"--date",
|
||||||
|
"2026-10-16",
|
||||||
|
]));
|
||||||
|
let report = &reports[0];
|
||||||
|
assert_eq!(reports.as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(report["symbol"], "BBCA.JK");
|
||||||
|
assert_eq!(report["method"], "top_concentration_v1");
|
||||||
|
let tiers: Vec<i64> = report["tiers"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|tier| tier["top"].as_i64().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(tiers, [1, 3, 5, 10]);
|
||||||
|
// Top 5 bought 2,939.5B and sold 4,540.4B of 6.6T traded.
|
||||||
|
let top5 = &report["tiers"][2];
|
||||||
|
assert_eq!(top5["buy_value"], 2_939_500_000_000_i64);
|
||||||
|
assert_eq!(top5["sell_value"], 4_540_400_000_000_i64);
|
||||||
|
assert_eq!(top5["imbalance_bps"], -2426);
|
||||||
|
assert_eq!(report["top"], 5);
|
||||||
|
assert_eq!(report["imbalance_bps"], -2426);
|
||||||
|
assert_eq!(report["signal"], "big_distribution");
|
||||||
|
assert_eq!(
|
||||||
|
report["both_sides"],
|
||||||
|
serde_json::json!(["AK", "CC", "MG", "XL", "YU", "ZP"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bandar_screen_ranks_symbols_and_reports_failures_on_stderr() {
|
||||||
|
let output = mock_bin("bandar-screen")
|
||||||
|
.args([
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
"stocks",
|
||||||
|
"bandar",
|
||||||
|
"BBCA,ZZZZ",
|
||||||
|
"TLKM",
|
||||||
|
"BBRI",
|
||||||
|
"--date",
|
||||||
|
"2026-10-16",
|
||||||
|
"--top",
|
||||||
|
"3",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(output.status.success(), "{output:?}");
|
||||||
|
let reports: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||||
|
let ranked: Vec<(&str, i64)> = reports
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
(
|
||||||
|
r["symbol"].as_str().unwrap(),
|
||||||
|
r["imbalance_bps"].as_i64().unwrap(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(ranked.len(), 3);
|
||||||
|
assert!(
|
||||||
|
ranked.windows(2).all(|pair| pair[0].1 >= pair[1].1),
|
||||||
|
"{ranked:?}"
|
||||||
|
);
|
||||||
|
assert!(reports.as_array().unwrap().iter().all(|r| r["top"] == 3));
|
||||||
|
let warning: Value = serde_json::from_slice(&output.stderr).expect("one JSON warning line");
|
||||||
|
assert_eq!(warning["level"], "warning");
|
||||||
|
assert!(warning["message"].as_str().unwrap().contains("ZZZZ.JK"));
|
||||||
|
|
||||||
|
// A one-symbol screen that loses a symbol still prints the screen table.
|
||||||
|
let table = run_success_stdout(mock_bin("bandar-screen-table").args([
|
||||||
|
"--no-color",
|
||||||
|
"stocks",
|
||||||
|
"bandar",
|
||||||
|
"BBCA,ZZZZ",
|
||||||
|
"--date",
|
||||||
|
"2026-10-16",
|
||||||
|
]));
|
||||||
|
assert!(table.contains("TOP 5 NET %"), "{table}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bandar_errors_follow_the_output_contract() {
|
||||||
|
let many: Vec<String> = (0..101).map(|i| format!("S{i}")).collect();
|
||||||
|
for (args, code) in [
|
||||||
|
(
|
||||||
|
vec!["BBCA".to_string(), "--top".into(), "4".into()],
|
||||||
|
"INVALIDINPUT",
|
||||||
|
),
|
||||||
|
(vec![many.join(",")], "INVALIDINPUT"),
|
||||||
|
(
|
||||||
|
vec!["ZZZZ".to_string(), "--date".into(), "2026-10-16".into()],
|
||||||
|
"SYMBOLNOTFOUND",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
"BBCA,BBRI".to_string(),
|
||||||
|
"--date".into(),
|
||||||
|
"2999-01-01".into(),
|
||||||
|
],
|
||||||
|
"INVALIDINPUT",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut cmd = mock_bin("bandar-errors");
|
||||||
|
cmd.args(["-o", "json", "stocks", "bandar"]).args(&args);
|
||||||
|
run_error_json(&mut cmd, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,15 @@ args = ["stocks", "broker", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"
|
||||||
name = "stocks-broker-no-trades"
|
name = "stocks-broker-no-trades"
|
||||||
args = ["stocks", "broker", "BBCA", "--date", "2026-10-17"]
|
args = ["stocks", "broker", "BBCA", "--date", "2026-10-17"]
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stocks-bandar"
|
||||||
|
args = ["stocks", "bandar", "BBCA", "--date", "2026-10-16"]
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stocks-bandar-screen"
|
||||||
|
formats = ["table", "json", "jsonl"]
|
||||||
|
args = ["stocks", "bandar", "BBCA,BBRI,TLKM", "--date", "2026-10-16", "--top", "3"]
|
||||||
|
|
||||||
[[case]]
|
[[case]]
|
||||||
name = "error-broker-unknown-symbol"
|
name = "error-broker-unknown-symbol"
|
||||||
args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"]
|
args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"]
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,9 @@ _idx() {
|
||||||
idx__help__ownership__resolve,unresolved)
|
idx__help__ownership__resolve,unresolved)
|
||||||
cmd="idx__help__ownership__resolve__unresolved"
|
cmd="idx__help__ownership__resolve__unresolved"
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks,bandar)
|
||||||
|
cmd="idx__help__stocks__bandar"
|
||||||
|
;;
|
||||||
idx__help__stocks,broker)
|
idx__help__stocks,broker)
|
||||||
cmd="idx__help__stocks__broker"
|
cmd="idx__help__stocks__broker"
|
||||||
;;
|
;;
|
||||||
|
|
@ -339,6 +342,9 @@ _idx() {
|
||||||
idx__ownership__resolve__help,unresolved)
|
idx__ownership__resolve__help,unresolved)
|
||||||
cmd="idx__ownership__resolve__help__unresolved"
|
cmd="idx__ownership__resolve__help__unresolved"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks,bandar)
|
||||||
|
cmd="idx__stocks__bandar"
|
||||||
|
;;
|
||||||
idx__stocks,broker)
|
idx__stocks,broker)
|
||||||
cmd="idx__stocks__broker"
|
cmd="idx__stocks__broker"
|
||||||
;;
|
;;
|
||||||
|
|
@ -390,6 +396,9 @@ _idx() {
|
||||||
idx__stocks,valuation)
|
idx__stocks,valuation)
|
||||||
cmd="idx__stocks__valuation"
|
cmd="idx__stocks__valuation"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help,bandar)
|
||||||
|
cmd="idx__stocks__help__bandar"
|
||||||
|
;;
|
||||||
idx__stocks__help,broker)
|
idx__stocks__help,broker)
|
||||||
cmd="idx__stocks__help__broker"
|
cmd="idx__stocks__help__broker"
|
||||||
;;
|
;;
|
||||||
|
|
@ -1200,7 +1209,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__help__stocks)
|
idx__help__stocks)
|
||||||
opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare"
|
opts="quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1213,6 +1222,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks__bandar)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__help__stocks__broker)
|
idx__help__stocks__broker)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -2216,7 +2239,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__stocks)
|
idx__stocks)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare help"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2237,6 +2260,52 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__bandar)
|
||||||
|
opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOLS>..."
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--date)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--from)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--to)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--investor)
|
||||||
|
COMPREPLY=($(compgen -W "all foreign domestic" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--board)
|
||||||
|
COMPREPLY=($(compgen -W "all regular cash negotiated" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--top)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__stocks__broker)
|
idx__stocks__broker)
|
||||||
opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
|
opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
@ -2394,7 +2463,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__stocks__help)
|
idx__stocks__help)
|
||||||
opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare help"
|
opts="quote history technical growth valuation risk fundamental broker bandar profile financials earnings sentiment insights news screen compare help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2407,6 +2476,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help__bandar)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__stocks__help__broker)
|
idx__stocks__help__broker)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ Commands:
|
||||||
risk Run risk analysis on a stock
|
risk Run risk analysis on a stock
|
||||||
fundamental Run full fundamental analysis on a stock
|
fundamental Run full fundamental analysis on a stock
|
||||||
broker Which brokers bought and sold a stock
|
broker Which brokers bought and sold a stock
|
||||||
|
bandar Bandarmology: is buying more concentrated than selling
|
||||||
profile Get company profile
|
profile Get company profile
|
||||||
financials Get financial statements
|
financials Get financial statements
|
||||||
earnings Get earnings report
|
earnings Get earnings report
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
[{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1749,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1503,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1749,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1655,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1322,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3334,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true},{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1994,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1546,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1994,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1733,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-560,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true},{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2914,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}]
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
{"symbol":"BBRI.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1749,"tiers":[{"top":1,"buy_value":504300000000,"sell_value":1000000000000,"imbalance_value":-495700000000,"imbalance_bps":-1503,"buy_share_bps":1528,"sell_share_bps":3030,"buy_average":2709,"sell_average":2716,"signal":"big_distribution"},{"top":3,"buy_value":1262700000000,"sell_value":1839600000000,"imbalance_value":-576900000000,"imbalance_bps":-1749,"buy_share_bps":3826,"sell_share_bps":5574,"buy_average":2715,"sell_average":2705,"signal":"big_distribution"},{"top":5,"buy_value":1737100000000,"sell_value":2283000000000,"imbalance_value":-545900000000,"imbalance_bps":-1655,"buy_share_bps":5263,"sell_share_bps":6918,"buy_average":2711,"sell_average":2707,"signal":"big_distribution"},{"top":10,"buy_value":2337100000000,"sell_value":2773100000000,"imbalance_value":-436000000000,"imbalance_bps":-1322,"buy_share_bps":7082,"sell_share_bps":8403,"buy_average":2709,"sell_average":2708,"signal":"distribution"}],"total_value":3300000000000,"average_price":2708,"foreign_net_value":-1100000000000,"foreign_net_bps":-3334,"both_sides":["AK","CC","NI","XL","YP","YU"],"rounded":true}
|
||||||
|
{"symbol":"TLKM.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-1994,"tiers":[{"top":1,"buy_value":381600000000,"sell_value":721600000000,"imbalance_value":-340000000000,"imbalance_bps":-1546,"buy_share_bps":1734,"sell_share_bps":3280,"buy_average":1960,"sell_average":1933,"signal":"big_distribution"},{"top":3,"buy_value":949100000000,"sell_value":1387700000000,"imbalance_value":-438600000000,"imbalance_bps":-1994,"buy_share_bps":4314,"sell_share_bps":6307,"buy_average":1939,"sell_average":1931,"signal":"big_distribution"},{"top":5,"buy_value":1341400000000,"sell_value":1722500000000,"imbalance_value":-381100000000,"imbalance_bps":-1733,"buy_share_bps":6097,"sell_share_bps":7829,"buy_average":1938,"sell_average":1932,"signal":"big_distribution"},{"top":10,"buy_value":1869600000000,"sell_value":1992600000000,"imbalance_value":-123000000000,"imbalance_bps":-560,"buy_share_bps":8498,"sell_share_bps":9057,"buy_average":1932,"sell_average":1930,"signal":"distribution"}],"total_value":2200000000000,"average_price":1931,"foreign_net_value":-90200000000,"foreign_net_bps":-410,"both_sides":["AK","BK","CC","KZ","MG","XL","YP","YU","ZP"],"rounded":true}
|
||||||
|
{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":3,"signal":"big_distribution","imbalance_bps":-2914,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
┌─────────┬────────────┬──────────────────┬─────────────┬───────────┬────────────┬───────┬─────────────┐
|
||||||
|
│ SYMBOL ┆ DATES ┆ SIGNAL ┆ TOP 3 NET % ┆ BUY SHARE ┆ SELL SHARE ┆ VALUE ┆ FOREIGN NET │
|
||||||
|
╞═════════╪════════════╪══════════════════╪═════════════╪═══════════╪════════════╪═══════╪═════════════╡
|
||||||
|
│ BBRI.JK ┆ 2026-10-16 ┆ big distribution ┆ -17.5% ┆ 38.3% ┆ 55.7% ┆ 3.3T ┆ -1.1T │
|
||||||
|
├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ TLKM.JK ┆ 2026-10-16 ┆ big distribution ┆ -19.9% ┆ 43.1% ┆ 63.1% ┆ 2.2T ┆ -90.2B │
|
||||||
|
├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ BBCA.JK ┆ 2026-10-16 ┆ big distribution ┆ -29.1% ┆ 30.1% ┆ 59.2% ┆ 6.6T ┆ -2.3T │
|
||||||
|
└─────────┴────────────┴──────────────────┴─────────────┴───────────┴────────────┴───────┴─────────────┘
|
||||||
|
Method top_concentration_v1: the top 3 buyers' value minus the top 3 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice.
|
||||||
|
--- stderr
|
||||||
4
crates/idx-cli/tests/golden/stocks-bandar.json.golden
Normal file
4
crates/idx-cli/tests/golden/stocks-bandar.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
[{"symbol":"BBCA.JK","from":"2026-10-16","to":"2026-10-16","investor":"all","board":"all","method":"top_concentration_v1","top":5,"signal":"big_distribution","imbalance_bps":-2426,"tiers":[{"top":1,"buy_value":841200000000,"sell_value":2200000000000,"imbalance_value":-1358800000000,"imbalance_bps":-2059,"buy_share_bps":1274,"sell_share_bps":3333,"buy_average":5191,"sell_average":5211,"signal":"big_distribution"},{"top":3,"buy_value":1984700000000,"sell_value":3907600000000,"imbalance_value":-1922900000000,"imbalance_bps":-2914,"buy_share_bps":3007,"sell_share_bps":5920,"buy_average":5212,"sell_average":5233,"signal":"big_distribution"},{"top":5,"buy_value":2939500000000,"sell_value":4540400000000,"imbalance_value":-1600900000000,"imbalance_bps":-2426,"buy_share_bps":4453,"sell_share_bps":6879,"buy_average":5211,"sell_average":5229,"signal":"big_distribution"},{"top":10,"buy_value":4477100000000,"sell_value":5551800000000,"imbalance_value":-1074700000000,"imbalance_bps":-1629,"buy_share_bps":6783,"sell_share_bps":8411,"buy_average":5213,"sell_average":5228,"signal":"big_distribution"}],"total_value":6600000000000,"average_price":5228,"foreign_net_value":-2300000000000,"foreign_net_bps":-3485,"both_sides":["AK","CC","MG","XL","YU","ZP"],"rounded":true}]
|
||||||
|
--- stderr
|
||||||
20
crates/idx-cli/tests/golden/stocks-bandar.table.golden
Normal file
20
crates/idx-cli/tests/golden/stocks-bandar.table.golden
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Bandar reading for BBCA.JK, 2026-10-16
|
||||||
|
Signal (top 5): big distribution, -24.3% of value
|
||||||
|
┌─────┬────────┬──────┬───────┬────────┬─────────┬──────────┬──────────────────┐
|
||||||
|
│ TOP ┆ BUY ┆ SELL ┆ NET ┆ NET % ┆ BUY AVG ┆ SELL AVG ┆ SIGNAL │
|
||||||
|
╞═════╪════════╪══════╪═══════╪════════╪═════════╪══════════╪══════════════════╡
|
||||||
|
│ 1 ┆ 841.2B ┆ 2.2T ┆ -1.4T ┆ -20.6% ┆ 5,191 ┆ 5,211 ┆ big distribution │
|
||||||
|
├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 3 ┆ 2.0T ┆ 3.9T ┆ -1.9T ┆ -29.1% ┆ 5,212 ┆ 5,233 ┆ big distribution │
|
||||||
|
├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 5 ┆ 2.9T ┆ 4.5T ┆ -1.6T ┆ -24.3% ┆ 5,211 ┆ 5,229 ┆ big distribution │
|
||||||
|
├╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 10 ┆ 4.5T ┆ 5.6T ┆ -1.1T ┆ -16.3% ┆ 5,213 ┆ 5,228 ┆ big distribution │
|
||||||
|
└─────┴────────┴──────┴───────┴────────┴─────────┴──────────┴──────────────────┘
|
||||||
|
Total 6.6T IDR, average 5,228; foreign net -2.3T (-34.9% of value).
|
||||||
|
On both sides: AK, CC, MG, XL, YU, ZP; their gross figures overstate which way they traded.
|
||||||
|
Method top_concentration_v1: the top 5 buyers' value minus the top 5 sellers' value, as a share of value traded; 5% or more reads accumulation, 15% or more big (negative: distribution). Brokers trade for many clients, so this is a heuristic, not advice.
|
||||||
|
Note: the source rounds figures of a million and more to one decimal.
|
||||||
|
--- stderr
|
||||||
360
crates/idx-core/src/analysis/bandar.rs
Normal file
360
crates/idx-core/src/analysis/bandar.rs
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
//! Bandarmology over a broker summary: is buying more concentrated in a few
|
||||||
|
//! brokers than selling? No network; see docs/ARCHITECTURE.md.
|
||||||
|
//!
|
||||||
|
//! The summary lists the top 10 buyers and the top 10 sellers by gross lots.
|
||||||
|
//! For N in 1, 3, 5 and 10 the top N buyers' value is set against the top N
|
||||||
|
//! sellers' value, as a share of the value traded:
|
||||||
|
//!
|
||||||
|
//! | top-N buy - sell value, % of value | signal |
|
||||||
|
//! |------------------------------------|--------------------|
|
||||||
|
//! | >= +15 | `big_accumulation` |
|
||||||
|
//! | +5 to +15 | `accumulation` |
|
||||||
|
//! | within +-5 | `neutral` |
|
||||||
|
//! | -15 to -5 | `distribution` |
|
||||||
|
//! | <= -15 | `big_distribution` |
|
||||||
|
//!
|
||||||
|
//! The thresholds follow fzn0x/idx (MIT), which set them against the 40 most
|
||||||
|
//! traded IDX stocks in October 2026. They are heuristics: a broker code is
|
||||||
|
//! not an investor, and gross figures overstate a broker that both bought
|
||||||
|
//! and sold (listed in `both_sides`).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerSummary};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
/// Names the rules below; bumped when they change.
|
||||||
|
pub const METHOD: &str = "top_concentration_v1";
|
||||||
|
/// Tiers every report carries.
|
||||||
|
pub const TIERS: [u8; 4] = [1, 3, 5, 10];
|
||||||
|
/// Tier behind the headline signal unless asked otherwise.
|
||||||
|
pub const DEFAULT_TOP: u8 = 5;
|
||||||
|
/// Imbalance, in basis points of value traded, from which a tier reads
|
||||||
|
/// accumulation (or, negative, distribution).
|
||||||
|
pub const ACCUMULATION_BPS: i64 = 500;
|
||||||
|
/// Imbalance from which the reading is big.
|
||||||
|
pub const BIG_BPS: i64 = 1500;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BandarSignal {
|
||||||
|
BigAccumulation,
|
||||||
|
Accumulation,
|
||||||
|
Neutral,
|
||||||
|
Distribution,
|
||||||
|
BigDistribution,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BandarSignal {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::BigAccumulation => "big_accumulation",
|
||||||
|
Self::Accumulation => "accumulation",
|
||||||
|
Self::Neutral => "neutral",
|
||||||
|
Self::Distribution => "distribution",
|
||||||
|
Self::BigDistribution => "big_distribution",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_imbalance_bps(bps: i64) -> Self {
|
||||||
|
match bps {
|
||||||
|
b if b >= BIG_BPS => Self::BigAccumulation,
|
||||||
|
b if b >= ACCUMULATION_BPS => Self::Accumulation,
|
||||||
|
b if b <= -BIG_BPS => Self::BigDistribution,
|
||||||
|
b if b <= -ACCUMULATION_BPS => Self::Distribution,
|
||||||
|
_ => Self::Neutral,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The top N buyers against the top N sellers. Every figure is `None` when
|
||||||
|
/// a broker in the tier lacks it, or when nothing traded.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BandarTier {
|
||||||
|
pub top: u8,
|
||||||
|
/// Value the top N buyers bought, whole IDR.
|
||||||
|
pub buy_value: Option<i64>,
|
||||||
|
/// Value the top N sellers sold, whole IDR.
|
||||||
|
pub sell_value: Option<i64>,
|
||||||
|
/// `buy_value - sell_value`, whole IDR.
|
||||||
|
pub imbalance_value: Option<i64>,
|
||||||
|
/// `imbalance_value` in basis points of the value traded.
|
||||||
|
pub imbalance_bps: Option<i64>,
|
||||||
|
/// The top N buyers' share of the value traded, basis points.
|
||||||
|
pub buy_share_bps: Option<i64>,
|
||||||
|
/// The top N sellers' share of the value traded, basis points.
|
||||||
|
pub sell_share_bps: Option<i64>,
|
||||||
|
/// Volume-weighted average price the top N buyers paid, whole IDR.
|
||||||
|
pub buy_average: Option<i64>,
|
||||||
|
/// Volume-weighted average price the top N sellers got, whole IDR.
|
||||||
|
pub sell_average: Option<i64>,
|
||||||
|
pub signal: Option<BandarSignal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BandarReport {
|
||||||
|
pub symbol: String,
|
||||||
|
pub from: NaiveDate,
|
||||||
|
pub to: NaiveDate,
|
||||||
|
pub investor: BrokerInvestor,
|
||||||
|
pub board: BrokerBoard,
|
||||||
|
/// The rules applied, e.g. `top_concentration_v1`.
|
||||||
|
pub method: String,
|
||||||
|
/// Tier behind the headline `signal`.
|
||||||
|
pub top: u8,
|
||||||
|
/// The headline tier's signal; `None` when nothing traded or a figure
|
||||||
|
/// is missing.
|
||||||
|
pub signal: Option<BandarSignal>,
|
||||||
|
/// The headline tier's imbalance, basis points of value traded.
|
||||||
|
pub imbalance_bps: Option<i64>,
|
||||||
|
pub tiers: Vec<BandarTier>,
|
||||||
|
/// Value traded over the range, whole IDR.
|
||||||
|
pub total_value: Option<i64>,
|
||||||
|
/// Average traded price, whole IDR.
|
||||||
|
pub average_price: Option<i64>,
|
||||||
|
/// Foreign net value, whole IDR; `None` for a filtered summary.
|
||||||
|
pub foreign_net_value: Option<i64>,
|
||||||
|
/// `foreign_net_value` in basis points of the value traded.
|
||||||
|
pub foreign_net_bps: Option<i64>,
|
||||||
|
/// Brokers among the top 10 on both sides, whose gross figures overstate
|
||||||
|
/// which way they traded.
|
||||||
|
pub both_sides: Vec<String>,
|
||||||
|
/// The source rounded figures of a million and more to one decimal.
|
||||||
|
pub rounded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The headline tier must be one the report carries.
|
||||||
|
pub fn check_top(top: u8) -> Result<u8, IdxError> {
|
||||||
|
if TIERS.contains(&top) {
|
||||||
|
Ok(top)
|
||||||
|
} else {
|
||||||
|
Err(IdxError::InvalidInput(format!(
|
||||||
|
"top must be 1, 3, 5 or 10 (got {top})"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `part` in basis points of `whole`; `None` without a positive whole.
|
||||||
|
fn bps(part: i64, whole: Option<i64>) -> Option<i64> {
|
||||||
|
let whole = whole.filter(|whole| *whole > 0)?;
|
||||||
|
i64::try_from((i128::from(part) * 10_000).div_euclid(i128::from(whole))).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum of `field` over `flows`; `None` when any flow lacks it.
|
||||||
|
fn sum(flows: &[BrokerFlow], field: fn(&BrokerFlow) -> Option<i64>) -> Option<i64> {
|
||||||
|
flows
|
||||||
|
.iter()
|
||||||
|
.try_fold(0_i64, |total, flow| total.checked_add(field(flow)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Value over shares, the shares taken from each broker's value and its
|
||||||
|
/// exact published average (lots of a million and more are rounded,
|
||||||
|
/// averages never are).
|
||||||
|
fn vwap(flows: &[BrokerFlow]) -> Option<i64> {
|
||||||
|
let (mut value, mut shares) = (0.0_f64, 0.0_f64);
|
||||||
|
for flow in flows {
|
||||||
|
let (flow_value, average) = (flow.value?, flow.average_price?);
|
||||||
|
if average <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
value += flow_value as f64;
|
||||||
|
shares += flow_value as f64 / average as f64;
|
||||||
|
}
|
||||||
|
(shares > 0.0).then(|| (value / shares).round() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bandar_tier(summary: &BrokerSummary, top: u8) -> BandarTier {
|
||||||
|
let n = usize::from(top);
|
||||||
|
let buyers = &summary.buyers[..n.min(summary.buyers.len())];
|
||||||
|
let sellers = &summary.sellers[..n.min(summary.sellers.len())];
|
||||||
|
let buy_value = sum(buyers, |flow| flow.value);
|
||||||
|
let sell_value = sum(sellers, |flow| flow.value);
|
||||||
|
let imbalance_value = buy_value
|
||||||
|
.zip(sell_value)
|
||||||
|
.and_then(|(buy, sell)| buy.checked_sub(sell));
|
||||||
|
let imbalance_bps = imbalance_value.and_then(|value| bps(value, summary.total_value));
|
||||||
|
BandarTier {
|
||||||
|
top,
|
||||||
|
buy_value,
|
||||||
|
sell_value,
|
||||||
|
imbalance_value,
|
||||||
|
imbalance_bps,
|
||||||
|
buy_share_bps: buy_value.and_then(|value| bps(value, summary.total_value)),
|
||||||
|
sell_share_bps: sell_value.and_then(|value| bps(value, summary.total_value)),
|
||||||
|
buy_average: vwap(buyers),
|
||||||
|
sell_average: vwap(sellers),
|
||||||
|
signal: imbalance_bps.map(BandarSignal::from_imbalance_bps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full reading of one summary, headlined by the top `top` tier (one of
|
||||||
|
/// [`TIERS`]; see [`check_top`]).
|
||||||
|
pub fn analyze_bandar(summary: &BrokerSummary, top: u8) -> BandarReport {
|
||||||
|
let tiers: Vec<BandarTier> = TIERS.iter().map(|n| bandar_tier(summary, *n)).collect();
|
||||||
|
let headline = tiers
|
||||||
|
.iter()
|
||||||
|
.find(|tier| tier.top == top)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| bandar_tier(summary, top));
|
||||||
|
let mut both_sides: Vec<String> = summary
|
||||||
|
.buyers
|
||||||
|
.iter()
|
||||||
|
.filter(|buyer| {
|
||||||
|
summary
|
||||||
|
.sellers
|
||||||
|
.iter()
|
||||||
|
.any(|seller| seller.code == buyer.code)
|
||||||
|
})
|
||||||
|
.map(|buyer| buyer.code.clone())
|
||||||
|
.collect();
|
||||||
|
both_sides.sort_unstable();
|
||||||
|
BandarReport {
|
||||||
|
symbol: summary.symbol.clone(),
|
||||||
|
from: summary.from,
|
||||||
|
to: summary.to,
|
||||||
|
investor: summary.investor,
|
||||||
|
board: summary.board,
|
||||||
|
method: METHOD.to_string(),
|
||||||
|
top,
|
||||||
|
signal: headline.signal,
|
||||||
|
imbalance_bps: headline.imbalance_bps,
|
||||||
|
tiers,
|
||||||
|
total_value: summary.total_value,
|
||||||
|
average_price: summary.average_price,
|
||||||
|
foreign_net_value: summary.foreign_net_value,
|
||||||
|
foreign_net_bps: summary
|
||||||
|
.foreign_net_value
|
||||||
|
.and_then(|value| bps(value, summary.total_value)),
|
||||||
|
both_sides,
|
||||||
|
rounded: summary.rounded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn flow(code: &str, value: Option<i64>, average: i64) -> BrokerFlow {
|
||||||
|
BrokerFlow {
|
||||||
|
rank: 0,
|
||||||
|
code: code.to_string(),
|
||||||
|
name: None,
|
||||||
|
kind: None,
|
||||||
|
lots: value.map(|value| value / average / 100),
|
||||||
|
value,
|
||||||
|
average_price: Some(average),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summary(buyers: Vec<BrokerFlow>, sellers: Vec<BrokerFlow>, total: i64) -> BrokerSummary {
|
||||||
|
let day = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap();
|
||||||
|
BrokerSummary {
|
||||||
|
symbol: "TEST.JK".to_string(),
|
||||||
|
from: day,
|
||||||
|
to: day,
|
||||||
|
investor: BrokerInvestor::All,
|
||||||
|
board: BrokerBoard::All,
|
||||||
|
buyers,
|
||||||
|
sellers,
|
||||||
|
total_value: Some(total),
|
||||||
|
total_lots: None,
|
||||||
|
average_price: None,
|
||||||
|
foreign_net_value: Some(-total / 10),
|
||||||
|
rounded: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bandar_signal_thresholds_are_inclusive() {
|
||||||
|
use BandarSignal::*;
|
||||||
|
for (bps, signal) in [
|
||||||
|
(1500, BigAccumulation),
|
||||||
|
(1499, Accumulation),
|
||||||
|
(500, Accumulation),
|
||||||
|
(499, Neutral),
|
||||||
|
(-499, Neutral),
|
||||||
|
(-500, Distribution),
|
||||||
|
(-1499, Distribution),
|
||||||
|
(-1500, BigDistribution),
|
||||||
|
] {
|
||||||
|
assert_eq!(BandarSignal::from_imbalance_bps(bps), signal, "{bps}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_bandar_weighs_top_buyers_against_top_sellers() {
|
||||||
|
let s = summary(
|
||||||
|
vec![
|
||||||
|
flow("AA", Some(400), 100),
|
||||||
|
flow("BB", Some(200), 100),
|
||||||
|
flow("CC", Some(100), 50),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
flow("DD", Some(300), 110),
|
||||||
|
flow("AA", Some(100), 110),
|
||||||
|
flow("EE", Some(50), 110),
|
||||||
|
],
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
let report = analyze_bandar(&s, 3);
|
||||||
|
let top1 = &report.tiers[0];
|
||||||
|
assert_eq!((top1.top, top1.imbalance_value), (1, Some(100)));
|
||||||
|
assert_eq!(top1.imbalance_bps, Some(1000));
|
||||||
|
assert_eq!(top1.signal, Some(BandarSignal::Accumulation));
|
||||||
|
|
||||||
|
let top3 = &report.tiers[1];
|
||||||
|
assert_eq!((top3.buy_value, top3.sell_value), (Some(700), Some(450)));
|
||||||
|
assert_eq!(
|
||||||
|
(top3.buy_share_bps, top3.sell_share_bps),
|
||||||
|
(Some(7000), Some(4500))
|
||||||
|
);
|
||||||
|
assert_eq!(top3.imbalance_bps, Some(2500));
|
||||||
|
// 700 IDR over 4 + 2 + 2 shares.
|
||||||
|
assert_eq!(top3.buy_average, Some(88));
|
||||||
|
assert_eq!(top3.sell_average, Some(110));
|
||||||
|
|
||||||
|
// Short lists: every broker on a side is listed, so the sums hold.
|
||||||
|
assert_eq!(report.tiers[3].imbalance_value, Some(250));
|
||||||
|
assert_eq!(
|
||||||
|
(report.top, report.signal),
|
||||||
|
(3, Some(BandarSignal::BigAccumulation))
|
||||||
|
);
|
||||||
|
assert_eq!(report.imbalance_bps, Some(2500));
|
||||||
|
assert_eq!(report.foreign_net_bps, Some(-1000));
|
||||||
|
assert_eq!(report.both_sides, vec!["AA".to_string()]);
|
||||||
|
assert_eq!(report.method, METHOD);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_bandar_never_counts_a_missing_figure_as_zero() {
|
||||||
|
let s = summary(
|
||||||
|
vec![flow("AA", Some(400), 100), flow("BB", None, 100)],
|
||||||
|
vec![flow("DD", Some(300), 100)],
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
let report = analyze_bandar(&s, 3);
|
||||||
|
assert_eq!(report.tiers[0].imbalance_bps, Some(1000));
|
||||||
|
let top3 = &report.tiers[1];
|
||||||
|
assert_eq!(
|
||||||
|
(top3.buy_value, top3.imbalance_bps, top3.signal),
|
||||||
|
(None, None, None)
|
||||||
|
);
|
||||||
|
assert_eq!(top3.buy_average, None);
|
||||||
|
assert_eq!(report.signal, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_bandar_without_trades_has_no_signal() {
|
||||||
|
let report = analyze_bandar(&summary(vec![], vec![], 0), DEFAULT_TOP);
|
||||||
|
assert!(report.tiers.iter().all(|tier| tier.signal.is_none()));
|
||||||
|
assert_eq!((report.signal, report.imbalance_bps), (None, None));
|
||||||
|
assert_eq!(report.tiers[2].buy_value, Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_top_allows_only_report_tiers() {
|
||||||
|
assert_eq!(check_top(5).unwrap(), 5);
|
||||||
|
assert!(matches!(check_top(4), Err(IdxError::InvalidInput(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod bandar;
|
||||||
pub mod fundamental;
|
pub mod fundamental;
|
||||||
pub mod report;
|
pub mod report;
|
||||||
pub mod signals;
|
pub mod signals;
|
||||||
|
|
@ -8,9 +9,11 @@ use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
use crate::api::history_provider;
|
use crate::api::history_provider;
|
||||||
use crate::api::types::{Fundamentals, Interval, Period};
|
use crate::api::types::{Fundamentals, Interval, Period};
|
||||||
|
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use crate::market::HistorySource;
|
use crate::market::HistorySource;
|
||||||
|
use bandar::{BandarReport, analyze_bandar, check_top};
|
||||||
use fundamental::{
|
use fundamental::{
|
||||||
FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental,
|
FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental,
|
||||||
analyze_growth, analyze_risk, analyze_valuation,
|
analyze_growth, analyze_risk, analyze_valuation,
|
||||||
|
|
@ -28,7 +31,69 @@ pub struct Comparison {
|
||||||
pub failures: Vec<(String, IdxError)>,
|
pub failures: Vec<(String, IdxError)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bandar readings for several symbols, strongest accumulation first;
|
||||||
|
/// symbols that failed are listed with their error.
|
||||||
|
pub struct BandarScreen {
|
||||||
|
pub reports: Vec<Fetched<BandarReport>>,
|
||||||
|
pub failures: Vec<(String, IdxError)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sessions and trades a bandar reading covers.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct BandarQuery {
|
||||||
|
pub range: BrokerRange,
|
||||||
|
pub investor: BrokerInvestor,
|
||||||
|
pub board: BrokerBoard,
|
||||||
|
/// Headline tier, one of `bandar::TIERS`.
|
||||||
|
pub top: u8,
|
||||||
|
}
|
||||||
|
|
||||||
impl Analysis<'_> {
|
impl Analysis<'_> {
|
||||||
|
/// Bandarmology over the broker summary `query` selects.
|
||||||
|
pub fn bandar(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
query: &BandarQuery,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<BandarReport>, IdxError> {
|
||||||
|
let top = check_top(query.top)?;
|
||||||
|
let summary =
|
||||||
|
self.idx
|
||||||
|
.broker()
|
||||||
|
.fetch(symbol, query.range, query.investor, query.board, policy)?;
|
||||||
|
Ok(summary.map(|summary| analyze_bandar(&summary, top)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::bandar`] for each symbol, ranked by the headline imbalance.
|
||||||
|
/// Invalid input fails the whole screen; other errors fail one symbol.
|
||||||
|
pub fn bandar_screen<S: AsRef<str>>(
|
||||||
|
&self,
|
||||||
|
symbols: &[S],
|
||||||
|
query: &BandarQuery,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<BandarScreen, IdxError> {
|
||||||
|
check_top(query.top)?;
|
||||||
|
let mut reports = Vec::new();
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for symbol in symbols {
|
||||||
|
let resolved = self.idx.resolve(symbol.as_ref())?;
|
||||||
|
match self.bandar(&resolved, query, policy) {
|
||||||
|
Ok(report) => reports.push(report),
|
||||||
|
Err(err @ IdxError::InvalidInput(_)) => return Err(err),
|
||||||
|
Err(err) => failures.push((resolved, err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reports.is_empty() {
|
||||||
|
return Err(failures
|
||||||
|
.pop()
|
||||||
|
.map(|(_, err)| err)
|
||||||
|
.unwrap_or_else(|| IdxError::InvalidInput("no symbols to screen".to_string())));
|
||||||
|
}
|
||||||
|
// Strongest accumulation first; readings without an imbalance last.
|
||||||
|
reports.sort_by_key(|report| std::cmp::Reverse(report.data.imbalance_bps));
|
||||||
|
Ok(BandarScreen { reports, failures })
|
||||||
|
}
|
||||||
|
|
||||||
/// Technical analysis over one year of daily history from `source`
|
/// Technical analysis over one year of daily history from `source`
|
||||||
/// (see `Market::history_source`).
|
/// (see `Market::history_source`).
|
||||||
pub fn technical(
|
pub fn technical(
|
||||||
|
|
|
||||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_bbri_20261016.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<div id="modContentBrokerSum"></div>
|
||||||
|
|
||||||
|
<!--DATA BROKER SUMMARY-->
|
||||||
|
<div class="pa5">
|
||||||
|
<div class="row mincol-3">
|
||||||
|
<div class="col col-xs-5 col-sm-3">
|
||||||
|
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||||
|
</div>
|
||||||
|
<div class="col col-xs-5 col-sm-3">
|
||||||
|
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||||
|
</div>
|
||||||
|
<div class="mt15 visible-xs"> </div>
|
||||||
|
<div class="col col-xs-5 col-sm-3">
|
||||||
|
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="F" >Foreign</option>
|
||||||
|
<option value="D" >Domestic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col col-xs-5 col-sm-2">
|
||||||
|
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||||
|
<option value="all">All Trade</option>
|
||||||
|
<option value="RG" >Regular</option>
|
||||||
|
<option value="TN" >Tunai</option>
|
||||||
|
<option value="NG" >Nego</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col col-xs-2 col-sm-1">
|
||||||
|
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th align="center">Buyer</th>
|
||||||
|
<th align="right">B.Lot</th>
|
||||||
|
<th align="right">B.Val</th>
|
||||||
|
<th align="right">B.Avg</th>
|
||||||
|
<th align="center">#</th>
|
||||||
|
<th align="center">Seller</th>
|
||||||
|
<th align="right">S.Lot</th>
|
||||||
|
<th align="right">S.Val</th>
|
||||||
|
<th align="right">S.Avg</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-bumn">CC</span></td>
|
||||||
|
<td align="right"><span class="text-up">1.9 M</span></td>
|
||||||
|
<td align="right"><span class="text-up">504.3 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">2,709 </span></td>
|
||||||
|
<td align="center">1</td>
|
||||||
|
<td align="center"><span class="text-foreign">AK</span></td>
|
||||||
|
<td align="right"><span class="text-down">3.7 M</span></td>
|
||||||
|
<td align="right"><span class="text-down">1.0 T</span></td>
|
||||||
|
<td align="right"><span class="text-down">2,716 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-local">XL</span></td>
|
||||||
|
<td align="right"><span class="text-up">1.6 M</span></td>
|
||||||
|
<td align="right"><span class="text-up">436.0 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">2,720 </span></td>
|
||||||
|
<td align="center">2</td>
|
||||||
|
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||||
|
<td align="right"><span class="text-down">1.8 M</span></td>
|
||||||
|
<td align="right"><span class="text-down">481.4 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">2,672 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-foreign">AK</span></td>
|
||||||
|
<td align="right"><span class="text-up">1.2 M</span></td>
|
||||||
|
<td align="right"><span class="text-up">322.4 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">2,719 </span></td>
|
||||||
|
<td align="center">3</td>
|
||||||
|
<td align="center"><span class="text-bumn">CC</span></td>
|
||||||
|
<td align="right"><span class="text-down">1.3 M</span></td>
|
||||||
|
<td align="right"><span class="text-down">358.2 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">2,717 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-foreign">YP</span></td>
|
||||||
|
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||||
|
<td align="right"><span class="text-up">281.3 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">2,694 </span></td>
|
||||||
|
<td align="center">4</td>
|
||||||
|
<td align="center"><span class="text-foreign">BK</span></td>
|
||||||
|
<td align="right"><span class="text-down">933,299 </span></td>
|
||||||
|
<td align="right"><span class="text-down">253.5 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">2,716 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-local">SQ</span></td>
|
||||||
|
<td align="right"><span class="text-up">712,722 </span></td>
|
||||||
|
<td align="right"><span class="text-up">193.1 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">2,709 </span></td>
|
||||||
|
<td align="center">5</td>
|
||||||
|
<td align="center"><span class="text-local">XL</span></td>
|
||||||
|
<td align="right"><span class="text-down">697,845 </span></td>
|
||||||
|
<td align="right"><span class="text-down">189.9 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">2,721 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-bumn">OD</span></td>
|
||||||
|
<td align="right"><span class="text-up">518,938 </span></td>
|
||||||
|
<td align="right"><span class="text-up">140.6 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">2,709 </span></td>
|
||||||
|
<td align="center">6</td>
|
||||||
|
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||||
|
<td align="right"><span class="text-down">477,399 </span></td>
|
||||||
|
<td align="right"><span class="text-down">129.4 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">2,711 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-bumn">NI</span></td>
|
||||||
|
<td align="right"><span class="text-up">496,800 </span></td>
|
||||||
|
<td align="right"><span class="text-up">133.9 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">2,695 </span></td>
|
||||||
|
<td align="center">7</td>
|
||||||
|
<td align="center"><span class="text-foreign">YU</span></td>
|
||||||
|
<td align="right"><span class="text-down">447,840 </span></td>
|
||||||
|
<td align="right"><span class="text-down">120.9 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">2,700 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-foreign">YU</span></td>
|
||||||
|
<td align="right"><span class="text-up">420,264 </span></td>
|
||||||
|
<td align="right"><span class="text-up">112.3 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">2,672 </span></td>
|
||||||
|
<td align="center">8</td>
|
||||||
|
<td align="center"><span class="text-foreign">YP</span></td>
|
||||||
|
<td align="right"><span class="text-down">431,663 </span></td>
|
||||||
|
<td align="right"><span class="text-down">117.6 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">2,725 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-local">PD</span></td>
|
||||||
|
<td align="right"><span class="text-up">403,468 </span></td>
|
||||||
|
<td align="right"><span class="text-up">109.5 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">2,713 </span></td>
|
||||||
|
<td align="center">9</td>
|
||||||
|
<td align="center"><span class="text-bumn">NI</span></td>
|
||||||
|
<td align="right"><span class="text-down">234,641 </span></td>
|
||||||
|
<td align="right"><span class="text-down">63.1 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">2,690 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-local">XC</span></td>
|
||||||
|
<td align="right"><span class="text-up">381,871 </span></td>
|
||||||
|
<td align="right"><span class="text-up">103.7 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">2,717 </span></td>
|
||||||
|
<td align="center">10</td>
|
||||||
|
<td align="center"><span class="text-local">XA</span></td>
|
||||||
|
<td align="right"><span class="text-down">217,888 </span></td>
|
||||||
|
<td align="right"><span class="text-down">59.1 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">2,714 </span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="bdr-t">
|
||||||
|
<th colspan="9" align="center">
|
||||||
|
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||||
|
<span class="fsize12 ml5 mr5">T. Val : 3.3 T</span>
|
||||||
|
<span class="fsize12 ml5 mr5">F. NVal : -1.1 T</span>
|
||||||
|
<span class="fsize12 ml5 mr5">T.Lot : 12.2 M</span>
|
||||||
|
<span class="fsize12 ml5 mr5">Avg : 2,708 </span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- =======================
|
||||||
|
======SCRIPT MODULE=========
|
||||||
|
============================-->
|
||||||
|
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||||
181
crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html
vendored
Normal file
181
crates/idx-core/tests/fixtures/ipot_brokersummary_tlkm_20261016.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<div id="modContentBrokerSum"></div>
|
||||||
|
|
||||||
|
<!--DATA BROKER SUMMARY-->
|
||||||
|
<div class="pa5">
|
||||||
|
<div class="row mincol-3">
|
||||||
|
<div class="col col-xs-5 col-sm-3">
|
||||||
|
<input class="form-control input-xs text-center" id="startDateBrokerSummary" name="startDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||||
|
</div>
|
||||||
|
<div class="col col-xs-5 col-sm-3">
|
||||||
|
<input class="form-control input-xs text-center" id="endDateBrokerSummary" name="endDate" placeholder="DD Month YYYY" value="10/16/2026" type="text">
|
||||||
|
</div>
|
||||||
|
<div class="mt15 visible-xs"> </div>
|
||||||
|
<div class="col col-xs-5 col-sm-3">
|
||||||
|
<select class="form-control input-xs" id="FDBrokerSummary">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="F" >Foreign</option>
|
||||||
|
<option value="D" >Domestic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col col-xs-5 col-sm-2">
|
||||||
|
<select class="form-control input-xs" id="boardBrokerSummary">
|
||||||
|
<option value="all">All Trade</option>
|
||||||
|
<option value="RG" >Regular</option>
|
||||||
|
<option value="TN" >Tunai</option>
|
||||||
|
<option value="NG" >Nego</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col col-xs-2 col-sm-1">
|
||||||
|
<button type="button" class="btn btn-default btn-block btn-xs-input" onClick="quickinfo_brokersummary($('#stockPageCode').val()!==undefined?$('#stockPageCode').val():$('#stockInfoCode').val(),$('#startDateBrokerSummary').val(),$('#endDateBrokerSummary').val(),$('#FDBrokerSummary').val(),$('#boardBrokerSummary').val());">GO</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="table table-summary table-hover noborder nm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th align="center">Buyer</th>
|
||||||
|
<th align="right">B.Lot</th>
|
||||||
|
<th align="right">B.Val</th>
|
||||||
|
<th align="right">B.Avg</th>
|
||||||
|
<th align="center">#</th>
|
||||||
|
<th align="center">Seller</th>
|
||||||
|
<th align="right">S.Lot</th>
|
||||||
|
<th align="right">S.Val</th>
|
||||||
|
<th align="right">S.Avg</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||||
|
<td align="right"><span class="text-up">1.9 M</span></td>
|
||||||
|
<td align="right"><span class="text-up">381.6 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">1,960 </span></td>
|
||||||
|
<td align="center">1</td>
|
||||||
|
<td align="center"><span class="text-foreign">KZ</span></td>
|
||||||
|
<td align="right"><span class="text-down">3.7 M</span></td>
|
||||||
|
<td align="right"><span class="text-down">721.6 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">1,933 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-bumn">CC</span></td>
|
||||||
|
<td align="right"><span class="text-up">1.5 M</span></td>
|
||||||
|
<td align="right"><span class="text-up">292.8 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">1,932 </span></td>
|
||||||
|
<td align="center">2</td>
|
||||||
|
<td align="center"><span class="text-bumn">CC</span></td>
|
||||||
|
<td align="right"><span class="text-down">1.8 M</span></td>
|
||||||
|
<td align="right"><span class="text-down">353.9 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">1,933 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-foreign">BK</span></td>
|
||||||
|
<td align="right"><span class="text-up">1.4 M</span></td>
|
||||||
|
<td align="right"><span class="text-up">274.7 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">1,917 </span></td>
|
||||||
|
<td align="center">3</td>
|
||||||
|
<td align="center"><span class="text-foreign">BK</span></td>
|
||||||
|
<td align="right"><span class="text-down">1.6 M</span></td>
|
||||||
|
<td align="right"><span class="text-down">312.2 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">1,926 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-foreign">RX</span></td>
|
||||||
|
<td align="right"><span class="text-up">1.0 M</span></td>
|
||||||
|
<td align="right"><span class="text-up">200.0 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">1,933 </span></td>
|
||||||
|
<td align="center">4</td>
|
||||||
|
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||||
|
<td align="right"><span class="text-down">1.0 M</span></td>
|
||||||
|
<td align="right"><span class="text-down">198.1 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">1,926 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-foreign">ZP</span></td>
|
||||||
|
<td align="right"><span class="text-up">992,588 </span></td>
|
||||||
|
<td align="right"><span class="text-up">192.3 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">1,937 </span></td>
|
||||||
|
<td align="center">5</td>
|
||||||
|
<td align="center"><span class="text-foreign">AK</span></td>
|
||||||
|
<td align="right"><span class="text-down">704,361 </span></td>
|
||||||
|
<td align="right"><span class="text-down">136.7 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">1,941 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-foreign">YU</span></td>
|
||||||
|
<td align="right"><span class="text-up">944,581 </span></td>
|
||||||
|
<td align="right"><span class="text-up">180.4 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">1,910 </span></td>
|
||||||
|
<td align="center">6</td>
|
||||||
|
<td align="center"><span class="text-local">MG</span></td>
|
||||||
|
<td align="right"><span class="text-down">529,257 </span></td>
|
||||||
|
<td align="right"><span class="text-down">101.4 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">1,916 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-foreign">AK</span></td>
|
||||||
|
<td align="right"><span class="text-up">715,278 </span></td>
|
||||||
|
<td align="right"><span class="text-up">138.2 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">1,932 </span></td>
|
||||||
|
<td align="center">7</td>
|
||||||
|
<td align="center"><span class="text-foreign">YU</span></td>
|
||||||
|
<td align="right"><span class="text-down">324,973 </span></td>
|
||||||
|
<td align="right"><span class="text-down">62.5 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">1,924 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-local">MG</span></td>
|
||||||
|
<td align="right"><span class="text-up">528,650 </span></td>
|
||||||
|
<td align="right"><span class="text-up">100.6 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">1,903 </span></td>
|
||||||
|
<td align="center">8</td>
|
||||||
|
<td align="center"><span class="text-bumn">NI</span></td>
|
||||||
|
<td align="right"><span class="text-down">208,165 </span></td>
|
||||||
|
<td align="right"><span class="text-down">39.8 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">1,911 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-local">XL</span></td>
|
||||||
|
<td align="right"><span class="text-up">331,760 </span></td>
|
||||||
|
<td align="right"><span class="text-up">64.1 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">1,931 </span></td>
|
||||||
|
<td align="center">9</td>
|
||||||
|
<td align="center"><span class="text-local">XL</span></td>
|
||||||
|
<td align="right"><span class="text-down">173,939 </span></td>
|
||||||
|
<td align="right"><span class="text-down">33.7 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">1,937 </span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><span class="text-foreign">YP</span></td>
|
||||||
|
<td align="right"><span class="text-up">234,664 </span></td>
|
||||||
|
<td align="right"><span class="text-up">44.9 B</span></td>
|
||||||
|
<td align="right"><span class="text-up">1,913 </span></td>
|
||||||
|
<td align="center">10</td>
|
||||||
|
<td align="center"><span class="text-foreign">YP</span></td>
|
||||||
|
<td align="right"><span class="text-down">168,848 </span></td>
|
||||||
|
<td align="right"><span class="text-down">32.7 B</span></td>
|
||||||
|
<td align="right"><span class="text-down">1,938 </span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="bdr-t">
|
||||||
|
<th colspan="9" align="center">
|
||||||
|
<div class="text-center pt20 valign-middle" style="height:50px;">
|
||||||
|
<span class="fsize12 ml5 mr5">T. Val : 2.2 T</span>
|
||||||
|
<span class="fsize12 ml5 mr5">F. NVal : -90.2 B</span>
|
||||||
|
<span class="fsize12 ml5 mr5">T.Lot : 11.3 M</span>
|
||||||
|
<span class="fsize12 ml5 mr5">Avg : 1,931 </span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- =======================
|
||||||
|
======SCRIPT MODULE=========
|
||||||
|
============================-->
|
||||||
|
<script src="../../../../module/saham/include/js/data-brokersummary.js"></script>
|
||||||
|
|
@ -115,10 +115,9 @@ pub struct AnalyzeParams {
|
||||||
pub kinds: Option<Vec<AnalysisKind>>,
|
pub kinds: Option<Vec<AnalysisKind>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which sessions and trades a broker tool covers.
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct BrokerParams {
|
pub struct BrokerScope {
|
||||||
/// IDX ticker, e.g. "BBCA".
|
|
||||||
pub symbol: String,
|
|
||||||
/// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.
|
/// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub date: Option<String>,
|
pub date: Option<String>,
|
||||||
|
|
@ -139,6 +138,25 @@ pub struct BrokerParams {
|
||||||
pub board: Option<String>,
|
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)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct ShareholdersParams {
|
pub struct ShareholdersParams {
|
||||||
/// IDX ticker, e.g. "BBCA".
|
/// IDX ticker, e.g. "BBCA".
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
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::analysis::fundamental::FundamentalReport;
|
||||||
use idx_core::broker::types::BrokerRange;
|
use idx_core::broker::types::BrokerRange;
|
||||||
use idx_core::config::CoreConfig;
|
use idx_core::config::CoreConfig;
|
||||||
|
|
@ -22,9 +24,9 @@ use serde_json::{Map, Value, json};
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
use crate::params::{
|
use crate::params::{
|
||||||
AnalysisKind, AnalyzeParams, BrokerParams, ChangesParams, CompanyParams, CompanySection,
|
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
||||||
EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams,
|
CompanySection, EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams,
|
||||||
SyncParams,
|
SymbolsParams, SyncParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SYNC_TOOL: &str = "ownership_sync";
|
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."
|
figures of a million and more carry one decimal of precision."
|
||||||
)]
|
)]
|
||||||
async fn get_broker_summary(&self, params: Parameters<BrokerParams>) -> ToolResult {
|
async fn get_broker_summary(&self, params: Parameters<BrokerParams>) -> ToolResult {
|
||||||
let BrokerParams {
|
let BrokerParams { symbol, scope } = params.0;
|
||||||
symbol,
|
|
||||||
date,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
investor,
|
|
||||||
board,
|
|
||||||
} = params.0;
|
|
||||||
self.live(move |idx| {
|
self.live(move |idx| {
|
||||||
let broker = idx.broker();
|
let broker = idx.broker();
|
||||||
let range = BrokerRange::from_args(
|
let range = BrokerRange::from_args(
|
||||||
date.as_deref(),
|
scope.date.as_deref(),
|
||||||
from.as_deref(),
|
scope.from.as_deref(),
|
||||||
to.as_deref(),
|
scope.to.as_deref(),
|
||||||
broker.today(),
|
broker.today(),
|
||||||
)?;
|
)?;
|
||||||
broker.fetch(
|
broker.fetch(
|
||||||
&symbol,
|
&symbol,
|
||||||
range,
|
range,
|
||||||
investor.as_deref().unwrap_or("all").parse()?,
|
scope.investor.as_deref().unwrap_or("all").parse()?,
|
||||||
board.as_deref().unwrap_or("all").parse()?,
|
scope.board.as_deref().unwrap_or("all").parse()?,
|
||||||
CachePolicy::Default,
|
CachePolicy::Default,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await
|
.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(
|
#[tool(
|
||||||
description = "Company data for one IDX ticker: profile, financial statements, \
|
description = "Company data for one IDX ticker: profile, financial statements, \
|
||||||
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
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| {
|
self.live(move |idx| {
|
||||||
let comparison = idx.analysis().compare(&symbols, CachePolicy::Default)?;
|
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;
|
let reports: &[Fetched<FundamentalReport>] = &comparison.reports;
|
||||||
Ok(json!({ "reports": reports, "failures": failures }))
|
Ok(json!({ "reports": reports, "failures": failures_json(&comparison.failures) }))
|
||||||
})
|
})
|
||||||
.await
|
.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 {
|
fn input_error(message: String) -> ToolResult {
|
||||||
let err = IdxError::InvalidInput(message);
|
let err = IdxError::InvalidInput(message);
|
||||||
Ok(CallToolResult::structured_error(Value::Object(error_body(
|
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.",
|
"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": {
|
"inputSchema": {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ use idx_core::config::CoreConfig;
|
||||||
use idx_core::investor::{ImportSource, Investor};
|
use idx_core::investor::{ImportSource, Investor};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
const TOOLS: [&str; 12] = [
|
const TOOLS: [&str; 13] = [
|
||||||
|
"analyze_bandar",
|
||||||
"analyze_stock",
|
"analyze_stock",
|
||||||
"compare_stocks",
|
"compare_stocks",
|
||||||
"get_broker_summary",
|
"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"]["sellers"][0]["name"], "MANDIRI SEKURITAS");
|
||||||
assert_eq!(brokers["data"]["foreign_net_value"], -2_300_000_000_000_i64);
|
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"]}));
|
let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]}));
|
||||||
assert_eq!(compared["reports"].as_array().unwrap().len(), 2);
|
assert_eq!(compared["reports"].as_array().unwrap().len(), 2);
|
||||||
assert_eq!(compared["failures"], json!([]));
|
assert_eq!(compared["failures"], json!([]));
|
||||||
|
|
@ -292,6 +309,8 @@ fn errors_carry_cli_codes() {
|
||||||
"get_broker_summary",
|
"get_broker_summary",
|
||||||
json!({"symbol": "BBCA", "date": "2026-10-16", "board": "dark"}),
|
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);
|
let (is_error, body) = client.call(tool, arguments);
|
||||||
assert!(is_error, "{tool}: {body}");
|
assert!(is_error, "{tool}: {body}");
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,11 @@ WIB as the fallback.
|
||||||
- Data is end of day: IDX hides broker codes during trading. Summaries that
|
- Data is end of day: IDX hides broker codes during trading. Summaries that
|
||||||
ended before today are cached for 7 days, today's for 5 minutes. Broker
|
ended before today are cached for 7 days, today's for 5 minutes. Broker
|
||||||
names come from `broker/codes.tsv` (IDX `GetBrokerCodeList`).
|
names come from `broker/codes.tsv` (IDX `GetBrokerCodeList`).
|
||||||
|
- Bandarmology (`analysis/bandar.rs`, method `top_concentration_v1`) is pure
|
||||||
|
over one summary: for the top 1/3/5/10 per side, buy value minus sell value
|
||||||
|
in basis points of value traded, with thresholds ±500/±1500 bps taken from
|
||||||
|
fzn0x/idx. A tier with a missing figure has no reading; missing values are
|
||||||
|
never summed as zero. A change to the rules gets a new method name.
|
||||||
|
|
||||||
### Capability Gating
|
### Capability Gating
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@
|
||||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
||||||
sleep 2
|
sleep 2
|
||||||
} | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl
|
} | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl
|
||||||
jq -se 'map(select(.id == 2))[0].result.tools | length == 12' out.jsonl
|
jq -se 'map(select(.id == 2))[0].result.tools | length == 13' out.jsonl
|
||||||
touch "$out"
|
touch "$out"
|
||||||
'';
|
'';
|
||||||
# The website's generated pages match the CLI help and the MCP
|
# The website's generated pages match the CLI help and the MCP
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ Commands:
|
||||||
risk Run risk analysis on a stock
|
risk Run risk analysis on a stock
|
||||||
fundamental Run full fundamental analysis on a stock
|
fundamental Run full fundamental analysis on a stock
|
||||||
broker Which brokers bought and sold a stock
|
broker Which brokers bought and sold a stock
|
||||||
|
bandar Bandarmology: is buying more concentrated than selling
|
||||||
profile Get company profile
|
profile Get company profile
|
||||||
financials Get financial statements
|
financials Get financial statements
|
||||||
earnings Get earnings report
|
earnings Get earnings report
|
||||||
|
|
@ -351,6 +352,83 @@ Examples:
|
||||||
idx -o json stocks broker BBCA --investor foreign
|
idx -o json stocks broker BBCA --investor foreign
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `idx stocks bandar`
|
||||||
|
|
||||||
|
```text
|
||||||
|
Bandarmology from the broker summary: the top 1, 3, 5 and 10 buyers' value against the same number of sellers', as a share of value traded, with a signal from big accumulation to big distribution. Several symbols give a screen ranked by the headline tier (at most 100, one request each). A heuristic over end-of-day broker codes, not advice.
|
||||||
|
|
||||||
|
Usage: idx stocks bandar [OPTIONS] <SYMBOLS>...
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
<SYMBOLS>...
|
||||||
|
One or more symbols, comma-separated or space-separated; `-` reads them from stdin
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--date <DATE>
|
||||||
|
One session, YYYY-MM-DD. Default: the latest session with trades
|
||||||
|
|
||||||
|
-o, --output <OUTPUT>
|
||||||
|
Output format [default: auto = table on a terminal, json when piped]
|
||||||
|
|
||||||
|
[possible values: auto, table, json, jsonl]
|
||||||
|
|
||||||
|
--from <FROM>
|
||||||
|
First session of a range, YYYY-MM-DD (at most 366 days)
|
||||||
|
|
||||||
|
--no-color
|
||||||
|
Disable colored output
|
||||||
|
|
||||||
|
-q, --quiet
|
||||||
|
Suppress non-essential output
|
||||||
|
|
||||||
|
--to <TO>
|
||||||
|
Last session of the range, YYYY-MM-DD. Default: today
|
||||||
|
|
||||||
|
--investor <INVESTOR>
|
||||||
|
Whose trades to count
|
||||||
|
|
||||||
|
Possible values:
|
||||||
|
- all: Every trade
|
||||||
|
- foreign: Trades by foreign investors only
|
||||||
|
- domestic: Trades by domestic investors only
|
||||||
|
|
||||||
|
[default: all]
|
||||||
|
|
||||||
|
-v, --verbose...
|
||||||
|
Increase verbosity (-v, -vv)
|
||||||
|
|
||||||
|
--board <BOARD>
|
||||||
|
Which board's trades to count; `regular` leaves out negotiated block trades
|
||||||
|
|
||||||
|
Possible values:
|
||||||
|
- all: Every board
|
||||||
|
- regular: Regular market (RG), the continuous order book
|
||||||
|
- cash: Cash market (TN)
|
||||||
|
- negotiated: Negotiated market (NG): pre-arranged block trades
|
||||||
|
|
||||||
|
[default: all]
|
||||||
|
|
||||||
|
--offline
|
||||||
|
Run without network requests
|
||||||
|
|
||||||
|
--no-cache
|
||||||
|
Bypass local cache
|
||||||
|
|
||||||
|
--top <TOP>
|
||||||
|
Tier behind the headline signal: 1, 3, 5 or 10 brokers per side
|
||||||
|
|
||||||
|
[default: 5]
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
idx stocks bandar BBCA
|
||||||
|
idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3
|
||||||
|
idx stocks bandar BBCA,BBRI,BMRI,TLKM
|
||||||
|
idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -
|
||||||
|
```
|
||||||
|
|
||||||
### `idx stocks profile`
|
### `idx stocks profile`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,32 @@ codes during trading. Buyers and sellers are ranked separately on gross
|
||||||
lots, so a broker can appear on both sides. The source rounds figures of a
|
lots, so a broker can appear on both sides. The source rounds figures of a
|
||||||
million and more to one decimal, and the output says so.
|
million and more to one decimal, and the output says so.
|
||||||
|
|
||||||
|
## Bandarmology
|
||||||
|
|
||||||
|
```bash
|
||||||
|
idx stocks bandar BBCA
|
||||||
|
idx stocks bandar BBCA --from 2026-10-12 --to 2026-10-16 --top 3
|
||||||
|
idx stocks screen --filter high-volume | jq -r '.[].symbol' | idx stocks bandar -
|
||||||
|
```
|
||||||
|
|
||||||
|
`bandar` reads the broker summary: for the top 1, 3, 5 and 10 brokers on each
|
||||||
|
side, the buyers' value minus the sellers' value as a share of the value
|
||||||
|
traded. The headline tier (`--top`, default 5) gives the signal:
|
||||||
|
|
||||||
|
| Top-N buy minus sell, % of value | Signal |
|
||||||
|
| --- | --- |
|
||||||
|
| +15% or more | big accumulation |
|
||||||
|
| +5% to +15% | accumulation |
|
||||||
|
| within ±5% | neutral |
|
||||||
|
| -15% to -5% | distribution |
|
||||||
|
| -15% or less | big distribution |
|
||||||
|
|
||||||
|
Several symbols give a screen ranked by that figure (at most 100, one request
|
||||||
|
each), and it takes the same date and filter flags as `broker`. It is a
|
||||||
|
heuristic: a broker trades for many clients, and a broker on both sides of
|
||||||
|
the summary is listed, since its gross figures overstate which way it traded.
|
||||||
|
The JSON names the rules in `method` (`top_concentration_v1`).
|
||||||
|
|
||||||
## Analysis
|
## Analysis
|
||||||
|
|
||||||
| Command | What it computes |
|
| Command | What it computes |
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,20 @@ starts with `--allow-sync`. Results that come from a live provider are wrapped
|
||||||
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
||||||
message}` with `isError` set.
|
message}` with `isError` set.
|
||||||
|
|
||||||
|
## `analyze_bandar`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
| Input | Type | Required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `board` | `all` / `regular` / `cash` / `negotiated` | no | Which board's trades to count (default "all"); "regular" leaves out negotiated block trades. |
|
||||||
|
| `date` | string | no | One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades. |
|
||||||
|
| `from` | string | no | First session of a range, YYYY-MM-DD (at most 366 days). |
|
||||||
|
| `investor` | `all` / `foreign` / `domestic` | no | Whose trades to count, by investor domicile (default "all"). |
|
||||||
|
| `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. |
|
||||||
|
| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). |
|
||||||
|
| `top` | integer | no | Tier behind the headline signal: 1, 3, 5 or 10 brokers per side (default 5). |
|
||||||
|
|
||||||
## `analyze_stock`
|
## `analyze_stock`
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue