mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 01:35:36 +00:00
Merge pull request #80 from 0xrsydn/feat/stockbit-movements
feat: shareholder movements from the Stockbit session (ownership movements)
This commit is contained in:
commit
48b2206db4
51 changed files with 3332 additions and 53 deletions
|
|
@ -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
|
market is open, delayed at least 10 minutes. With your own account
|
||||||
(`idx stockbit login`): `stocks orderbook` depth, live
|
(`idx stockbit login`): `stocks orderbook` depth, live
|
||||||
quotes, Stockbit daily history, and corporate actions (`stocks actions`,
|
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/)
|
the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
|
||||||
- Local file cache with TTL, and `--offline` mode with stale-cache fallback
|
- 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
|
## 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_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`,
|
`analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`,
|
||||||
`get_foreign_flow`, `get_shareholders`, `get_investor_holdings`,
|
`get_foreign_flow`, `get_shareholders`, `get_investor_holdings`,
|
||||||
`search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as
|
`search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ history lives in git.
|
||||||
- Multi-period financial statements (today `financials` is single-period)
|
- Multi-period financial statements (today `financials` is single-period)
|
||||||
- Order book depth without a login: no free source exists (IDX sells delayed
|
- Order book depth without a login: no free source exists (IDX sells delayed
|
||||||
depth to vendors); `stocks orderbook` needs the user's Stockbit login.
|
depth to vendors); `stocks orderbook` needs the user's Stockbit login.
|
||||||
- Shareholder movements (`ownership movements`) from the Stockbit session;
|
- Shareholder movement follow-ups (`docs/STOCKBIT.md`): `ownership movements
|
||||||
design and endpoints in `docs/STOCKBIT.md`. Corporate-action follow-ups:
|
--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
|
ex-dates of splits, bonuses and rights noted in `stocks history`, and the
|
||||||
`stock_dividend` / `reversesplit` row shapes once Stockbit lists one.
|
`stock_dividend` / `reversesplit` row shapes once Stockbit lists one.
|
||||||
- Stockbit login follow-ups: hourly OHLCV from the session's `1w` chart (the
|
- Stockbit login follow-ups: hourly OHLCV from the session's `1w` chart (the
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ use idx_core::error::IdxError;
|
||||||
use idx_core::investor::{
|
use idx_core::investor::{
|
||||||
ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family,
|
ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family,
|
||||||
};
|
};
|
||||||
|
use idx_core::movements::types::{ActionFilter, MovementQuery, MovementSource};
|
||||||
use idx_core::ownership::graph;
|
use idx_core::ownership::graph;
|
||||||
use idx_core::ownership::snapshot;
|
use idx_core::ownership::snapshot;
|
||||||
use idx_core::ownership::types::{ChangeType, FlowSignal, HolderRow, OwnershipSource};
|
use idx_core::ownership::types::{ChangeType, FlowSignal, HolderRow, OwnershipSource};
|
||||||
|
use idx_core::{CachePolicy, Idx};
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
#[command(
|
#[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"
|
after_help = "Examples:\n idx ownership releases\n idx ownership changes --from 2026-08-31 --to 2026-09-30"
|
||||||
)]
|
)]
|
||||||
Changes(ChangesArgs),
|
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.
|
/// Ownership network graph.
|
||||||
Graph(GraphArgs),
|
Graph(GraphArgs),
|
||||||
/// Manual entity resolution workflow.
|
/// Manual entity resolution workflow.
|
||||||
|
|
@ -168,6 +181,30 @@ pub struct ChangesArgs {
|
||||||
pub to: String,
|
pub to: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct MovementsArgs {
|
||||||
|
/// Single ticker symbol (e.g. BBCA); omit for the whole market.
|
||||||
|
pub symbol: Option<String>,
|
||||||
|
/// First day, YYYY-MM-DD.
|
||||||
|
#[arg(long)]
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// Last day, YYYY-MM-DD.
|
||||||
|
#[arg(long)]
|
||||||
|
pub to: Option<String>,
|
||||||
|
/// One holder's movements, by the id in the HOLDER ID column.
|
||||||
|
#[arg(long = "holder", value_name = "ID")]
|
||||||
|
pub holder_id: Option<String>,
|
||||||
|
/// Only this kind of movement.
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
pub action: Option<ActionFilter>,
|
||||||
|
/// Only IDX insider disclosures or KSEI 5% holder changes.
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
pub source: Option<MovementSource>,
|
||||||
|
/// Pages of 50 rows to read (1-10).
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
pub pages: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
pub struct GraphArgs {
|
pub struct GraphArgs {
|
||||||
/// Ticker code or entity name to start from.
|
/// Ticker code or entity name to start from.
|
||||||
|
|
@ -209,7 +246,12 @@ pub enum ResolveCommand {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError> {
|
pub fn handle(
|
||||||
|
cmd: &OwnershipCommand,
|
||||||
|
config: &IdxConfig,
|
||||||
|
offline: bool,
|
||||||
|
no_cache: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
let investor = Investor::new(&config.core);
|
let investor = Investor::new(&config.core);
|
||||||
let inv = &investor;
|
let inv = &investor;
|
||||||
match cmd {
|
match cmd {
|
||||||
|
|
@ -223,6 +265,9 @@ pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError
|
||||||
OwnershipCommand::Concentration(args) => handle_concentration(inv, args, config),
|
OwnershipCommand::Concentration(args) => handle_concentration(inv, args, config),
|
||||||
OwnershipCommand::Flow(args) => handle_flow(inv, args, config),
|
OwnershipCommand::Flow(args) => handle_flow(inv, args, config),
|
||||||
OwnershipCommand::Changes(args) => handle_changes(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::Graph(args) => handle_graph(inv, args, config),
|
||||||
OwnershipCommand::Resolve(args) => handle_resolve(inv, args, config),
|
OwnershipCommand::Resolve(args) => handle_resolve(inv, args, config),
|
||||||
OwnershipCommand::Releases => handle_releases(inv, config),
|
OwnershipCommand::Releases => handle_releases(inv, config),
|
||||||
|
|
@ -678,6 +723,38 @@ fn handle_changes(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_movements(
|
||||||
|
args: &MovementsArgs,
|
||||||
|
config: &IdxConfig,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
let mut query = MovementQuery::from_args(
|
||||||
|
args.symbol.as_deref(),
|
||||||
|
args.from.as_deref(),
|
||||||
|
args.to.as_deref(),
|
||||||
|
args.holder_id.as_deref(),
|
||||||
|
args.pages,
|
||||||
|
)?;
|
||||||
|
query.action = args.action;
|
||||||
|
query.source = args.source;
|
||||||
|
let idx = Idx::new(config.core.clone())?;
|
||||||
|
let list = idx.movements().list(&query, policy)?;
|
||||||
|
let symbol = match &query.symbol {
|
||||||
|
Some(symbol) => Some(idx_core::api::resolve_symbol(
|
||||||
|
symbol,
|
||||||
|
&config.core.exchange,
|
||||||
|
)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
crate::output::render_shareholder_movements(
|
||||||
|
symbol.as_deref(),
|
||||||
|
&list.data,
|
||||||
|
query.pages,
|
||||||
|
&config.output,
|
||||||
|
config.no_color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_resolve(
|
fn handle_resolve(
|
||||||
investor: &Investor,
|
investor: &Investor,
|
||||||
args: &ResolveArgs,
|
args: &ResolveArgs,
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,9 @@ fn run() -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
Commands::Ownership(cmd) => {
|
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);
|
emit_error(&err, &config.output);
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ use idx_core::config::ProviderKind;
|
||||||
use idx_core::corporate::types::{CalendarRange, CorporateAction};
|
use idx_core::corporate::types::{CalendarRange, CorporateAction};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::flow::types::ForeignFlow;
|
use idx_core::flow::types::ForeignFlow;
|
||||||
|
use idx_core::movements::types::MovementList;
|
||||||
|
|
||||||
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
||||||
|
|
@ -126,6 +127,31 @@ pub fn render_corporate_calendar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// JSON is `{movements, more}` (`more`: rows remain past the pages read), plus
|
||||||
|
/// `incomplete` when a later page failed and only the rows before it came back;
|
||||||
|
/// JSONL is one movement per line.
|
||||||
|
/// `pages` is how many pages the query asked for, for the hint when more
|
||||||
|
/// rows exist. JSONL streams the rows only, so the hint goes to stderr.
|
||||||
|
pub fn render_shareholder_movements(
|
||||||
|
symbol: Option<&str>,
|
||||||
|
list: &MovementList,
|
||||||
|
pages: u32,
|
||||||
|
format: &OutputFormat,
|
||||||
|
no_color: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => table::print_shareholder_movements(symbol, list, pages, no_color),
|
||||||
|
OutputFormat::Json => json::print_json(list),
|
||||||
|
OutputFormat::Jsonl => {
|
||||||
|
json::print_json(&list.movements)?;
|
||||||
|
if list.more {
|
||||||
|
idx_core::runtime::info(table::more_movements_hint(pages));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_broker_summary(
|
pub fn render_broker_summary(
|
||||||
summary: &BrokerSummary,
|
summary: &BrokerSummary,
|
||||||
format: &OutputFormat,
|
format: &OutputFormat,
|
||||||
|
|
|
||||||
|
|
@ -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::corporate::types::{ActionKind, CalendarRange, CorporateAction, DateRange};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::flow::types::{FlowSource, ForeignFlow};
|
use idx_core::flow::types::{FlowSource, ForeignFlow};
|
||||||
|
use idx_core::movements::types::{
|
||||||
|
MovementAction, MovementList, MovementSource, ShareholderMovement,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn format_idr(value: i64) -> String {
|
pub fn format_idr(value: i64) -> String {
|
||||||
let sign = if value.is_negative() { "-" } else { "" };
|
let sign = if value.is_negative() { "-" } else { "" };
|
||||||
|
|
@ -1906,6 +1909,131 @@ pub fn print_corporate_calendar(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn movement_action_label(action: MovementAction, no_color: bool) -> String {
|
||||||
|
let label = action.as_str().replace('_', " ");
|
||||||
|
match action {
|
||||||
|
_ if no_color => label,
|
||||||
|
MovementAction::Buy => label.green().to_string(),
|
||||||
|
MovementAction::Sell => label.red().to_string(),
|
||||||
|
_ => label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signed exact shares: `+147,933`, `-10,000,000`.
|
||||||
|
fn format_signed_shares(value: i64) -> String {
|
||||||
|
let text = format_idr(value);
|
||||||
|
if value > 0 { format!("+{text}") } else { text }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stake after the change and the change in percentage points:
|
||||||
|
/// `40.65% (-0.16)`. A stake that rounds to 0 basis points reads `<0.01%`.
|
||||||
|
fn movement_stake(movement: &ShareholderMovement) -> String {
|
||||||
|
let after = match movement.percentage_after_bps {
|
||||||
|
0 if movement.shares_after > 0 => "<0.01%".to_string(),
|
||||||
|
bps => format!("{:.2}%", bps as f64 / 100.0),
|
||||||
|
};
|
||||||
|
match movement.percentage_change_bps {
|
||||||
|
0 => after,
|
||||||
|
bps => format!("{after} ({:+.2})", bps as f64 / 100.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOVEMENT_NOTE: &str = "IDX rows are insider disclosures (directors, commissioners and \
|
||||||
|
other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated \
|
||||||
|
price. Rows appear days after the change.";
|
||||||
|
|
||||||
|
/// What to do when rows remain past the pages read.
|
||||||
|
pub fn more_movements_hint(pages: u32) -> String {
|
||||||
|
if pages >= idx_core::movements::types::MAX_PAGES {
|
||||||
|
"More rows exist: narrow --from/--to (10 pages is the most one query reads).".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"More rows exist: raise --pages (up to {}) or narrow --from/--to.",
|
||||||
|
idx_core::movements::types::MAX_PAGES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_shareholder_movements(
|
||||||
|
symbol: Option<&str>,
|
||||||
|
list: &MovementList,
|
||||||
|
pages: u32,
|
||||||
|
no_color: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
let title = match symbol {
|
||||||
|
Some(symbol) => format!("Shareholder movements for {}", terminal_safe(symbol)),
|
||||||
|
None => "Shareholder movements across IDX".to_string(),
|
||||||
|
};
|
||||||
|
println!("{}", format!("{title} ({})", list.movements.len()).bold());
|
||||||
|
if list.movements.is_empty() {
|
||||||
|
println!("No movements reported for this query.");
|
||||||
|
} else {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec![
|
||||||
|
"DATE",
|
||||||
|
"SYMBOL",
|
||||||
|
"HOLDER",
|
||||||
|
"ROLE",
|
||||||
|
"ACTION",
|
||||||
|
"SHARES",
|
||||||
|
"STAKE",
|
||||||
|
"PRICE",
|
||||||
|
"VALUE",
|
||||||
|
"SOURCE",
|
||||||
|
"HOLDER ID",
|
||||||
|
]);
|
||||||
|
for movement in &list.movements {
|
||||||
|
let estimated = if movement.price_estimated { "~" } else { "" };
|
||||||
|
table.add_row(vec![
|
||||||
|
cell(movement.date),
|
||||||
|
cell(&movement.symbol),
|
||||||
|
cell(&movement.holder),
|
||||||
|
cell(if movement.roles.is_empty() {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
movement.roles.join(", ")
|
||||||
|
}),
|
||||||
|
painted(movement_action_label(movement.action, no_color)),
|
||||||
|
cell(format_signed_shares(movement.shares_change)),
|
||||||
|
cell(movement_stake(movement)),
|
||||||
|
cell(
|
||||||
|
movement
|
||||||
|
.price
|
||||||
|
.map(|p| format!("{estimated}{}", format_idr(p)))
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
|
cell(
|
||||||
|
movement
|
||||||
|
.value
|
||||||
|
.map(|v| format!("{estimated}{}", format_compact(v)))
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
|
cell(match movement.source {
|
||||||
|
Some(MovementSource::Idx) => "IDX",
|
||||||
|
Some(MovementSource::Ksei) => "KSEI",
|
||||||
|
None => "-",
|
||||||
|
}),
|
||||||
|
cell(&movement.holder_id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
println!("{MOVEMENT_NOTE}");
|
||||||
|
}
|
||||||
|
if let Some(reason) = &list.incomplete {
|
||||||
|
println!(
|
||||||
|
"Reading stopped early ({}); these are the rows read before it.",
|
||||||
|
terminal_safe(reason)
|
||||||
|
);
|
||||||
|
} else if list.more {
|
||||||
|
println!("{}", more_movements_hint(pages));
|
||||||
|
}
|
||||||
|
println!("{STOCKBIT_SESSION_NOTE}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
|
|
|
||||||
|
|
@ -2239,6 +2239,33 @@ fn auto_history_falls_back_to_yahoo_when_the_session_is_refused() {
|
||||||
let _ = fs::remove_dir_all(root);
|
let _ = fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A page after the first that fails keeps the rows read before it, says
|
||||||
|
/// why, and is not cached; a query's pages are cached as one answer.
|
||||||
|
#[test]
|
||||||
|
fn movements_keep_the_pages_read_when_a_later_one_fails() {
|
||||||
|
let root = test_env_dir("movements-partial");
|
||||||
|
let run = |fail_page: Option<&str>| {
|
||||||
|
let mut cmd = mock_bin_with_root(&root);
|
||||||
|
cmd.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
|
||||||
|
.args(["-o", "json", "ownership", "movements", "--pages", "2"]);
|
||||||
|
if let Some(page) = fail_page {
|
||||||
|
cmd.env("IDX_MOCK_MOVEMENTS_FAIL_PAGE", page);
|
||||||
|
}
|
||||||
|
run_success_json(&mut cmd)
|
||||||
|
};
|
||||||
|
let partial = run(Some("2"));
|
||||||
|
assert_eq!(partial["movements"].as_array().unwrap().len(), 8);
|
||||||
|
assert_eq!(partial["more"], true);
|
||||||
|
assert!(partial["incomplete"].is_string(), "{partial}");
|
||||||
|
// Not cached: the next run reads both pages.
|
||||||
|
let full = run(None);
|
||||||
|
assert_eq!(full["movements"].as_array().unwrap().len(), 14);
|
||||||
|
assert!(full.get("incomplete").is_none());
|
||||||
|
// Cached as one answer: a failing page 2 now changes nothing.
|
||||||
|
assert_eq!(run(Some("2")), full);
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
/// A test session, signed by nobody, that expires in 2100.
|
/// A test session, signed by nobody, that expires in 2100.
|
||||||
const FAKE_STOCKBIT_TOKEN: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln";
|
const FAKE_STOCKBIT_TOKEN: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -420,6 +420,15 @@ args = ["stocks", "orderbook", "BBCA"]
|
||||||
name = "error-actions-no-session"
|
name = "error-actions-no-session"
|
||||||
args = ["stocks", "actions", "BBCA"]
|
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]]
|
[[case]]
|
||||||
name = "error-calendar-range"
|
name = "error-calendar-range"
|
||||||
args = ["stocks", "calendar", "--from", "2026-11-10", "--to", "2026-11-01"]
|
args = ["stocks", "calendar", "--from", "2026-11-10", "--to", "2026-11-01"]
|
||||||
|
|
@ -432,6 +441,21 @@ env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0OD
|
||||||
|
|
||||||
# --- ownership --------------------------------------------------------------------
|
# --- 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]]
|
[[case]]
|
||||||
name = "ownership-releases-empty"
|
name = "ownership-releases-empty"
|
||||||
args = ["ownership", "releases"]
|
args = ["ownership", "releases"]
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,9 @@ _idx() {
|
||||||
idx__help__ownership,import)
|
idx__help__ownership,import)
|
||||||
cmd="idx__help__ownership__import"
|
cmd="idx__help__ownership__import"
|
||||||
;;
|
;;
|
||||||
|
idx__help__ownership,movements)
|
||||||
|
cmd="idx__help__ownership__movements"
|
||||||
|
;;
|
||||||
idx__help__ownership,releases)
|
idx__help__ownership,releases)
|
||||||
cmd="idx__help__ownership__releases"
|
cmd="idx__help__ownership__releases"
|
||||||
;;
|
;;
|
||||||
|
|
@ -282,6 +285,9 @@ _idx() {
|
||||||
idx__ownership,import)
|
idx__ownership,import)
|
||||||
cmd="idx__ownership__import"
|
cmd="idx__ownership__import"
|
||||||
;;
|
;;
|
||||||
|
idx__ownership,movements)
|
||||||
|
cmd="idx__ownership__movements"
|
||||||
|
;;
|
||||||
idx__ownership,releases)
|
idx__ownership,releases)
|
||||||
cmd="idx__ownership__releases"
|
cmd="idx__ownership__releases"
|
||||||
;;
|
;;
|
||||||
|
|
@ -324,6 +330,9 @@ _idx() {
|
||||||
idx__ownership__help,import)
|
idx__ownership__help,import)
|
||||||
cmd="idx__ownership__help__import"
|
cmd="idx__ownership__help__import"
|
||||||
;;
|
;;
|
||||||
|
idx__ownership__help,movements)
|
||||||
|
cmd="idx__ownership__help__movements"
|
||||||
|
;;
|
||||||
idx__ownership__help,releases)
|
idx__ownership__help,releases)
|
||||||
cmd="idx__ownership__help__releases"
|
cmd="idx__ownership__help__releases"
|
||||||
;;
|
;;
|
||||||
|
|
@ -1095,7 +1104,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__help__ownership)
|
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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1220,6 +1229,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
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)
|
idx__help__ownership__releases)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -1725,7 +1748,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__ownership)
|
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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1973,7 +1996,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__ownership__help)
|
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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2112,6 +2135,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
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)
|
idx__ownership__help__releases)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -2262,6 +2299,56 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
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)
|
idx__ownership__releases)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 4
|
exit: 4
|
||||||
--- stdout
|
--- stdout
|
||||||
--- stderr
|
--- stderr
|
||||||
{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit"}
|
{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 4
|
exit: 4
|
||||||
--- stdout
|
--- stdout
|
||||||
--- stderr
|
--- stderr
|
||||||
Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit
|
Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 4
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)
|
||||||
|
|
@ -20,6 +20,7 @@ Commands:
|
||||||
concentration Rank tickers by ownership concentration
|
concentration Rank tickers by ownership concentration
|
||||||
flow Show Bing institutional flow for a ticker
|
flow Show Bing institutional flow for a ticker
|
||||||
changes Diff two KSEI releases
|
changes Diff two KSEI releases
|
||||||
|
movements Insider and major holder buys and sells, as reported (Stockbit login)
|
||||||
graph Ownership network graph
|
graph Ownership network graph
|
||||||
resolve Manual entity resolution workflow
|
resolve Manual entity resolution workflow
|
||||||
releases List imported KSEI releases
|
releases List imported KSEI releases
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
File diff suppressed because one or more lines are too long
37
crates/idx-cli/tests/golden/ownership-movements.table.golden
Normal file
37
crates/idx-cli/tests/golden/ownership-movements.table.golden
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
||||||
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
//! `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::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||||
use super::{
|
use super::{
|
||||||
BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider,
|
BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider,
|
||||||
IntradayProvider, OrderBookProvider, QuoteProvider, TradingSummaryProvider, idx, ipot, msn,
|
IntradayProvider, OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
|
||||||
stockbit, yahoo,
|
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
||||||
};
|
};
|
||||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||||
use crate::config::ProviderKind;
|
use crate::config::ProviderKind;
|
||||||
use crate::corporate::types::{ActionKind, CorporateAction};
|
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use crate::movements::types::{MovementPage, MovementQuery};
|
||||||
|
|
||||||
pub(crate) fn enabled() -> bool {
|
pub(crate) fn enabled() -> bool {
|
||||||
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
|
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
|
||||||
|
|
@ -186,6 +187,10 @@ impl MockStockbitProvider {
|
||||||
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
|
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// The session API's own block, as `exodus::fetch` reports it.
|
||||||
|
if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("403") {
|
||||||
|
return Err(stockbit::exodus::blocked());
|
||||||
|
}
|
||||||
if let Some(err) = self.failure(symbol) {
|
if let Some(err) = self.failure(symbol) {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
@ -260,6 +265,40 @@ impl CorporateActionProvider for MockStockbitProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `stockbit_exodus_majorholder[_<code>][_p<page>].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<MovementPage, IdxError> {
|
||||||
|
let subject = query.symbol.as_deref().unwrap_or("shareholder movements");
|
||||||
|
// `IDX_MOCK_MOVEMENTS_FAIL_PAGE=N` rate-limits page N alone.
|
||||||
|
if std::env::var("IDX_MOCK_MOVEMENTS_FAIL_PAGE").ok() == Some(page.to_string()) {
|
||||||
|
return Err(IdxError::RateLimited);
|
||||||
|
}
|
||||||
|
let mut name = "majorholder".to_string();
|
||||||
|
if let Some(symbol) = &query.symbol {
|
||||||
|
name.push('_');
|
||||||
|
name.push_str(&stockbit::stockbit_code(symbol)?.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
name.push_str(&format!("_p{page}"));
|
||||||
|
}
|
||||||
|
let body = match self.exodus(subject, &format!("{name}.json")) {
|
||||||
|
Err(IdxError::SymbolNotFound(_)) if query.symbol.is_some() || page > 1 => {
|
||||||
|
r#"{"data":{"is_more":false,"movement":[]}}"#.to_string()
|
||||||
|
}
|
||||||
|
other => other?,
|
||||||
|
};
|
||||||
|
let mut parsed = movement::parse_movements(&body)?;
|
||||||
|
parsed.movements.retain(|m| query.admits(m));
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl HistoryProvider for MockStockbitProvider {
|
impl HistoryProvider for MockStockbitProvider {
|
||||||
fn history(
|
fn history(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,17 @@ pub trait CorporateActionProvider {
|
||||||
) -> Result<Vec<crate::corporate::types::CorporateAction>, IdxError>;
|
) -> Result<Vec<crate::corporate::types::CorporateAction>, 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<crate::movements::types::MovementPage, IdxError>;
|
||||||
|
}
|
||||||
|
|
||||||
pub trait FundamentalsProvider {
|
pub trait FundamentalsProvider {
|
||||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
@ -364,6 +375,14 @@ pub fn corporate_action_provider() -> Box<dyn CorporateActionProvider> {
|
||||||
Box::new(stockbit::StockbitProvider::new())
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn shareholder_movement_provider() -> Box<dyn ShareholderMovementProvider> {
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
if mock::enabled() {
|
||||||
|
return Box::new(mock::MockStockbitProvider);
|
||||||
|
}
|
||||||
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
|
}
|
||||||
|
|
||||||
/// Today in WIB, the date IDX trades on.
|
/// Today in WIB, the date IDX trades on.
|
||||||
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,13 @@ use chrono::{Months, NaiveDate};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::auth::SessionStore;
|
use super::auth::SessionStore;
|
||||||
use super::corpaction;
|
|
||||||
use super::parse::number;
|
use super::parse::number;
|
||||||
use super::{USER_AGENT, pace, stockbit_code};
|
use super::{USER_AGENT, pace, stockbit_code};
|
||||||
|
use super::{corpaction, movement};
|
||||||
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
|
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
|
||||||
use crate::corporate::types::{ActionKind, CorporateAction};
|
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use crate::movements::types::{MovementPage, MovementQuery};
|
||||||
|
|
||||||
const BASE_URL: &str = "https://exodus.stockbit.com";
|
const BASE_URL: &str = "https://exodus.stockbit.com";
|
||||||
const REFRESH_PATH: &str = "/login/refresh";
|
const REFRESH_PATH: &str = "/login/refresh";
|
||||||
|
|
@ -38,7 +39,12 @@ const REFRESH_PATH: &str = "/login/refresh";
|
||||||
/// withdraws cash and changes the PIN; a path outside these fails before any
|
/// withdraws cash and changes the PIN; a path outside these fails before any
|
||||||
/// request (or token refresh) leaves the process. A feature that reads a new
|
/// request (or token refresh) leaves the process. A feature that reads a new
|
||||||
/// endpoint adds its prefix here.
|
/// endpoint adds its prefix here.
|
||||||
const READ_PATHS: &[&str] = &["/company-price-feed/", "/charts/", "/corpaction/"];
|
const READ_PATHS: &[&str] = &[
|
||||||
|
"/company-price-feed/",
|
||||||
|
"/charts/",
|
||||||
|
"/corpaction/",
|
||||||
|
"/insider/company/majorholder?",
|
||||||
|
];
|
||||||
/// The prefix of the states in which the regular market trades:
|
/// The prefix of the states in which the regular market trades:
|
||||||
/// `STATE_NAME_SESSION_2` was recorded on 2026-11-03 from 13:30; session 1 is
|
/// `STATE_NAME_SESSION_2` was recorded on 2026-11-03 from 13:30; session 1 is
|
||||||
/// assumed to follow the same naming. Every other state (`STATE_NAME_BREAK`,
|
/// assumed to follow the same naming. Every other state (`STATE_NAME_BREAK`,
|
||||||
|
|
@ -145,6 +151,25 @@ impl Exodus {
|
||||||
corpaction::parse_corporate_actions(kind, &body)
|
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<MovementPage, IdxError> {
|
||||||
|
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.
|
/// The market's state, read at most every `MARKET_STATE_TTL` per process.
|
||||||
/// Unknown on any failure, which hides bid/offer.
|
/// Unknown on any failure, which hides bid/offer.
|
||||||
fn market_status(&self) -> Option<String> {
|
fn market_status(&self) -> Option<String> {
|
||||||
|
|
@ -247,11 +272,7 @@ fn classify(reply: Reply, subject: &str) -> Result<String, IdxError> {
|
||||||
Err(IdxError::SymbolNotFound(subject.to_string()))
|
Err(IdxError::SymbolNotFound(subject.to_string()))
|
||||||
}
|
}
|
||||||
429 => Err(IdxError::RateLimited),
|
429 => Err(IdxError::RateLimited),
|
||||||
403 => Err(IdxError::UpstreamBlocked(
|
403 => Err(blocked()),
|
||||||
"exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \
|
|
||||||
retry a block. Try again later or from another network"
|
|
||||||
.to_string(),
|
|
||||||
)),
|
|
||||||
status => Err(IdxError::Http(format!(
|
status => Err(IdxError::Http(format!(
|
||||||
"stockbit api: HTTP {status}{}",
|
"stockbit api: HTTP {status}{}",
|
||||||
message(&reply.body)
|
message(&reply.body)
|
||||||
|
|
@ -487,6 +508,16 @@ fn trim_to_period(mut bars: Vec<Ohlc>, period: &Period) -> Vec<Ohlc> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A 403 from the CDN in front of the session API: a block, not a refused
|
||||||
|
/// session, so nothing is retried.
|
||||||
|
pub(crate) fn blocked() -> IdxError {
|
||||||
|
IdxError::UpstreamBlocked(
|
||||||
|
"exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \
|
||||||
|
retry a block. Try again later or from another network"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `path` is one idx reads; `..` never climbs out of a prefix.
|
/// Whether `path` is one idx reads; `..` never climbs out of a prefix.
|
||||||
fn is_read_path(path: &str) -> bool {
|
fn is_read_path(path: &str) -> bool {
|
||||||
READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..")
|
READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..")
|
||||||
|
|
@ -505,13 +536,24 @@ mod tests {
|
||||||
"/company-price-feed/v2/orderbook/companies/BBCA",
|
"/company-price-feed/v2/orderbook/companies/BBCA",
|
||||||
"/charts/BBCA/daily?timeframe=1y&chart_type=PRICE_CHART_TYPE_CANDLE",
|
"/charts/BBCA/daily?timeframe=1y&chart_type=PRICE_CHART_TYPE_CANDLE",
|
||||||
"/corpaction/dividend?symbol=BBCA",
|
"/corpaction/dividend?symbol=BBCA",
|
||||||
|
"/insider/company/majorholder?page=1&symbol=BBCA",
|
||||||
] {
|
] {
|
||||||
assert!(is_read_path(path), "{path}");
|
assert!(is_read_path(path), "{path}");
|
||||||
}
|
}
|
||||||
|
// Paths another module builds come from its own builder, so the
|
||||||
|
// allowlist cannot drift from them.
|
||||||
|
let query = MovementQuery::default();
|
||||||
|
for path in [
|
||||||
|
movement::request_path(&query, None, 1),
|
||||||
|
movement::request_path(&query, Some("BBCA"), 3),
|
||||||
|
] {
|
||||||
|
assert!(is_read_path(&path), "{path}");
|
||||||
|
}
|
||||||
for path in [
|
for path in [
|
||||||
"/order/buy",
|
"/order/buy",
|
||||||
"/login/refresh",
|
"/login/refresh",
|
||||||
"/corpaction/../order/buy",
|
"/corpaction/../order/buy",
|
||||||
|
"/insider/company/majorholder/../../order",
|
||||||
"/charts",
|
"/charts",
|
||||||
"",
|
"",
|
||||||
] {
|
] {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ pub mod browser;
|
||||||
pub mod corpaction;
|
pub mod corpaction;
|
||||||
pub mod exodus;
|
pub mod exodus;
|
||||||
pub mod loopback;
|
pub mod loopback;
|
||||||
|
pub mod movement;
|
||||||
mod parse;
|
mod parse;
|
||||||
pub mod signin;
|
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::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||||
use super::{
|
use super::{
|
||||||
CorporateActionProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
|
CorporateActionProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
|
||||||
OrderBookProvider, QuoteProvider,
|
OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
|
||||||
};
|
};
|
||||||
use crate::corporate::types::{ActionKind, CorporateAction};
|
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use crate::movements::types::{MovementPage, MovementQuery};
|
||||||
use auth::SessionStore;
|
use auth::SessionStore;
|
||||||
use exodus::Exodus;
|
use exodus::Exodus;
|
||||||
|
|
||||||
|
|
@ -216,6 +218,16 @@ impl CorporateActionProvider for StockbitProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ShareholderMovementProvider for StockbitProvider {
|
||||||
|
fn shareholder_movements(
|
||||||
|
&self,
|
||||||
|
query: &MovementQuery,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<MovementPage, IdxError> {
|
||||||
|
self.require_session()?.shareholder_movements(query, page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl HistoryProvider for StockbitProvider {
|
impl HistoryProvider for StockbitProvider {
|
||||||
fn history(
|
fn history(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
328
crates/idx-core/src/api/stockbit/movement.rs
Normal file
328
crates/idx-core/src/api/stockbit/movement.rs
Normal file
|
|
@ -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<MovementPage, IdxError> {
|
||||||
|
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<ShareholderMovement> {
|
||||||
|
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<i64> {
|
||||||
|
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<f64> {
|
||||||
|
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("<html>").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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -124,8 +124,8 @@ pub fn status_error(status: u16, resolved: &str) -> IdxError {
|
||||||
match status {
|
match status {
|
||||||
403 => IdxError::UpstreamBlocked(
|
403 => IdxError::UpstreamBlocked(
|
||||||
"stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \
|
"stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \
|
||||||
block. Try again later or from another network; `--provider msn` serves quotes \
|
block. Try again later or from another network; the msn provider (`--provider msn`, \
|
||||||
without Stockbit"
|
or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
404 => IdxError::SymbolNotFound(resolved.to_string()),
|
404 => IdxError::SymbolNotFound(resolved.to_string()),
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,10 @@ impl Idx {
|
||||||
crate::corporate::Corporate { idx: self }
|
crate::corporate::Corporate { idx: self }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn movements(&self) -> crate::movements::Movements<'_> {
|
||||||
|
crate::movements::Movements { idx: self }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
||||||
crate::financial::Financial { idx: self }
|
crate::financial::Financial { idx: self }
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +199,7 @@ where
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if use_cache && let Some(stale) = cache.any::<T>(spec.bucket, spec.key)? {
|
if use_cache && let Some(stale) = cache.any::<T>(spec.bucket, spec.key)? {
|
||||||
runtime::warn(format!(
|
runtime::warn(format!(
|
||||||
"network failed, serving stale cache for {}",
|
"network failed ({err}), serving stale cache for {}",
|
||||||
spec.subject
|
spec.subject
|
||||||
));
|
));
|
||||||
return Ok(hit(stale, true));
|
return Ok(hit(stale, true));
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ pub mod flow;
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
pub mod investor;
|
pub mod investor;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
|
pub mod movements;
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
pub mod ownership;
|
pub mod ownership;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
|
|
|
||||||
165
crates/idx-core/src/movements/mod.rs
Normal file
165
crates/idx-core/src/movements/mod.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
//! Shareholder movements from the user's Stockbit session: reported changes
|
||||||
|
//! in insiders' and major holders' stakes (IDX insider disclosures and KSEI's
|
||||||
|
//! 5% holder changes), one stock's or the market's, newest first. Each page
|
||||||
|
//! is 50 rows and one request (paced 1 s apart). A query's pages are read
|
||||||
|
//! together and cached together for an hour, so they come from one moment
|
||||||
|
//! of a feed that gains rows at the top.
|
||||||
|
//!
|
||||||
|
//! These are the reports as filed. Whether they predict anything is not
|
||||||
|
//! claimed here: share plans read as a cluster of buys at one price, and a
|
||||||
|
//! row appears days after the trade (see `docs/STOCKBIT.md`).
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::api::{ShareholderMovementProvider, shareholder_movement_provider, stockbit};
|
||||||
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use crate::runtime;
|
||||||
|
use types::{MAX_PAGES, MovementList, MovementQuery};
|
||||||
|
|
||||||
|
/// The feed gains rows through the day; an hour keeps a session's repeated
|
||||||
|
/// queries cheap without hiding a day's filings for long.
|
||||||
|
const TTL_SECS: u64 = 3600;
|
||||||
|
const BUCKET: &str = "stockbit-movements";
|
||||||
|
|
||||||
|
pub struct Movements<'a> {
|
||||||
|
pub(crate) idx: &'a Idx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Movements<'_> {
|
||||||
|
/// Up to `query.pages` pages of the feed (at most `MAX_PAGES`), newest
|
||||||
|
/// first; `more` says whether rows remain past them.
|
||||||
|
///
|
||||||
|
/// When a page after the first fails and no cached answer is at hand,
|
||||||
|
/// the rows read before it are returned with `incomplete` saying why,
|
||||||
|
/// and are not cached.
|
||||||
|
pub fn list(
|
||||||
|
&self,
|
||||||
|
query: &MovementQuery,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<MovementList>, IdxError> {
|
||||||
|
let mut query = query.clone();
|
||||||
|
query.pages = query.pages.clamp(1, MAX_PAGES);
|
||||||
|
if let Some(symbol) = &query.symbol {
|
||||||
|
let (resolved, _) = self.idx.idx_code(symbol, "shareholder movements")?;
|
||||||
|
query.symbol = Some(resolved);
|
||||||
|
}
|
||||||
|
let provider = shareholder_movement_provider();
|
||||||
|
let subject = query
|
||||||
|
.symbol
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "shareholder movements".to_string());
|
||||||
|
let key = cache_key(&query);
|
||||||
|
let partial = RefCell::new(None);
|
||||||
|
let result = cached_fetch(
|
||||||
|
&self.idx.cache,
|
||||||
|
CacheSpec {
|
||||||
|
bucket: BUCKET,
|
||||||
|
key: &key,
|
||||||
|
ttl_secs: TTL_SECS,
|
||||||
|
subject: &subject,
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
|| read_pages(provider.as_ref(), &query, &partial),
|
||||||
|
Ok,
|
||||||
|
);
|
||||||
|
match (result, partial.into_inner()) {
|
||||||
|
(Err(err), Some(mut list)) => {
|
||||||
|
runtime::warn(format!(
|
||||||
|
"{err}; showing the {} rows read before it",
|
||||||
|
list.movements.len()
|
||||||
|
));
|
||||||
|
list.incomplete = Some(err.to_string());
|
||||||
|
Ok(Fetched {
|
||||||
|
data: list,
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
fetched_at: Utc::now(),
|
||||||
|
stale: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(result, _) => result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the query's pages in turn. A failure after the first page leaves
|
||||||
|
/// the rows read so far in `partial` and fails, so they are not cached.
|
||||||
|
fn read_pages(
|
||||||
|
provider: &dyn ShareholderMovementProvider,
|
||||||
|
query: &MovementQuery,
|
||||||
|
partial: &RefCell<Option<MovementList>>,
|
||||||
|
) -> Result<MovementList, IdxError> {
|
||||||
|
let mut list = MovementList::default();
|
||||||
|
for page in 1..=query.pages {
|
||||||
|
match provider.shareholder_movements(query, page) {
|
||||||
|
Ok(part) => {
|
||||||
|
list.movements.extend(part.movements);
|
||||||
|
list.more = part.more;
|
||||||
|
if !part.more {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) if page > 1 => {
|
||||||
|
list.more = true;
|
||||||
|
*partial.borrow_mut() = Some(list);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every filter and the page count, so no two queries share an entry.
|
||||||
|
fn cache_key(query: &MovementQuery) -> String {
|
||||||
|
let opt = |value: Option<String>| value.unwrap_or_else(|| "-".to_string());
|
||||||
|
format!(
|
||||||
|
"{}:{}:{}:{}:{}:{}:{}p",
|
||||||
|
query.symbol.as_deref().unwrap_or("market"),
|
||||||
|
opt(query.from.map(|d| d.to_string())),
|
||||||
|
opt(query.to.map(|d| d.to_string())),
|
||||||
|
query.holder_id.as_deref().unwrap_or("-"),
|
||||||
|
query.action.map(|a| a.as_str()).unwrap_or("-"),
|
||||||
|
query.source.map(|s| s.as_str()).unwrap_or("-"),
|
||||||
|
query.pages,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use types::{ActionFilter, MovementSource};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_keys_differ_by_every_filter() {
|
||||||
|
let base = MovementQuery::from_args(None, None, None, None, 1).unwrap();
|
||||||
|
let mut two = base.clone();
|
||||||
|
two.pages = 2;
|
||||||
|
let mut keys = vec![cache_key(&base), cache_key(&two)];
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.symbol = Some("BBCA.JK".to_string());
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.from = chrono::NaiveDate::from_ymd_opt(2026, 10, 1);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.to = chrono::NaiveDate::from_ymd_opt(2026, 10, 1);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.holder_id = Some("14074".to_string());
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.action = Some(ActionFilter::Sell);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base;
|
||||||
|
q.source = Some(MovementSource::Ksei);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let unique: std::collections::HashSet<_> = keys.iter().collect();
|
||||||
|
assert_eq!(unique.len(), keys.len(), "{keys:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
339
crates/idx-core/src/movements/types.rs
Normal file
339
crates/idx-core/src/movements/types.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
/// What a holder's row records, as Stockbit labels it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MovementAction {
|
||||||
|
Buy,
|
||||||
|
Sell,
|
||||||
|
/// Shares moved between accounts or parties, not a market trade.
|
||||||
|
Transfer,
|
||||||
|
/// A crossing: a negotiated trade between two parties.
|
||||||
|
Cross,
|
||||||
|
RightsIssue,
|
||||||
|
PrivatePlacement,
|
||||||
|
TenderOffer,
|
||||||
|
BonusStock,
|
||||||
|
StockDividend,
|
||||||
|
CashDividend,
|
||||||
|
/// A label Stockbit added after this release.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovementAction {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Buy => "buy",
|
||||||
|
Self::Sell => "sell",
|
||||||
|
Self::Transfer => "transfer",
|
||||||
|
Self::Cross => "cross",
|
||||||
|
Self::RightsIssue => "rights_issue",
|
||||||
|
Self::PrivatePlacement => "private_placement",
|
||||||
|
Self::TenderOffer => "tender_offer",
|
||||||
|
Self::BonusStock => "bonus_stock",
|
||||||
|
Self::StockDividend => "stock_dividend",
|
||||||
|
Self::CashDividend => "cash_dividend",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `filter` (Stockbit's `action_type`) selects this action.
|
||||||
|
pub fn matches(self, filter: ActionFilter) -> bool {
|
||||||
|
match filter {
|
||||||
|
ActionFilter::Buy => self == Self::Buy,
|
||||||
|
ActionFilter::Sell => self == Self::Sell,
|
||||||
|
ActionFilter::Transfer => self == Self::Transfer,
|
||||||
|
ActionFilter::Cross => self == Self::Cross,
|
||||||
|
ActionFilter::CorporateAction => matches!(
|
||||||
|
self,
|
||||||
|
Self::RightsIssue
|
||||||
|
| Self::PrivatePlacement
|
||||||
|
| Self::TenderOffer
|
||||||
|
| Self::BonusStock
|
||||||
|
| Self::StockDividend
|
||||||
|
| Self::CashDividend
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The action filters Stockbit's feed takes (`action_type`). Corporate
|
||||||
|
/// action covers rights issues, private placements and the like.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum ActionFilter {
|
||||||
|
Buy,
|
||||||
|
Sell,
|
||||||
|
Transfer,
|
||||||
|
Cross,
|
||||||
|
#[cfg_attr(feature = "clap", value(name = "corporate-action"))]
|
||||||
|
CorporateAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionFilter {
|
||||||
|
pub const ALL: [Self; 5] = [
|
||||||
|
Self::Buy,
|
||||||
|
Self::Sell,
|
||||||
|
Self::Transfer,
|
||||||
|
Self::Cross,
|
||||||
|
Self::CorporateAction,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The CLI's name (`--action`), also used in MCP inputs.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Buy => "buy",
|
||||||
|
Self::Sell => "sell",
|
||||||
|
Self::Transfer => "transfer",
|
||||||
|
Self::Cross => "cross",
|
||||||
|
Self::CorporateAction => "corporate-action",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for ActionFilter {
|
||||||
|
type Err = IdxError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
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<Self, Self::Err> {
|
||||||
|
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<MovementSource>,
|
||||||
|
/// `Local` or `Foreign`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub locality: Option<String>,
|
||||||
|
/// Insider roles at the company: `director`, `commissioner`.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
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<i64>,
|
||||||
|
/// 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<i64>,
|
||||||
|
/// The stated purpose, free text (`Investasi`, `Divestasi`, ...).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub purpose: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of Stockbit's feed (50 rows), newest first.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MovementPage {
|
||||||
|
pub movements: Vec<ShareholderMovement>,
|
||||||
|
/// 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<String>,
|
||||||
|
pub from: Option<NaiveDate>,
|
||||||
|
pub to: Option<NaiveDate>,
|
||||||
|
pub holder_id: Option<String>,
|
||||||
|
pub action: Option<ActionFilter>,
|
||||||
|
pub source: Option<MovementSource>,
|
||||||
|
/// 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<Self, IdxError> {
|
||||||
|
let parse = |name: &str, value: &str| {
|
||||||
|
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| {
|
||||||
|
IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD"))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
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<ShareholderMovement>,
|
||||||
|
/// More rows exist past the pages read (raise `--pages`, or narrow the
|
||||||
|
/// dates).
|
||||||
|
pub more: bool,
|
||||||
|
/// Why reading stopped before the pages asked for: a later page failed,
|
||||||
|
/// and `movements` holds the rows read before it. Absent when every page
|
||||||
|
/// asked for was read.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incomplete: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<ActionFilter>().unwrap(), filter);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
"corporate_action".parse::<ActionFilter>().unwrap(),
|
||||||
|
ActionFilter::CorporateAction
|
||||||
|
);
|
||||||
|
assert!("dividend".parse::<ActionFilter>().is_err());
|
||||||
|
assert!(MovementAction::RightsIssue.matches(ActionFilter::CorporateAction));
|
||||||
|
assert!(!MovementAction::Transfer.matches(ActionFilter::CorporateAction));
|
||||||
|
}
|
||||||
|
}
|
||||||
416
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json
vendored
Normal file
416
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
430
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json
vendored
Normal file
430
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
220
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json
vendored
Normal file
220
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
314
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json
vendored
Normal file
314
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ use idx_core::broker::types::{BrokerBoard, BrokerInvestor};
|
||||||
use idx_core::corporate::types::ActionKind;
|
use idx_core::corporate::types::ActionKind;
|
||||||
use idx_core::flow::types::FlowSource;
|
use idx_core::flow::types::FlowSource;
|
||||||
use idx_core::market::{ScreenFilter, ScreenRegion};
|
use idx_core::market::{ScreenFilter, ScreenRegion};
|
||||||
|
use idx_core::movements::types::{ActionFilter, MAX_PAGES};
|
||||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
|
@ -253,6 +254,34 @@ pub struct CorporateCalendarParams {
|
||||||
pub kinds: Option<Vec<String>>,
|
pub kinds: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct MovementsParams {
|
||||||
|
/// IDX ticker, e.g. "BBCA"; omit for the whole market.
|
||||||
|
#[serde(default)]
|
||||||
|
pub symbol: Option<String>,
|
||||||
|
/// First day, YYYY-MM-DD.
|
||||||
|
#[serde(default)]
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// Last day, YYYY-MM-DD.
|
||||||
|
#[serde(default)]
|
||||||
|
pub to: Option<String>,
|
||||||
|
/// One holder's movements: the `holder_id` of a row.
|
||||||
|
#[serde(default)]
|
||||||
|
pub holder_id: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// "idx" (insider disclosures, any size) or "ksei" (holders at 5% and above).
|
||||||
|
#[serde(default)]
|
||||||
|
#[schemars(schema_with = "movement_source_schema")]
|
||||||
|
pub source: Option<String>,
|
||||||
|
/// 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<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses `kinds`, or `default` when absent.
|
/// Parses `kinds`, or `default` when absent.
|
||||||
pub fn action_kinds(
|
pub fn action_kinds(
|
||||||
kinds: Option<Vec<String>>,
|
kinds: Option<Vec<String>>,
|
||||||
|
|
@ -346,6 +375,18 @@ fn action_kinds_schema(_: &mut SchemaGenerator) -> Schema {
|
||||||
json_schema!({ "type": "array", "items": { "type": "string", "enum": kinds } })
|
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 {
|
fn login_method_schema(_: &mut SchemaGenerator) -> Schema {
|
||||||
string_enum(LoginMethod::ALL.map(LoginMethod::as_str).to_vec())
|
string_enum(LoginMethod::ALL.map(LoginMethod::as_str).to_vec())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk
|
//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk
|
||||||
//! cache and ownership database.
|
//! cache and ownership database.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use idx_core::analysis::BandarQuery;
|
use idx_core::analysis::BandarQuery;
|
||||||
use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP};
|
use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP};
|
||||||
|
|
@ -16,8 +18,9 @@ use idx_core::financial::{EarningsFilter, StatementFilter};
|
||||||
use idx_core::flow::types::FlowRange;
|
use idx_core::flow::types::FlowRange;
|
||||||
use idx_core::investor::{HolderSource, Investor};
|
use idx_core::investor::{HolderSource, Investor};
|
||||||
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
||||||
|
use idx_core::movements::types::MovementQuery;
|
||||||
use idx_core::ownership::types::ChangeRow;
|
use idx_core::ownership::types::ChangeRow;
|
||||||
use idx_core::{CachePolicy, Fetched, Idx};
|
use idx_core::{CachePolicy, Fetched, Idx, runtime};
|
||||||
use rmcp::handler::server::router::tool::ToolRouter;
|
use rmcp::handler::server::router::tool::ToolRouter;
|
||||||
use rmcp::handler::server::wrapper::Parameters;
|
use rmcp::handler::server::wrapper::Parameters;
|
||||||
use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig};
|
use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig};
|
||||||
|
|
@ -29,8 +32,8 @@ use tokio::sync::Semaphore;
|
||||||
use crate::params::{
|
use crate::params::{
|
||||||
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
||||||
CompanySection, CorporateActionsParams, CorporateCalendarParams, EntityParams, FlowParams,
|
CompanySection, CorporateActionsParams, CorporateCalendarParams, EntityParams, FlowParams,
|
||||||
HistoryParams, IntradayParams, LoginMethod, LoginParams, OrderBookParams, ScreenParams,
|
HistoryParams, IntradayParams, LoginMethod, LoginParams, MovementsParams, OrderBookParams,
|
||||||
SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds,
|
ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SYNC_TOOL: &str = "ownership_sync";
|
const SYNC_TOOL: &str = "ownership_sync";
|
||||||
|
|
@ -56,7 +59,9 @@ static CALL_SLOTS: Semaphore = Semaphore::const_new(4);
|
||||||
|
|
||||||
const INSTRUCTIONS: &str = "Indonesian stock market (IDX) data. Market and financial \
|
const INSTRUCTIONS: &str = "Indonesian stock market (IDX) data. Market and financial \
|
||||||
tools fetch live data (cached on disk) and wrap it as {data, source, fetched_at, stale}; \
|
tools fetch live data (cached on disk) and wrap it as {data, source, fetched_at, stale}; \
|
||||||
stale=true means an expired cache entry was served. Ownership tools read a local KSEI \
|
stale=true means an expired cache entry was served. An answer (or error) may carry \
|
||||||
|
`warnings`: what idx worked around, such as a refused Stockbit session that made history \
|
||||||
|
fall back to yahoo, or the failure behind a stale entry; pass on any that need the user. Ownership tools read a local KSEI \
|
||||||
database of holders above 1%: call ownership_status first to see which release dates \
|
database of holders above 1%: call ownership_status first to see which release dates \
|
||||||
exist. Percentages ending in _bps are basis points (5494 = 54.94%). Errors carry a \
|
exist. Percentages ending in _bps are basis points (5494 = 54.94%). Errors carry a \
|
||||||
stable code, e.g. SYMBOLNOTFOUND, RATELIMITED, INVALIDINPUT.";
|
stable code, e.g. SYMBOLNOTFOUND, RATELIMITED, INVALIDINPUT.";
|
||||||
|
|
@ -407,6 +412,43 @@ AUTHERROR means there is no session: call stockbit_status, then stockbit_login."
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Reported changes in insiders' and major holders' stakes in IDX stocks, \
|
||||||
|
from the user's own Stockbit session, newest first: IDX insider disclosures (directors, \
|
||||||
|
commissioners and other insiders, any size, with the trade price) and KSEI changes of holders \
|
||||||
|
at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without \
|
||||||
|
`symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis \
|
||||||
|
points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain \
|
||||||
|
past `pages`; `incomplete`, when present, says a later page failed and the rows are those read \
|
||||||
|
before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is \
|
||||||
|
usually a share plan, and rows appear days after the change. AUTHERROR means there is no \
|
||||||
|
session: call stockbit_status, then stockbit_login."
|
||||||
|
)]
|
||||||
|
async fn get_shareholder_movements(&self, params: Parameters<MovementsParams>) -> 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(
|
#[tool(
|
||||||
description = "Company data for one IDX ticker: profile, financial statements, \
|
description = "Company data for one IDX ticker: profile, financial statements, \
|
||||||
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
||||||
|
|
@ -709,13 +751,43 @@ where
|
||||||
.acquire()
|
.acquire()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
||||||
let result = tokio::task::spawn_blocking(move || f().and_then(to_json))
|
let (result, warnings) = tokio::task::spawn_blocking(move || {
|
||||||
.await
|
// idx-core warns on stderr, which the agent never sees; what this
|
||||||
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
// call warns about (a fallback, a stale cache, a partial answer)
|
||||||
Ok(match result {
|
// goes into its answer instead. The call runs on this thread.
|
||||||
Ok(value) => CallToolResult::structured(value),
|
let warnings = Rc::new(RefCell::new(Vec::<String>::new()));
|
||||||
Err(err) => CallToolResult::structured_error(Value::Object(error_body(&err))),
|
let sink = {
|
||||||
|
let warnings = warnings.clone();
|
||||||
|
runtime::on_warning(move |message| {
|
||||||
|
let mut warnings = warnings.borrow_mut();
|
||||||
|
if !warnings.iter().any(|seen| seen == message) {
|
||||||
|
warnings.push(message.to_string());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let result = f().and_then(to_json);
|
||||||
|
drop(sink);
|
||||||
|
(result, warnings.take())
|
||||||
})
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
||||||
|
Ok(match result {
|
||||||
|
Ok(value) => CallToolResult::structured(with_warnings(value, warnings)),
|
||||||
|
Err(err) => CallToolResult::structured_error(with_warnings(
|
||||||
|
Value::Object(error_body(&err)),
|
||||||
|
warnings,
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds `warnings` to an answer when the call had any.
|
||||||
|
fn with_warnings(mut value: Value, warnings: Vec<String>) -> Value {
|
||||||
|
if !warnings.is_empty()
|
||||||
|
&& let Some(object) = value.as_object_mut()
|
||||||
|
{
|
||||||
|
object.insert("warnings".into(), json!(warnings));
|
||||||
|
}
|
||||||
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-symbol failures as error bodies carrying their symbol.
|
/// Per-symbol failures as error bodies carrying their symbol.
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,76 @@
|
||||||
},
|
},
|
||||||
"name": "get_quotes"
|
"name": "get_quotes"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`; `incomplete`, when present, says a later page failed and the rows are those read before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.",
|
||||||
|
"inputSchema": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Only this kind of movement; \"corporate-action\" covers rights issues, private placements and the like.",
|
||||||
|
"enum": [
|
||||||
|
"buy",
|
||||||
|
"sell",
|
||||||
|
"transfer",
|
||||||
|
"cross",
|
||||||
|
"corporate-action"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"default": null,
|
||||||
|
"description": "First day, YYYY-MM-DD.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"holder_id": {
|
||||||
|
"default": null,
|
||||||
|
"description": "One holder's movements: the `holder_id` of a row.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit.",
|
||||||
|
"maximum": 10,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"default": null,
|
||||||
|
"description": "\"idx\" (insider disclosures, any size) or \"ksei\" (holders at 5% and above).",
|
||||||
|
"enum": [
|
||||||
|
"idx",
|
||||||
|
"ksei"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"symbol": {
|
||||||
|
"default": null,
|
||||||
|
"description": "IDX ticker, e.g. \"BBCA\"; omit for the whole market.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Last day, YYYY-MM-DD.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"name": "get_shareholder_movements"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).",
|
"description": "Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use idx_core::config::CoreConfig;
|
||||||
use idx_core::investor::{ImportSource, Investor};
|
use idx_core::investor::{ImportSource, Investor};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
const TOOLS: [&str; 20] = [
|
const TOOLS: [&str; 21] = [
|
||||||
"analyze_bandar",
|
"analyze_bandar",
|
||||||
"analyze_stock",
|
"analyze_stock",
|
||||||
"compare_stocks",
|
"compare_stocks",
|
||||||
|
|
@ -24,6 +24,7 @@ const TOOLS: [&str; 20] = [
|
||||||
"get_ownership_changes",
|
"get_ownership_changes",
|
||||||
"get_price_history",
|
"get_price_history",
|
||||||
"get_quotes",
|
"get_quotes",
|
||||||
|
"get_shareholder_movements",
|
||||||
"get_shareholders",
|
"get_shareholders",
|
||||||
"ownership_status",
|
"ownership_status",
|
||||||
"screen_stocks",
|
"screen_stocks",
|
||||||
|
|
@ -373,6 +374,72 @@ fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What idx works around reaches the agent as `warnings`, since it never
|
||||||
|
/// sees idx-mcp's stderr: a refused session that made history fall back to
|
||||||
|
/// yahoo, and a movements query cut short. A clean answer has none.
|
||||||
|
#[test]
|
||||||
|
fn workarounds_reach_the_agent_as_warnings() {
|
||||||
|
let mut client = Client::start_with_env(
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
&[
|
||||||
|
TEST_SESSION,
|
||||||
|
("IDX_PROVIDER", "stockbit"),
|
||||||
|
("IDX_MOCK_STOCKBIT_STATUS", "401"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let history = client.ok(
|
||||||
|
"get_price_history",
|
||||||
|
json!({"symbol": "BBCA", "period": "1mo"}),
|
||||||
|
);
|
||||||
|
assert_eq!(history["source"], "yahoo");
|
||||||
|
let warnings = history["warnings"].as_array().expect("warnings");
|
||||||
|
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||||
|
assert!(
|
||||||
|
warnings[0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Stockbit rejected the session")
|
||||||
|
&& warnings[0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("falls back to yahoo"),
|
||||||
|
"{warnings:?}"
|
||||||
|
);
|
||||||
|
let analysis = client.ok("analyze_stock", json!({"symbol": "BBCA"}));
|
||||||
|
assert!(
|
||||||
|
analysis["warnings"][0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("falls back to yahoo")
|
||||||
|
);
|
||||||
|
// The next call starts with none of the last one's warnings.
|
||||||
|
let (is_error, body) = client.call("get_orderbook", json!({"symbol": "BBCA"}));
|
||||||
|
assert!(is_error && body["code"] == "AUTHERROR", "{body}");
|
||||||
|
assert!(body.get("warnings").is_none(), "{body}");
|
||||||
|
|
||||||
|
let mut client = Client::start_with_env(
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
&[TEST_SESSION, ("IDX_MOCK_MOVEMENTS_FAIL_PAGE", "2")],
|
||||||
|
);
|
||||||
|
let partial = client.ok(
|
||||||
|
"get_shareholder_movements",
|
||||||
|
json!({"symbol": "BBCA", "pages": 3}),
|
||||||
|
);
|
||||||
|
assert_eq!(partial["data"]["incomplete"], "provider rate limited");
|
||||||
|
assert!(
|
||||||
|
partial["warnings"][0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("showing the 8 rows read before it")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]);
|
||||||
|
let clean = client.ok("get_shareholder_movements", json!({"symbol": "BBCA"}));
|
||||||
|
assert!(clean.get("warnings").is_none(), "{clean}");
|
||||||
|
}
|
||||||
|
|
||||||
/// The sign-in tool answers at once. With no browser to open (the mock
|
/// The sign-in tool answers at once. With no browser to open (the mock
|
||||||
/// provider never starts one), auto falls back to the page and says why; the
|
/// provider never starts one), auto falls back to the page and says why; the
|
||||||
/// page takes the cookie the way the browser would, and stockbit_status
|
/// page takes the cookie the way the browser would, and stockbit_status
|
||||||
|
|
@ -551,6 +618,54 @@ fn corporate_actions_need_a_session_and_keep_kinds_apart() {
|
||||||
assert_eq!(body["code"], "INVALIDINPUT");
|
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]
|
#[test]
|
||||||
fn errors_carry_cli_codes() {
|
fn errors_carry_cli_codes() {
|
||||||
let mut client = Client::start(&[], false);
|
let mut client = Client::start(&[], false);
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,15 @@ WIB as the fallback.
|
||||||
rights, tender or IPO `period` that overlaps it (not a warrant's, which
|
rights, tender or IPO `period` that overlaps it (not a warrant's, which
|
||||||
runs for years), sorted oldest first. Endpoint notes and the wider
|
runs for years), sorted oldest first. Endpoint notes and the wider
|
||||||
design: `docs/STOCKBIT.md`.
|
design: `docs/STOCKBIT.md`.
|
||||||
|
- Shareholder movements (`movements`, `api/stockbit/movement.rs`) read
|
||||||
|
`/insider/company/majorholder`: IDX insider disclosures and KSEI 5% holder
|
||||||
|
changes, 50 rows a page, filtered by Stockbit (`symbol`, `date_start`,
|
||||||
|
`date_end`, `insider` (holder id), `action_type`, `source_type`).
|
||||||
|
`ownership movements` and `get_shareholder_movements` read up to 10 pages
|
||||||
|
as one answer, cached an hour under `stockbit-movements`, keyed by every
|
||||||
|
filter and the page count. If a page after the first fails, the rows read
|
||||||
|
before it come back with `incomplete` set and nothing is cached. The KSEI
|
||||||
|
register in SQLite is not involved.
|
||||||
|
|
||||||
### Capability Gating
|
### Capability Gating
|
||||||
```
|
```
|
||||||
|
|
@ -315,6 +324,7 @@ Factory functions:
|
||||||
intraday_provider() → Box<dyn IntradayProvider>
|
intraday_provider() → Box<dyn IntradayProvider>
|
||||||
orderbook_provider() → Box<dyn OrderBookProvider>
|
orderbook_provider() → Box<dyn OrderBookProvider>
|
||||||
corporate_action_provider() → Box<dyn CorporateActionProvider>
|
corporate_action_provider() → Box<dyn CorporateActionProvider>
|
||||||
|
shareholder_movement_provider() → Box<dyn ShareholderMovementProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
`ProviderKind::Stockbit` serves quotes (and, with a session, daily history):
|
`ProviderKind::Stockbit` serves quotes (and, with a session, daily history):
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,9 @@ Key decisions:
|
||||||
- **`Fetched<T>` envelope** for live data: `{ data, source, fetched_at, stale }`,
|
- **`Fetched<T>` envelope** for live data: `{ data, source, fetched_at, stale }`,
|
||||||
so agents and API clients can see freshness. idx-core still emits the
|
so agents and API clients can see freshness. idx-core still emits the
|
||||||
stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI
|
stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI
|
||||||
stderr stays identical.
|
stderr stays identical. idx-mcp also catches a call's warnings with
|
||||||
|
`runtime::on_warning` (the call runs on one blocking thread) and returns
|
||||||
|
them as `warnings`, since an agent never reads the server's stderr.
|
||||||
- **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` /
|
- **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` /
|
||||||
`--no-cache` flag handling now spread through `cli/stocks.rs`.
|
`--no-cache` flag handling now spread through `cli/stocks.rs`.
|
||||||
- **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads
|
- **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,8 @@ scripts/live-smoke.sh --dry-run
|
||||||
session sees `market_status: close` and no bid/offer. These run without a
|
session sees `market_status: close` and no bid/offer. These run without a
|
||||||
Stockbit login: each side has its own config, and seeding it with a real
|
Stockbit login: each side has its own config, and seeding it with a real
|
||||||
refresh token would rotate (and so retire) that token. When
|
refresh token would rotate (and so retire) that token. When
|
||||||
`idx stockbit status` reports a session, `stocks orderbook`, `stocks actions`
|
`idx stockbit status` reports a session, `stocks orderbook`, `stocks actions`,
|
||||||
and `stocks calendar` also run, on the new binary with your real config
|
`stocks calendar` and `ownership movements` (market-wide, one page) also run, on the new binary with your real config
|
||||||
directory (the session file is shared under its lock, never copied; cache
|
directory (the session file is shared under its lock, never copied; cache
|
||||||
and data stay isolated). Check the rest by hand:
|
and data stay isolated). Check the rest by hand:
|
||||||
`idx --provider stockbit stocks quote BBCA` and
|
`idx --provider stockbit stocks quote BBCA` and
|
||||||
|
|
|
||||||
|
|
@ -183,19 +183,33 @@ lists are not errors. A row whose dates are all placeholders is dropped.
|
||||||
|
|
||||||
### Endpoint
|
### Endpoint
|
||||||
|
|
||||||
`GET /insider/company/majorholder?symbol=&insider=&date_start=&date_end=&page=`;
|
Rows shipped as `idx ownership movements` and `get_shareholder_movements`;
|
||||||
all parameters optional. 50 rows a page, `data.is_more` for the next page.
|
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
|
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.
|
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"`),
|
Each row (`data.movement[]`): `name`, `symbol`, `date` (`"25 Mar 26"`),
|
||||||
`previous` / `current` / `changes` `{value: shares as "193,206", percentage}`,
|
`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`,
|
(`NATIONALITY_TYPE_LOCAL|FOREIGN`), `badges` (`SHAREHOLDER_BADGE_DIREKTUR`,
|
||||||
`..._KOMISARIS`), `price.raw.value`, `value.raw.value` (IDR),
|
`..._KOMISARIS`), `price.raw.value`, `value.raw.value` (IDR),
|
||||||
`is_estimated_price`, `purpose` (free text such as "Pembelian", "Investasi"),
|
`is_estimated_price`, `purpose` (free text such as "Pembelian", "Investasi"),
|
||||||
`data_source.type` (`SOURCE_TYPE_IDX` for insider disclosures,
|
`data_source.type` (`SOURCE_TYPE_IDX` for insider disclosures,
|
||||||
`SOURCE_TYPE_KSEI` for 5% holder changes), `broker_detail` (empty so far).
|
`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`
|
### Relation to `ownership`
|
||||||
|
|
||||||
|
|
@ -224,10 +238,15 @@ Yahoo prices, forward 5/20/60-session returns against IHSG).
|
||||||
|
|
||||||
### Surface
|
### Surface
|
||||||
|
|
||||||
- `idx ownership movements [SYM] [--from DATE] [--to DATE] [--holder NAME]
|
- `idx ownership movements [SYM] [--from DATE] [--to DATE] [--holder ID]
|
||||||
[--action buy|sell|transfer] [--pages N]`: rows, newest first.
|
[--action buy|sell|transfer|cross|corporate-action] [--source idx|ksei]
|
||||||
- `idx ownership movements --summary [--from DATE]`: the per-symbol score.
|
[--pages N]`: rows, newest first (shipped). `--holder` takes the holder id
|
||||||
- MCP: `get_shareholder_movements {symbol?, from?, to?, holder?, pages?}`.
|
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
|
## P2 and P3
|
||||||
|
|
||||||
|
|
@ -252,7 +271,7 @@ Yahoo prices, forward 5/20/60-session returns against IHSG).
|
||||||
states (done 2026-11-03).
|
states (done 2026-11-03).
|
||||||
2. Corporate actions (parsers, `stocks actions`, `stocks calendar`, MCP,
|
2. Corporate actions (parsers, `stocks actions`, `stocks calendar`, MCP,
|
||||||
goldens) (done).
|
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.
|
4. Event study for the movement score; P2 by demand.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@ contract() {
|
||||||
echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;;
|
echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;;
|
||||||
"stocks calendar"*)
|
"stocks calendar"*)
|
||||||
echo 'type == "array" and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;;
|
echo 'type == "array" and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;;
|
||||||
|
"ownership movements"*)
|
||||||
|
echo '(.more | type == "boolean") and (.movements | length > 0) and all(.movements[]; (.symbol | type == "string") and (.date | type == "string") and (.holder_id | type == "string") and (.action | type == "string") and (.shares_change | type == "number"))' ;;
|
||||||
"stocks broker "*)
|
"stocks broker "*)
|
||||||
echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;;
|
echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;;
|
||||||
"stocks bandar "*)
|
"stocks bandar "*)
|
||||||
|
|
@ -271,9 +273,10 @@ if selected stockbit; then
|
||||||
run_new_case orderbook "stocks orderbook $SYMBOL --depth 5"
|
run_new_case orderbook "stocks orderbook $SYMBOL --depth 5"
|
||||||
run_new_case actions "stocks actions $SYMBOL --kind dividend,split"
|
run_new_case actions "stocks actions $SYMBOL --kind dividend,split"
|
||||||
run_new_case calendar "stocks calendar --kind dividend,rights"
|
run_new_case calendar "stocks calendar --kind dividend,rights"
|
||||||
|
run_new_case movements "ownership movements"
|
||||||
REAL_CONFIG=0
|
REAL_CONFIG=0
|
||||||
else
|
else
|
||||||
echo " no Stockbit session: orderbook, actions and calendar not run"
|
echo " no Stockbit session: orderbook, actions, calendar and movements not run"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ description: Every idx-mcp tool and its inputs, generated from the server's tool
|
||||||
idx-mcp serves these tools. `ownership_sync` is listed only when the server
|
idx-mcp serves these tools. `ownership_sync` is listed only when the server
|
||||||
starts with `--allow-sync`. Results that come from a live provider are wrapped
|
starts with `--allow-sync`. Results that come from a live provider are wrapped
|
||||||
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
||||||
message}` with `isError` set.
|
message}` with `isError` set. Either may carry `warnings`, what idx worked
|
||||||
|
around (such as a history fallback or a stale cache entry), when there was
|
||||||
|
any.
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,34 @@ two releases. `ticker` also reports concentration: the top holder's share,
|
||||||
the top three, the Herfindahl index (HHI) and the free float (shares held by
|
the top three, the Herfindahl index (HHI) and the free float (shares held by
|
||||||
holders below 1%).
|
holders below 1%).
|
||||||
|
|
||||||
|
## Insider and major holder movements
|
||||||
|
|
||||||
|
With your own Stockbit login (`idx stockbit login`, see [Stockbit](/cli/stockbit/)),
|
||||||
|
`movements` lists reported changes in holders' stakes, newest first. It reads
|
||||||
|
Stockbit live and needs no import:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
idx ownership movements BBCA # one stock, 50 rows
|
||||||
|
idx ownership movements # the whole market
|
||||||
|
idx ownership movements --action sell --source ksei --pages 3
|
||||||
|
idx ownership movements --from 2026-10-01 --to 2026-10-31
|
||||||
|
idx ownership movements --holder 1000004481 # one holder's history
|
||||||
|
```
|
||||||
|
|
||||||
|
Two sources share the list. `IDX` rows are insider disclosures: directors,
|
||||||
|
commissioners and other insiders, at any size, with the trade price. `KSEI`
|
||||||
|
rows are changes of holders at 5% and above, which report no price; Stockbit
|
||||||
|
estimates one, shown with `~`. `--holder` takes the number in the HOLDER ID
|
||||||
|
column. Shares are exact. Stakes are as Stockbit rounds them, so a director's
|
||||||
|
small holding reads `<0.01%`.
|
||||||
|
|
||||||
|
These are filings, not signals. Many insiders buying at one price on one day
|
||||||
|
is usually a share plan, transfers are not trades, and a row appears days
|
||||||
|
after the change. Each page is 50 rows and one request to Stockbit (`--pages`
|
||||||
|
up to 10); a query's pages are read and cached together, for an hour. JSON's
|
||||||
|
`more` says rows remain past them. If a later page fails, you get the rows
|
||||||
|
read before it, with `incomplete` saying why, and nothing is cached.
|
||||||
|
|
||||||
## Import reports yourself
|
## Import reports yourself
|
||||||
|
|
||||||
The snapshot covers most needs. To load a month it lacks, or older history,
|
The snapshot covers most needs. To load a month it lacks, or older history,
|
||||||
|
|
|
||||||
|
|
@ -1158,6 +1158,7 @@ Commands:
|
||||||
concentration Rank tickers by ownership concentration
|
concentration Rank tickers by ownership concentration
|
||||||
flow Show Bing institutional flow for a ticker
|
flow Show Bing institutional flow for a ticker
|
||||||
changes Diff two KSEI releases
|
changes Diff two KSEI releases
|
||||||
|
movements Insider and major holder buys and sells, as reported (Stockbit login)
|
||||||
graph Ownership network graph
|
graph Ownership network graph
|
||||||
resolve Manual entity resolution workflow
|
resolve Manual entity resolution workflow
|
||||||
releases List imported KSEI releases
|
releases List imported KSEI releases
|
||||||
|
|
@ -1547,6 +1548,79 @@ Examples:
|
||||||
idx ownership changes --from 2026-08-31 --to 2026-09-30
|
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 <FROM>
|
||||||
|
First day, YYYY-MM-DD
|
||||||
|
|
||||||
|
-o, --output <OUTPUT>
|
||||||
|
Output format [default: auto = table on a terminal, json when piped]
|
||||||
|
|
||||||
|
[possible values: auto, table, json, jsonl]
|
||||||
|
|
||||||
|
--no-color
|
||||||
|
Disable colored output
|
||||||
|
|
||||||
|
--to <TO>
|
||||||
|
Last day, YYYY-MM-DD
|
||||||
|
|
||||||
|
--holder <ID>
|
||||||
|
One holder's movements, by the id in the HOLDER ID column
|
||||||
|
|
||||||
|
-q, --quiet
|
||||||
|
Suppress non-essential output
|
||||||
|
|
||||||
|
--action <ACTION>
|
||||||
|
Only this kind of movement
|
||||||
|
|
||||||
|
[possible values: buy, sell, transfer, cross, corporate-action]
|
||||||
|
|
||||||
|
-v, --verbose...
|
||||||
|
Increase verbosity (-v, -vv)
|
||||||
|
|
||||||
|
--provider <PROVIDER>
|
||||||
|
Market data provider [default: general.provider, else msn]
|
||||||
|
|
||||||
|
[possible values: yahoo, msn, stockbit]
|
||||||
|
|
||||||
|
--source <SOURCE>
|
||||||
|
Only IDX insider disclosures or KSEI 5% holder changes
|
||||||
|
|
||||||
|
[possible values: idx, ksei]
|
||||||
|
|
||||||
|
--offline
|
||||||
|
Run without network requests
|
||||||
|
|
||||||
|
--pages <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`
|
### `idx ownership graph`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ idx stocks orderbook BBCA # depth, queued orders, foreign flow
|
||||||
idx stocks orderbook BBRI --depth 5
|
idx stocks orderbook BBRI --depth 5
|
||||||
idx stocks actions BBCA # dividends, splits, meetings, ...
|
idx stocks actions BBCA # dividends, splits, meetings, ...
|
||||||
idx stocks calendar --kind dividend # cum dates in the next 30 days
|
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 quote BBCA # live, not delayed
|
||||||
idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily candles
|
idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily candles
|
||||||
idx stockbit logout
|
idx stockbit logout
|
||||||
|
|
@ -204,6 +205,7 @@ What the login adds:
|
||||||
| `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows |
|
| `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows |
|
||||||
| `stocks intraday` | public page | public page |
|
| `stocks intraday` | public page | public page |
|
||||||
| `stocks actions`, `stocks calendar` | `AUTHERROR` | corporate actions: one stock's history, or the market's calendar |
|
| `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
|
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
|
book as the market left it, and the output says so. Foreign buy, sell and net
|
||||||
|
|
@ -232,6 +234,6 @@ market or republishing prices. With your login, automated use is tied to your
|
||||||
account: keep it human-paced.
|
account: keep it human-paced.
|
||||||
|
|
||||||
Over MCP, the same data comes from `get_intraday`, `get_orderbook`,
|
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
|
server runs with `IDX_PROVIDER=stockbit`. The server never logs in; run
|
||||||
`idx stockbit login` in a terminal.
|
`idx stockbit login` in a terminal.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
`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
|
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
|
[21 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
|
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,
|
login), price history, screening, company data, analysis, comparison, broker summaries,
|
||||||
bandarmology, foreign flow, and the KSEI ownership queries.
|
bandarmology, foreign flow, and the KSEI ownership queries.
|
||||||
|
|
||||||
|
|
@ -33,6 +33,21 @@ Tools that fetch live data return:
|
||||||
failed. Ownership tools return their data directly, from the local
|
failed. Ownership tools return their data directly, from the local
|
||||||
database.
|
database.
|
||||||
|
|
||||||
|
What `idx` prints as a warning in a terminal comes back in `warnings`, on an
|
||||||
|
answer or an error, when there was any. For example, history that fell back to
|
||||||
|
Yahoo because Stockbit refused your session says so, and a stale answer names
|
||||||
|
the failure behind it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [{ "date": "2026-11-03", "close": 9875, "...": "..." }],
|
||||||
|
"source": "yahoo",
|
||||||
|
"fetched_at": "2026-11-04T03:20:11Z",
|
||||||
|
"stale": false,
|
||||||
|
"warnings": ["Stockbit rejected the session; run `idx stockbit login` again; history falls back to yahoo meanwhile"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Errors come back as a tool result with `isError` set and the CLI's error
|
Errors come back as a tool result with `isError` set and the CLI's error
|
||||||
body, so the agent can decide whether to retry:
|
body, so the agent can decide whether to retry:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ description: Every idx-mcp tool and its inputs, generated from the server's tool
|
||||||
idx-mcp serves these tools. `ownership_sync` is listed only when the server
|
idx-mcp serves these tools. `ownership_sync` is listed only when the server
|
||||||
starts with `--allow-sync`. Results that come from a live provider are wrapped
|
starts with `--allow-sync`. Results that come from a live provider are wrapped
|
||||||
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
||||||
message}` with `isError` set.
|
message}` with `isError` set. Either may carry `warnings`, what idx worked
|
||||||
|
around (such as a history fallback or a stale cache entry), when there was
|
||||||
|
any.
|
||||||
|
|
||||||
## `analyze_bandar`
|
## `analyze_bandar`
|
||||||
|
|
||||||
|
|
@ -150,6 +152,20 @@ Latest quotes (price, change, volume, 52-week range, market cap) for one or more
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. |
|
| `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. |
|
||||||
|
|
||||||
|
## `get_shareholder_movements`
|
||||||
|
|
||||||
|
Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`; `incomplete`, when present, says a later page failed and the rows are those read before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.
|
||||||
|
|
||||||
|
| Input | Type | Required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `action` | `buy` / `sell` / `transfer` / `cross` / `corporate-action` | no | Only this kind of movement; "corporate-action" covers rights issues, private placements and the like. |
|
||||||
|
| `from` | string | no | First day, YYYY-MM-DD. |
|
||||||
|
| `holder_id` | string | no | One holder's movements: the `holder_id` of a row. |
|
||||||
|
| `pages` | integer | no | Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit. |
|
||||||
|
| `source` | `idx` / `ksei` | no | "idx" (insider disclosures, any size) or "ksei" (holders at 5% and above). |
|
||||||
|
| `symbol` | string | no | IDX ticker, e.g. "BBCA"; omit for the whole market. |
|
||||||
|
| `to` | string | no | Last day, YYYY-MM-DD. |
|
||||||
|
|
||||||
## `get_shareholders`
|
## `get_shareholders`
|
||||||
|
|
||||||
Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).
|
Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue