mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
feat(cli,mcp): idx stocks broker and get_broker_summary
`idx stocks broker SYMBOL` shows the top 10 buyers and sellers of a stock by lots for the latest session with trades, one `--date`, or a `--from`/`--to` range, filtered by `--investor` and `--board`. The MCP server exposes the same as get_broker_summary. - Range and filter parsing lives in idx-core (BrokerRange::from_args, FromStr for the filters), so both frontends accept the same input. - Without dates, the latest session is found by walking back over weekdays; offline, uncached days are skipped. - Table output lists foreign and state-owned brokers, and says when the source rounded figures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
7c8151a3a6
commit
860d4daf77
25 changed files with 939 additions and 13 deletions
|
|
@ -4,11 +4,12 @@ use clap::{Args, Subcommand};
|
|||
|
||||
use crate::config::{HistoryProviderKind, IdxConfig};
|
||||
use crate::output::{
|
||||
render_compare, render_earnings, render_financials, render_fundamental, render_growth,
|
||||
render_history, render_insights, render_news, render_profile, render_quotes, render_risk,
|
||||
render_screener, render_sentiment, render_technical, render_valuation,
|
||||
render_broker_summary, render_compare, render_earnings, render_financials, render_fundamental,
|
||||
render_growth, render_history, render_insights, render_news, render_profile, render_quotes,
|
||||
render_risk, render_screener, render_sentiment, render_technical, render_valuation,
|
||||
};
|
||||
use idx_core::api::types::{Interval, Period};
|
||||
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||
use idx_core::error::IdxError;
|
||||
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
|
||||
use idx_core::market::ScreenQuery;
|
||||
|
|
@ -76,6 +77,37 @@ impl From<EarningsFilterArgs> for EarningsFilter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Which sessions and trades a broker command covers.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct BrokerScopeArgs {
|
||||
/// One session, YYYY-MM-DD. Default: the latest session with trades.
|
||||
#[arg(long, conflicts_with_all = ["from", "to"])]
|
||||
date: Option<String>,
|
||||
/// First session of a range, YYYY-MM-DD (at most 366 days).
|
||||
#[arg(long)]
|
||||
from: Option<String>,
|
||||
/// Last session of the range, YYYY-MM-DD. Default: today.
|
||||
#[arg(long, requires = "from")]
|
||||
to: Option<String>,
|
||||
/// Whose trades to count.
|
||||
#[arg(long, value_enum, default_value_t = BrokerInvestor::All)]
|
||||
investor: BrokerInvestor,
|
||||
/// Which board's trades to count; `regular` leaves out negotiated block trades.
|
||||
#[arg(long, value_enum, default_value_t = BrokerBoard::All)]
|
||||
board: BrokerBoard,
|
||||
}
|
||||
|
||||
impl BrokerScopeArgs {
|
||||
fn range(&self, idx: &Idx) -> Result<BrokerRange, IdxError> {
|
||||
BrokerRange::from_args(
|
||||
self.date.as_deref(),
|
||||
self.from.as_deref(),
|
||||
self.to.as_deref(),
|
||||
idx.broker().today(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[command(about = "Stock data and analysis")]
|
||||
pub struct StocksCmd {
|
||||
|
|
@ -150,6 +182,19 @@ pub enum StocksSubcommand {
|
|||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
},
|
||||
#[command(
|
||||
about = "Which brokers bought and sold a stock",
|
||||
long_about = "Which brokers bought and sold a stock: the top 10 buyers and top 10 \
|
||||
sellers by lots, with value and average price, from Indo Premier (IPOT). End of day: IDX \
|
||||
does not show broker codes during trading. Without dates, the latest session with trades.",
|
||||
after_help = "Examples:\n idx stocks broker BBCA\n idx stocks broker BBCA --date 2026-10-16\n idx stocks broker BBCA --from 2026-10-12 --to 2026-10-16 --board regular\n idx -o json stocks broker BBCA --investor foreign"
|
||||
)]
|
||||
Broker {
|
||||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
#[command(flatten)]
|
||||
scope: BrokerScopeArgs,
|
||||
},
|
||||
#[command(about = "Get company profile")]
|
||||
Profile { symbol: String },
|
||||
#[command(
|
||||
|
|
@ -256,6 +301,16 @@ pub fn handle(
|
|||
let report = idx.analysis().fundamental(symbol, policy)?;
|
||||
render_fundamental(&report.data, format, no_color)
|
||||
}
|
||||
StocksSubcommand::Broker { symbol, scope } => {
|
||||
let summary = idx.broker().fetch(
|
||||
symbol,
|
||||
scope.range(&idx)?,
|
||||
scope.investor,
|
||||
scope.board,
|
||||
policy,
|
||||
)?;
|
||||
render_broker_summary(&summary.data, format)
|
||||
}
|
||||
StocksSubcommand::Profile { symbol } => {
|
||||
let profile = idx.financial().profile(symbol, policy)?;
|
||||
render_profile(&profile.data, format)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use idx_core::api::types::{
|
|||
CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote,
|
||||
SentimentData,
|
||||
};
|
||||
use idx_core::broker::types::BrokerSummary;
|
||||
use idx_core::error::IdxError;
|
||||
|
||||
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
||||
|
|
@ -73,6 +74,16 @@ pub fn render_quotes(
|
|||
}
|
||||
}
|
||||
|
||||
pub fn render_broker_summary(
|
||||
summary: &BrokerSummary,
|
||||
format: &OutputFormat,
|
||||
) -> Result<(), IdxError> {
|
||||
match format {
|
||||
OutputFormat::Table => table::print_broker_summary(summary),
|
||||
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(summary),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_history(
|
||||
symbol: &str,
|
||||
history: &[Ohlc],
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use idx_core::api::types::{
|
|||
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc,
|
||||
Quote, SentimentData,
|
||||
};
|
||||
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
||||
use idx_core::error::IdxError;
|
||||
|
||||
pub fn format_idr(value: i64) -> String {
|
||||
|
|
@ -893,6 +894,126 @@ fn truncate_url(url: &str) -> String {
|
|||
truncate_chars(url, 72)
|
||||
}
|
||||
|
||||
/// A figure at the precision the broker summary source publishes:
|
||||
/// `841.2B`, `1.6M`, exact below a million.
|
||||
fn format_compact(value: i64) -> String {
|
||||
let abs = value.unsigned_abs();
|
||||
let (unit, suffix) = match abs {
|
||||
1_000_000_000_000.. => (1e12, "T"),
|
||||
1_000_000_000.. => (1e9, "B"),
|
||||
1_000_000.. => (1e6, "M"),
|
||||
_ => return format_idr(value),
|
||||
};
|
||||
format!("{:.1}{suffix}", value as f64 / unit)
|
||||
}
|
||||
|
||||
fn format_compact_option(value: Option<i64>) -> String {
|
||||
value.map(format_compact).unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn broker_cells(flow: Option<&BrokerFlow>) -> [Cell; 4] {
|
||||
match flow {
|
||||
Some(flow) => [
|
||||
cell(&flow.code),
|
||||
cell(format_compact_option(flow.lots)),
|
||||
cell(format_compact_option(flow.value)),
|
||||
cell(
|
||||
flow.average_price
|
||||
.map(format_idr)
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
],
|
||||
None => [cell(""), cell(""), cell(""), cell("")],
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-16`, or `2026-10-12 to 2026-10-16`, plus any filters.
|
||||
pub(crate) fn broker_scope(
|
||||
from: chrono::NaiveDate,
|
||||
to: chrono::NaiveDate,
|
||||
investor: BrokerInvestor,
|
||||
board: BrokerBoard,
|
||||
) -> String {
|
||||
let mut scope = if from == to {
|
||||
from.to_string()
|
||||
} else {
|
||||
format!("{from} to {to}")
|
||||
};
|
||||
if investor != BrokerInvestor::All {
|
||||
scope.push_str(&format!(", {} investors", investor.as_str()));
|
||||
}
|
||||
if board != BrokerBoard::All {
|
||||
scope.push_str(&format!(", {} board", board.as_str()));
|
||||
}
|
||||
scope
|
||||
}
|
||||
|
||||
pub fn print_broker_summary(summary: &BrokerSummary) -> Result<(), IdxError> {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"Broker summary for {}, {}",
|
||||
terminal_safe(&summary.symbol),
|
||||
broker_scope(summary.from, summary.to, summary.investor, summary.board)
|
||||
)
|
||||
.bold()
|
||||
);
|
||||
if summary.is_empty() {
|
||||
println!("No trades.");
|
||||
return Ok(());
|
||||
}
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
"#", "BUYER", "LOTS", "VALUE", "AVG", "SELLER", "LOTS", "VALUE", "AVG",
|
||||
]);
|
||||
let rows = summary.buyers.len().max(summary.sellers.len());
|
||||
for index in 0..rows {
|
||||
let mut row = vec![cell(index + 1)];
|
||||
row.extend(broker_cells(summary.buyers.get(index)));
|
||||
row.extend(broker_cells(summary.sellers.get(index)));
|
||||
table.add_row(row);
|
||||
}
|
||||
println!("{table}");
|
||||
|
||||
let mut totals = format!(
|
||||
"Total {} IDR, {} lots",
|
||||
format_compact_option(summary.total_value),
|
||||
format_compact_option(summary.total_lots)
|
||||
);
|
||||
if let Some(average) = summary.average_price {
|
||||
totals.push_str(&format!(", average {}", format_idr(average)));
|
||||
}
|
||||
if let Some(net) = summary.foreign_net_value {
|
||||
totals.push_str(&format!("; foreign net {:+}", format_compact(net)));
|
||||
}
|
||||
println!("{totals}.");
|
||||
for (kind, label) in [
|
||||
(BrokerKind::Foreign, "Foreign brokers"),
|
||||
(BrokerKind::StateOwned, "State-owned brokers"),
|
||||
] {
|
||||
let mut codes: Vec<&str> = summary
|
||||
.buyers
|
||||
.iter()
|
||||
.chain(&summary.sellers)
|
||||
.filter(|flow| flow.kind == Some(kind))
|
||||
.map(|flow| flow.code.as_str())
|
||||
.collect();
|
||||
codes.sort_unstable();
|
||||
codes.dedup();
|
||||
if !codes.is_empty() {
|
||||
println!("{label}: {}.", terminal_safe(&codes.join(", ")));
|
||||
}
|
||||
}
|
||||
println!("Buyers and sellers are each ranked by lots; a broker can be on both sides.");
|
||||
if summary.rounded {
|
||||
println!("Note: the source rounds figures of a million and more to one decimal.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
|
|
|||
|
|
@ -2434,3 +2434,154 @@ fn msn_screen_rejects_invalid_totals_without_populating_cache() {
|
|||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A command on the fixture-backed mock provider, never the network.
|
||||
fn mock_bin(name: &str) -> Command {
|
||||
let mut cmd = test_bin(name);
|
||||
cmd.env("IDX_USE_MOCK_PROVIDER", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn mock_bin_with_root(root: &Path) -> Command {
|
||||
let mut cmd = bin_with_root(root);
|
||||
cmd.env("IDX_USE_MOCK_PROVIDER", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_summary_json_names_brokers_and_keeps_source_totals() {
|
||||
let summary = run_success_json(mock_bin("broker-json").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"bbca",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]));
|
||||
assert_eq!(summary["symbol"], "BBCA.JK");
|
||||
assert_eq!(
|
||||
(summary["from"].as_str(), summary["to"].as_str()),
|
||||
(Some("2026-10-16"), Some("2026-10-16"))
|
||||
);
|
||||
assert_eq!(summary["buyers"].as_array().unwrap().len(), 10);
|
||||
let top = &summary["buyers"][0];
|
||||
assert_eq!(top["code"], "MG");
|
||||
assert_eq!(top["name"], "SEMESTA INDOVEST SEKURITAS");
|
||||
assert_eq!(top["kind"], "local");
|
||||
assert_eq!(top["lots"], 1_600_000);
|
||||
assert_eq!(top["value"], 841_200_000_000_i64);
|
||||
assert_eq!(top["average_price"], 5_191);
|
||||
assert_eq!(summary["sellers"][0]["kind"], "state_owned");
|
||||
assert_eq!(summary["total_value"], 6_600_000_000_000_i64);
|
||||
assert_eq!(summary["foreign_net_value"], -2_300_000_000_000_i64);
|
||||
assert_eq!(summary["rounded"], true);
|
||||
|
||||
// The source does not filter its foreign net figure, so a filtered
|
||||
// summary leaves it out rather than passing it off as filtered.
|
||||
let filtered = run_success_json(mock_bin("broker-filtered").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
"--investor",
|
||||
"foreign",
|
||||
"--board",
|
||||
"regular",
|
||||
]));
|
||||
assert_eq!(filtered["investor"], "foreign");
|
||||
assert_eq!(filtered["board"], "regular");
|
||||
assert_eq!(filtered["buyers"][0]["code"], "KZ");
|
||||
assert!(filtered["foreign_net_value"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_summary_without_dates_finds_the_latest_session_offline() {
|
||||
let root = test_env_dir("broker-latest");
|
||||
run_success_json(mock_bin_with_root(&root).args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]));
|
||||
// Monday 2026-10-19 is not cached and the weekend is skipped, so the
|
||||
// offline lookup lands on Friday's cached session.
|
||||
let latest = run_success_json(
|
||||
mock_bin_with_root(&root)
|
||||
.env("IDX_MOCK_TODAY", "2026-10-19")
|
||||
.args(["-o", "json", "--offline", "stocks", "broker", "BBCA"]),
|
||||
);
|
||||
assert_eq!(latest["from"], "2026-10-16");
|
||||
assert_eq!(latest["buyers"][0]["code"], "MG");
|
||||
run_error_json(
|
||||
mock_bin_with_root(&root)
|
||||
.env("IDX_MOCK_TODAY", "2026-10-19")
|
||||
.args(["-o", "json", "--offline", "stocks", "broker", "BBRI"]),
|
||||
"CACHEMISS",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_summary_session_without_trades_is_empty_not_an_error() {
|
||||
let summary = run_success_json(mock_bin("broker-empty").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-17",
|
||||
]));
|
||||
assert_eq!(summary["buyers"], serde_json::json!([]));
|
||||
assert_eq!(summary["total_value"], 0);
|
||||
assert!(summary["average_price"].is_null());
|
||||
|
||||
let table = run_success_stdout(mock_bin("broker-empty-table").args([
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-17",
|
||||
]));
|
||||
assert!(table.contains("No trades."), "{table}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_summary_errors_follow_the_output_contract() {
|
||||
for (args, code) in [
|
||||
(vec!["ZZZZ", "--date", "2026-10-16"], "SYMBOLNOTFOUND"),
|
||||
(vec!["BBCA.US", "--date", "2026-10-16"], "UNSUPPORTED"),
|
||||
(vec!["BBCA", "--date", "16/10/2026"], "INVALIDINPUT"),
|
||||
(
|
||||
vec!["BBCA", "--from", "2026-10-16", "--to", "2026-10-12"],
|
||||
"INVALIDINPUT",
|
||||
),
|
||||
(vec!["BBCA", "--to", "2026-10-16"], "INVALIDINPUT"),
|
||||
(
|
||||
vec!["BBCA", "--date", "2026-10-16", "--from", "2026-10-12"],
|
||||
"INVALIDINPUT",
|
||||
),
|
||||
(vec!["BBCA", "--date", "2999-01-01"], "INVALIDINPUT"),
|
||||
] {
|
||||
let mut cmd = mock_bin("broker-errors");
|
||||
cmd.args(["-o", "json", "stocks", "broker"]).args(&args);
|
||||
run_error_json(&mut cmd, code);
|
||||
}
|
||||
let mut failing = mock_bin("broker-provider-error");
|
||||
failing.env("IDX_MOCK_ERROR", "1").args([
|
||||
"-o",
|
||||
"json",
|
||||
"stocks",
|
||||
"broker",
|
||||
"BBCA",
|
||||
"--date",
|
||||
"2026-10-16",
|
||||
]);
|
||||
run_error_json(&mut failing, "PROVIDERUNAVAILABLE");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,6 +244,24 @@ name = "yahoo-fundamental"
|
|||
args = ["stocks", "fundamental", "BBCA"]
|
||||
env = { IDX_PROVIDER = "yahoo" }
|
||||
|
||||
# --- stocks: broker summaries (IPOT) -------------------------------------------
|
||||
|
||||
[[case]]
|
||||
name = "stocks-broker"
|
||||
args = ["stocks", "broker", "BBCA", "--date", "2026-10-16"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-broker-range"
|
||||
args = ["stocks", "broker", "BBCA", "--from", "2026-10-12", "--to", "2026-10-16"]
|
||||
|
||||
[[case]]
|
||||
name = "stocks-broker-no-trades"
|
||||
args = ["stocks", "broker", "BBCA", "--date", "2026-10-17"]
|
||||
|
||||
[[case]]
|
||||
name = "error-broker-unknown-symbol"
|
||||
args = ["stocks", "broker", "ZZZZ", "--date", "2026-10-16"]
|
||||
|
||||
# --- errors ---------------------------------------------------------------------
|
||||
|
||||
[[case]]
|
||||
|
|
|
|||
|
|
@ -174,6 +174,9 @@ _idx() {
|
|||
idx__help__ownership__resolve,unresolved)
|
||||
cmd="idx__help__ownership__resolve__unresolved"
|
||||
;;
|
||||
idx__help__stocks,broker)
|
||||
cmd="idx__help__stocks__broker"
|
||||
;;
|
||||
idx__help__stocks,compare)
|
||||
cmd="idx__help__stocks__compare"
|
||||
;;
|
||||
|
|
@ -336,6 +339,9 @@ _idx() {
|
|||
idx__ownership__resolve__help,unresolved)
|
||||
cmd="idx__ownership__resolve__help__unresolved"
|
||||
;;
|
||||
idx__stocks,broker)
|
||||
cmd="idx__stocks__broker"
|
||||
;;
|
||||
idx__stocks,compare)
|
||||
cmd="idx__stocks__compare"
|
||||
;;
|
||||
|
|
@ -384,6 +390,9 @@ _idx() {
|
|||
idx__stocks,valuation)
|
||||
cmd="idx__stocks__valuation"
|
||||
;;
|
||||
idx__stocks__help,broker)
|
||||
cmd="idx__stocks__help__broker"
|
||||
;;
|
||||
idx__stocks__help,compare)
|
||||
cmd="idx__stocks__help__compare"
|
||||
;;
|
||||
|
|
@ -1191,7 +1200,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__help__stocks)
|
||||
opts="quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare"
|
||||
opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -1204,6 +1213,20 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stocks__broker)
|
||||
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__compare)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
|
|
@ -2193,7 +2216,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__stocks)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote history technical growth valuation risk fundamental 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 profile financials earnings sentiment insights news screen compare help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -2214,6 +2237,48 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__broker)
|
||||
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
|
||||
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
|
||||
;;
|
||||
--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__compare)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOLS>..."
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
|
|
@ -2329,7 +2394,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__stocks__help)
|
||||
opts="quote history technical growth valuation risk fundamental profile financials earnings sentiment insights news screen compare help"
|
||||
opts="quote history technical growth valuation risk fundamental broker profile financials earnings sentiment insights news screen compare help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -2342,6 +2407,20 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__help__broker)
|
||||
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__compare)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: ZZZZ"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: symbol not found: ZZZZ
|
||||
|
|
@ -12,6 +12,7 @@ Commands:
|
|||
valuation Run valuation analysis on a stock
|
||||
risk Run risk analysis on a stock
|
||||
fundamental Run full fundamental analysis on a stock
|
||||
broker Which brokers bought and sold a stock
|
||||
profile Get company profile
|
||||
financials Get financial statements
|
||||
earnings Get earnings report
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"symbol":"BBCA.JK","from":"2026-10-17","to":"2026-10-17","investor":"all","board":"all","buyers":[],"sellers":[],"total_value":0,"total_lots":0,"average_price":null,"foreign_net_value":0,"rounded":false}
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Broker summary for BBCA.JK, 2026-10-17
|
||||
No trades.
|
||||
--- stderr
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
{"symbol":"BBCA.JK","from":"2026-10-12","to":"2026-10-16","investor":"all","board":"all","buyers":[{"rank":1,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":963000000000,"average_price":5573},{"rank":2,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":842000000000,"average_price":5192},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":900900000000,"average_price":5780},{"rank":4,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":781700000000,"average_price":5626},{"rank":5,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1400000,"value":737900000000,"average_price":5408},{"rank":6,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1300000,"value":687200000000,"average_price":5313},{"rank":7,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1100000,"value":643900000000,"average_price":5771},{"rank":8,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":973921,"value":519400000000,"average_price":5333},{"rank":9,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":919655,"value":487100000000,"average_price":5296},{"rank":10,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":646162,"value":341200000000,"average_price":5280}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":5100000,"value":2700000000000,"average_price":5336},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1800000,"value":928500000000,"average_price":5295},{"rank":3,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":1600000,"value":942100000000,"average_price":5756},{"rank":4,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":800500000000,"average_price":5239},{"rank":5,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":1400000,"value":786500000000,"average_price":5785},{"rank":6,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":852704,"value":457700000000,"average_price":5368},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":830940,"value":466700000000,"average_price":5617},{"rank":8,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":709658,"value":393300000000,"average_price":5542},{"rank":9,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":621425,"value":363300000000,"average_price":5847},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":441903,"value":242600000000,"average_price":5490}],"total_value":9900000000000,"total_lots":18100000,"average_price":5471,"foreign_net_value":-4100000000000,"rounded":true}
|
||||
--- stderr
|
||||
32
crates/idx-cli/tests/golden/stocks-broker-range.table.golden
Normal file
32
crates/idx-cli/tests/golden/stocks-broker-range.table.golden
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Broker summary for BBCA.JK, 2026-10-12 to 2026-10-16
|
||||
┌────┬───────┬─────────┬────────┬───────┬────────┬─────────┬────────┬───────┐
|
||||
│ # ┆ BUYER ┆ LOTS ┆ VALUE ┆ AVG ┆ SELLER ┆ LOTS ┆ VALUE ┆ AVG │
|
||||
╞════╪═══════╪═════════╪════════╪═══════╪════════╪═════════╪════════╪═══════╡
|
||||
│ 1 ┆ KZ ┆ 1.7M ┆ 963.0B ┆ 5,573 ┆ CC ┆ 5.1M ┆ 2.7T ┆ 5,336 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 2 ┆ MG ┆ 1.6M ┆ 842.0B ┆ 5,192 ┆ RX ┆ 1.8M ┆ 928.5B ┆ 5,295 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 3 ┆ ZP ┆ 1.6M ┆ 900.9B ┆ 5,780 ┆ ZP ┆ 1.6M ┆ 942.1B ┆ 5,756 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 4 ┆ YU ┆ 1.4M ┆ 781.7B ┆ 5,626 ┆ MG ┆ 1.5M ┆ 800.5B ┆ 5,239 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 5 ┆ CC ┆ 1.4M ┆ 737.9B ┆ 5,408 ┆ AK ┆ 1.4M ┆ 786.5B ┆ 5,785 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 6 ┆ XL ┆ 1.3M ┆ 687.2B ┆ 5,313 ┆ BB ┆ 852,704 ┆ 457.7B ┆ 5,368 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 7 ┆ AK ┆ 1.1M ┆ 643.9B ┆ 5,771 ┆ YU ┆ 830,940 ┆ 466.7B ┆ 5,617 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 8 ┆ SQ ┆ 973,921 ┆ 519.4B ┆ 5,333 ┆ BK ┆ 709,658 ┆ 393.3B ┆ 5,542 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 9 ┆ YP ┆ 919,655 ┆ 487.1B ┆ 5,296 ┆ KZ ┆ 621,425 ┆ 363.3B ┆ 5,847 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 10 ┆ PD ┆ 646,162 ┆ 341.2B ┆ 5,280 ┆ XL ┆ 441,903 ┆ 242.6B ┆ 5,490 │
|
||||
└────┴───────┴─────────┴────────┴───────┴────────┴─────────┴────────┴───────┘
|
||||
Total 9.9T IDR, 18.1M lots, average 5,471; foreign net -4.1T.
|
||||
Foreign brokers: AK, BK, KZ, RX, YP, YU, ZP.
|
||||
State-owned brokers: CC.
|
||||
Buyers and sellers are each ranked by lots; a broker can be on both sides.
|
||||
Note: the source rounds figures of a million and more to one decimal.
|
||||
--- stderr
|
||||
4
crates/idx-cli/tests/golden/stocks-broker.json.golden
Normal file
4
crates/idx-cli/tests/golden/stocks-broker.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","buyers":[{"rank":1,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1600000,"value":841200000000,"average_price":5191},{"rank":2,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":1100000,"value":600000000000,"average_price":5229},{"rank":3,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":1000000,"value":543500000000,"average_price":5227},{"rank":4,"code":"KZ","name":"CLSA SEKURITAS INDONESIA","kind":"foreign","lots":1000000,"value":522400000000,"average_price":5198},{"rank":5,"code":"SQ","name":"BCA SEKURITAS","kind":"local","lots":828163,"value":432400000000,"average_price":5221},{"rank":6,"code":"YP","name":"MIRAE ASSET SEKURITAS INDONESIA","kind":"foreign","lots":827340,"value":431700000000,"average_price":5218},{"rank":7,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":687603,"value":356600000000,"average_price":5186},{"rank":8,"code":"PD","name":"INDO PREMIER SEKURITAS","kind":"local","lots":592667,"value":309200000000,"average_price":5218},{"rank":9,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":441000,"value":230200000000,"average_price":5220},{"rank":10,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":398474,"value":209900000000,"average_price":5268}],"sellers":[{"rank":1,"code":"CC","name":"MANDIRI SEKURITAS","kind":"state_owned","lots":4300000,"value":2200000000000,"average_price":5211},{"rank":2,"code":"RX","name":"MACQUARIE SEKURITAS INDONESIA","kind":"foreign","lots":1700000,"value":908200000000,"average_price":5282},{"rank":3,"code":"MG","name":"SEMESTA INDOVEST SEKURITAS","kind":"local","lots":1500000,"value":799400000000,"average_price":5238},{"rank":4,"code":"BB","name":"VERDHANA SEKURITAS INDONESIA","kind":"local","lots":687067,"value":357400000000,"average_price":5202},{"rank":5,"code":"ZP","name":"MAYBANK SEKURITAS INDONESIA","kind":"foreign","lots":529223,"value":275400000000,"average_price":5203},{"rank":6,"code":"AK","name":"UBS SEKURITAS INDONESIA","kind":"foreign","lots":444696,"value":233500000000,"average_price":5251},{"rank":7,"code":"BK","name":"J.P. MORGAN SEKURITAS INDONESIA","kind":"foreign","lots":419266,"value":218800000000,"average_price":5219},{"rank":8,"code":"YU","name":"CGS INTERNATIONAL SEKURITAS INDONESIA","kind":"foreign","lots":418986,"value":218000000000,"average_price":5202},{"rank":9,"code":"NI","name":"BNI SEKURITAS","kind":"state_owned","lots":357177,"value":186100000000,"average_price":5212},{"rank":10,"code":"XL","name":"STOCKBIT SEKURITAS DIGITAL","kind":"local","lots":296269,"value":155000000000,"average_price":5232}],"total_value":6600000000000,"total_lots":12600000,"average_price":5228,"foreign_net_value":-2300000000000,"rounded":true}
|
||||
--- stderr
|
||||
32
crates/idx-cli/tests/golden/stocks-broker.table.golden
Normal file
32
crates/idx-cli/tests/golden/stocks-broker.table.golden
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
exit: 0
|
||||
--- stdout
|
||||
Broker summary for BBCA.JK, 2026-10-16
|
||||
┌────┬───────┬─────────┬────────┬───────┬────────┬─────────┬────────┬───────┐
|
||||
│ # ┆ BUYER ┆ LOTS ┆ VALUE ┆ AVG ┆ SELLER ┆ LOTS ┆ VALUE ┆ AVG │
|
||||
╞════╪═══════╪═════════╪════════╪═══════╪════════╪═════════╪════════╪═══════╡
|
||||
│ 1 ┆ MG ┆ 1.6M ┆ 841.2B ┆ 5,191 ┆ CC ┆ 4.3M ┆ 2.2T ┆ 5,211 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 2 ┆ XL ┆ 1.1M ┆ 600.0B ┆ 5,229 ┆ RX ┆ 1.7M ┆ 908.2B ┆ 5,282 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 3 ┆ CC ┆ 1.0M ┆ 543.5B ┆ 5,227 ┆ MG ┆ 1.5M ┆ 799.4B ┆ 5,238 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 4 ┆ KZ ┆ 1.0M ┆ 522.4B ┆ 5,198 ┆ BB ┆ 687,067 ┆ 357.4B ┆ 5,202 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 5 ┆ SQ ┆ 828,163 ┆ 432.4B ┆ 5,221 ┆ ZP ┆ 529,223 ┆ 275.4B ┆ 5,203 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 6 ┆ YP ┆ 827,340 ┆ 431.7B ┆ 5,218 ┆ AK ┆ 444,696 ┆ 233.5B ┆ 5,251 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 7 ┆ YU ┆ 687,603 ┆ 356.6B ┆ 5,186 ┆ BK ┆ 419,266 ┆ 218.8B ┆ 5,219 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 8 ┆ PD ┆ 592,667 ┆ 309.2B ┆ 5,218 ┆ YU ┆ 418,986 ┆ 218.0B ┆ 5,202 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 9 ┆ ZP ┆ 441,000 ┆ 230.2B ┆ 5,220 ┆ NI ┆ 357,177 ┆ 186.1B ┆ 5,212 │
|
||||
├╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ 10 ┆ AK ┆ 398,474 ┆ 209.9B ┆ 5,268 ┆ XL ┆ 296,269 ┆ 155.0B ┆ 5,232 │
|
||||
└────┴───────┴─────────┴────────┴───────┴────────┴─────────┴────────┴───────┘
|
||||
Total 6.6T IDR, 12.6M lots, average 5,228; foreign net -2.3T.
|
||||
Foreign brokers: AK, BK, KZ, RX, YP, YU, ZP.
|
||||
State-owned brokers: CC, NI.
|
||||
Buyers and sellers are each ranked by lots; a broker can be on both sides.
|
||||
Note: the source rounds figures of a million and more to one decimal.
|
||||
--- stderr
|
||||
|
|
@ -12,7 +12,7 @@ use crate::api::ipot::BrokerSummaryRaw;
|
|||
use crate::api::{broker_provider, today_wib};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::error::IdxError;
|
||||
use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerSummary};
|
||||
use types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerRange, BrokerSummary};
|
||||
|
||||
const SOURCE: &str = "ipot";
|
||||
/// Longest range one summary may cover.
|
||||
|
|
@ -47,6 +47,35 @@ pub struct Broker<'a> {
|
|||
}
|
||||
|
||||
impl Broker<'_> {
|
||||
/// Today in WIB, the date `BrokerRange::from_args` defaults `to` to.
|
||||
pub fn today(&self) -> NaiveDate {
|
||||
today_wib()
|
||||
}
|
||||
|
||||
/// The summary a frontend asked for: the latest session or a range.
|
||||
pub fn fetch(
|
||||
&self,
|
||||
symbol: &str,
|
||||
range: BrokerRange,
|
||||
investor: BrokerInvestor,
|
||||
board: BrokerBoard,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<BrokerSummary>, IdxError> {
|
||||
match range {
|
||||
BrokerRange::Latest => self.latest(symbol, investor, board, policy),
|
||||
BrokerRange::Dates { from, to } => self.summary(
|
||||
symbol,
|
||||
&BrokerQuery {
|
||||
from,
|
||||
to,
|
||||
investor,
|
||||
board,
|
||||
},
|
||||
policy,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The broker summary for `symbol` over `query`'s range. An empty
|
||||
/// summary means no trades (weekend, holiday, suspension).
|
||||
pub fn summary(
|
||||
|
|
@ -247,6 +276,38 @@ mod tests {
|
|||
assert_eq!(filtered.sellers[0].value, Some(2_000_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_broker_range_from_args() {
|
||||
let today = date(2026, 10, 18);
|
||||
let range = |date, from, to| BrokerRange::from_args(date, from, to, today);
|
||||
assert_eq!(range(None, None, None).unwrap(), BrokerRange::Latest);
|
||||
assert_eq!(
|
||||
range(Some("2026-10-16"), None, None).unwrap(),
|
||||
BrokerRange::Dates {
|
||||
from: date(2026, 10, 16),
|
||||
to: date(2026, 10, 16)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
range(None, Some("2026-10-12"), None).unwrap(),
|
||||
BrokerRange::Dates {
|
||||
from: date(2026, 10, 12),
|
||||
to: today
|
||||
}
|
||||
);
|
||||
for (d, f, t) in [
|
||||
(None, None, Some("2026-10-16")),
|
||||
(Some("2026-10-16"), Some("2026-10-12"), None),
|
||||
(Some("16/10/2026"), None, None),
|
||||
(None, Some("2026-10-12"), Some("tomorrow")),
|
||||
] {
|
||||
assert!(
|
||||
matches!(range(d, f, t), Err(IdxError::InvalidInput(_))),
|
||||
"{d:?} {f:?} {t:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_range_bounds() {
|
||||
let today = date(2026, 10, 18);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
//! Broker summary types: which IDX brokers bought and sold a stock.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::IdxError;
|
||||
|
||||
/// Whose trades a broker summary counts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
|
|
@ -18,6 +22,8 @@ pub enum BrokerInvestor {
|
|||
}
|
||||
|
||||
impl BrokerInvestor {
|
||||
pub const ALL: [Self; 3] = [Self::All, Self::Foreign, Self::Domestic];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "all",
|
||||
|
|
@ -27,6 +33,21 @@ impl BrokerInvestor {
|
|||
}
|
||||
}
|
||||
|
||||
impl FromStr for BrokerInvestor {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"all" => Ok(Self::All),
|
||||
"foreign" => Ok(Self::Foreign),
|
||||
"domestic" => Ok(Self::Domestic),
|
||||
_ => Err(IdxError::InvalidInput(format!(
|
||||
"invalid investor '{value}'; use all, foreign or domestic"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which market board's trades a broker summary counts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
|
|
@ -44,6 +65,8 @@ pub enum BrokerBoard {
|
|||
}
|
||||
|
||||
impl BrokerBoard {
|
||||
pub const ALL: [Self; 4] = [Self::All, Self::Regular, Self::Cash, Self::Negotiated];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "all",
|
||||
|
|
@ -54,6 +77,22 @@ impl BrokerBoard {
|
|||
}
|
||||
}
|
||||
|
||||
impl FromStr for BrokerBoard {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"all" => Ok(Self::All),
|
||||
"regular" => Ok(Self::Regular),
|
||||
"cash" => Ok(Self::Cash),
|
||||
"negotiated" => Ok(Self::Negotiated),
|
||||
_ => Err(IdxError::InvalidInput(format!(
|
||||
"invalid board '{value}'; use all, regular, cash or negotiated"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The source's classification of a securities house. This is the broker's
|
||||
/// own status, not the nationality of its clients.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -75,6 +114,49 @@ impl BrokerKind {
|
|||
}
|
||||
}
|
||||
|
||||
/// Which sessions a frontend asked for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BrokerRange {
|
||||
/// The latest session with trades.
|
||||
Latest,
|
||||
/// `from..=to`, WIB dates.
|
||||
Dates { from: NaiveDate, to: NaiveDate },
|
||||
}
|
||||
|
||||
impl BrokerRange {
|
||||
/// From a frontend's `date`, or `from` with an optional `to` (default
|
||||
/// today), as `YYYY-MM-DD`; none of them means the latest session.
|
||||
pub fn from_args(
|
||||
date: Option<&str>,
|
||||
from: Option<&str>,
|
||||
to: Option<&str>,
|
||||
today: NaiveDate,
|
||||
) -> Result<Self, IdxError> {
|
||||
let parse = |name: &str, value: &str| {
|
||||
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| {
|
||||
IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD"))
|
||||
})
|
||||
};
|
||||
match (date, from, to) {
|
||||
(None, None, None) => Ok(Self::Latest),
|
||||
(Some(date), None, None) => {
|
||||
let day = parse("date", date)?;
|
||||
Ok(Self::Dates { from: day, to: day })
|
||||
}
|
||||
(None, Some(from), to) => Ok(Self::Dates {
|
||||
from: parse("from", from)?,
|
||||
to: to.map(|to| parse("to", to)).transpose()?.unwrap_or(today),
|
||||
}),
|
||||
(None, None, Some(_)) => Err(IdxError::InvalidInput(
|
||||
"to needs from (or use date for one session)".to_string(),
|
||||
)),
|
||||
(Some(_), _, _) => Err(IdxError::InvalidInput(
|
||||
"date cannot be combined with from or to".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trading days a broker summary covers, both ends included (WIB dates).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BrokerQuery {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
//! accepted values and error messages match the CLI; the schemas list them.
|
||||
|
||||
use idx_core::api::types::{Interval, Period};
|
||||
use idx_core::broker::types::{BrokerBoard, BrokerInvestor};
|
||||
use idx_core::market::{ScreenFilter, ScreenRegion};
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::Deserialize;
|
||||
|
|
@ -114,6 +115,30 @@ pub struct AnalyzeParams {
|
|||
pub kinds: Option<Vec<AnalysisKind>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BrokerParams {
|
||||
/// IDX ticker, e.g. "BBCA".
|
||||
pub symbol: String,
|
||||
/// One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.
|
||||
#[serde(default)]
|
||||
pub date: Option<String>,
|
||||
/// First session of a range, YYYY-MM-DD (at most 366 days).
|
||||
#[serde(default)]
|
||||
pub from: Option<String>,
|
||||
/// Last session of the range, YYYY-MM-DD (default today).
|
||||
#[serde(default)]
|
||||
pub to: Option<String>,
|
||||
/// Whose trades to count, by investor domicile (default "all").
|
||||
#[serde(default)]
|
||||
#[schemars(schema_with = "investor_schema")]
|
||||
pub investor: Option<String>,
|
||||
/// Which board's trades to count (default "all"); "regular" leaves out
|
||||
/// negotiated block trades.
|
||||
#[serde(default)]
|
||||
#[schemars(schema_with = "board_schema")]
|
||||
pub board: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ShareholdersParams {
|
||||
/// IDX ticker, e.g. "BBCA".
|
||||
|
|
@ -178,3 +203,11 @@ fn filter_schema(_: &mut SchemaGenerator) -> Schema {
|
|||
fn region_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(ScreenRegion::ALL.map(ScreenRegion::as_str).to_vec())
|
||||
}
|
||||
|
||||
fn investor_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(BrokerInvestor::ALL.map(BrokerInvestor::as_str).to_vec())
|
||||
}
|
||||
|
||||
fn board_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(BrokerBoard::ALL.map(BrokerBoard::as_str).to_vec())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use idx_core::analysis::fundamental::FundamentalReport;
|
||||
use idx_core::broker::types::BrokerRange;
|
||||
use idx_core::config::CoreConfig;
|
||||
use idx_core::error::IdxError;
|
||||
use idx_core::financial::{EarningsFilter, StatementFilter};
|
||||
|
|
@ -21,8 +22,9 @@ use serde_json::{Map, Value, json};
|
|||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::params::{
|
||||
AnalysisKind, AnalyzeParams, ChangesParams, CompanyParams, CompanySection, EntityParams,
|
||||
HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
|
||||
AnalysisKind, AnalyzeParams, BrokerParams, ChangesParams, CompanyParams, CompanySection,
|
||||
EntityParams, HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams,
|
||||
SyncParams,
|
||||
};
|
||||
|
||||
const SYNC_TOOL: &str = "ownership_sync";
|
||||
|
|
@ -165,6 +167,42 @@ for one or more IDX tickers."
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers \
|
||||
by lots over a session or date range, each with lots (1 lot = 100 shares), value and average \
|
||||
price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can \
|
||||
appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the \
|
||||
broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means \
|
||||
figures of a million and more carry one decimal of precision."
|
||||
)]
|
||||
async fn get_broker_summary(&self, params: Parameters<BrokerParams>) -> ToolResult {
|
||||
let BrokerParams {
|
||||
symbol,
|
||||
date,
|
||||
from,
|
||||
to,
|
||||
investor,
|
||||
board,
|
||||
} = params.0;
|
||||
self.live(move |idx| {
|
||||
let broker = idx.broker();
|
||||
let range = BrokerRange::from_args(
|
||||
date.as_deref(),
|
||||
from.as_deref(),
|
||||
to.as_deref(),
|
||||
broker.today(),
|
||||
)?;
|
||||
broker.fetch(
|
||||
&symbol,
|
||||
range,
|
||||
investor.as_deref().unwrap_or("all").parse()?,
|
||||
board.as_deref().unwrap_or("all").parse()?,
|
||||
CachePolicy::Default,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Company data for one IDX ticker: profile, financial statements, \
|
||||
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
||||
|
|
|
|||
|
|
@ -57,6 +57,68 @@
|
|||
},
|
||||
"name": "compare_stocks"
|
||||
},
|
||||
{
|
||||
"description": "Broker summary for one IDX ticker: the top 10 buyers and top 10 sellers by lots over a session or date range, each with lots (1 lot = 100 shares), value and average price in IDR, plus totals. Buyers and sellers are ranked separately and gross, so a broker can appear on both sides. End of day only (IDX hides broker codes during trading); `kind` is the broker's own status (local, foreign, state_owned), not its clients'. `rounded` = true means figures of a million and more carry one decimal of precision.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"board": {
|
||||
"default": null,
|
||||
"description": "Which board's trades to count (default \"all\"); \"regular\" leaves out\nnegotiated block trades.",
|
||||
"enum": [
|
||||
"all",
|
||||
"regular",
|
||||
"cash",
|
||||
"negotiated"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"date": {
|
||||
"default": null,
|
||||
"description": "One session, YYYY-MM-DD. Omit date, from and to for the latest session with trades.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"from": {
|
||||
"default": null,
|
||||
"description": "First session of a range, YYYY-MM-DD (at most 366 days).",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"investor": {
|
||||
"default": null,
|
||||
"description": "Whose trades to count, by investor domicile (default \"all\").",
|
||||
"enum": [
|
||||
"all",
|
||||
"foreign",
|
||||
"domestic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"description": "IDX ticker, e.g. \"BBCA\".",
|
||||
"type": "string"
|
||||
},
|
||||
"to": {
|
||||
"default": null,
|
||||
"description": "Last session of the range, YYYY-MM-DD (default today).",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"symbol"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_broker_summary"
|
||||
},
|
||||
{
|
||||
"description": "Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. News items carry `relevance`: `company` (about this ticker) first, then `market` (general market news). Sections that fail are listed under `errors`; the call fails only if every section fails.",
|
||||
"inputSchema": {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ use idx_core::config::CoreConfig;
|
|||
use idx_core::investor::{ImportSource, Investor};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const TOOLS: [&str; 11] = [
|
||||
const TOOLS: [&str; 12] = [
|
||||
"analyze_stock",
|
||||
"compare_stocks",
|
||||
"get_broker_summary",
|
||||
"get_company",
|
||||
"get_investor_holdings",
|
||||
"get_ownership_changes",
|
||||
|
|
@ -247,6 +248,16 @@ fn live_tools_wrap_data_with_freshness() {
|
|||
assert_fetched(&analysis[kind]);
|
||||
}
|
||||
|
||||
let brokers = client.ok(
|
||||
"get_broker_summary",
|
||||
json!({"symbol": "BBCA", "date": "2026-10-16"}),
|
||||
);
|
||||
assert_fetched(&brokers);
|
||||
assert_eq!(brokers["source"], "ipot");
|
||||
assert_eq!(brokers["data"]["buyers"][0]["code"], "MG");
|
||||
assert_eq!(brokers["data"]["sellers"][0]["name"], "MANDIRI SEKURITAS");
|
||||
assert_eq!(brokers["data"]["foreign_net_value"], -2_300_000_000_000_i64);
|
||||
|
||||
let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]}));
|
||||
assert_eq!(compared["reports"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(compared["failures"], json!([]));
|
||||
|
|
@ -273,6 +284,14 @@ fn errors_carry_cli_codes() {
|
|||
("compare_stocks", json!({"symbols": []})),
|
||||
("get_company", json!({"symbol": "BBCA", "sections": []})),
|
||||
("analyze_stock", json!({"symbol": "BBCA", "kinds": []})),
|
||||
(
|
||||
"get_broker_summary",
|
||||
json!({"symbol": "BBCA", "date": "2026-10-16", "from": "2026-10-12"}),
|
||||
),
|
||||
(
|
||||
"get_broker_summary",
|
||||
json!({"symbol": "BBCA", "date": "2026-10-16", "board": "dark"}),
|
||||
),
|
||||
] {
|
||||
let (is_error, body) = client.call(tool, arguments);
|
||||
assert!(is_error, "{tool}: {body}");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue