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..63cef02 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,12 @@ 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> { let investor = Investor::new(&config.core); let inv = &investor; match cmd { @@ -223,6 +265,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 +723,38 @@ 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, + query.pages, + &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..4b7aad2 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,31 @@ pub fn render_corporate_calendar( } } +/// 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. +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, pages, no_color), + OutputFormat::Json => json::print_json(list), + OutputFormat::Jsonl => { + json::print_json(&list.movements)?; + if list.more { + idx_core::runtime::info(table::more_movements_hint(pages)); + } + Ok(()) + } + } +} + 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 945f731..0943690 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 { "" }; @@ -1906,6 +1909,131 @@ 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."; + +/// 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 { + 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 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(()) +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 05b627a..ee02057 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-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-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-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 393359a..8477e7e 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() @@ -186,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); } @@ -260,6 +265,40 @@ 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"); + // `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('_'); + 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 78e201a..a872162 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"; @@ -38,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`, @@ -145,6 +151,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 { @@ -247,11 +272,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) @@ -487,6 +508,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(), + ) +} + /// Whether `path` is one idx reads; `..` never climbs out of a prefix. fn is_read_path(path: &str) -> bool { READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..") @@ -505,13 +536,24 @@ 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}"); } + // 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", "/corpaction/../order/buy", + "/insider/company/majorholder/../../order", "/charts", "", ] { diff --git a/crates/idx-core/src/api/stockbit/mod.rs b/crates/idx-core/src/api/stockbit/mod.rs index 36078be..990467b 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/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 aef1c39..9fe1940 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 } } @@ -195,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-core/src/lib.rs b/crates/idx-core/src/lib.rs index dab7f39..1bd3434 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..293d0a7 --- /dev/null +++ b/crates/idx-core/src/movements/mod.rs @@ -0,0 +1,165 @@ +//! 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). 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 +//! row appears days after the trade (see `docs/STOCKBIT.md`). + +pub mod types; + +use std::cell::RefCell; + +use chrono::Utc; + +use crate::api::{ShareholderMovementProvider, shareholder_movement_provider, stockbit}; +use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::error::IdxError; +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. +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 (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); + } + let provider = shareholder_movement_provider(); + let subject = query + .symbol + .clone() + .unwrap_or_else(|| "shareholder movements".to_string()); + 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, + fetched_at: Utc::now(), + stale: false, + }) + } + (result, _) => result, + } + } +} + +/// 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", + 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, + ) +} + +#[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 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)); + let mut q = base.clone(); + q.from = chrono::NaiveDate::from_ymd_opt(2026, 10, 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)); + let mut q = base.clone(); + q.holder_id = Some("14074".to_string()); + keys.push(cache_key(&q)); + let mut q = base.clone(); + q.action = Some(ActionFilter::Sell); + keys.push(cache_key(&q)); + let mut q = base; + q.source = Some(MovementSource::Ksei); + 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 new file mode 100644 index 0000000..6c974b4 --- /dev/null +++ b/crates/idx-core/src/movements/types.rs @@ -0,0 +1,339 @@ +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, + /// 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)] +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 5df708e..2fa6dd0 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}; @@ -16,8 +18,9 @@ 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 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}; @@ -29,8 +32,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"; @@ -56,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."; @@ -407,6 +412,43 @@ 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`; `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." + )] + 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`: \ @@ -709,13 +751,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/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 53ef641..6506f18 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`; `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": { + "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..fa27569 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", @@ -373,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 @@ -551,6 +618,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 3af5ac5..edfd44a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -301,6 +301,15 @@ WIB as the fallback. rights, tender or IPO `period` that overlaps it (not a warrant's, which runs for years), sorted 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`). + `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 ``` @@ -315,6 +324,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/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/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/docs/STOCKBIT.md b/docs/STOCKBIT.md index 17c692d..6895228 100644 --- a/docs/STOCKBIT.md +++ b/docs/STOCKBIT.md @@ -183,19 +183,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` @@ -224,10 +238,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 @@ -252,7 +271,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/scripts/live-smoke.sh b/scripts/live-smoke.sh index fd0e292..47c2c60 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -140,6 +140,8 @@ contract() { echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;; "stocks calendar"*) echo 'type == "array" 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 "*) @@ -271,9 +273,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 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/cli/ownership.md b/web/src/content/docs/cli/ownership.md index 07b1037..7258b2e 100644 --- a/web/src/content/docs/cli/ownership.md +++ b/web/src/content/docs/cli/ownership.md @@ -36,6 +36,34 @@ 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); 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 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 dda1a85..9f816dd 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 d9b9e14..17aa96c 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 @@ -204,6 +205,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 @@ -232,6 +234,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..940e93a 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. @@ -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: diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index b9ed56f..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` @@ -150,6 +152,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`; `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 | +| --- | --- | --- | --- | +| `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).