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