From ae0ca0c1096e281753d5f74046ab3eb8c45d3a4b Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 3 Nov 2026 16:02:26 +0700 Subject: [PATCH 1/8] feat: shareholder movements from the Stockbit session (ownership movements) `idx ownership movements [SYMBOL]` and the MCP tool `get_shareholder_movements` list reported changes in insiders' and major holders' stakes, newest first, from Stockbit's /insider/company/majorholder: IDX insider disclosures (any size, with the trade price) and KSEI changes of holders at 5% and above (Stockbit's estimated price). Stockbit filters by date range, holder id, action and source; each page is 50 rows and one request, up to 10 pages, cached an hour per page under every filter. Rows keep exact signed share counts and the stake in basis points as Stockbit rounds it; unknown action labels are kept as `other` rather than dropped. JSON is {movements, more}; JSONL is one movement per line. The command lives under `ownership` but needs no import, and `ownership` commands now take --offline / --no-cache for it. The per-symbol summary score is the next PR; docs/STOCKBIT.md records the endpoint's parameters and action types as checked on 2026-11-03. Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY --- README.md | 7 +- ROADMAP.md | 6 +- crates/idx-cli/src/cli/ownership.rs | 81 +++- crates/idx-cli/src/main.rs | 4 +- crates/idx-cli/src/output/mod.rs | 16 + crates/idx-cli/src/output/table.rs | 110 +++++ crates/idx-cli/tests/golden/cases.toml | 24 + .../tests/golden/completions-bash.golden | 93 +++- .../golden/error-movements-holder.json.golden | 4 + .../error-movements-holder.table.golden | 4 + .../error-movements-no-session.json.golden | 4 + .../error-movements-no-session.table.golden | 4 + .../tests/golden/help-ownership.golden | 1 + .../ownership-movements-bbca.json.golden | 4 + .../ownership-movements-bbca.table.golden | 26 ++ ...ownership-movements-ksei-sells.json.golden | 4 + ...wnership-movements-ksei-sells.table.golden | 15 + .../golden/ownership-movements.json.golden | 4 + .../golden/ownership-movements.table.golden | 37 ++ crates/idx-core/src/api/mock.rs | 37 +- crates/idx-core/src/api/mod.rs | 19 + crates/idx-core/src/api/stockbit/exodus.rs | 22 +- crates/idx-core/src/api/stockbit/mod.rs | 14 +- crates/idx-core/src/api/stockbit/movement.rs | 328 +++++++++++++ crates/idx-core/src/client.rs | 4 + crates/idx-core/src/lib.rs | 1 + crates/idx-core/src/movements/mod.rs | 124 +++++ crates/idx-core/src/movements/types.rs | 334 ++++++++++++++ .../fixtures/stockbit_exodus_majorholder.json | 416 +++++++++++++++++ .../stockbit_exodus_majorholder_bbca.json | 430 ++++++++++++++++++ .../stockbit_exodus_majorholder_bbca_p2.json | 220 +++++++++ .../stockbit_exodus_majorholder_p2.json | 314 +++++++++++++ crates/idx-mcp/src/params.rs | 41 ++ crates/idx-mcp/src/server.rs | 41 +- crates/idx-mcp/tests/golden/tools.json | 70 +++ crates/idx-mcp/tests/mcp.rs | 51 ++- docs/ARCHITECTURE.md | 8 + docs/STOCKBIT.md | 35 +- web/src/content/docs/cli/ownership.md | 26 ++ web/src/content/docs/cli/reference.md | 74 +++ web/src/content/docs/cli/stockbit.md | 4 +- web/src/content/docs/mcp/index.md | 4 +- web/src/content/docs/mcp/tools.md | 14 + 43 files changed, 3050 insertions(+), 29 deletions(-) create mode 100644 crates/idx-cli/tests/golden/error-movements-holder.json.golden create mode 100644 crates/idx-cli/tests/golden/error-movements-holder.table.golden create mode 100644 crates/idx-cli/tests/golden/error-movements-no-session.json.golden create mode 100644 crates/idx-cli/tests/golden/error-movements-no-session.table.golden create mode 100644 crates/idx-cli/tests/golden/ownership-movements-bbca.json.golden create mode 100644 crates/idx-cli/tests/golden/ownership-movements-bbca.table.golden create mode 100644 crates/idx-cli/tests/golden/ownership-movements-ksei-sells.json.golden create mode 100644 crates/idx-cli/tests/golden/ownership-movements-ksei-sells.table.golden create mode 100644 crates/idx-cli/tests/golden/ownership-movements.json.golden create mode 100644 crates/idx-cli/tests/golden/ownership-movements.table.golden create mode 100644 crates/idx-core/src/api/stockbit/movement.rs create mode 100644 crates/idx-core/src/movements/mod.rs create mode 100644 crates/idx-core/src/movements/types.rs create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json diff --git a/README.md b/README.md index 8b5281c..695c8d3 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31 market is open, delayed at least 10 minutes. With your own account (`idx stockbit login`): `stocks orderbook` depth, live quotes, Stockbit daily history, and corporate actions (`stocks actions`, - `stocks calendar`: dividends, rights, splits, meetings, IPOs and more). Personal use under Stockbit's terms; see + `stocks calendar`: dividends, rights, splits, meetings, IPOs and more), and + insider and major holder buys and sells (`ownership movements`). Personal use under Stockbit's terms; see the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/) - Local file cache with TTL, and `--offline` mode with stale-cache fallback @@ -166,9 +167,9 @@ change it to `auto` with `idx config set general.output auto`. ## MCP server -`idx-mcp` serves the same data to MCP clients over stdio, as 20 tools: +`idx-mcp` serves the same data to MCP clients over stdio, as 21 tools: `get_quotes`, `get_intraday`, `get_orderbook`, `get_corporate_actions`, -`get_corporate_calendar`, `stockbit_status`, `stockbit_login`, `get_price_history`, `screen_stocks`, `get_company`, +`get_corporate_calendar`, `get_shareholder_movements`, `stockbit_status`, `stockbit_login`, `get_price_history`, `screen_stocks`, `get_company`, `analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`, `get_foreign_flow`, `get_shareholders`, `get_investor_holdings`, `search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as diff --git a/ROADMAP.md b/ROADMAP.md index 88d1ea7..a2929cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,8 +14,10 @@ history lives in git. - Multi-period financial statements (today `financials` is single-period) - Order book depth without a login: no free source exists (IDX sells delayed depth to vendors); `stocks orderbook` needs the user's Stockbit login. -- Shareholder movements (`ownership movements`) from the Stockbit session; - design and endpoints in `docs/STOCKBIT.md`. Corporate-action follow-ups: +- Shareholder movement follow-ups (`docs/STOCKBIT.md`): `ownership movements + --summary`, a per-symbol score (net value, distinct buyers, share-plan + flag), then an event study before calling any of it a signal; joining + holders to ownership entities by name. Corporate-action follow-ups: ex-dates of splits, bonuses and rights noted in `stocks history`, and the `stock_dividend` / `reversesplit` row shapes once Stockbit lists one. - Stockbit login follow-ups: hourly OHLCV from the session's `1w` chart (the diff --git a/crates/idx-cli/src/cli/ownership.rs b/crates/idx-cli/src/cli/ownership.rs index 125952f..cd2850f 100644 --- a/crates/idx-cli/src/cli/ownership.rs +++ b/crates/idx-cli/src/cli/ownership.rs @@ -13,9 +13,11 @@ use idx_core::error::IdxError; use idx_core::investor::{ ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family, }; +use idx_core::movements::types::{ActionFilter, MovementQuery, MovementSource}; use idx_core::ownership::graph; use idx_core::ownership::snapshot; use idx_core::ownership::types::{ChangeType, FlowSignal, HolderRow, OwnershipSource}; +use idx_core::{CachePolicy, Idx}; #[derive(Debug, Args)] #[command( @@ -72,6 +74,17 @@ pub enum OwnershipCommand { after_help = "Examples:\n idx ownership releases\n idx ownership changes --from 2026-08-31 --to 2026-09-30" )] Changes(ChangesArgs), + #[command( + about = "Insider and major holder buys and sells, as reported (Stockbit login)", + long_about = "Reported changes in holders' stakes from your own Stockbit session (`idx \ +stockbit login`), newest first: IDX insider disclosures (directors, commissioners and other \ +insiders, any size, with the trade price) and KSEI's changes of holders at 5% and above (Stockbit \ +estimates their price). One stock's, or the whole market's without SYMBOL. Each page is 50 rows \ +and one request; answers are cached for an hour.\n\nThese are filings, not signals: a share \ +plan shows as many insiders buying at one price, and a row appears days after the change.", + after_help = "Examples:\n idx ownership movements BBCA\n idx ownership movements --action sell --source ksei\n idx ownership movements --from 2026-10-01 --to 2026-10-31 --pages 5\n idx ownership movements --holder 1000004481 # one holder's history (HOLDER ID column)" + )] + Movements(MovementsArgs), /// Ownership network graph. Graph(GraphArgs), /// Manual entity resolution workflow. @@ -168,6 +181,30 @@ pub struct ChangesArgs { pub to: String, } +#[derive(Debug, Args)] +pub struct MovementsArgs { + /// Single ticker symbol (e.g. BBCA); omit for the whole market. + pub symbol: Option, + /// First day, YYYY-MM-DD. + #[arg(long)] + pub from: Option, + /// Last day, YYYY-MM-DD. + #[arg(long)] + pub to: Option, + /// One holder's movements, by the id in the HOLDER ID column. + #[arg(long = "holder", value_name = "ID")] + pub holder_id: Option, + /// Only this kind of movement. + #[arg(long, value_enum)] + pub action: Option, + /// Only IDX insider disclosures or KSEI 5% holder changes. + #[arg(long, value_enum)] + pub source: Option, + /// Pages of 50 rows to read (1-10). + #[arg(long, default_value_t = 1)] + pub pages: u32, +} + #[derive(Debug, Args)] pub struct GraphArgs { /// Ticker code or entity name to start from. @@ -209,7 +246,15 @@ pub enum ResolveCommand { }, } -pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError> { +pub fn handle( + cmd: &OwnershipCommand, + config: &IdxConfig, + offline: bool, + no_cache: bool, +) -> Result<(), IdxError> { + if let OwnershipCommand::Movements(args) = cmd { + return handle_movements(args, config, CachePolicy::from_flags(offline, no_cache)?); + } let investor = Investor::new(&config.core); let inv = &investor; match cmd { @@ -223,6 +268,9 @@ pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError OwnershipCommand::Concentration(args) => handle_concentration(inv, args, config), OwnershipCommand::Flow(args) => handle_flow(inv, args, config), OwnershipCommand::Changes(args) => handle_changes(inv, args, config), + OwnershipCommand::Movements(args) => { + handle_movements(args, config, CachePolicy::from_flags(offline, no_cache)?) + } OwnershipCommand::Graph(args) => handle_graph(inv, args, config), OwnershipCommand::Resolve(args) => handle_resolve(inv, args, config), OwnershipCommand::Releases => handle_releases(inv, config), @@ -678,6 +726,37 @@ fn handle_changes( Ok(()) } +fn handle_movements( + args: &MovementsArgs, + config: &IdxConfig, + policy: CachePolicy, +) -> Result<(), IdxError> { + let mut query = MovementQuery::from_args( + args.symbol.as_deref(), + args.from.as_deref(), + args.to.as_deref(), + args.holder_id.as_deref(), + args.pages, + )?; + query.action = args.action; + query.source = args.source; + let idx = Idx::new(config.core.clone())?; + let list = idx.movements().list(&query, policy)?; + let symbol = match &query.symbol { + Some(symbol) => Some(idx_core::api::resolve_symbol( + symbol, + &config.core.exchange, + )?), + None => None, + }; + crate::output::render_shareholder_movements( + symbol.as_deref(), + &list.data, + &config.output, + config.no_color, + ) +} + fn handle_resolve( investor: &Investor, args: &ResolveArgs, diff --git a/crates/idx-cli/src/main.rs b/crates/idx-cli/src/main.rs index bbaab3f..e7da72d 100644 --- a/crates/idx-cli/src/main.rs +++ b/crates/idx-cli/src/main.rs @@ -81,7 +81,9 @@ fn run() -> Result<(), IdxError> { } #[cfg(feature = "ownership")] Commands::Ownership(cmd) => { - if let Err(err) = cli::ownership::handle(&cmd.command, &config) { + if let Err(err) = + cli::ownership::handle(&cmd.command, &config, cli.offline, cli.no_cache) + { emit_error(&err, &config.output); return Err(err); } diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 83dd257..633acb2 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -22,6 +22,7 @@ use idx_core::config::ProviderKind; use idx_core::corporate::types::{CalendarRange, CorporateAction}; use idx_core::error::IdxError; use idx_core::flow::types::ForeignFlow; +use idx_core::movements::types::MovementList; /// Output format requested by the user (flag, `IDX_OUTPUT`, or config file). #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)] @@ -126,6 +127,21 @@ pub fn render_corporate_calendar( } } +/// JSON is `{movements, more}` (`more`: rows remain past the pages read); +/// JSONL is one movement per line. +pub fn render_shareholder_movements( + symbol: Option<&str>, + list: &MovementList, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_shareholder_movements(symbol, list, no_color), + OutputFormat::Json => json::print_json(list), + OutputFormat::Jsonl => json::print_json(&list.movements), + } +} + pub fn render_broker_summary( summary: &BrokerSummary, format: &OutputFormat, diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 994c454..35cb88d 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -18,6 +18,9 @@ use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKin use idx_core::corporate::types::{ActionKind, CalendarRange, CorporateAction, DateRange}; use idx_core::error::IdxError; use idx_core::flow::types::{FlowSource, ForeignFlow}; +use idx_core::movements::types::{ + MovementAction, MovementList, MovementSource, ShareholderMovement, +}; pub fn format_idr(value: i64) -> String { let sign = if value.is_negative() { "-" } else { "" }; @@ -1855,6 +1858,113 @@ pub fn print_corporate_calendar( Ok(()) } +fn movement_action_label(action: MovementAction, no_color: bool) -> String { + let label = action.as_str().replace('_', " "); + match action { + _ if no_color => label, + MovementAction::Buy => label.green().to_string(), + MovementAction::Sell => label.red().to_string(), + _ => label, + } +} + +/// Signed exact shares: `+147,933`, `-10,000,000`. +fn format_signed_shares(value: i64) -> String { + let text = format_idr(value); + if value > 0 { format!("+{text}") } else { text } +} + +/// The stake after the change and the change in percentage points: +/// `40.65% (-0.16)`. A stake that rounds to 0 basis points reads `<0.01%`. +fn movement_stake(movement: &ShareholderMovement) -> String { + let after = match movement.percentage_after_bps { + 0 if movement.shares_after > 0 => "<0.01%".to_string(), + bps => format!("{:.2}%", bps as f64 / 100.0), + }; + match movement.percentage_change_bps { + 0 => after, + bps => format!("{after} ({:+.2})", bps as f64 / 100.0), + } +} + +const MOVEMENT_NOTE: &str = "IDX rows are insider disclosures (directors, commissioners and \ +other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated \ +price. Rows appear days after the change."; + +pub fn print_shareholder_movements( + symbol: Option<&str>, + list: &MovementList, + no_color: bool, +) -> Result<(), IdxError> { + let title = match symbol { + Some(symbol) => format!("Shareholder movements for {}", terminal_safe(symbol)), + None => "Shareholder movements across IDX".to_string(), + }; + println!("{}", format!("{title} ({})", list.movements.len()).bold()); + if list.movements.is_empty() { + println!("No movements reported for this query."); + } else { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "DATE", + "SYMBOL", + "HOLDER", + "ROLE", + "ACTION", + "SHARES", + "STAKE", + "PRICE", + "VALUE", + "SOURCE", + "HOLDER ID", + ]); + for movement in &list.movements { + let estimated = if movement.price_estimated { "~" } else { "" }; + table.add_row(vec![ + cell(movement.date), + cell(&movement.symbol), + cell(&movement.holder), + cell(if movement.roles.is_empty() { + "-".to_string() + } else { + movement.roles.join(", ") + }), + painted(movement_action_label(movement.action, no_color)), + cell(format_signed_shares(movement.shares_change)), + cell(movement_stake(movement)), + cell( + movement + .price + .map(|p| format!("{estimated}{}", format_idr(p))) + .unwrap_or_else(|| "-".to_string()), + ), + cell( + movement + .value + .map(|v| format!("{estimated}{}", format_compact(v))) + .unwrap_or_else(|| "-".to_string()), + ), + cell(match movement.source { + Some(MovementSource::Idx) => "IDX", + Some(MovementSource::Ksei) => "KSEI", + None => "-", + }), + cell(&movement.holder_id), + ]); + } + println!("{table}"); + println!("{MOVEMENT_NOTE}"); + } + if list.more { + println!("More rows exist: raise --pages (up to 10) or narrow --from/--to."); + } + println!("{STOCKBIT_SESSION_NOTE}"); + Ok(()) +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 942f618..af7eced 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -420,6 +420,15 @@ args = ["stocks", "orderbook", "BBCA"] name = "error-actions-no-session" args = ["stocks", "actions", "BBCA"] +[[case]] +name = "error-movements-no-session" +args = ["ownership", "movements", "BBCA"] + +[[case]] +name = "error-movements-holder" +args = ["ownership", "movements", "--holder", "RUDI"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + [[case]] name = "error-calendar-range" args = ["stocks", "calendar", "--from", "2026-11-10", "--to", "2026-11-01"] @@ -432,6 +441,21 @@ env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0OD # --- ownership -------------------------------------------------------------------- +[[case]] +name = "ownership-movements" +args = ["ownership", "movements", "--pages", "2"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "ownership-movements-bbca" +args = ["ownership", "movements", "BBCA"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "ownership-movements-ksei-sells" +args = ["ownership", "movements", "--action", "sell", "--source", "ksei", "--pages", "3"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + [[case]] name = "ownership-releases-empty" args = ["ownership", "releases"] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index 603c3e0..b7506c3 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -156,6 +156,9 @@ _idx() { idx__help__ownership,import) cmd="idx__help__ownership__import" ;; + idx__help__ownership,movements) + cmd="idx__help__ownership__movements" + ;; idx__help__ownership,releases) cmd="idx__help__ownership__releases" ;; @@ -282,6 +285,9 @@ _idx() { idx__ownership,import) cmd="idx__ownership__import" ;; + idx__ownership,movements) + cmd="idx__ownership__movements" + ;; idx__ownership,releases) cmd="idx__ownership__releases" ;; @@ -324,6 +330,9 @@ _idx() { idx__ownership__help,import) cmd="idx__ownership__help__import" ;; + idx__ownership__help,movements) + cmd="idx__ownership__help__movements" + ;; idx__ownership__help,releases) cmd="idx__ownership__help__releases" ;; @@ -1095,7 +1104,7 @@ _idx() { return 0 ;; idx__help__ownership) - opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases" + opts="discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1220,6 +1229,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__ownership__movements) + 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__ownership__releases) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -1725,7 +1748,7 @@ _idx() { return 0 ;; idx__ownership) - opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1973,7 +1996,7 @@ _idx() { return 0 ;; idx__ownership__help) - opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help" + opts="discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2112,6 +2135,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__ownership__help__movements) + 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__ownership__help__releases) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2262,6 +2299,56 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__ownership__movements) + opts="-o -q -v -h --from --to --holder --action --source --pages --output --no-color --quiet --verbose --provider --offline --no-cache --help [SYMBOL]" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --holder) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --action) + COMPREPLY=($(compgen -W "buy sell transfer cross corporate-action" -- "${cur}")) + return 0 + ;; + --source) + COMPREPLY=($(compgen -W "idx ksei" -- "${cur}")) + return 0 + ;; + --pages) + 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 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__ownership__releases) opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then diff --git a/crates/idx-cli/tests/golden/error-movements-holder.json.golden b/crates/idx-cli/tests/golden/error-movements-holder.json.golden new file mode 100644 index 0000000..782a248 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-movements-holder.json.golden @@ -0,0 +1,4 @@ +exit: 2 +--- stdout +--- stderr +{"code":"INVALIDINPUT","error":true,"message":"invalid input: holder 'RUDI' is not a holder id; ids are digits, shown in the HOLDER ID column and as holder_id in JSON"} diff --git a/crates/idx-cli/tests/golden/error-movements-holder.table.golden b/crates/idx-cli/tests/golden/error-movements-holder.table.golden new file mode 100644 index 0000000..2f44611 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-movements-holder.table.golden @@ -0,0 +1,4 @@ +exit: 2 +--- stdout +--- stderr +Error: invalid input: holder 'RUDI' is not a holder id; ids are digits, shown in the HOLDER ID column and as holder_id in JSON diff --git a/crates/idx-cli/tests/golden/error-movements-no-session.json.golden b/crates/idx-cli/tests/golden/error-movements-no-session.json.golden new file mode 100644 index 0000000..d38ac26 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-movements-no-session.json.golden @@ -0,0 +1,4 @@ +exit: 4 +--- stdout +--- stderr +{"code":"AUTHERROR","error":true,"message":"auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)"} diff --git a/crates/idx-cli/tests/golden/error-movements-no-session.table.golden b/crates/idx-cli/tests/golden/error-movements-no-session.table.golden new file mode 100644 index 0000000..e1fcda0 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-movements-no-session.table.golden @@ -0,0 +1,4 @@ +exit: 4 +--- stdout +--- stderr +Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN) diff --git a/crates/idx-cli/tests/golden/help-ownership.golden b/crates/idx-cli/tests/golden/help-ownership.golden index 202f0da..406d191 100644 --- a/crates/idx-cli/tests/golden/help-ownership.golden +++ b/crates/idx-cli/tests/golden/help-ownership.golden @@ -20,6 +20,7 @@ Commands: concentration Rank tickers by ownership concentration flow Show Bing institutional flow for a ticker changes Diff two KSEI releases + movements Insider and major holder buys and sells, as reported (Stockbit login) graph Ownership network graph resolve Manual entity resolution workflow releases List imported KSEI releases diff --git a/crates/idx-cli/tests/golden/ownership-movements-bbca.json.golden b/crates/idx-cli/tests/golden/ownership-movements-bbca.json.golden new file mode 100644 index 0000000..fa4664c --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements-bbca.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"movements":[{"symbol":"BBCA","date":"2026-03-25","holder":"HENDRA TANUMIHARDJA","holder_id":"1000000439","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":193206,"shares_after":341139,"shares_change":147933,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":1032868206},{"symbol":"BBCA","date":"2026-03-25","holder":"A. WIDODO MULYONO","holder_id":"1000000438","action":"buy","source":"idx","locality":"Local","shares_before":440838,"shares_after":681407,"shares_change":240569,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":1679652758},{"symbol":"BBCA","date":"2026-03-25","holder":"GREGORY HENDRA LEMBONG","holder_id":"1000000437","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":1531282,"shares_after":2666921,"shares_change":1135639,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":7929031498},{"symbol":"BBCA","date":"2026-03-25","holder":"SANTOSO","holder_id":"1000000209","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":3269028,"shares_after":3764062,"shares_change":495034,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":3456327388},{"symbol":"BBCA","date":"2026-03-25","holder":"TONNY KUSNADI","holder_id":"14421","action":"buy","source":"idx","locality":"Local","roles":["commissioner"],"shares_before":7502058,"shares_after":7819950,"shares_change":317892,"percentage_before_bps":1,"percentage_after_bps":1,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":2219521944},{"symbol":"BBCA","date":"2026-03-25","holder":"FRENGKY CHANDRA KUSUMA","holder_id":"14418","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":2429926,"shares_after":2835916,"shares_change":405990,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":2834622180},{"symbol":"BBCA","date":"2026-03-25","holder":"HARYANTO TIARA BUDIMAN","holder_id":"14417","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":1057378,"shares_after":1436806,"shares_change":379428,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":2649166296},{"symbol":"BBCA","date":"2026-03-25","holder":"VERA EVE LIM","holder_id":"14416","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":2731601,"shares_after":3281460,"shares_change":549859,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":3839115538}],"more":true} +--- stderr diff --git a/crates/idx-cli/tests/golden/ownership-movements-bbca.table.golden b/crates/idx-cli/tests/golden/ownership-movements-bbca.table.golden new file mode 100644 index 0000000..537acfd --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements-bbca.table.golden @@ -0,0 +1,26 @@ +exit: 0 +--- stdout +Shareholder movements for BBCA.JK (8) +┌────────────┬────────┬────────────────────────┬──────────────┬────────┬────────────┬────────┬───────┬───────┬────────┬────────────┐ +│ DATE ┆ SYMBOL ┆ HOLDER ┆ ROLE ┆ ACTION ┆ SHARES ┆ STAKE ┆ PRICE ┆ VALUE ┆ SOURCE ┆ HOLDER ID │ +╞════════════╪════════╪════════════════════════╪══════════════╪════════╪════════════╪════════╪═══════╪═══════╪════════╪════════════╡ +│ 2026-03-25 ┆ BBCA ┆ HENDRA TANUMIHARDJA ┆ director ┆ buy ┆ +147,933 ┆ <0.01% ┆ 6,982 ┆ 1.0B ┆ IDX ┆ 1000000439 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-03-25 ┆ BBCA ┆ A. WIDODO MULYONO ┆ - ┆ buy ┆ +240,569 ┆ <0.01% ┆ 6,982 ┆ 1.7B ┆ IDX ┆ 1000000438 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-03-25 ┆ BBCA ┆ GREGORY HENDRA LEMBONG ┆ director ┆ buy ┆ +1,135,639 ┆ <0.01% ┆ 6,982 ┆ 7.9B ┆ IDX ┆ 1000000437 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-03-25 ┆ BBCA ┆ SANTOSO ┆ director ┆ buy ┆ +495,034 ┆ <0.01% ┆ 6,982 ┆ 3.5B ┆ IDX ┆ 1000000209 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-03-25 ┆ BBCA ┆ TONNY KUSNADI ┆ commissioner ┆ buy ┆ +317,892 ┆ 0.01% ┆ 6,982 ┆ 2.2B ┆ IDX ┆ 14421 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-03-25 ┆ BBCA ┆ FRENGKY CHANDRA KUSUMA ┆ director ┆ buy ┆ +405,990 ┆ <0.01% ┆ 6,982 ┆ 2.8B ┆ IDX ┆ 14418 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-03-25 ┆ BBCA ┆ HARYANTO TIARA BUDIMAN ┆ director ┆ buy ┆ +379,428 ┆ <0.01% ┆ 6,982 ┆ 2.6B ┆ IDX ┆ 14417 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-03-25 ┆ BBCA ┆ VERA EVE LIM ┆ director ┆ buy ┆ +549,859 ┆ <0.01% ┆ 6,982 ┆ 3.8B ┆ IDX ┆ 14416 │ +└────────────┴────────┴────────────────────────┴──────────────┴────────┴────────────┴────────┴───────┴───────┴────────┴────────────┘ +IDX rows are insider disclosures (directors, commissioners and other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated price. Rows appear days after the change. +More rows exist: raise --pages (up to 10) or narrow --from/--to. +Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms). +--- stderr diff --git a/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.json.golden b/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.json.golden new file mode 100644 index 0000000..8f0f8c7 --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"movements":[{"symbol":"ATLA","date":"2026-10-30","holder":"RUDI R. SUTANTRA","holder_id":"1000004481","action":"sell","source":"ksei","locality":"Local","shares_before":2530254200,"shares_after":2520254200,"shares_change":-10000000,"percentage_before_bps":4081,"percentage_after_bps":4065,"percentage_change_bps":-16,"price":21,"price_estimated":true,"value":210000000},{"symbol":"FUJI","date":"2026-10-15","holder":"JEFRI NEDI","holder_id":"1000006017","action":"sell","source":"ksei","locality":"Local","shares_before":109431600,"shares_after":109251600,"shares_change":-180000,"percentage_before_bps":842,"percentage_after_bps":840,"percentage_change_bps":-1,"price":298,"price_estimated":true,"value":53640000},{"symbol":"ATLA","date":"2026-10-15","holder":"RUDI R. SUTANTRA","holder_id":"1000004481","action":"sell","source":"ksei","locality":"Local","shares_before":2638254200,"shares_after":2610254200,"shares_change":-28000000,"percentage_before_bps":4256,"percentage_after_bps":4210,"percentage_change_bps":-45,"price":29,"price_estimated":true,"value":812000000}],"more":false} +--- stderr diff --git a/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.table.golden b/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.table.golden new file mode 100644 index 0000000..b67a877 --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.table.golden @@ -0,0 +1,15 @@ +exit: 0 +--- stdout +Shareholder movements across IDX (3) +┌────────────┬────────┬──────────────────┬──────┬────────┬─────────────┬────────────────┬───────┬─────────┬────────┬────────────┐ +│ DATE ┆ SYMBOL ┆ HOLDER ┆ ROLE ┆ ACTION ┆ SHARES ┆ STAKE ┆ PRICE ┆ VALUE ┆ SOURCE ┆ HOLDER ID │ +╞════════════╪════════╪══════════════════╪══════╪════════╪═════════════╪════════════════╪═══════╪═════════╪════════╪════════════╡ +│ 2026-10-30 ┆ ATLA ┆ RUDI R. SUTANTRA ┆ - ┆ sell ┆ -10,000,000 ┆ 40.65% (-0.16) ┆ ~21 ┆ ~210.0M ┆ KSEI ┆ 1000004481 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ FUJI ┆ JEFRI NEDI ┆ - ┆ sell ┆ -180,000 ┆ 8.40% (-0.01) ┆ ~298 ┆ ~53.6M ┆ KSEI ┆ 1000006017 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ ATLA ┆ RUDI R. SUTANTRA ┆ - ┆ sell ┆ -28,000,000 ┆ 42.10% (-0.45) ┆ ~29 ┆ ~812.0M ┆ KSEI ┆ 1000004481 │ +└────────────┴────────┴──────────────────┴──────┴────────┴─────────────┴────────────────┴───────┴─────────┴────────┴────────────┘ +IDX rows are insider disclosures (directors, commissioners and other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated price. Rows appear days after the change. +Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms). +--- stderr diff --git a/crates/idx-cli/tests/golden/ownership-movements.json.golden b/crates/idx-cli/tests/golden/ownership-movements.json.golden new file mode 100644 index 0000000..3e5b499 --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"movements":[{"symbol":"PEGE","date":"2026-11-02","holder":"OPTIMUS VISION GLOBAL","holder_id":"1000006115","action":"buy","source":"idx","locality":"Foreign","shares_before":249112774,"shares_after":251612774,"shares_change":2500000,"percentage_before_bps":659,"percentage_after_bps":666,"percentage_change_bps":7,"price":120,"price_estimated":false,"value":300000000,"purpose":"Pembelian"},{"symbol":"PEGE","date":"2026-11-02","holder":"OPTIMUS VISION GLOBAL","holder_id":"1000006115","action":"buy","source":"idx","locality":"Foreign","shares_before":246212774,"shares_after":249112774,"shares_change":2900000,"percentage_before_bps":652,"percentage_after_bps":659,"percentage_change_bps":8,"price":122,"price_estimated":false,"value":353800000,"purpose":"Pembelian"},{"symbol":"PEGE","date":"2026-11-02","holder":"OPTIMUS VISION GLOBAL","holder_id":"1000006115","action":"buy","source":"idx","locality":"Foreign","shares_before":242512774,"shares_after":243712774,"shares_change":1200000,"percentage_before_bps":642,"percentage_after_bps":645,"percentage_change_bps":3,"price":123,"price_estimated":false,"value":147600000,"purpose":"Pembelian"},{"symbol":"PEGE","date":"2026-11-02","holder":"OPTIMUS VISION GLOBAL","holder_id":"1000006115","action":"buy","source":"idx","locality":"Foreign","shares_before":241674474,"shares_after":242512774,"shares_change":838300,"percentage_before_bps":640,"percentage_after_bps":642,"percentage_change_bps":2,"price":119,"price_estimated":false,"value":99757700,"purpose":"Pembelian"},{"symbol":"PEGE","date":"2026-11-02","holder":"OPTIMUS VISION GLOBAL","holder_id":"1000006115","action":"buy","source":"idx","locality":"Foreign","shares_before":241650974,"shares_after":241674474,"shares_change":23500,"percentage_before_bps":640,"percentage_after_bps":640,"percentage_change_bps":0,"price":118,"price_estimated":false,"value":2773000,"purpose":"Pembelian"},{"symbol":"ATLA","date":"2026-10-30","holder":"RUDI R. SUTANTRA","holder_id":"1000004481","action":"sell","source":"ksei","locality":"Local","shares_before":2530254200,"shares_after":2520254200,"shares_change":-10000000,"percentage_before_bps":4081,"percentage_after_bps":4065,"percentage_change_bps":-16,"price":21,"price_estimated":true,"value":210000000},{"symbol":"BUVA","date":"2026-10-30","holder":"NUSANTARA UTAMA INVESTAMA","holder_id":"12606","action":"rights_issue","source":"ksei","locality":"Local","shares_before":18508539715,"shares_after":18508539715,"shares_change":0,"percentage_before_bps":6251,"percentage_after_bps":6154,"percentage_change_bps":-97,"price":353,"price_estimated":true},{"symbol":"SGER","date":"2026-10-30","holder":"WELLY THOMAS","holder_id":"14074","action":"transfer","source":"ksei","locality":"Local","shares_before":1285644762,"shares_after":1341269756,"shares_change":55624994,"percentage_before_bps":825,"percentage_after_bps":861,"percentage_change_bps":36,"price":794,"price_estimated":true,"value":44166245236},{"symbol":"ENRG","date":"2026-10-29","holder":"CGS INTERNATIONAL SEKURITAS INDONESIA","holder_id":"13898","action":"cross","source":"idx","locality":"Local","shares_before":3600545611,"shares_after":3100545614,"shares_change":-499999997,"percentage_before_bps":904,"percentage_after_bps":778,"percentage_change_bps":-125,"price":715,"price_estimated":false,"value":357499997855,"purpose":"Reverse REPO"},{"symbol":"ENRG","date":"2026-10-28","holder":"MAYBANK SEKURITAS INDONESIA","holder_id":"12202","action":"cross","source":"idx","locality":"Local","shares_before":2380252500,"shares_after":2442752500,"shares_change":62500000,"percentage_before_bps":597,"percentage_after_bps":613,"percentage_change_bps":16,"price":724,"price_estimated":true,"value":45250000000,"purpose":"Penambahan jaminan saham untuk transaksi repo"},{"symbol":"VISI","date":"2026-10-28","holder":"TRINUGRAHA THOHIR HARMONI","holder_id":"1000000448","action":"private_placement","source":"idx","locality":"Local","shares_before":246000000,"shares_after":270144900,"shares_change":24144900,"percentage_before_bps":770,"percentage_after_bps":845,"percentage_change_bps":76,"price":994,"price_estimated":false,"value":24000030600,"purpose":"PMTHMETD"},{"symbol":"MBSS","date":"2026-10-15","holder":"WIBOWO GROUP CAPITAL","holder_id":"1000005952","action":"tender_offer","source":"idx","locality":"Local","shares_before":1443766800,"shares_after":1576605000,"shares_change":132838200,"percentage_before_bps":8250,"percentage_after_bps":9009,"percentage_change_bps":759,"price":2915,"price_estimated":false,"value":387223353000},{"symbol":"FUJI","date":"2026-10-15","holder":"JEFRI NEDI","holder_id":"1000006017","action":"sell","source":"ksei","locality":"Local","shares_before":109431600,"shares_after":109251600,"shares_change":-180000,"percentage_before_bps":842,"percentage_after_bps":840,"percentage_change_bps":-1,"price":298,"price_estimated":true,"value":53640000},{"symbol":"ATLA","date":"2026-10-15","holder":"RUDI R. SUTANTRA","holder_id":"1000004481","action":"sell","source":"ksei","locality":"Local","shares_before":2638254200,"shares_after":2610254200,"shares_change":-28000000,"percentage_before_bps":4256,"percentage_after_bps":4210,"percentage_change_bps":-45,"price":29,"price_estimated":true,"value":812000000}],"more":false} +--- stderr diff --git a/crates/idx-cli/tests/golden/ownership-movements.table.golden b/crates/idx-cli/tests/golden/ownership-movements.table.golden new file mode 100644 index 0000000..2500f25 --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements.table.golden @@ -0,0 +1,37 @@ +exit: 0 +--- stdout +Shareholder movements across IDX (14) +┌────────────┬────────┬───────────────────────────────────────┬──────┬───────────────────┬──────────────┬────────────────┬───────┬─────────┬────────┬────────────┐ +│ DATE ┆ SYMBOL ┆ HOLDER ┆ ROLE ┆ ACTION ┆ SHARES ┆ STAKE ┆ PRICE ┆ VALUE ┆ SOURCE ┆ HOLDER ID │ +╞════════════╪════════╪═══════════════════════════════════════╪══════╪═══════════════════╪══════════════╪════════════════╪═══════╪═════════╪════════╪════════════╡ +│ 2026-11-02 ┆ PEGE ┆ OPTIMUS VISION GLOBAL ┆ - ┆ buy ┆ +2,500,000 ┆ 6.66% (+0.07) ┆ 120 ┆ 300.0M ┆ IDX ┆ 1000006115 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-11-02 ┆ PEGE ┆ OPTIMUS VISION GLOBAL ┆ - ┆ buy ┆ +2,900,000 ┆ 6.59% (+0.08) ┆ 122 ┆ 353.8M ┆ IDX ┆ 1000006115 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-11-02 ┆ PEGE ┆ OPTIMUS VISION GLOBAL ┆ - ┆ buy ┆ +1,200,000 ┆ 6.45% (+0.03) ┆ 123 ┆ 147.6M ┆ IDX ┆ 1000006115 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-11-02 ┆ PEGE ┆ OPTIMUS VISION GLOBAL ┆ - ┆ buy ┆ +838,300 ┆ 6.42% (+0.02) ┆ 119 ┆ 99.8M ┆ IDX ┆ 1000006115 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-11-02 ┆ PEGE ┆ OPTIMUS VISION GLOBAL ┆ - ┆ buy ┆ +23,500 ┆ 6.40% ┆ 118 ┆ 2.8M ┆ IDX ┆ 1000006115 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-30 ┆ ATLA ┆ RUDI R. SUTANTRA ┆ - ┆ sell ┆ -10,000,000 ┆ 40.65% (-0.16) ┆ ~21 ┆ ~210.0M ┆ KSEI ┆ 1000004481 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-30 ┆ BUVA ┆ NUSANTARA UTAMA INVESTAMA ┆ - ┆ rights issue ┆ 0 ┆ 61.54% (-0.97) ┆ ~353 ┆ - ┆ KSEI ┆ 12606 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-30 ┆ SGER ┆ WELLY THOMAS ┆ - ┆ transfer ┆ +55,624,994 ┆ 8.61% (+0.36) ┆ ~794 ┆ ~44.2B ┆ KSEI ┆ 14074 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-29 ┆ ENRG ┆ CGS INTERNATIONAL SEKURITAS INDONESIA ┆ - ┆ cross ┆ -499,999,997 ┆ 7.78% (-1.25) ┆ 715 ┆ 357.5B ┆ IDX ┆ 13898 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-28 ┆ ENRG ┆ MAYBANK SEKURITAS INDONESIA ┆ - ┆ cross ┆ +62,500,000 ┆ 6.13% (+0.16) ┆ ~724 ┆ ~45.2B ┆ IDX ┆ 12202 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-28 ┆ VISI ┆ TRINUGRAHA THOHIR HARMONI ┆ - ┆ private placement ┆ +24,144,900 ┆ 8.45% (+0.76) ┆ 994 ┆ 24.0B ┆ IDX ┆ 1000000448 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ MBSS ┆ WIBOWO GROUP CAPITAL ┆ - ┆ tender offer ┆ +132,838,200 ┆ 90.09% (+7.59) ┆ 2,915 ┆ 387.2B ┆ IDX ┆ 1000005952 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ FUJI ┆ JEFRI NEDI ┆ - ┆ sell ┆ -180,000 ┆ 8.40% (-0.01) ┆ ~298 ┆ ~53.6M ┆ KSEI ┆ 1000006017 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ ATLA ┆ RUDI R. SUTANTRA ┆ - ┆ sell ┆ -28,000,000 ┆ 42.10% (-0.45) ┆ ~29 ┆ ~812.0M ┆ KSEI ┆ 1000004481 │ +└────────────┴────────┴───────────────────────────────────────┴──────┴───────────────────┴──────────────┴────────────────┴───────┴─────────┴────────┴────────────┘ +IDX rows are insider disclosures (directors, commissioners and other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated price. Rows appear days after the change. +Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms). +--- stderr diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index 6a76d4d..d21b03a 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -1,17 +1,18 @@ //! Fixture-backed provider for tests, enabled by the `testing` feature and //! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it. -use super::stockbit::{corpaction, exodus}; +use super::stockbit::{corpaction, exodus, movement}; use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote}; use super::{ BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider, - IntradayProvider, OrderBookProvider, QuoteProvider, TradingSummaryProvider, idx, ipot, msn, - stockbit, yahoo, + IntradayProvider, OrderBookProvider, QuoteProvider, ShareholderMovementProvider, + TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo, }; use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; use crate::config::ProviderKind; use crate::corporate::types::{ActionKind, CorporateAction}; use crate::error::IdxError; +use crate::movements::types::{MovementPage, MovementQuery}; pub(crate) fn enabled() -> bool { std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() @@ -254,6 +255,36 @@ impl CorporateActionProvider for MockStockbitProvider { } } +/// `stockbit_exodus_majorholder[_][_p].json` (no suffix for page +/// 1); a missing fixture is an empty page, as Stockbit answers past the end. +/// The query's filters apply to the fixture's rows, as Stockbit applies them. +impl ShareholderMovementProvider for MockStockbitProvider { + fn shareholder_movements( + &self, + query: &MovementQuery, + page: u32, + ) -> Result { + let subject = query.symbol.as_deref().unwrap_or("shareholder movements"); + let mut name = "majorholder".to_string(); + if let Some(symbol) = &query.symbol { + name.push('_'); + name.push_str(&stockbit::stockbit_code(symbol)?.to_ascii_lowercase()); + } + if page > 1 { + name.push_str(&format!("_p{page}")); + } + let body = match self.exodus(subject, &format!("{name}.json")) { + Err(IdxError::SymbolNotFound(_)) if query.symbol.is_some() || page > 1 => { + r#"{"data":{"is_more":false,"movement":[]}}"#.to_string() + } + other => other?, + }; + let mut parsed = movement::parse_movements(&body)?; + parsed.movements.retain(|m| query.admits(m)); + Ok(parsed) + } +} + impl HistoryProvider for MockStockbitProvider { fn history( &self, diff --git a/crates/idx-core/src/api/mod.rs b/crates/idx-core/src/api/mod.rs index b3fb07a..f64cb8e 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -89,6 +89,17 @@ pub trait CorporateActionProvider { ) -> Result, IdxError>; } +/// Shareholder movements: needs the user's Stockbit session. +pub trait ShareholderMovementProvider { + /// Page `page` (from 1) of `query`, whose `symbol` is resolved + /// (`BBCA.JK`) or `None` for the market. + fn shareholder_movements( + &self, + query: &crate::movements::types::MovementQuery, + page: u32, + ) -> Result; +} + pub trait FundamentalsProvider { fn fundamentals(&self, symbol: &str) -> Result; } @@ -364,6 +375,14 @@ pub fn corporate_action_provider() -> Box { Box::new(stockbit::StockbitProvider::new()) } +pub fn shareholder_movement_provider() -> Box { + #[cfg(feature = "testing")] + if mock::enabled() { + return Box::new(mock::MockStockbitProvider); + } + Box::new(stockbit::StockbitProvider::new()) +} + /// Today in WIB, the date IDX trades on. pub(crate) fn today_wib() -> chrono::NaiveDate { #[cfg(feature = "testing")] diff --git a/crates/idx-core/src/api/stockbit/exodus.rs b/crates/idx-core/src/api/stockbit/exodus.rs index ddd0fca..1b3ec3d 100644 --- a/crates/idx-core/src/api/stockbit/exodus.rs +++ b/crates/idx-core/src/api/stockbit/exodus.rs @@ -25,12 +25,13 @@ use chrono::{Months, NaiveDate}; use serde_json::Value; use super::auth::SessionStore; -use super::corpaction; use super::parse::number; use super::{USER_AGENT, pace, stockbit_code}; +use super::{corpaction, movement}; use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote}; use crate::corporate::types::{ActionKind, CorporateAction}; use crate::error::IdxError; +use crate::movements::types::{MovementPage, MovementQuery}; const BASE_URL: &str = "https://exodus.stockbit.com"; const REFRESH_PATH: &str = "/login/refresh"; @@ -140,6 +141,25 @@ impl Exodus { corpaction::parse_corporate_actions(kind, &body) } + /// Page `page` of shareholder movements: one stock's (`query.symbol`, + /// resolved) or the market's. + pub(crate) fn shareholder_movements( + &self, + query: &MovementQuery, + page: u32, + ) -> Result { + let code = query.symbol.as_deref().map(stockbit_code).transpose()?; + let subject = query + .symbol + .clone() + .unwrap_or_else(|| "shareholder movements".to_string()); + let body = self.get( + &movement::request_path(query, code.as_deref(), page), + &subject, + )?; + movement::parse_movements(&body) + } + /// The market's state, read at most every `MARKET_STATE_TTL` per process. /// Unknown on any failure, which hides bid/offer. fn market_status(&self) -> Option { diff --git a/crates/idx-core/src/api/stockbit/mod.rs b/crates/idx-core/src/api/stockbit/mod.rs index a00abf1..ead4d60 100644 --- a/crates/idx-core/src/api/stockbit/mod.rs +++ b/crates/idx-core/src/api/stockbit/mod.rs @@ -20,6 +20,7 @@ pub mod browser; pub mod corpaction; pub mod exodus; pub mod loopback; +pub mod movement; mod parse; pub mod signin; @@ -33,10 +34,11 @@ pub use parse::{parse_symbol_page, status_error, stockbit_code}; use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote}; use super::{ CorporateActionProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, - OrderBookProvider, QuoteProvider, + OrderBookProvider, QuoteProvider, ShareholderMovementProvider, }; use crate::corporate::types::{ActionKind, CorporateAction}; use crate::error::IdxError; +use crate::movements::types::{MovementPage, MovementQuery}; use auth::SessionStore; use exodus::Exodus; @@ -216,6 +218,16 @@ impl CorporateActionProvider for StockbitProvider { } } +impl ShareholderMovementProvider for StockbitProvider { + fn shareholder_movements( + &self, + query: &MovementQuery, + page: u32, + ) -> Result { + self.require_session()?.shareholder_movements(query, page) + } +} + impl HistoryProvider for StockbitProvider { fn history( &self, diff --git a/crates/idx-core/src/api/stockbit/movement.rs b/crates/idx-core/src/api/stockbit/movement.rs new file mode 100644 index 0000000..3453b8c --- /dev/null +++ b/crates/idx-core/src/api/stockbit/movement.rs @@ -0,0 +1,328 @@ +//! Shareholder movements from `GET /insider/company/majorholder`: reported +//! changes in a holder's stake, newest first, 50 rows a page. +//! +//! Two sources share the feed: IDX insider disclosures (directors, +//! commissioners and other insiders, any size; a trade price) and KSEI's +//! daily changes of holders at or above 5% (no price; Stockbit estimates +//! one). Each row (`data.movement[]`) carries `id` (the holder's id, not the +//! row's), `name`, `symbol`, `date` (`"25 Mar 26"`), `previous` / `current` / +//! `changes` (`value`: shares as `"+147,933"`, `percentage`: `"+0.0001"`), +//! `action_type` (`ACTION_TYPE_BUY`, ..._SELL, ..._TRANSFER, ..._CROSS, +//! ..._RIGHT_ISSUE, ..._PRIVATE_PLACEMENT, ..._TENDER_OFFER, ...), +//! `nationality`, `badges`, `price.raw.value`, `value.raw.value`, +//! `is_estimated_price`, `purpose` and `data_source.type`. +//! +//! Query parameters (all optional): `symbol`, `date_start` / `date_end` +//! (YYYY-MM-DD), `insider` (holder id), `action_type`, `source_type`, `page` +//! (from 1). + +use chrono::NaiveDate; +use serde_json::Value; + +use crate::error::IdxError; +use crate::movements::types::{ + ActionFilter, MovementAction, MovementPage, MovementQuery, MovementSource, ShareholderMovement, +}; + +pub const PATH: &str = "/insider/company/majorholder"; + +/// Stockbit's `action_type` value for `filter`. +pub fn action_type(filter: ActionFilter) -> &'static str { + match filter { + ActionFilter::Buy => "ACTION_TYPE_BUY", + ActionFilter::Sell => "ACTION_TYPE_SELL", + ActionFilter::Transfer => "ACTION_TYPE_TRANSFER", + ActionFilter::Cross => "ACTION_TYPE_CROSS", + ActionFilter::CorporateAction => "ACTION_TYPE_CORPACTION", + } +} + +/// Stockbit's `source_type` value for `source`. +pub fn source_type(source: MovementSource) -> &'static str { + match source { + MovementSource::Idx => "SOURCE_TYPE_IDX", + MovementSource::Ksei => "SOURCE_TYPE_KSEI", + } +} + +/// The request path for `page` of `query`; `code` is the bare IDX code. +/// Every value is a date, a digit id, a code or a fixed enum name, so +/// nothing needs escaping. +pub fn request_path(query: &MovementQuery, code: Option<&str>, page: u32) -> String { + let mut path = format!("{PATH}?page={page}"); + if let Some(code) = code { + path.push_str(&format!("&symbol={code}")); + } + if let Some(from) = query.from { + path.push_str(&format!("&date_start={from}")); + } + if let Some(to) = query.to { + path.push_str(&format!("&date_end={to}")); + } + if let Some(id) = &query.holder_id { + path.push_str(&format!("&insider={id}")); + } + if let Some(action) = query.action { + path.push_str(&format!("&action_type={}", action_type(action))); + } + if let Some(source) = query.source { + path.push_str(&format!("&source_type={}", source_type(source))); + } + path +} + +/// One page of the feed. A row without a symbol, holder or readable date is +/// skipped; a body without `data` is a parse error. +pub fn parse_movements(body: &str) -> Result { + let value: Value = serde_json::from_str(body) + .map_err(|e| IdxError::ParseError(format!("Stockbit majorholder: {e}")))?; + let data = value + .get("data") + .filter(|data| data.is_object()) + .ok_or_else(|| IdxError::ParseError("Stockbit majorholder: no data".to_string()))?; + let movements = data + .get("movement") + .and_then(Value::as_array) + .map(|rows| rows.iter().filter_map(parse_row).collect()) + .unwrap_or_default(); + Ok(MovementPage { + movements, + more: data + .get("is_more") + .and_then(Value::as_bool) + .unwrap_or(false), + }) +} + +fn parse_row(row: &Value) -> Option { + let symbol = text(row.get("symbol"))?.to_ascii_uppercase(); + let holder = text(row.get("name"))?.to_string(); + let date = NaiveDate::parse_from_str(text(row.get("date"))?, "%d %b %y").ok()?; + let shares = |key: &str| signed(row.get(key).and_then(|v| v.get("value"))).unwrap_or(0); + let bps = |key: &str| { + percent(row.get(key).and_then(|v| v.get("percentage"))) + .map(|p| (p * 100.0).round() as i64) + .unwrap_or(0) + }; + let raw = |key: &str| { + signed( + row.get(key) + .and_then(|v| v.get("raw")) + .and_then(|v| v.get("value")), + ) + .filter(|n| *n != 0) + }; + Some(ShareholderMovement { + symbol, + date, + holder, + holder_id: text(row.get("id")).unwrap_or_default().to_string(), + action: action(text(row.get("action_type")).unwrap_or_default()), + source: match text(row.get("data_source").and_then(|v| v.get("type"))) { + Some("SOURCE_TYPE_IDX") => Some(MovementSource::Idx), + Some("SOURCE_TYPE_KSEI") => Some(MovementSource::Ksei), + _ => None, + }, + locality: match text(row.get("nationality")) { + Some("NATIONALITY_TYPE_LOCAL") => Some("Local".to_string()), + Some("NATIONALITY_TYPE_FOREIGN") => Some("Foreign".to_string()), + _ => None, + }, + roles: row + .get("badges") + .and_then(Value::as_array) + .map(|badges| { + badges + .iter() + .filter_map(|b| text(Some(b))) + .map(role) + .collect() + }) + .unwrap_or_default(), + shares_before: shares("previous"), + shares_after: shares("current"), + shares_change: shares("changes"), + percentage_before_bps: bps("previous"), + percentage_after_bps: bps("current"), + percentage_change_bps: bps("changes"), + price: raw("price"), + price_estimated: row + .get("is_estimated_price") + .and_then(Value::as_bool) + .unwrap_or(false), + value: raw("value"), + purpose: text(row.get("purpose")) + .filter(|p| *p != "-") + .map(str::to_string), + }) +} + +fn action(raw: &str) -> MovementAction { + match raw.strip_prefix("ACTION_TYPE_").unwrap_or(raw) { + "BUY" => MovementAction::Buy, + "SELL" => MovementAction::Sell, + "TRANSFER" => MovementAction::Transfer, + "CROSS" => MovementAction::Cross, + "RIGHT_ISSUE" => MovementAction::RightsIssue, + "PRIVATE_PLACEMENT" => MovementAction::PrivatePlacement, + "TENDER_OFFER" => MovementAction::TenderOffer, + "BONUS_STOCK" => MovementAction::BonusStock, + "DIVIDEND_STOCK" => MovementAction::StockDividend, + "DIVIDEND_CASH" => MovementAction::CashDividend, + _ => MovementAction::Other, + } +} + +/// `SHAREHOLDER_BADGE_DIREKTUR` -> `director`; an unknown badge keeps its +/// own name, lowercased. +fn role(badge: &str) -> String { + match badge.strip_prefix("SHAREHOLDER_BADGE_").unwrap_or(badge) { + "DIREKTUR" => "director".to_string(), + "KOMISARIS" => "commissioner".to_string(), + other => other.to_ascii_lowercase(), + } +} + +fn text(value: Option<&Value>) -> Option<&str> { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) +} + +/// `"+147,933"`, `"-10,000,000"`, `"6982"` or a JSON number. +fn signed(value: Option<&Value>) -> Option { + match value? { + Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f.round() as i64)), + Value::String(s) => s.trim().replace(',', "").parse().ok(), + _ => None, + } +} + +/// `"+0.0001"`, `"-0.16"`, `"54.94"`. +fn percent(value: Option<&Value>) -> Option { + let n = match value? { + Value::Number(n) => n.as_f64()?, + Value::String(s) => s.trim().replace(',', "").parse().ok()?, + _ => return None, + }; + n.is_finite().then_some(n) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(name: &str) -> String { + std::fs::read_to_string(format!( + "{}/tests/fixtures/stockbit_exodus_{name}.json", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() + } + + #[test] + fn insider_buy_reads_shares_price_and_roles() { + let page = parse_movements(&fixture("majorholder_bbca")).unwrap(); + assert!(page.more); + let row = page + .movements + .iter() + .find(|m| m.holder == "HENDRA TANUMIHARDJA") + .expect("BBCA director row"); + assert_eq!(row.symbol, "BBCA"); + assert_eq!(row.date, NaiveDate::from_ymd_opt(2026, 3, 25).unwrap()); + assert_eq!(row.action, MovementAction::Buy); + assert_eq!(row.source, Some(MovementSource::Idx)); + assert_eq!(row.roles, ["director"]); + assert_eq!( + (row.shares_before, row.shares_after, row.shares_change), + (193_206, 341_139, 147_933) + ); + // 0.0003% of BBCA rounds to 0 basis points; the shares stay exact. + assert_eq!(row.percentage_after_bps, 0); + assert_eq!(row.price, Some(6982)); + assert_eq!(row.value, Some(1_032_868_206)); + assert!(!row.price_estimated); + assert_eq!(row.locality.as_deref(), Some("Local")); + } + + #[test] + fn ksei_sale_is_negative_with_an_estimated_price() { + let page = parse_movements(&fixture("majorholder")).unwrap(); + let row = page + .movements + .iter() + .find(|m| m.holder == "RUDI R. SUTANTRA") + .expect("ATLA KSEI sale"); + assert_eq!(row.action, MovementAction::Sell); + assert_eq!(row.source, Some(MovementSource::Ksei)); + assert_eq!(row.holder_id, "1000004481"); + assert_eq!(row.shares_change, -10_000_000); + assert_eq!(row.percentage_before_bps, 4081); + assert_eq!(row.percentage_change_bps, -16); + assert!(row.price_estimated); + assert!(row.roles.is_empty()); + assert_eq!(row.purpose, None); + } + + #[test] + fn unknown_labels_are_kept_not_dropped() { + let body = r#"{"data":{"is_more":false,"movement":[ + {"id":"7","name":"X","symbol":"abcd","date":"02 Nov 26", + "action_type":"ACTION_TYPE_SOMETHING_NEW","badges":["SHAREHOLDER_BADGE_CEO"], + "data_source":{"type":"SOURCE_TYPE_NEW"},"purpose":"-", + "previous":{"value":"0","percentage":"0"}, + "current":{"value":"1,000","percentage":"0.01"}, + "changes":{"value":"+1,000","percentage":"+0.01"}, + "price":{"raw":{"value":"0"}},"value":{"raw":{"value":"0"}}}, + {"id":"8","name":"","symbol":"ABCD","date":"02 Nov 26"}, + {"id":"9","name":"Y","symbol":"ABCD","date":"2026-11-02"} + ]}}"#; + let page = parse_movements(body).unwrap(); + assert!(!page.more); + assert_eq!(page.movements.len(), 1); + let row = &page.movements[0]; + assert_eq!(row.symbol, "ABCD"); + assert_eq!(row.action, MovementAction::Other); + assert_eq!(row.source, None); + assert_eq!(row.roles, ["ceo"]); + assert_eq!( + (row.price, row.value, row.purpose.as_deref()), + (None, None, None) + ); + assert_eq!(row.percentage_after_bps, 1); + } + + #[test] + fn empty_and_broken_bodies() { + let empty = parse_movements(r#"{"data":{"is_more":false,"movement":null}}"#).unwrap(); + assert_eq!(empty, MovementPage::default()); + assert!(parse_movements(r#"{"message":"unauthorized"}"#).is_err()); + assert!(parse_movements("").is_err()); + } + + #[test] + fn request_path_carries_every_filter() { + let mut query = MovementQuery::from_args( + None, + Some("2026-10-01"), + Some("2026-10-15"), + Some("14074"), + 1, + ) + .unwrap(); + query.action = Some(ActionFilter::CorporateAction); + query.source = Some(MovementSource::Ksei); + assert_eq!( + request_path(&query, Some("BBCA"), 2), + "/insider/company/majorholder?page=2&symbol=BBCA&date_start=2026-10-01\ + &date_end=2026-10-15&insider=14074&action_type=ACTION_TYPE_CORPACTION\ + &source_type=SOURCE_TYPE_KSEI" + ); + assert_eq!( + request_path(&MovementQuery::default(), None, 1), + "/insider/company/majorholder?page=1" + ); + } +} diff --git a/crates/idx-core/src/client.rs b/crates/idx-core/src/client.rs index aef1c39..57e6333 100644 --- a/crates/idx-core/src/client.rs +++ b/crates/idx-core/src/client.rs @@ -98,6 +98,10 @@ impl Idx { crate::corporate::Corporate { idx: self } } + pub fn movements(&self) -> crate::movements::Movements<'_> { + crate::movements::Movements { idx: self } + } + pub fn financial(&self) -> crate::financial::Financial<'_> { crate::financial::Financial { idx: self } } diff --git a/crates/idx-core/src/lib.rs b/crates/idx-core/src/lib.rs index cd49711..12e1656 100644 --- a/crates/idx-core/src/lib.rs +++ b/crates/idx-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod flow; #[cfg(feature = "ownership")] pub mod investor; pub mod market; +pub mod movements; #[cfg(feature = "ownership")] pub mod ownership; pub mod text; diff --git a/crates/idx-core/src/movements/mod.rs b/crates/idx-core/src/movements/mod.rs new file mode 100644 index 0000000..f35387e --- /dev/null +++ b/crates/idx-core/src/movements/mod.rs @@ -0,0 +1,124 @@ +//! Shareholder movements from the user's Stockbit session: reported changes +//! in insiders' and major holders' stakes (IDX insider disclosures and KSEI's +//! 5% holder changes), one stock's or the market's, newest first. Each page +//! is 50 rows and one request (paced 1 s apart), cached for an hour. +//! +//! These are the reports as filed. Whether they predict anything is not +//! claimed here: share plans read as a cluster of buys at one price, and a +//! row appears days after the trade (see `docs/STOCKBIT.md`). + +pub mod types; + +use chrono::Utc; + +use crate::api::{shareholder_movement_provider, stockbit}; +use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::error::IdxError; +use types::{MovementList, MovementQuery}; + +/// The feed gains rows through the day; an hour keeps a session's repeated +/// queries cheap without hiding a day's filings for long. +const TTL_SECS: u64 = 3600; +const BUCKET: &str = "stockbit-movements"; + +pub struct Movements<'a> { + pub(crate) idx: &'a Idx, +} + +impl Movements<'_> { + /// Up to `query.pages` pages of the feed, newest first; `more` says + /// whether rows remain past them. The result is as old as its oldest page + /// and stale if any page is. + pub fn list( + &self, + query: &MovementQuery, + policy: CachePolicy, + ) -> Result, IdxError> { + let mut query = query.clone(); + if let Some(symbol) = &query.symbol { + let (resolved, _) = self.idx.idx_code(symbol, "shareholder movements")?; + query.symbol = Some(resolved); + } + let provider = shareholder_movement_provider(); + let subject = query + .symbol + .clone() + .unwrap_or_else(|| "shareholder movements".to_string()); + let mut list = Fetched { + data: MovementList::default(), + source: stockbit::SOURCE, + fetched_at: Utc::now(), + stale: false, + }; + for page in 1..=query.pages.max(1) { + let key = cache_key(&query, page); + let part = cached_fetch( + &self.idx.cache, + CacheSpec { + bucket: BUCKET, + key: &key, + ttl_secs: TTL_SECS, + subject: &subject, + source: stockbit::SOURCE, + }, + policy, + || provider.shareholder_movements(&query, page), + Ok, + )?; + list.fetched_at = list.fetched_at.min(part.fetched_at); + list.stale |= part.stale; + list.data.movements.extend(part.data.movements); + list.data.more = part.data.more; + if !part.data.more { + break; + } + } + Ok(list) + } +} + +/// Every filter and the page, so no two queries share an entry. +fn cache_key(query: &MovementQuery, page: u32) -> String { + let opt = |value: Option| value.unwrap_or_else(|| "-".to_string()); + format!( + "{}:{}:{}:{}:{}:{}:p{page}", + query.symbol.as_deref().unwrap_or("market"), + opt(query.from.map(|d| d.to_string())), + opt(query.to.map(|d| d.to_string())), + query.holder_id.as_deref().unwrap_or("-"), + query.action.map(|a| a.as_str()).unwrap_or("-"), + query.source.map(|s| s.as_str()).unwrap_or("-"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use types::{ActionFilter, MovementSource}; + + #[test] + fn cache_keys_differ_by_every_filter() { + let base = MovementQuery::from_args(None, None, None, None, 1).unwrap(); + let mut keys = vec![cache_key(&base, 1), cache_key(&base, 2)]; + let mut q = base.clone(); + q.symbol = Some("BBCA.JK".to_string()); + keys.push(cache_key(&q, 1)); + let mut q = base.clone(); + q.from = chrono::NaiveDate::from_ymd_opt(2026, 10, 1); + keys.push(cache_key(&q, 1)); + let mut q = base.clone(); + q.to = chrono::NaiveDate::from_ymd_opt(2026, 10, 1); + keys.push(cache_key(&q, 1)); + let mut q = base.clone(); + q.holder_id = Some("14074".to_string()); + keys.push(cache_key(&q, 1)); + let mut q = base.clone(); + q.action = Some(ActionFilter::Sell); + keys.push(cache_key(&q, 1)); + let mut q = base; + q.source = Some(MovementSource::Ksei); + keys.push(cache_key(&q, 1)); + let unique: std::collections::HashSet<_> = keys.iter().collect(); + assert_eq!(unique.len(), keys.len(), "{keys:?}"); + } +} diff --git a/crates/idx-core/src/movements/types.rs b/crates/idx-core/src/movements/types.rs new file mode 100644 index 0000000..1d031ce --- /dev/null +++ b/crates/idx-core/src/movements/types.rs @@ -0,0 +1,334 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +use crate::error::IdxError; + +/// What a holder's row records, as Stockbit labels it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MovementAction { + Buy, + Sell, + /// Shares moved between accounts or parties, not a market trade. + Transfer, + /// A crossing: a negotiated trade between two parties. + Cross, + RightsIssue, + PrivatePlacement, + TenderOffer, + BonusStock, + StockDividend, + CashDividend, + /// A label Stockbit added after this release. + Other, +} + +impl MovementAction { + pub fn as_str(self) -> &'static str { + match self { + Self::Buy => "buy", + Self::Sell => "sell", + Self::Transfer => "transfer", + Self::Cross => "cross", + Self::RightsIssue => "rights_issue", + Self::PrivatePlacement => "private_placement", + Self::TenderOffer => "tender_offer", + Self::BonusStock => "bonus_stock", + Self::StockDividend => "stock_dividend", + Self::CashDividend => "cash_dividend", + Self::Other => "other", + } + } + + /// Whether `filter` (Stockbit's `action_type`) selects this action. + pub fn matches(self, filter: ActionFilter) -> bool { + match filter { + ActionFilter::Buy => self == Self::Buy, + ActionFilter::Sell => self == Self::Sell, + ActionFilter::Transfer => self == Self::Transfer, + ActionFilter::Cross => self == Self::Cross, + ActionFilter::CorporateAction => matches!( + self, + Self::RightsIssue + | Self::PrivatePlacement + | Self::TenderOffer + | Self::BonusStock + | Self::StockDividend + | Self::CashDividend + ), + } + } +} + +/// The action filters Stockbit's feed takes (`action_type`). Corporate +/// action covers rights issues, private placements and the like. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "kebab-case")] +pub enum ActionFilter { + Buy, + Sell, + Transfer, + Cross, + #[cfg_attr(feature = "clap", value(name = "corporate-action"))] + CorporateAction, +} + +impl ActionFilter { + pub const ALL: [Self; 5] = [ + Self::Buy, + Self::Sell, + Self::Transfer, + Self::Cross, + Self::CorporateAction, + ]; + + /// The CLI's name (`--action`), also used in MCP inputs. + pub fn as_str(self) -> &'static str { + match self { + Self::Buy => "buy", + Self::Sell => "sell", + Self::Transfer => "transfer", + Self::Cross => "cross", + Self::CorporateAction => "corporate-action", + } + } +} + +impl std::str::FromStr for ActionFilter { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + let wanted = value.trim().to_ascii_lowercase().replace('_', "-"); + Self::ALL + .into_iter() + .find(|filter| filter.as_str() == wanted) + .ok_or_else(|| { + IdxError::InvalidInput(format!( + "unknown action '{value}'; use one of: {}", + Self::ALL.map(Self::as_str).join(", ") + )) + }) + } +} + +/// Who reported the change: IDX insider disclosures (directors, +/// commissioners and other insiders, any size), or KSEI's daily changes of +/// holders at or above 5%. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "lowercase")] +pub enum MovementSource { + Idx, + Ksei, +} + +impl MovementSource { + pub fn as_str(self) -> &'static str { + match self { + Self::Idx => "idx", + Self::Ksei => "ksei", + } + } +} + +impl std::str::FromStr for MovementSource { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "idx" => Ok(Self::Idx), + "ksei" => Ok(Self::Ksei), + _ => Err(IdxError::InvalidInput(format!( + "unknown source '{value}'; use idx or ksei" + ))), + } + } +} + +/// One reported change in a holder's stake. Share counts are exact; +/// percentages are of the company's shares, in basis points as Stockbit +/// rounds them, so a small insider's stake can read 0. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ShareholderMovement { + pub symbol: String, + /// The day of the change (the trade or the register date). + pub date: NaiveDate, + pub holder: String, + /// Stockbit's id for the holder; `--holder` takes it. + pub holder_id: String, + pub action: MovementAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + /// `Local` or `Foreign`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub locality: Option, + /// Insider roles at the company: `director`, `commissioner`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub roles: Vec, + pub shares_before: i64, + pub shares_after: i64, + /// Signed: negative for a sale. + pub shares_change: i64, + pub percentage_before_bps: i64, + pub percentage_after_bps: i64, + pub percentage_change_bps: i64, + /// Whole IDR per share. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price: Option, + /// The price is Stockbit's estimate (KSEI rows report no price). + pub price_estimated: bool, + /// Whole IDR: shares changed times price. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + /// The stated purpose, free text (`Investasi`, `Divestasi`, ...). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub purpose: Option, +} + +/// One page of Stockbit's feed (50 rows), newest first. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct MovementPage { + pub movements: Vec, + /// Whether a further page exists. + pub more: bool, +} + +/// Most pages one query reads (500 rows). +pub const MAX_PAGES: u32 = 10; + +/// What to list. `symbol` is resolved (`BBCA.JK`) once it reaches a +/// provider; `None` is the whole market. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MovementQuery { + pub symbol: Option, + pub from: Option, + pub to: Option, + pub holder_id: Option, + pub action: Option, + pub source: Option, + /// Pages to read, 1 to `MAX_PAGES`. + pub pages: u32, +} + +impl MovementQuery { + /// Validates user input: dates as YYYY-MM-DD with `from` not after + /// `to`, a numeric holder id, and 1 to `MAX_PAGES` pages. + pub fn from_args( + symbol: Option<&str>, + from: Option<&str>, + to: Option<&str>, + holder_id: Option<&str>, + pages: u32, + ) -> Result { + 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")) + }) + }; + let from = from.map(|v| parse("from", v)).transpose()?; + let to = to.map(|v| parse("to", v)).transpose()?; + if let (Some(from), Some(to)) = (from, to) + && to < from + { + return Err(IdxError::InvalidInput(format!( + "to ({to}) is before from ({from})" + ))); + } + let holder_id = holder_id.map(str::trim).filter(|id| !id.is_empty()); + if let Some(id) = holder_id + && !id.bytes().all(|b| b.is_ascii_digit()) + { + return Err(IdxError::InvalidInput(format!( + "holder '{id}' is not a holder id; ids are digits, shown in the HOLDER ID \ + column and as holder_id in JSON" + ))); + } + if !(1..=MAX_PAGES).contains(&pages) { + return Err(IdxError::InvalidInput(format!( + "pages must be 1 to {MAX_PAGES}" + ))); + } + Ok(Self { + symbol: symbol + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string), + from, + to, + holder_id: holder_id.map(str::to_string), + action: None, + source: None, + pages, + }) + } + + /// Whether `movement` passes the query's filters (what Stockbit applies + /// on its side; the mock provider uses it). + pub fn admits(&self, movement: &ShareholderMovement) -> bool { + self.from.is_none_or(|from| movement.date >= from) + && self.to.is_none_or(|to| movement.date <= to) + && self + .holder_id + .as_ref() + .is_none_or(|id| &movement.holder_id == id) + && self + .action + .is_none_or(|filter| movement.action.matches(filter)) + && self + .source + .is_none_or(|source| movement.source == Some(source)) + } +} + +/// The rows a query read, newest first. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct MovementList { + pub movements: Vec, + /// More rows exist past the pages read (raise `--pages`, or narrow the + /// dates). + pub more: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_validates_dates_holder_and_pages() { + let query = + MovementQuery::from_args(Some(" bbca "), Some("2026-10-01"), None, Some("14074"), 2) + .unwrap(); + assert_eq!(query.symbol.as_deref(), Some("bbca")); + assert_eq!(query.from, NaiveDate::from_ymd_opt(2026, 10, 1)); + assert_eq!(query.holder_id.as_deref(), Some("14074")); + + for (from, to, holder, pages) in [ + (Some("01-10-2026"), None, None, 1), + (Some("2026-10-02"), Some("2026-10-01"), None, 1), + (None, None, Some("RUDI"), 1), + (None, None, Some("1&page=9"), 1), + (None, None, None, 0), + (None, None, None, MAX_PAGES + 1), + ] { + assert!( + MovementQuery::from_args(None, from, to, holder, pages).is_err(), + "{from:?} {to:?} {holder:?} {pages}" + ); + } + } + + #[test] + fn action_filter_names_round_trip() { + for filter in ActionFilter::ALL { + assert_eq!(filter.as_str().parse::().unwrap(), filter); + } + assert_eq!( + "corporate_action".parse::().unwrap(), + ActionFilter::CorporateAction + ); + assert!("dividend".parse::().is_err()); + assert!(MovementAction::RightsIssue.matches(ActionFilter::CorporateAction)); + assert!(!MovementAction::Transfer.matches(ActionFilter::CorporateAction)); + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json new file mode 100644 index 0000000..1d9fccb --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json @@ -0,0 +1,416 @@ +{ + "message": "Successfully majorholder data", + "data": { + "is_more": true, + "movement": [ + { + "id": "1000006115", + "name": "OPTIMUS VISION GLOBAL", + "symbol": "PEGE", + "date": "02 Nov 26", + "previous": { + "value": "249,112,774", + "percentage": "6.59", + "formatted_value": "" + }, + "current": { + "value": "251,612,774", + "percentage": "6.66", + "formatted_value": "" + }, + "changes": { + "value": "+2,500,000", + "percentage": "+0.07", + "formatted_value": "2,500,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_FOREIGN", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "120", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "120" + }, + "formatted": "120" + }, + "value": { + "raw": { + "value": "300000000" + }, + "formatted": "300.00M" + }, + "is_estimated_price": false, + "purpose": "Pembelian", + "icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png" + }, + { + "id": "1000006115", + "name": "OPTIMUS VISION GLOBAL", + "symbol": "PEGE", + "date": "02 Nov 26", + "previous": { + "value": "246,212,774", + "percentage": "6.52", + "formatted_value": "" + }, + "current": { + "value": "249,112,774", + "percentage": "6.59", + "formatted_value": "" + }, + "changes": { + "value": "+2,900,000", + "percentage": "+0.08", + "formatted_value": "2,900,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_FOREIGN", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "122", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "122" + }, + "formatted": "122" + }, + "value": { + "raw": { + "value": "353800000" + }, + "formatted": "353.80M" + }, + "is_estimated_price": false, + "purpose": "Pembelian", + "icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png" + }, + { + "id": "1000006115", + "name": "OPTIMUS VISION GLOBAL", + "symbol": "PEGE", + "date": "02 Nov 26", + "previous": { + "value": "242,512,774", + "percentage": "6.42", + "formatted_value": "" + }, + "current": { + "value": "243,712,774", + "percentage": "6.45", + "formatted_value": "" + }, + "changes": { + "value": "+1,200,000", + "percentage": "+0.03", + "formatted_value": "1,200,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_FOREIGN", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "123", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "123" + }, + "formatted": "123" + }, + "value": { + "raw": { + "value": "147600000" + }, + "formatted": "147.60M" + }, + "is_estimated_price": false, + "purpose": "Pembelian", + "icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png" + }, + { + "id": "1000006115", + "name": "OPTIMUS VISION GLOBAL", + "symbol": "PEGE", + "date": "02 Nov 26", + "previous": { + "value": "241,674,474", + "percentage": "6.40", + "formatted_value": "" + }, + "current": { + "value": "242,512,774", + "percentage": "6.42", + "formatted_value": "" + }, + "changes": { + "value": "+838,300", + "percentage": "+0.02", + "formatted_value": "838,300" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_FOREIGN", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "119", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "119" + }, + "formatted": "119" + }, + "value": { + "raw": { + "value": "99757700" + }, + "formatted": "99.76M" + }, + "is_estimated_price": false, + "purpose": "Pembelian", + "icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png" + }, + { + "id": "1000006115", + "name": "OPTIMUS VISION GLOBAL", + "symbol": "PEGE", + "date": "02 Nov 26", + "previous": { + "value": "241,650,974", + "percentage": "6.40", + "formatted_value": "" + }, + "current": { + "value": "241,674,474", + "percentage": "6.40", + "formatted_value": "" + }, + "changes": { + "value": "+23,500", + "percentage": "+0.0006", + "formatted_value": "23,500" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_FOREIGN", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "118", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "118" + }, + "formatted": "118" + }, + "value": { + "raw": { + "value": "2773000" + }, + "formatted": "2.77M" + }, + "is_estimated_price": false, + "purpose": "Pembelian", + "icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png" + }, + { + "id": "1000004481", + "name": "RUDI R. SUTANTRA", + "symbol": "ATLA", + "date": "30 Oct 26", + "previous": { + "value": "2,530,254,200", + "percentage": "40.81", + "formatted_value": "" + }, + "current": { + "value": "2,520,254,200", + "percentage": "40.65", + "formatted_value": "" + }, + "changes": { + "value": "-10,000,000", + "percentage": "-0.16", + "formatted_value": "10,000,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_SELL", + "data_source": { + "label": "Sumber: KSEI", + "type": "SOURCE_TYPE_KSEI" + }, + "price_formatted": "", + "broker_detail": { + "code": "AG", + "group": "BROKER_GROUP_FOREIGN" + }, + "badges": [], + "price": { + "raw": { + "value": "21" + }, + "formatted": "21" + }, + "value": { + "raw": { + "value": "210000000" + }, + "formatted": "210.00M" + }, + "is_estimated_price": true, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/ATLA.png" + }, + { + "id": "12606", + "name": "NUSANTARA UTAMA INVESTAMA", + "symbol": "BUVA", + "date": "30 Oct 26", + "previous": { + "value": "18,508,539,715", + "percentage": "62.51", + "formatted_value": "" + }, + "current": { + "value": "18,508,539,715", + "percentage": "61.54", + "formatted_value": "" + }, + "changes": { + "value": "0", + "percentage": "-0.97", + "formatted_value": "0" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_RIGHT_ISSUE", + "data_source": { + "label": "Sumber: KSEI", + "type": "SOURCE_TYPE_KSEI" + }, + "price_formatted": "", + "broker_detail": { + "code": "YU", + "group": "BROKER_GROUP_FOREIGN" + }, + "badges": [], + "price": { + "raw": { + "value": "353" + }, + "formatted": "353" + }, + "value": { + "raw": { + "value": "0" + }, + "formatted": "-" + }, + "is_estimated_price": true, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BUVA.png?version=1785918174174738481" + }, + { + "id": "14074", + "name": "WELLY THOMAS", + "symbol": "SGER", + "date": "30 Oct 26", + "previous": { + "value": "1,285,644,762", + "percentage": "8.25", + "formatted_value": "" + }, + "current": { + "value": "1,341,269,756", + "percentage": "8.61", + "formatted_value": "" + }, + "changes": { + "value": "+55,624,994", + "percentage": "+0.36", + "formatted_value": "55,624,994" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_TRANSFER", + "data_source": { + "label": "Sumber: KSEI", + "type": "SOURCE_TYPE_KSEI" + }, + "price_formatted": "", + "broker_detail": { + "code": "OD", + "group": "BROKER_GROUP_GOVERNMENT" + }, + "badges": [], + "price": { + "raw": { + "value": "794" + }, + "formatted": "794" + }, + "value": { + "raw": { + "value": "44166245236" + }, + "formatted": "44.17B" + }, + "is_estimated_price": true, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/SGER.png" + } + ] + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json new file mode 100644 index 0000000..bf4534c --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json @@ -0,0 +1,430 @@ +{ + "message": "Successfully majorholder data", + "data": { + "is_more": true, + "movement": [ + { + "id": "1000000439", + "name": "HENDRA TANUMIHARDJA", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "193,206", + "percentage": "0.0002", + "formatted_value": "" + }, + "current": { + "value": "341,139", + "percentage": "0.0003", + "formatted_value": "" + }, + "changes": { + "value": "+147,933", + "percentage": "+0.0001", + "formatted_value": "147,933" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "1032868206" + }, + "formatted": "1.03B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "1000000438", + "name": "A. WIDODO MULYONO", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "440,838", + "percentage": "0.0004", + "formatted_value": "" + }, + "current": { + "value": "681,407", + "percentage": "0.0006", + "formatted_value": "" + }, + "changes": { + "value": "+240,569", + "percentage": "+0.0002", + "formatted_value": "240,569" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "1679652758" + }, + "formatted": "1.68B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "1000000437", + "name": "GREGORY HENDRA LEMBONG", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "1,531,282", + "percentage": "0.0012", + "formatted_value": "" + }, + "current": { + "value": "2,666,921", + "percentage": "0.0022", + "formatted_value": "" + }, + "changes": { + "value": "+1,135,639", + "percentage": "+0.0010", + "formatted_value": "1,135,639" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "7929031498" + }, + "formatted": "7.93B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "1000000209", + "name": "SANTOSO", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "3,269,028", + "percentage": "0.0027", + "formatted_value": "" + }, + "current": { + "value": "3,764,062", + "percentage": "0.0031", + "formatted_value": "" + }, + "changes": { + "value": "+495,034", + "percentage": "+0.0004", + "formatted_value": "495,034" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "3456327388" + }, + "formatted": "3.46B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14421", + "name": "TONNY KUSNADI", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "7,502,058", + "percentage": "0.0061", + "formatted_value": "" + }, + "current": { + "value": "7,819,950", + "percentage": "0.0063", + "formatted_value": "" + }, + "changes": { + "value": "+317,892", + "percentage": "+0.0002", + "formatted_value": "317,892" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_KOMISARIS" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "2219521944" + }, + "formatted": "2.22B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14418", + "name": "FRENGKY CHANDRA KUSUMA", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "2,429,926", + "percentage": "0.0020", + "formatted_value": "" + }, + "current": { + "value": "2,835,916", + "percentage": "0.0023", + "formatted_value": "" + }, + "changes": { + "value": "+405,990", + "percentage": "+0.0003", + "formatted_value": "405,990" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "2834622180" + }, + "formatted": "2.83B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14417", + "name": "HARYANTO TIARA BUDIMAN", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "1,057,378", + "percentage": "0.0009", + "formatted_value": "" + }, + "current": { + "value": "1,436,806", + "percentage": "0.0012", + "formatted_value": "" + }, + "changes": { + "value": "+379,428", + "percentage": "+0.0003", + "formatted_value": "379,428" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "2649166296" + }, + "formatted": "2.65B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14416", + "name": "VERA EVE LIM", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "2,731,601", + "percentage": "0.0022", + "formatted_value": "" + }, + "current": { + "value": "3,281,460", + "percentage": "0.0027", + "formatted_value": "" + }, + "changes": { + "value": "+549,859", + "percentage": "+0.0005", + "formatted_value": "549,859" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "3839115538" + }, + "formatted": "3.84B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + } + ] + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json new file mode 100644 index 0000000..d4bc7d3 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json @@ -0,0 +1,220 @@ +{ + "message": "Successfully majorholder data", + "data": { + "is_more": false, + "movement": [ + { + "id": "14418", + "name": "FRENGKY CHANDRA KUSUMA", + "symbol": "BBCA", + "date": "22 Mar 24", + "previous": { + "value": "1,891,049", + "percentage": "0.0015", + "formatted_value": "" + }, + "current": { + "value": "2,107,984", + "percentage": "0.0017", + "formatted_value": "" + }, + "changes": { + "value": "+216,935", + "percentage": "+0.0002", + "formatted_value": "216,935" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "10,108", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "10108" + }, + "formatted": "10,108" + }, + "value": { + "raw": { + "value": "2192778980" + }, + "formatted": "2.19B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14417", + "name": "HARYANTO TIARA BUDIMAN", + "symbol": "BBCA", + "date": "22 Mar 24", + "previous": { + "value": "561,695", + "percentage": "0.0005", + "formatted_value": "" + }, + "current": { + "value": "776,099", + "percentage": "0.0006", + "formatted_value": "" + }, + "changes": { + "value": "+214,404", + "percentage": "+0.0001", + "formatted_value": "214,404" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "10,108", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "10108" + }, + "formatted": "10,108" + }, + "value": { + "raw": { + "value": "2167195632" + }, + "formatted": "2.17B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14416", + "name": "VERA EVE LIM", + "symbol": "BBCA", + "date": "22 Mar 24", + "previous": { + "value": "1,912,261", + "percentage": "0.0016", + "formatted_value": "" + }, + "current": { + "value": "2,212,324", + "percentage": "0.0018", + "formatted_value": "" + }, + "changes": { + "value": "+300,063", + "percentage": "+0.0002", + "formatted_value": "300,063" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "10,108", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "10108" + }, + "formatted": "10,108" + }, + "value": { + "raw": { + "value": "3033036804" + }, + "formatted": "3.03B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14415", + "name": "LIANAWATY SUWONO", + "symbol": "BBCA", + "date": "22 Mar 24", + "previous": { + "value": "2,012,880", + "percentage": "0.0016", + "formatted_value": "" + }, + "current": { + "value": "2,264,685", + "percentage": "0.0018", + "formatted_value": "" + }, + "changes": { + "value": "+251,805", + "percentage": "+0.0002", + "formatted_value": "251,805" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "10,108", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "10108" + }, + "formatted": "10,108" + }, + "value": { + "raw": { + "value": "2545244940" + }, + "formatted": "2.55B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + } + ] + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json new file mode 100644 index 0000000..6571c36 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json @@ -0,0 +1,314 @@ +{ + "message": "Successfully majorholder data", + "data": { + "is_more": false, + "movement": [ + { + "id": "13898", + "name": "CGS INTERNATIONAL SEKURITAS INDONESIA", + "symbol": "ENRG", + "date": "29 Oct 26", + "previous": { + "value": "3,600,545,611", + "percentage": "9.04", + "formatted_value": "" + }, + "current": { + "value": "3,100,545,614", + "percentage": "7.78", + "formatted_value": "" + }, + "changes": { + "value": "-499,999,997", + "percentage": "-1.25", + "formatted_value": "499,999,997" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_CROSS", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "715", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "715" + }, + "formatted": "715" + }, + "value": { + "raw": { + "value": "357499997855" + }, + "formatted": "357.50B" + }, + "is_estimated_price": false, + "purpose": "Reverse REPO", + "icon_url": "https://assets.stockbit.com/logos/companies/ENRG.png?version=1750036449822958153" + }, + { + "id": "12202", + "name": "MAYBANK SEKURITAS INDONESIA", + "symbol": "ENRG", + "date": "28 Oct 26", + "previous": { + "value": "2,380,252,500", + "percentage": "5.97", + "formatted_value": "" + }, + "current": { + "value": "2,442,752,500", + "percentage": "6.13", + "formatted_value": "" + }, + "changes": { + "value": "+62,500,000", + "percentage": "+0.16", + "formatted_value": "62,500,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_CROSS", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "724" + }, + "formatted": "724" + }, + "value": { + "raw": { + "value": "45250000000" + }, + "formatted": "45.25B" + }, + "is_estimated_price": true, + "purpose": "Penambahan jaminan saham untuk transaksi repo", + "icon_url": "https://assets.stockbit.com/logos/companies/ENRG.png?version=1750036449822958153" + }, + { + "id": "1000000448", + "name": "TRINUGRAHA THOHIR HARMONI", + "symbol": "VISI", + "date": "28 Oct 26", + "previous": { + "value": "246,000,000", + "percentage": "7.70", + "formatted_value": "" + }, + "current": { + "value": "270,144,900", + "percentage": "8.45", + "formatted_value": "" + }, + "changes": { + "value": "+24,144,900", + "percentage": "+0.76", + "formatted_value": "24,144,900" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_PRIVATE_PLACEMENT", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "994", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "994" + }, + "formatted": "994" + }, + "value": { + "raw": { + "value": "24000030600" + }, + "formatted": "24.00B" + }, + "is_estimated_price": false, + "purpose": "PMTHMETD", + "icon_url": "https://assets.stockbit.com/logos/companies/VISI.png" + }, + { + "id": "1000005952", + "name": "WIBOWO GROUP CAPITAL", + "symbol": "MBSS", + "date": "15 Oct 26", + "previous": { + "value": "1,443,766,800", + "percentage": "82.50", + "formatted_value": "" + }, + "current": { + "value": "1,576,605,000", + "percentage": "90.09", + "formatted_value": "" + }, + "changes": { + "value": "+132,838,200", + "percentage": "+7.59", + "formatted_value": "132,838,200" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_TENDER_OFFER", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "2,915", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "2915" + }, + "formatted": "2,915" + }, + "value": { + "raw": { + "value": "387223353000" + }, + "formatted": "387.22B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/MBSS.png" + }, + { + "id": "1000006017", + "name": "JEFRI NEDI", + "symbol": "FUJI", + "date": "15 Oct 26", + "previous": { + "value": "109,431,600", + "percentage": "8.42", + "formatted_value": "" + }, + "current": { + "value": "109,251,600", + "percentage": "8.40", + "formatted_value": "" + }, + "changes": { + "value": "-180,000", + "percentage": "-0.01", + "formatted_value": "180,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_SELL", + "data_source": { + "label": "Sumber: KSEI", + "type": "SOURCE_TYPE_KSEI" + }, + "price_formatted": "", + "broker_detail": { + "code": "CP", + "group": "BROKER_GROUP_FOREIGN" + }, + "badges": [], + "price": { + "raw": { + "value": "298" + }, + "formatted": "298" + }, + "value": { + "raw": { + "value": "53640000" + }, + "formatted": "53.64M" + }, + "is_estimated_price": true, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/FUJI.png" + }, + { + "id": "1000004481", + "name": "RUDI R. SUTANTRA", + "symbol": "ATLA", + "date": "15 Oct 26", + "previous": { + "value": "2,638,254,200", + "percentage": "42.56", + "formatted_value": "" + }, + "current": { + "value": "2,610,254,200", + "percentage": "42.10", + "formatted_value": "" + }, + "changes": { + "value": "-28,000,000", + "percentage": "-0.45", + "formatted_value": "28,000,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_SELL", + "data_source": { + "label": "Sumber: KSEI", + "type": "SOURCE_TYPE_KSEI" + }, + "price_formatted": "", + "broker_detail": { + "code": "AG", + "group": "BROKER_GROUP_FOREIGN" + }, + "badges": [], + "price": { + "raw": { + "value": "29" + }, + "formatted": "29" + }, + "value": { + "raw": { + "value": "812000000" + }, + "formatted": "812.00M" + }, + "is_estimated_price": true, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/ATLA.png" + } + ] + } +} diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 3142955..774c635 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -7,6 +7,7 @@ use idx_core::broker::types::{BrokerBoard, BrokerInvestor}; use idx_core::corporate::types::ActionKind; use idx_core::flow::types::FlowSource; use idx_core::market::{ScreenFilter, ScreenRegion}; +use idx_core::movements::types::{ActionFilter, MAX_PAGES}; use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::Deserialize; @@ -253,6 +254,34 @@ pub struct CorporateCalendarParams { pub kinds: Option>, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MovementsParams { + /// IDX ticker, e.g. "BBCA"; omit for the whole market. + #[serde(default)] + pub symbol: Option, + /// First day, YYYY-MM-DD. + #[serde(default)] + pub from: Option, + /// Last day, YYYY-MM-DD. + #[serde(default)] + pub to: Option, + /// One holder's movements: the `holder_id` of a row. + #[serde(default)] + pub holder_id: Option, + /// Only this kind of movement; "corporate-action" covers rights issues, private placements and the like. + #[serde(default)] + #[schemars(schema_with = "movement_action_schema")] + pub action: Option, + /// "idx" (insider disclosures, any size) or "ksei" (holders at 5% and above). + #[serde(default)] + #[schemars(schema_with = "movement_source_schema")] + pub source: Option, + /// Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit. + #[serde(default)] + #[schemars(schema_with = "movement_pages_schema")] + pub pages: Option, +} + /// Parses `kinds`, or `default` when absent. pub fn action_kinds( kinds: Option>, @@ -346,6 +375,18 @@ fn action_kinds_schema(_: &mut SchemaGenerator) -> Schema { json_schema!({ "type": "array", "items": { "type": "string", "enum": kinds } }) } +fn movement_action_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(ActionFilter::ALL.map(ActionFilter::as_str).to_vec()) +} + +fn movement_source_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(vec!["idx", "ksei"]) +} + +fn movement_pages_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "type": "integer", "minimum": 1, "maximum": MAX_PAGES }) +} + fn login_method_schema(_: &mut SchemaGenerator) -> Schema { string_enum(LoginMethod::ALL.map(LoginMethod::as_str).to_vec()) } diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index bcf375c..4c48842 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -16,6 +16,7 @@ use idx_core::financial::{EarningsFilter, StatementFilter}; use idx_core::flow::types::FlowRange; use idx_core::investor::{HolderSource, Investor}; use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion}; +use idx_core::movements::types::MovementQuery; use idx_core::ownership::types::ChangeRow; use idx_core::{CachePolicy, Fetched, Idx}; use rmcp::handler::server::router::tool::ToolRouter; @@ -29,8 +30,8 @@ use tokio::sync::Semaphore; use crate::params::{ AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams, CompanySection, CorporateActionsParams, CorporateCalendarParams, EntityParams, FlowParams, - HistoryParams, IntradayParams, LoginMethod, LoginParams, OrderBookParams, ScreenParams, - SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds, + HistoryParams, IntradayParams, LoginMethod, LoginParams, MovementsParams, OrderBookParams, + ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds, }; const SYNC_TOOL: &str = "ownership_sync"; @@ -401,6 +402,42 @@ AUTHERROR means there is no session: call stockbit_status, then stockbit_login." .await } + #[tool( + description = "Reported changes in insiders' and major holders' stakes in IDX stocks, \ +from the user's own Stockbit session, newest first: IDX insider disclosures (directors, \ +commissioners and other insiders, any size, with the trade price) and KSEI changes of holders \ +at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without \ +`symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis \ +points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain \ +past `pages`. These are filings, not signals: many insiders buying at one price on one day is \ +usually a share plan, and rows appear days after the change. AUTHERROR means there is no \ +session: call stockbit_status, then stockbit_login." + )] + async fn get_shareholder_movements(&self, params: Parameters) -> ToolResult { + let MovementsParams { + symbol, + from, + to, + holder_id, + action, + source, + pages, + } = params.0; + self.live(move |idx| { + let mut query = MovementQuery::from_args( + symbol.as_deref(), + from.as_deref(), + to.as_deref(), + holder_id.as_deref(), + pages.unwrap_or(1), + )?; + query.action = action.as_deref().map(str::parse).transpose()?; + query.source = source.as_deref().map(str::parse).transpose()?; + idx.movements().list(&query, CachePolicy::Default) + }) + .await + } + #[tool( description = "Company data for one IDX ticker: profile, financial statements, \ earnings, analyst sentiment, insights, and news. News items carry `relevance`: \ diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index b4a8878..6ba541f 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -555,6 +555,76 @@ }, "name": "get_quotes" }, + { + "description": "Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`. These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "action": { + "default": null, + "description": "Only this kind of movement; \"corporate-action\" covers rights issues, private placements and the like.", + "enum": [ + "buy", + "sell", + "transfer", + "cross", + "corporate-action" + ], + "type": "string" + }, + "from": { + "default": null, + "description": "First day, YYYY-MM-DD.", + "type": [ + "string", + "null" + ] + }, + "holder_id": { + "default": null, + "description": "One holder's movements: the `holder_id` of a row.", + "type": [ + "string", + "null" + ] + }, + "pages": { + "default": null, + "description": "Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit.", + "maximum": 10, + "minimum": 1, + "type": "integer" + }, + "source": { + "default": null, + "description": "\"idx\" (insider disclosures, any size) or \"ksei\" (holders at 5% and above).", + "enum": [ + "idx", + "ksei" + ], + "type": "string" + }, + "symbol": { + "default": null, + "description": "IDX ticker, e.g. \"BBCA\"; omit for the whole market.", + "type": [ + "string", + "null" + ] + }, + "to": { + "default": null, + "description": "Last day, YYYY-MM-DD.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "name": "get_shareholder_movements" + }, { "description": "Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).", "inputSchema": { diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index 5274ffc..b705104 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -9,7 +9,7 @@ use idx_core::config::CoreConfig; use idx_core::investor::{ImportSource, Investor}; use serde_json::{Value, json}; -const TOOLS: [&str; 20] = [ +const TOOLS: [&str; 21] = [ "analyze_bandar", "analyze_stock", "compare_stocks", @@ -24,6 +24,7 @@ const TOOLS: [&str; 20] = [ "get_ownership_changes", "get_price_history", "get_quotes", + "get_shareholder_movements", "get_shareholders", "ownership_status", "screen_stocks", @@ -551,6 +552,54 @@ fn corporate_actions_need_a_session_and_keep_kinds_apart() { assert_eq!(body["code"], "INVALIDINPUT"); } +#[test] +fn shareholder_movements_page_and_filter() { + let mut client = Client::start(&[], false); + let (is_error, body) = client.call("get_shareholder_movements", json!({})); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "AUTHERROR"); + + let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]); + let market = client.ok("get_shareholder_movements", json!({"pages": 2})); + assert_fetched(&market); + assert_eq!(market["source"], "stockbit"); + let rows = market["data"]["movements"].as_array().unwrap(); + assert_eq!(rows.len(), 14, "8 rows on page 1, 6 on the last page"); + assert_eq!(market["data"]["more"], false); + assert!( + rows.windows(2) + .all(|w| w[0]["date"].as_str() >= w[1]["date"].as_str()), + "newest first" + ); + + let sells = client.ok( + "get_shareholder_movements", + json!({"action": "sell", "source": "ksei", "holder_id": "1000004481", "pages": 3}), + ); + let rows = sells["data"]["movements"].as_array().unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| r["symbol"] == "ATLA" + && r["action"] == "sell" + && r["shares_change"].as_i64() < Some(0) + && r["price_estimated"] == true)); + + let bbca = client.ok("get_shareholder_movements", json!({"symbol": "bbca"})); + assert_eq!(bbca["data"]["more"], true, "BBCA has a second page"); + assert_eq!(bbca["data"]["movements"][0]["roles"], json!(["director"])); + + for bad in [ + json!({"action": "dividend"}), + json!({"source": "bing"}), + json!({"holder_id": "RUDI"}), + json!({"pages": 11}), + json!({"from": "2026-11-02", "to": "2026-11-01"}), + ] { + let (is_error, body) = client.call("get_shareholder_movements", bad.clone()); + assert!(is_error, "{bad} -> {body}"); + assert_eq!(body["code"], "INVALIDINPUT", "{bad} -> {body}"); + } +} + #[test] fn errors_carry_cli_codes() { let mut client = Client::start(&[], false); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0997cd4..b7c6832 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -296,6 +296,13 @@ WIB as the fallback. `get_corporate_calendar` filter by `CorporateAction::date` (the cum date for entitlements) and sort oldest first. Endpoint notes and the wider design: `docs/STOCKBIT.md`. +- Shareholder movements (`movements`, `api/stockbit/movement.rs`) read + `/insider/company/majorholder`: IDX insider disclosures and KSEI 5% holder + changes, 50 rows a page, filtered by Stockbit (`symbol`, `date_start`, + `date_end`, `insider` (holder id), `action_type`, `source_type`). Each page + is cached an hour under `stockbit-movements`, keyed by every filter and the + page. `ownership movements` and `get_shareholder_movements` read up to 10 + pages; the KSEI register in SQLite is not involved. ### Capability Gating ``` @@ -310,6 +317,7 @@ Factory functions: intraday_provider() → Box orderbook_provider() → Box corporate_action_provider() → Box + shareholder_movement_provider() → Box ``` `ProviderKind::Stockbit` serves quotes (and, with a session, daily history): diff --git a/docs/STOCKBIT.md b/docs/STOCKBIT.md index 037de85..4a0b507 100644 --- a/docs/STOCKBIT.md +++ b/docs/STOCKBIT.md @@ -167,19 +167,33 @@ lists are not errors. A row whose dates are all placeholders is dropped. ### Endpoint -`GET /insider/company/majorholder?symbol=&insider=&date_start=&date_end=&page=`; -all parameters optional. 50 rows a page, `data.is_more` for the next page. +Rows shipped as `idx ownership movements` and `get_shareholder_movements`; +the summary score is next. + +`GET /insider/company/majorholder?symbol=&insider=&date_start=&date_end=&action_type=&source_type=&page=`; +all parameters optional, all filtered by Stockbit (checked 2026-11-03). +`date_start` / `date_end` are YYYY-MM-DD; `insider` is the holder id, which is +the row's `id` (not unique per row); `action_type` takes `ACTION_TYPE_BUY`, +`_SELL`, `_TRANSFER`, `_CROSS` or `_CORPACTION` (the app's filter list); +`source_type` takes `SOURCE_TYPE_IDX` or `_KSEI`. `page` counts from 1. 50 rows +a page, `data.is_more` for the next page; a busy day has more than 50 rows. Without `symbol` it is market-wide, newest first: on 2026-11-03 the newest rows were from 2026-11-02. BBCA's 50 rows span 2024-03-22 to 2026-03-25. Each row (`data.movement[]`): `name`, `symbol`, `date` (`"25 Mar 26"`), `previous` / `current` / `changes` `{value: shares as "193,206", percentage}`, -`action_type` (`ACTION_TYPE_BUY|SELL|TRANSFER`), `nationality` +`action_type` (`ACTION_TYPE_BUY`, `_SELL`, `_TRANSFER`, `_CROSS`, +`_RIGHT_ISSUE`, `_PRIVATE_PLACEMENT`, `_TENDER_OFFER`; the bundle also names +`_BONUS_STOCK`, `_DIVIDEND_STOCK`, `_DIVIDEND_CASH`), `nationality` (`NATIONALITY_TYPE_LOCAL|FOREIGN`), `badges` (`SHAREHOLDER_BADGE_DIREKTUR`, `..._KOMISARIS`), `price.raw.value`, `value.raw.value` (IDR), `is_estimated_price`, `purpose` (free text such as "Pembelian", "Investasi"), `data_source.type` (`SOURCE_TYPE_IDX` for insider disclosures, `SOURCE_TYPE_KSEI` for 5% holder changes), `broker_detail` (empty so far). +KSEI rows carry an estimated price (`is_estimated_price`: true); IDX rows +mostly the trade price, though crossings can be estimated too. `CROSS` rows +name securities firms as the holder. A rights issue can change the stake +with no change in shares (dilution). ### Relation to `ownership` @@ -208,10 +222,15 @@ Yahoo prices, forward 5/20/60-session returns against IHSG). ### Surface -- `idx ownership movements [SYM] [--from DATE] [--to DATE] [--holder NAME] - [--action buy|sell|transfer] [--pages N]`: rows, newest first. -- `idx ownership movements --summary [--from DATE]`: the per-symbol score. -- MCP: `get_shareholder_movements {symbol?, from?, to?, holder?, pages?}`. +- `idx ownership movements [SYM] [--from DATE] [--to DATE] [--holder ID] + [--action buy|sell|transfer|cross|corporate-action] [--source idx|ksei] + [--pages N]`: rows, newest first (shipped). `--holder` takes the holder id + rather than a name: Stockbit filters by id, and a name search would only see + the pages already read. +- MCP: `get_shareholder_movements {symbol?, from?, to?, holder_id?, action?, + source?, pages?}` (shipped). +- Next: `idx ownership movements --summary [--from DATE]`, the per-symbol + score. ## P2 and P3 @@ -236,7 +255,7 @@ Yahoo prices, forward 5/20/60-session returns against IHSG). states (done 2026-11-03). 2. Corporate actions (parsers, `stocks actions`, `stocks calendar`, MCP, goldens) (done). -3. Shareholder movements, rows first, then `--summary`. +3. Shareholder movements, rows first (done), then `--summary`. 4. Event study for the movement score; P2 by demand. ## Open questions diff --git a/web/src/content/docs/cli/ownership.md b/web/src/content/docs/cli/ownership.md index 07b1037..f22f76c 100644 --- a/web/src/content/docs/cli/ownership.md +++ b/web/src/content/docs/cli/ownership.md @@ -36,6 +36,32 @@ two releases. `ticker` also reports concentration: the top holder's share, the top three, the Herfindahl index (HHI) and the free float (shares held by holders below 1%). +## Insider and major holder movements + +With your own Stockbit login (`idx stockbit login`, see [Stockbit](/cli/stockbit/)), +`movements` lists reported changes in holders' stakes, newest first. It reads +Stockbit live and needs no import: + +```bash +idx ownership movements BBCA # one stock, 50 rows +idx ownership movements # the whole market +idx ownership movements --action sell --source ksei --pages 3 +idx ownership movements --from 2026-10-01 --to 2026-10-31 +idx ownership movements --holder 1000004481 # one holder's history +``` + +Two sources share the list. `IDX` rows are insider disclosures: directors, +commissioners and other insiders, at any size, with the trade price. `KSEI` +rows are changes of holders at 5% and above, which report no price; Stockbit +estimates one, shown with `~`. `--holder` takes the number in the HOLDER ID +column. Shares are exact. Stakes are as Stockbit rounds them, so a director's +small holding reads `<0.01%`. + +These are filings, not signals. Many insiders buying at one price on one day +is usually a share plan, transfers are not trades, and a row appears days +after the change. Each page is 50 rows and one request to Stockbit (`--pages` +up to 10); answers are cached for an hour. + ## Import reports yourself The snapshot covers most needs. To load a month it lacks, or older history, diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index e02f3a4..6e35899 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -1158,6 +1158,7 @@ Commands: concentration Rank tickers by ownership concentration flow Show Bing institutional flow for a ticker changes Diff two KSEI releases + movements Insider and major holder buys and sells, as reported (Stockbit login) graph Ownership network graph resolve Manual entity resolution workflow releases List imported KSEI releases @@ -1547,6 +1548,79 @@ Examples: idx ownership changes --from 2026-08-31 --to 2026-09-30 ``` +### `idx ownership movements` + +```text +Reported changes in holders' stakes from your own Stockbit session (`idx stockbit login`), newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI's changes of holders at 5% and above (Stockbit estimates their price). One stock's, or the whole market's without SYMBOL. Each page is 50 rows and one request; answers are cached for an hour. + +These are filings, not signals: a share plan shows as many insiders buying at one price, and a row appears days after the change. + +Usage: idx ownership movements [OPTIONS] [SYMBOL] + +Arguments: + [SYMBOL] + Single ticker symbol (e.g. BBCA); omit for the whole market + +Options: + --from + First day, YYYY-MM-DD + + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --no-color + Disable colored output + + --to + Last day, YYYY-MM-DD + + --holder + One holder's movements, by the id in the HOLDER ID column + + -q, --quiet + Suppress non-essential output + + --action + Only this kind of movement + + [possible values: buy, sell, transfer, cross, corporate-action] + + -v, --verbose... + Increase verbosity (-v, -vv) + + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --source + Only IDX insider disclosures or KSEI 5% holder changes + + [possible values: idx, ksei] + + --offline + Run without network requests + + --pages + Pages of 50 rows to read (1-10) + + [default: 1] + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx ownership movements BBCA + idx ownership movements --action sell --source ksei + idx ownership movements --from 2026-10-01 --to 2026-10-31 --pages 5 + idx ownership movements --holder 1000004481 # one holder's history (HOLDER ID column) +``` + ### `idx ownership graph` ```text diff --git a/web/src/content/docs/cli/stockbit.md b/web/src/content/docs/cli/stockbit.md index 5c54f8f..735e066 100644 --- a/web/src/content/docs/cli/stockbit.md +++ b/web/src/content/docs/cli/stockbit.md @@ -77,6 +77,7 @@ idx stocks orderbook BBCA # depth, queued orders, foreign flow idx stocks orderbook BBRI --depth 5 idx stocks actions BBCA # dividends, splits, meetings, ... idx stocks calendar --kind dividend # cum dates in the next 30 days +idx ownership movements BBCA # insider and major holder buys and sells idx --provider stockbit stocks quote BBCA # live, not delayed idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily candles idx stockbit logout @@ -201,6 +202,7 @@ What the login adds: | `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows | | `stocks intraday` | public page | public page | | `stocks actions`, `stocks calendar` | `AUTHERROR` | corporate actions: one stock's history, or the market's calendar | +| `ownership movements` | `AUTHERROR` | insider and major holder buys and sells, as reported to IDX and KSEI | The order book is what Stockbit's own app shows. Outside trading it is the book as the market left it, and the output says so. Foreign buy, sell and net @@ -222,6 +224,6 @@ market or republishing prices. With your login, automated use is tied to your account: keep it human-paced. Over MCP, the same data comes from `get_intraday`, `get_orderbook`, -`get_corporate_actions`, `get_corporate_calendar` and `stockbit_status`, and from `get_quotes` and `get_price_history` when the +`get_corporate_actions`, `get_corporate_calendar`, `get_shareholder_movements` and `stockbit_status`, and from `get_quotes` and `get_price_history` when the server runs with `IDX_PROVIDER=stockbit`. The server never logs in; run `idx stockbit login` in a terminal. diff --git a/web/src/content/docs/mcp/index.md b/web/src/content/docs/mcp/index.md index d73870a..d45736d 100644 --- a/web/src/content/docs/mcp/index.md +++ b/web/src/content/docs/mcp/index.md @@ -5,8 +5,8 @@ description: idx-mcp serves idx's stock and ownership data to AI agents over MCP `idx-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio. It gives an agent the same data as the `idx` CLI, as -[20 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's -session by the minute, the order book and corporate actions (with a Stockbit +[21 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's +session by the minute, the order book, corporate actions and insider movements (with a Stockbit login), price history, screening, company data, analysis, comparison, broker summaries, bandarmology, foreign flow, and the KSEI ownership queries. diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 19e1260..4dca344 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -150,6 +150,20 @@ Latest quotes (price, change, volume, 52-week range, market cap) for one or more | --- | --- | --- | --- | | `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. | +## `get_shareholder_movements` + +Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`. These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `action` | `buy` / `sell` / `transfer` / `cross` / `corporate-action` | no | Only this kind of movement; "corporate-action" covers rights issues, private placements and the like. | +| `from` | string | no | First day, YYYY-MM-DD. | +| `holder_id` | string | no | One holder's movements: the `holder_id` of a row. | +| `pages` | integer | no | Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit. | +| `source` | `idx` / `ksei` | no | "idx" (insider disclosures, any size) or "ksei" (holders at 5% and above). | +| `symbol` | string | no | IDX ticker, e.g. "BBCA"; omit for the whole market. | +| `to` | string | no | Last day, YYYY-MM-DD. | + ## `get_shareholders` Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float). From 615083ee0ba8314433598a385cc885cb7af1f068 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 3 Nov 2026 16:02:26 +0700 Subject: [PATCH 2/8] test(smoke): live case for ownership movements with a Stockbit session Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY --- docs/SMOKE.md | 4 ++-- scripts/live-smoke.sh | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/SMOKE.md b/docs/SMOKE.md index b87705f..5faad19 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -63,8 +63,8 @@ scripts/live-smoke.sh --dry-run session sees `market_status: close` and no bid/offer. These run without a Stockbit login: each side has its own config, and seeding it with a real refresh token would rotate (and so retire) that token. When - `idx stockbit status` reports a session, `stocks orderbook`, `stocks actions` - and `stocks calendar` also run, on the new binary with your real config + `idx stockbit status` reports a session, `stocks orderbook`, `stocks actions`, + `stocks calendar` and `ownership movements` (market-wide, one page) also run, on the new binary with your real config directory (the session file is shared under its lock, never copied; cache and data stay isolated). Check the rest by hand: `idx --provider stockbit stocks quote BBCA` and diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 026911f..a63ba96 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -136,6 +136,8 @@ contract() { echo '(.symbol | type == "string") and (.price | type == "number") and (.market_status | type == "string") and (.bids | type == "array") and (.offers | type == "array")' ;; "stocks actions "*|"stocks calendar"*) echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;; + "ownership movements"*) + echo '(.more | type == "boolean") and (.movements | length > 0) and all(.movements[]; (.symbol | type == "string") and (.date | type == "string") and (.holder_id | type == "string") and (.action | type == "string") and (.shares_change | type == "number"))' ;; "stocks broker "*) echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;; "stocks bandar "*) @@ -267,9 +269,10 @@ if selected stockbit; then run_new_case orderbook "stocks orderbook $SYMBOL --depth 5" run_new_case actions "stocks actions $SYMBOL --kind dividend,split" run_new_case calendar "stocks calendar --kind dividend,rights" + run_new_case movements "ownership movements" REAL_CONFIG=0 else - echo " no Stockbit session: orderbook, actions and calendar not run" + echo " no Stockbit session: orderbook, actions, calendar and movements not run" fi fi From 0c7320a343c4d7cbecce73e91870190759e5a0f3 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 4 Nov 2026 17:39:10 +0700 Subject: [PATCH 3/8] fix(movements): read a query's pages as one answer and keep a partial one - Each page was cached on its own, so a multi-page answer could join pages read at different times from a feed that gains rows at the top, repeating some rows and missing others. A query's pages are now read together and cached as one entry. - A failed later page threw away the pages already read. They are now returned with incomplete saying why, and not cached. - The library did not cap pages at MAX_PAGES itself; it clamps now. - The hint said to raise --pages even at 10; at 10 it says to narrow the dates. -o jsonl, which streams rows only, gives the hint on stderr. - A duplicate match arm in the CLI was dead; it is gone. Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY --- crates/idx-cli/src/cli/ownership.rs | 4 +- crates/idx-cli/src/output/mod.rs | 13 ++- crates/idx-cli/src/output/table.rs | 22 ++++- crates/idx-cli/tests/cli.rs | 27 ++++++ crates/idx-core/src/api/mock.rs | 4 + crates/idx-core/src/movements/mod.rs | 127 ++++++++++++++++--------- crates/idx-core/src/movements/types.rs | 5 + crates/idx-mcp/src/server.rs | 3 +- crates/idx-mcp/tests/golden/tools.json | 2 +- web/src/content/docs/cli/ownership.md | 4 +- web/src/content/docs/mcp/tools.md | 2 +- 11 files changed, 159 insertions(+), 54 deletions(-) diff --git a/crates/idx-cli/src/cli/ownership.rs b/crates/idx-cli/src/cli/ownership.rs index cd2850f..63cef02 100644 --- a/crates/idx-cli/src/cli/ownership.rs +++ b/crates/idx-cli/src/cli/ownership.rs @@ -252,9 +252,6 @@ pub fn handle( offline: bool, no_cache: bool, ) -> Result<(), IdxError> { - if let OwnershipCommand::Movements(args) = cmd { - return handle_movements(args, config, CachePolicy::from_flags(offline, no_cache)?); - } let investor = Investor::new(&config.core); let inv = &investor; match cmd { @@ -752,6 +749,7 @@ fn handle_movements( crate::output::render_shareholder_movements( symbol.as_deref(), &list.data, + query.pages, &config.output, config.no_color, ) diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 633acb2..62506f4 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -129,16 +129,25 @@ pub fn render_corporate_calendar( /// JSON is `{movements, more}` (`more`: rows remain past the pages read); /// JSONL is one movement per line. +/// `pages` is how many pages the query asked for, for the hint when more +/// rows exist. JSONL streams the rows only, so the hint goes to stderr. pub fn render_shareholder_movements( symbol: Option<&str>, list: &MovementList, + pages: u32, format: &OutputFormat, no_color: bool, ) -> Result<(), IdxError> { match format { - OutputFormat::Table => table::print_shareholder_movements(symbol, list, no_color), + OutputFormat::Table => table::print_shareholder_movements(symbol, list, pages, no_color), OutputFormat::Json => json::print_json(list), - OutputFormat::Jsonl => json::print_json(&list.movements), + OutputFormat::Jsonl => { + json::print_json(&list.movements)?; + if list.more { + idx_core::runtime::info(table::more_movements_hint(pages)); + } + Ok(()) + } } } diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index d3afec8..216fce0 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -1920,9 +1920,22 @@ const MOVEMENT_NOTE: &str = "IDX rows are insider disclosures (directors, commis other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated \ price. Rows appear days after the change."; +/// What to do when rows remain past the pages read. +pub fn more_movements_hint(pages: u32) -> String { + if pages >= idx_core::movements::types::MAX_PAGES { + "More rows exist: narrow --from/--to (10 pages is the most one query reads).".to_string() + } else { + format!( + "More rows exist: raise --pages (up to {}) or narrow --from/--to.", + idx_core::movements::types::MAX_PAGES + ) + } +} + pub fn print_shareholder_movements( symbol: Option<&str>, list: &MovementList, + pages: u32, no_color: bool, ) -> Result<(), IdxError> { let title = match symbol { @@ -1987,8 +2000,13 @@ pub fn print_shareholder_movements( println!("{table}"); println!("{MOVEMENT_NOTE}"); } - if list.more { - println!("More rows exist: raise --pages (up to 10) or narrow --from/--to."); + if let Some(reason) = &list.incomplete { + println!( + "Reading stopped early ({}); these are the rows read before it.", + terminal_safe(reason) + ); + } else if list.more { + println!("{}", more_movements_hint(pages)); } println!("{STOCKBIT_SESSION_NOTE}"); Ok(()) diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 490e254..b1e5cbe 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2239,6 +2239,33 @@ fn auto_history_falls_back_to_yahoo_when_the_session_is_refused() { let _ = fs::remove_dir_all(root); } +/// A page after the first that fails keeps the rows read before it, says +/// why, and is not cached; a query's pages are cached as one answer. +#[test] +fn movements_keep_the_pages_read_when_a_later_one_fails() { + let root = test_env_dir("movements-partial"); + let run = |fail_page: Option<&str>| { + let mut cmd = mock_bin_with_root(&root); + cmd.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN) + .args(["-o", "json", "ownership", "movements", "--pages", "2"]); + if let Some(page) = fail_page { + cmd.env("IDX_MOCK_MOVEMENTS_FAIL_PAGE", page); + } + run_success_json(&mut cmd) + }; + let partial = run(Some("2")); + assert_eq!(partial["movements"].as_array().unwrap().len(), 8); + assert_eq!(partial["more"], true); + assert!(partial["incomplete"].is_string(), "{partial}"); + // Not cached: the next run reads both pages. + let full = run(None); + assert_eq!(full["movements"].as_array().unwrap().len(), 14); + assert!(full.get("incomplete").is_none()); + // Cached as one answer: a failing page 2 now changes nothing. + assert_eq!(run(Some("2")), full); + let _ = fs::remove_dir_all(root); +} + /// A test session, signed by nobody, that expires in 2100. const FAKE_STOCKBIT_TOKEN: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln"; diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index 3e13721..9dbf60a 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -271,6 +271,10 @@ impl ShareholderMovementProvider for MockStockbitProvider { page: u32, ) -> Result { let subject = query.symbol.as_deref().unwrap_or("shareholder movements"); + // `IDX_MOCK_MOVEMENTS_FAIL_PAGE=N` rate-limits page N alone. + if std::env::var("IDX_MOCK_MOVEMENTS_FAIL_PAGE").ok() == Some(page.to_string()) { + return Err(IdxError::RateLimited); + } let mut name = "majorholder".to_string(); if let Some(symbol) = &query.symbol { name.push('_'); diff --git a/crates/idx-core/src/movements/mod.rs b/crates/idx-core/src/movements/mod.rs index f35387e..293d0a7 100644 --- a/crates/idx-core/src/movements/mod.rs +++ b/crates/idx-core/src/movements/mod.rs @@ -1,7 +1,9 @@ //! Shareholder movements from the user's Stockbit session: reported changes //! in insiders' and major holders' stakes (IDX insider disclosures and KSEI's //! 5% holder changes), one stock's or the market's, newest first. Each page -//! is 50 rows and one request (paced 1 s apart), cached for an hour. +//! is 50 rows and one request (paced 1 s apart). A query's pages are read +//! together and cached together for an hour, so they come from one moment +//! of a feed that gains rows at the top. //! //! These are the reports as filed. Whether they predict anything is not //! claimed here: share plans read as a cluster of buys at one price, and a @@ -9,12 +11,15 @@ pub mod types; +use std::cell::RefCell; + use chrono::Utc; -use crate::api::{shareholder_movement_provider, stockbit}; +use crate::api::{ShareholderMovementProvider, shareholder_movement_provider, stockbit}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::error::IdxError; -use types::{MovementList, MovementQuery}; +use crate::runtime; +use types::{MAX_PAGES, MovementList, MovementQuery}; /// The feed gains rows through the day; an hour keeps a session's repeated /// queries cheap without hiding a day's filings for long. @@ -26,15 +31,19 @@ pub struct Movements<'a> { } impl Movements<'_> { - /// Up to `query.pages` pages of the feed, newest first; `more` says - /// whether rows remain past them. The result is as old as its oldest page - /// and stale if any page is. + /// Up to `query.pages` pages of the feed (at most `MAX_PAGES`), newest + /// first; `more` says whether rows remain past them. + /// + /// When a page after the first fails and no cached answer is at hand, + /// the rows read before it are returned with `incomplete` saying why, + /// and are not cached. pub fn list( &self, query: &MovementQuery, policy: CachePolicy, ) -> Result, IdxError> { let mut query = query.clone(); + query.pages = query.pages.clamp(1, MAX_PAGES); if let Some(symbol) = &query.symbol { let (resolved, _) = self.idx.idx_code(symbol, "shareholder movements")?; query.symbol = Some(resolved); @@ -44,50 +53,80 @@ impl Movements<'_> { .symbol .clone() .unwrap_or_else(|| "shareholder movements".to_string()); - let mut list = Fetched { - data: MovementList::default(), - source: stockbit::SOURCE, - fetched_at: Utc::now(), - stale: false, - }; - for page in 1..=query.pages.max(1) { - let key = cache_key(&query, page); - let part = cached_fetch( - &self.idx.cache, - CacheSpec { - bucket: BUCKET, - key: &key, - ttl_secs: TTL_SECS, - subject: &subject, + let key = cache_key(&query); + let partial = RefCell::new(None); + let result = cached_fetch( + &self.idx.cache, + CacheSpec { + bucket: BUCKET, + key: &key, + ttl_secs: TTL_SECS, + subject: &subject, + source: stockbit::SOURCE, + }, + policy, + || read_pages(provider.as_ref(), &query, &partial), + Ok, + ); + match (result, partial.into_inner()) { + (Err(err), Some(mut list)) => { + runtime::warn(format!( + "{err}; showing the {} rows read before it", + list.movements.len() + )); + list.incomplete = Some(err.to_string()); + Ok(Fetched { + data: list, source: stockbit::SOURCE, - }, - policy, - || provider.shareholder_movements(&query, page), - Ok, - )?; - list.fetched_at = list.fetched_at.min(part.fetched_at); - list.stale |= part.stale; - list.data.movements.extend(part.data.movements); - list.data.more = part.data.more; - if !part.data.more { - break; + fetched_at: Utc::now(), + stale: false, + }) } + (result, _) => result, } - Ok(list) } } -/// Every filter and the page, so no two queries share an entry. -fn cache_key(query: &MovementQuery, page: u32) -> String { +/// Reads the query's pages in turn. A failure after the first page leaves +/// the rows read so far in `partial` and fails, so they are not cached. +fn read_pages( + provider: &dyn ShareholderMovementProvider, + query: &MovementQuery, + partial: &RefCell>, +) -> Result { + let mut list = MovementList::default(); + for page in 1..=query.pages { + match provider.shareholder_movements(query, page) { + Ok(part) => { + list.movements.extend(part.movements); + list.more = part.more; + if !part.more { + break; + } + } + Err(err) if page > 1 => { + list.more = true; + *partial.borrow_mut() = Some(list); + return Err(err); + } + Err(err) => return Err(err), + } + } + Ok(list) +} + +/// Every filter and the page count, so no two queries share an entry. +fn cache_key(query: &MovementQuery) -> String { let opt = |value: Option| value.unwrap_or_else(|| "-".to_string()); format!( - "{}:{}:{}:{}:{}:{}:p{page}", + "{}:{}:{}:{}:{}:{}:{}p", query.symbol.as_deref().unwrap_or("market"), opt(query.from.map(|d| d.to_string())), opt(query.to.map(|d| d.to_string())), query.holder_id.as_deref().unwrap_or("-"), query.action.map(|a| a.as_str()).unwrap_or("-"), query.source.map(|s| s.as_str()).unwrap_or("-"), + query.pages, ) } @@ -99,25 +138,27 @@ mod tests { #[test] fn cache_keys_differ_by_every_filter() { let base = MovementQuery::from_args(None, None, None, None, 1).unwrap(); - let mut keys = vec![cache_key(&base, 1), cache_key(&base, 2)]; + let mut two = base.clone(); + two.pages = 2; + let mut keys = vec![cache_key(&base), cache_key(&two)]; let mut q = base.clone(); q.symbol = Some("BBCA.JK".to_string()); - keys.push(cache_key(&q, 1)); + keys.push(cache_key(&q)); let mut q = base.clone(); q.from = chrono::NaiveDate::from_ymd_opt(2026, 10, 1); - keys.push(cache_key(&q, 1)); + keys.push(cache_key(&q)); let mut q = base.clone(); q.to = chrono::NaiveDate::from_ymd_opt(2026, 10, 1); - keys.push(cache_key(&q, 1)); + keys.push(cache_key(&q)); let mut q = base.clone(); q.holder_id = Some("14074".to_string()); - keys.push(cache_key(&q, 1)); + keys.push(cache_key(&q)); let mut q = base.clone(); q.action = Some(ActionFilter::Sell); - keys.push(cache_key(&q, 1)); + keys.push(cache_key(&q)); let mut q = base; q.source = Some(MovementSource::Ksei); - keys.push(cache_key(&q, 1)); + keys.push(cache_key(&q)); let unique: std::collections::HashSet<_> = keys.iter().collect(); assert_eq!(unique.len(), keys.len(), "{keys:?}"); } diff --git a/crates/idx-core/src/movements/types.rs b/crates/idx-core/src/movements/types.rs index 1d031ce..6c974b4 100644 --- a/crates/idx-core/src/movements/types.rs +++ b/crates/idx-core/src/movements/types.rs @@ -288,6 +288,11 @@ pub struct MovementList { /// More rows exist past the pages read (raise `--pages`, or narrow the /// dates). pub more: bool, + /// Why reading stopped before the pages asked for: a later page failed, + /// and `movements` holds the rows read before it. Absent when every page + /// asked for was read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub incomplete: Option, } #[cfg(test)] diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index d78708f..7e74026 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -411,7 +411,8 @@ commissioners and other insiders, any size, with the trade price) and KSEI chang at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without \ `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis \ points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain \ -past `pages`. These are filings, not signals: many insiders buying at one price on one day is \ +past `pages`; `incomplete`, when present, says a later page failed and the rows are those read \ +before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is \ usually a share plan, and rows appear days after the change. AUTHERROR means there is no \ session: call stockbit_status, then stockbit_login." )] diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index aec2f76..1342bfa 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -556,7 +556,7 @@ "name": "get_quotes" }, { - "description": "Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`. These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.", + "description": "Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`; `incomplete`, when present, says a later page failed and the rows are those read before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/web/src/content/docs/cli/ownership.md b/web/src/content/docs/cli/ownership.md index f22f76c..7258b2e 100644 --- a/web/src/content/docs/cli/ownership.md +++ b/web/src/content/docs/cli/ownership.md @@ -60,7 +60,9 @@ small holding reads `<0.01%`. These are filings, not signals. Many insiders buying at one price on one day is usually a share plan, transfers are not trades, and a row appears days after the change. Each page is 50 rows and one request to Stockbit (`--pages` -up to 10); answers are cached for an hour. +up to 10); a query's pages are read and cached together, for an hour. JSON's +`more` says rows remain past them. If a later page fails, you get the rows +read before it, with `incomplete` saying why, and nothing is cached. ## Import reports yourself diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index ecca1a1..21118de 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -152,7 +152,7 @@ Latest quotes (price, change, volume, 52-week range, market cap) for one or more ## `get_shareholder_movements` -Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`. These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login. +Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`; `incomplete`, when present, says a later page failed and the rows are those read before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login. | Input | Type | Required | Description | | --- | --- | --- | --- | From 9b46161566739d37f09bbf16c7ae18c824376f93 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 4 Nov 2026 19:26:10 +0700 Subject: [PATCH 4/8] fix(mcp): hand a call's warnings to the agent idx-core reports what it works around (history falling back to yahoo when Stockbit refuses the session, a stale cache served after a failed fetch, a movements query cut short) as warnings on stderr. An agent never reads idx-mcp's stderr, so it saw `source: "yahoo"` or `stale: true` with no reason. - Each tool call collects its warnings with runtime::on_warning and returns them as `warnings` on the answer or the error, when there are any. - The stale-cache warning names the failure behind it. - The mock's session API 403 is the real session API's block message; the public page's block names IDX_PROVIDER=msn as well as `--provider msn`. Goldens: error-intraday-blocked (json, table) carry the reworded block hint. Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY --- .../golden/error-intraday-blocked.json.golden | 2 +- .../error-intraday-blocked.table.golden | 2 +- crates/idx-core/src/api/mock.rs | 4 ++ crates/idx-core/src/api/stockbit/exodus.rs | 16 +++-- crates/idx-core/src/api/stockbit/parse.rs | 4 +- crates/idx-core/src/client.rs | 2 +- crates/idx-mcp/src/server.rs | 50 +++++++++++--- crates/idx-mcp/tests/mcp.rs | 66 +++++++++++++++++++ docs/DESIGN.md | 4 +- web/src/content/docs/mcp/index.md | 15 +++++ 10 files changed, 146 insertions(+), 19 deletions(-) diff --git a/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden b/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden index 402cc2b..7554cf0 100644 --- a/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden +++ b/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden @@ -1,4 +1,4 @@ exit: 4 --- stdout --- stderr -{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit"} +{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"} diff --git a/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden b/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden index fd793c7..d7b4759 100644 --- a/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden +++ b/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden @@ -1,4 +1,4 @@ exit: 4 --- stdout --- stderr -Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit +Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index 9dbf60a..8477e7e 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -187,6 +187,10 @@ impl MockStockbitProvider { "Stockbit rejected the session; run `idx stockbit login` again".to_string(), )); } + // The session API's own block, as `exodus::fetch` reports it. + if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("403") { + return Err(stockbit::exodus::blocked()); + } if let Some(err) = self.failure(symbol) { return Err(err); } diff --git a/crates/idx-core/src/api/stockbit/exodus.rs b/crates/idx-core/src/api/stockbit/exodus.rs index d594ffe..0569609 100644 --- a/crates/idx-core/src/api/stockbit/exodus.rs +++ b/crates/idx-core/src/api/stockbit/exodus.rs @@ -257,11 +257,7 @@ fn classify(reply: Reply, subject: &str) -> Result { Err(IdxError::SymbolNotFound(subject.to_string())) } 429 => Err(IdxError::RateLimited), - 403 => Err(IdxError::UpstreamBlocked( - "exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \ - retry a block. Try again later or from another network" - .to_string(), - )), + 403 => Err(blocked()), status => Err(IdxError::Http(format!( "stockbit api: HTTP {status}{}", message(&reply.body) @@ -497,6 +493,16 @@ fn trim_to_period(mut bars: Vec, period: &Period) -> Vec { } } +/// A 403 from the CDN in front of the session API: a block, not a refused +/// session, so nothing is retried. +pub(crate) fn blocked() -> IdxError { + IdxError::UpstreamBlocked( + "exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \ + retry a block. Try again later or from another network" + .to_string(), + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/idx-core/src/api/stockbit/parse.rs b/crates/idx-core/src/api/stockbit/parse.rs index 9ecbd5c..17a0b90 100644 --- a/crates/idx-core/src/api/stockbit/parse.rs +++ b/crates/idx-core/src/api/stockbit/parse.rs @@ -124,8 +124,8 @@ pub fn status_error(status: u16, resolved: &str) -> IdxError { match status { 403 => IdxError::UpstreamBlocked( "stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \ - block. Try again later or from another network; `--provider msn` serves quotes \ - without Stockbit" + block. Try again later or from another network; the msn provider (`--provider msn`, \ + or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit" .to_string(), ), 404 => IdxError::SymbolNotFound(resolved.to_string()), diff --git a/crates/idx-core/src/client.rs b/crates/idx-core/src/client.rs index 57e6333..9fe1940 100644 --- a/crates/idx-core/src/client.rs +++ b/crates/idx-core/src/client.rs @@ -199,7 +199,7 @@ where Err(err) => { if use_cache && let Some(stale) = cache.any::(spec.bucket, spec.key)? { runtime::warn(format!( - "network failed, serving stale cache for {}", + "network failed ({err}), serving stale cache for {}", spec.subject )); return Ok(hit(stale, true)); diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 37b52a5..8d2722e 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -2,7 +2,9 @@ //! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk //! cache and ownership database. +use std::cell::RefCell; use std::collections::BTreeMap; +use std::rc::Rc; use idx_core::analysis::BandarQuery; use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP}; @@ -18,7 +20,7 @@ use idx_core::investor::{HolderSource, Investor}; use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion}; use idx_core::movements::types::MovementQuery; use idx_core::ownership::types::ChangeRow; -use idx_core::{CachePolicy, Fetched, Idx}; +use idx_core::{CachePolicy, Fetched, Idx, runtime}; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig}; @@ -57,7 +59,9 @@ static CALL_SLOTS: Semaphore = Semaphore::const_new(4); const INSTRUCTIONS: &str = "Indonesian stock market (IDX) data. Market and financial \ tools fetch live data (cached on disk) and wrap it as {data, source, fetched_at, stale}; \ -stale=true means an expired cache entry was served. Ownership tools read a local KSEI \ +stale=true means an expired cache entry was served. An answer (or error) may carry \ +`warnings`: what idx worked around, such as a refused Stockbit session that made history \ +fall back to yahoo, or the failure behind a stale entry; pass on any that need the user. Ownership tools read a local KSEI \ database of holders above 1%: call ownership_status first to see which release dates \ exist. Percentages ending in _bps are basis points (5494 = 54.94%). Errors carry a \ stable code, e.g. SYMBOLNOTFOUND, RATELIMITED, INVALIDINPUT."; @@ -745,13 +749,43 @@ where .acquire() .await .map_err(|err| ErrorData::internal_error(err.to_string(), None))?; - let result = tokio::task::spawn_blocking(move || f().and_then(to_json)) - .await - .map_err(|err| ErrorData::internal_error(err.to_string(), None))?; - Ok(match result { - Ok(value) => CallToolResult::structured(value), - Err(err) => CallToolResult::structured_error(Value::Object(error_body(&err))), + let (result, warnings) = tokio::task::spawn_blocking(move || { + // idx-core warns on stderr, which the agent never sees; what this + // call warns about (a fallback, a stale cache, a partial answer) + // goes into its answer instead. The call runs on this thread. + let warnings = Rc::new(RefCell::new(Vec::::new())); + let sink = { + let warnings = warnings.clone(); + runtime::on_warning(move |message| { + let mut warnings = warnings.borrow_mut(); + if !warnings.iter().any(|seen| seen == message) { + warnings.push(message.to_string()); + } + }) + }; + let result = f().and_then(to_json); + drop(sink); + (result, warnings.take()) }) + .await + .map_err(|err| ErrorData::internal_error(err.to_string(), None))?; + Ok(match result { + Ok(value) => CallToolResult::structured(with_warnings(value, warnings)), + Err(err) => CallToolResult::structured_error(with_warnings( + Value::Object(error_body(&err)), + warnings, + )), + }) +} + +/// Adds `warnings` to an answer when the call had any. +fn with_warnings(mut value: Value, warnings: Vec) -> Value { + if !warnings.is_empty() + && let Some(object) = value.as_object_mut() + { + object.insert("warnings".into(), json!(warnings)); + } + value } /// Per-symbol failures as error bodies carrying their symbol. diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index b705104..fa27569 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -374,6 +374,72 @@ fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() { ); } +/// What idx works around reaches the agent as `warnings`, since it never +/// sees idx-mcp's stderr: a refused session that made history fall back to +/// yahoo, and a movements query cut short. A clean answer has none. +#[test] +fn workarounds_reach_the_agent_as_warnings() { + let mut client = Client::start_with_env( + &[], + false, + &[ + TEST_SESSION, + ("IDX_PROVIDER", "stockbit"), + ("IDX_MOCK_STOCKBIT_STATUS", "401"), + ], + ); + let history = client.ok( + "get_price_history", + json!({"symbol": "BBCA", "period": "1mo"}), + ); + assert_eq!(history["source"], "yahoo"); + let warnings = history["warnings"].as_array().expect("warnings"); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!( + warnings[0] + .as_str() + .unwrap() + .contains("Stockbit rejected the session") + && warnings[0] + .as_str() + .unwrap() + .contains("falls back to yahoo"), + "{warnings:?}" + ); + let analysis = client.ok("analyze_stock", json!({"symbol": "BBCA"})); + assert!( + analysis["warnings"][0] + .as_str() + .unwrap() + .contains("falls back to yahoo") + ); + // The next call starts with none of the last one's warnings. + let (is_error, body) = client.call("get_orderbook", json!({"symbol": "BBCA"})); + assert!(is_error && body["code"] == "AUTHERROR", "{body}"); + assert!(body.get("warnings").is_none(), "{body}"); + + let mut client = Client::start_with_env( + &[], + false, + &[TEST_SESSION, ("IDX_MOCK_MOVEMENTS_FAIL_PAGE", "2")], + ); + let partial = client.ok( + "get_shareholder_movements", + json!({"symbol": "BBCA", "pages": 3}), + ); + assert_eq!(partial["data"]["incomplete"], "provider rate limited"); + assert!( + partial["warnings"][0] + .as_str() + .unwrap() + .contains("showing the 8 rows read before it") + ); + + let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]); + let clean = client.ok("get_shareholder_movements", json!({"symbol": "BBCA"})); + assert!(clean.get("warnings").is_none(), "{clean}"); +} + /// The sign-in tool answers at once. With no browser to open (the mock /// provider never starts one), auto falls back to the page and says why; the /// page takes the cookie the way the browser would, and stockbit_status diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f9e8b20..ba75a77 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -93,7 +93,9 @@ Key decisions: - **`Fetched` envelope** for live data: `{ data, source, fetched_at, stale }`, so agents and API clients can see freshness. idx-core still emits the stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI - stderr stays identical. + stderr stays identical. idx-mcp also catches a call's warnings with + `runtime::on_warning` (the call runs on one blocking thread) and returns + them as `warnings`, since an agent never reads the server's stderr. - **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` / `--no-cache` flag handling now spread through `cli/stocks.rs`. - **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads diff --git a/web/src/content/docs/mcp/index.md b/web/src/content/docs/mcp/index.md index d45736d..940e93a 100644 --- a/web/src/content/docs/mcp/index.md +++ b/web/src/content/docs/mcp/index.md @@ -33,6 +33,21 @@ Tools that fetch live data return: failed. Ownership tools return their data directly, from the local database. +What `idx` prints as a warning in a terminal comes back in `warnings`, on an +answer or an error, when there was any. For example, history that fell back to +Yahoo because Stockbit refused your session says so, and a stale answer names +the failure behind it: + +```json +{ + "data": [{ "date": "2026-11-03", "close": 9875, "...": "..." }], + "source": "yahoo", + "fetched_at": "2026-11-04T03:20:11Z", + "stale": false, + "warnings": ["Stockbit rejected the session; run `idx stockbit login` again; history falls back to yahoo meanwhile"] +} +``` + Errors come back as a tool result with `isError` set and the CLI's error body, so the agent can decide whether to retry: From 8f5c56e464ad5be1bd21d62229a8bedaf506aefb Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 4 Nov 2026 20:44:51 +0700 Subject: [PATCH 5/8] fix(movements): allow the movements path, and describe its one cache entry - The session client's new read-path allowlist (#78) gains `/insider/company/majorholder?`, the one endpoint movements reads; without it every live movements query would fail before leaving the process. The mock does not go through the client, so its tests could not show this. - docs/ARCHITECTURE.md described one cache entry per page: a query's pages are read and cached together, keyed by the page count, and a failed later page returns the rows before it with `incomplete` (Pullfrog on #80). Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY --- crates/idx-core/src/api/stockbit/exodus.rs | 9 ++++++++- docs/ARCHITECTURE.md | 10 ++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/idx-core/src/api/stockbit/exodus.rs b/crates/idx-core/src/api/stockbit/exodus.rs index 3d832b9..8948347 100644 --- a/crates/idx-core/src/api/stockbit/exodus.rs +++ b/crates/idx-core/src/api/stockbit/exodus.rs @@ -39,7 +39,12 @@ const REFRESH_PATH: &str = "/login/refresh"; /// withdraws cash and changes the PIN; a path outside these fails before any /// request (or token refresh) leaves the process. A feature that reads a new /// endpoint adds its prefix here. -const READ_PATHS: &[&str] = &["/company-price-feed/", "/charts/", "/corpaction/"]; +const READ_PATHS: &[&str] = &[ + "/company-price-feed/", + "/charts/", + "/corpaction/", + "/insider/company/majorholder?", +]; /// The prefix of the states in which the regular market trades: /// `STATE_NAME_SESSION_2` was recorded on 2026-11-03 from 13:30; session 1 is /// assumed to follow the same naming. Every other state (`STATE_NAME_BREAK`, @@ -525,6 +530,7 @@ mod tests { "/company-price-feed/v2/orderbook/companies/BBCA", "/charts/BBCA/daily?timeframe=1y&chart_type=PRICE_CHART_TYPE_CANDLE", "/corpaction/dividend?symbol=BBCA", + "/insider/company/majorholder?page=1&symbol=BBCA", ] { assert!(is_read_path(path), "{path}"); } @@ -532,6 +538,7 @@ mod tests { "/order/buy", "/login/refresh", "/corpaction/../order/buy", + "/insider/company/majorholder/../../order", "/charts", "", ] { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e724e59..edfd44a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -304,10 +304,12 @@ WIB as the fallback. - Shareholder movements (`movements`, `api/stockbit/movement.rs`) read `/insider/company/majorholder`: IDX insider disclosures and KSEI 5% holder changes, 50 rows a page, filtered by Stockbit (`symbol`, `date_start`, - `date_end`, `insider` (holder id), `action_type`, `source_type`). Each page - is cached an hour under `stockbit-movements`, keyed by every filter and the - page. `ownership movements` and `get_shareholder_movements` read up to 10 - pages; the KSEI register in SQLite is not involved. + `date_end`, `insider` (holder id), `action_type`, `source_type`). + `ownership movements` and `get_shareholder_movements` read up to 10 pages + as one answer, cached an hour under `stockbit-movements`, keyed by every + filter and the page count. If a page after the first fails, the rows read + before it come back with `incomplete` set and nothing is cached. The KSEI + register in SQLite is not involved. ### Capability Gating ``` From 7830de36361e0dc9ebb37f7592c12110b7d37f0a Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 4 Nov 2026 20:46:27 +0700 Subject: [PATCH 6/8] docs(mcp): the generated tools page names `warnings` The tools reference page described results as {data, source, fetched_at, stale} and failures as {error, code, message}, without the `warnings` this PR adds (Pullfrog on #81). The generator's intro says so; page regenerated. Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY --- web/scripts/gen-mcp-tools.sh | 4 +++- web/src/content/docs/mcp/tools.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/scripts/gen-mcp-tools.sh b/web/scripts/gen-mcp-tools.sh index fefb17f..ad687e7 100755 --- a/web/scripts/gen-mcp-tools.sh +++ b/web/scripts/gen-mcp-tools.sh @@ -19,7 +19,9 @@ description: Every idx-mcp tool and its inputs, generated from the server's tool idx-mcp serves these tools. `ownership_sync` is listed only when the server starts with `--allow-sync`. Results that come from a live provider are wrapped as `{data, source, fetched_at, stale}`; failures return `{error, code, -message}` with `isError` set. +message}` with `isError` set. Either may carry `warnings`, what idx worked +around (such as a history fallback or a stale cache entry), when there was +any. EOF diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 43ec637..e6dbb65 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -8,7 +8,9 @@ description: Every idx-mcp tool and its inputs, generated from the server's tool idx-mcp serves these tools. `ownership_sync` is listed only when the server starts with `--allow-sync`. Results that come from a live provider are wrapped as `{data, source, fetched_at, stale}`; failures return `{error, code, -message}` with `isError` set. +message}` with `isError` set. Either may carry `warnings`, what idx worked +around (such as a history fallback or a stale cache entry), when there was +any. ## `analyze_bandar` From 9373be222a35e16998b39f16fb2bc698376830c5 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 4 Nov 2026 21:05:01 +0700 Subject: [PATCH 7/8] test(movements): check the allowlist against the real movements path builder The read-path test listed a hand-written movements path; it now also builds paths with movement::request_path, so the test fails offline if the allowlist and the builder drift apart (Pullfrog on #80). Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY --- crates/idx-core/src/api/stockbit/exodus.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/idx-core/src/api/stockbit/exodus.rs b/crates/idx-core/src/api/stockbit/exodus.rs index 8948347..8e3e60b 100644 --- a/crates/idx-core/src/api/stockbit/exodus.rs +++ b/crates/idx-core/src/api/stockbit/exodus.rs @@ -534,6 +534,15 @@ mod tests { ] { assert!(is_read_path(path), "{path}"); } + // Paths another module builds come from its own builder, so the + // allowlist cannot drift from them. + let query = MovementQuery::default(); + for path in [ + movement::request_path(&query, None, 1), + movement::request_path(&query, Some("BBCA"), 3), + ] { + assert!(is_read_path(&path), "{path}"); + } for path in [ "/order/buy", "/login/refresh", From 32b978b16cdad3d106ba05eefbcc586837d0b556 Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 5 Nov 2026 10:22:03 +0000 Subject: [PATCH 8/8] docs(movements): say the JSON can carry incomplete --- crates/idx-cli/src/output/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 62506f4..4b7aad2 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -127,7 +127,8 @@ pub fn render_corporate_calendar( } } -/// JSON is `{movements, more}` (`more`: rows remain past the pages read); +/// JSON is `{movements, more}` (`more`: rows remain past the pages read), plus +/// `incomplete` when a later page failed and only the rows before it came back; /// JSONL is one movement per line. /// `pages` is how many pages the query asked for, for the hint when more /// rows exist. JSONL streams the rows only, so the hint goes to stderr.