mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
feat: shareholder movements from the Stockbit session (ownership movements)
`idx ownership movements [SYMBOL]` and the MCP tool
`get_shareholder_movements` list reported changes in insiders' and major
holders' stakes, newest first, from Stockbit's
/insider/company/majorholder: IDX insider disclosures (any size, with the
trade price) and KSEI changes of holders at 5% and above (Stockbit's
estimated price). Stockbit filters by date range, holder id, action and
source; each page is 50 rows and one request, up to 10 pages, cached an
hour per page under every filter.
Rows keep exact signed share counts and the stake in basis points as
Stockbit rounds it; unknown action labels are kept as `other` rather than
dropped. JSON is {movements, more}; JSONL is one movement per line. The
command lives under `ownership` but needs no import, and `ownership`
commands now take --offline / --no-cache for it.
The per-symbol summary score is the next PR; docs/STOCKBIT.md records the
endpoint's parameters and action types as checked on 2026-11-03.
Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
parent
1b72c3ed6b
commit
ae0ca0c109
43 changed files with 3050 additions and 29 deletions
|
|
@ -13,9 +13,11 @@ use idx_core::error::IdxError;
|
|||
use idx_core::investor::{
|
||||
ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family,
|
||||
};
|
||||
use idx_core::movements::types::{ActionFilter, MovementQuery, MovementSource};
|
||||
use idx_core::ownership::graph;
|
||||
use idx_core::ownership::snapshot;
|
||||
use idx_core::ownership::types::{ChangeType, FlowSignal, HolderRow, OwnershipSource};
|
||||
use idx_core::{CachePolicy, Idx};
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[command(
|
||||
|
|
@ -72,6 +74,17 @@ pub enum OwnershipCommand {
|
|||
after_help = "Examples:\n idx ownership releases\n idx ownership changes --from 2026-08-31 --to 2026-09-30"
|
||||
)]
|
||||
Changes(ChangesArgs),
|
||||
#[command(
|
||||
about = "Insider and major holder buys and sells, as reported (Stockbit login)",
|
||||
long_about = "Reported changes in holders' stakes from your own Stockbit session (`idx \
|
||||
stockbit login`), newest first: IDX insider disclosures (directors, commissioners and other \
|
||||
insiders, any size, with the trade price) and KSEI's changes of holders at 5% and above (Stockbit \
|
||||
estimates their price). One stock's, or the whole market's without SYMBOL. Each page is 50 rows \
|
||||
and one request; answers are cached for an hour.\n\nThese are filings, not signals: a share \
|
||||
plan shows as many insiders buying at one price, and a row appears days after the change.",
|
||||
after_help = "Examples:\n idx ownership movements BBCA\n idx ownership movements --action sell --source ksei\n idx ownership movements --from 2026-10-01 --to 2026-10-31 --pages 5\n idx ownership movements --holder 1000004481 # one holder's history (HOLDER ID column)"
|
||||
)]
|
||||
Movements(MovementsArgs),
|
||||
/// Ownership network graph.
|
||||
Graph(GraphArgs),
|
||||
/// Manual entity resolution workflow.
|
||||
|
|
@ -168,6 +181,30 @@ pub struct ChangesArgs {
|
|||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct MovementsArgs {
|
||||
/// Single ticker symbol (e.g. BBCA); omit for the whole market.
|
||||
pub symbol: Option<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)]
|
||||
pub struct GraphArgs {
|
||||
/// Ticker code or entity name to start from.
|
||||
|
|
@ -209,7 +246,15 @@ pub enum ResolveCommand {
|
|||
},
|
||||
}
|
||||
|
||||
pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError> {
|
||||
pub fn handle(
|
||||
cmd: &OwnershipCommand,
|
||||
config: &IdxConfig,
|
||||
offline: bool,
|
||||
no_cache: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
if let OwnershipCommand::Movements(args) = cmd {
|
||||
return handle_movements(args, config, CachePolicy::from_flags(offline, no_cache)?);
|
||||
}
|
||||
let investor = Investor::new(&config.core);
|
||||
let inv = &investor;
|
||||
match cmd {
|
||||
|
|
@ -223,6 +268,9 @@ pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError
|
|||
OwnershipCommand::Concentration(args) => handle_concentration(inv, args, config),
|
||||
OwnershipCommand::Flow(args) => handle_flow(inv, args, config),
|
||||
OwnershipCommand::Changes(args) => handle_changes(inv, args, config),
|
||||
OwnershipCommand::Movements(args) => {
|
||||
handle_movements(args, config, CachePolicy::from_flags(offline, no_cache)?)
|
||||
}
|
||||
OwnershipCommand::Graph(args) => handle_graph(inv, args, config),
|
||||
OwnershipCommand::Resolve(args) => handle_resolve(inv, args, config),
|
||||
OwnershipCommand::Releases => handle_releases(inv, config),
|
||||
|
|
@ -678,6 +726,37 @@ fn handle_changes(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_movements(
|
||||
args: &MovementsArgs,
|
||||
config: &IdxConfig,
|
||||
policy: CachePolicy,
|
||||
) -> Result<(), IdxError> {
|
||||
let mut query = MovementQuery::from_args(
|
||||
args.symbol.as_deref(),
|
||||
args.from.as_deref(),
|
||||
args.to.as_deref(),
|
||||
args.holder_id.as_deref(),
|
||||
args.pages,
|
||||
)?;
|
||||
query.action = args.action;
|
||||
query.source = args.source;
|
||||
let idx = Idx::new(config.core.clone())?;
|
||||
let list = idx.movements().list(&query, policy)?;
|
||||
let symbol = match &query.symbol {
|
||||
Some(symbol) => Some(idx_core::api::resolve_symbol(
|
||||
symbol,
|
||||
&config.core.exchange,
|
||||
)?),
|
||||
None => None,
|
||||
};
|
||||
crate::output::render_shareholder_movements(
|
||||
symbol.as_deref(),
|
||||
&list.data,
|
||||
&config.output,
|
||||
config.no_color,
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_resolve(
|
||||
investor: &Investor,
|
||||
args: &ResolveArgs,
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ fn run() -> Result<(), IdxError> {
|
|||
}
|
||||
#[cfg(feature = "ownership")]
|
||||
Commands::Ownership(cmd) => {
|
||||
if let Err(err) = cli::ownership::handle(&cmd.command, &config) {
|
||||
if let Err(err) =
|
||||
cli::ownership::handle(&cmd.command, &config, cli.offline, cli.no_cache)
|
||||
{
|
||||
emit_error(&err, &config.output);
|
||||
return Err(err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ use idx_core::config::ProviderKind;
|
|||
use idx_core::corporate::types::{CalendarRange, CorporateAction};
|
||||
use idx_core::error::IdxError;
|
||||
use idx_core::flow::types::ForeignFlow;
|
||||
use idx_core::movements::types::MovementList;
|
||||
|
||||
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
||||
|
|
@ -126,6 +127,21 @@ pub fn render_corporate_calendar(
|
|||
}
|
||||
}
|
||||
|
||||
/// JSON is `{movements, more}` (`more`: rows remain past the pages read);
|
||||
/// JSONL is one movement per line.
|
||||
pub fn render_shareholder_movements(
|
||||
symbol: Option<&str>,
|
||||
list: &MovementList,
|
||||
format: &OutputFormat,
|
||||
no_color: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
match format {
|
||||
OutputFormat::Table => table::print_shareholder_movements(symbol, list, no_color),
|
||||
OutputFormat::Json => json::print_json(list),
|
||||
OutputFormat::Jsonl => json::print_json(&list.movements),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_broker_summary(
|
||||
summary: &BrokerSummary,
|
||||
format: &OutputFormat,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKin
|
|||
use idx_core::corporate::types::{ActionKind, CalendarRange, CorporateAction, DateRange};
|
||||
use idx_core::error::IdxError;
|
||||
use idx_core::flow::types::{FlowSource, ForeignFlow};
|
||||
use idx_core::movements::types::{
|
||||
MovementAction, MovementList, MovementSource, ShareholderMovement,
|
||||
};
|
||||
|
||||
pub fn format_idr(value: i64) -> String {
|
||||
let sign = if value.is_negative() { "-" } else { "" };
|
||||
|
|
@ -1855,6 +1858,113 @@ pub fn print_corporate_calendar(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn movement_action_label(action: MovementAction, no_color: bool) -> String {
|
||||
let label = action.as_str().replace('_', " ");
|
||||
match action {
|
||||
_ if no_color => label,
|
||||
MovementAction::Buy => label.green().to_string(),
|
||||
MovementAction::Sell => label.red().to_string(),
|
||||
_ => label,
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed exact shares: `+147,933`, `-10,000,000`.
|
||||
fn format_signed_shares(value: i64) -> String {
|
||||
let text = format_idr(value);
|
||||
if value > 0 { format!("+{text}") } else { text }
|
||||
}
|
||||
|
||||
/// The stake after the change and the change in percentage points:
|
||||
/// `40.65% (-0.16)`. A stake that rounds to 0 basis points reads `<0.01%`.
|
||||
fn movement_stake(movement: &ShareholderMovement) -> String {
|
||||
let after = match movement.percentage_after_bps {
|
||||
0 if movement.shares_after > 0 => "<0.01%".to_string(),
|
||||
bps => format!("{:.2}%", bps as f64 / 100.0),
|
||||
};
|
||||
match movement.percentage_change_bps {
|
||||
0 => after,
|
||||
bps => format!("{after} ({:+.2})", bps as f64 / 100.0),
|
||||
}
|
||||
}
|
||||
|
||||
const MOVEMENT_NOTE: &str = "IDX rows are insider disclosures (directors, commissioners and \
|
||||
other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated \
|
||||
price. Rows appear days after the change.";
|
||||
|
||||
pub fn print_shareholder_movements(
|
||||
symbol: Option<&str>,
|
||||
list: &MovementList,
|
||||
no_color: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
let title = match symbol {
|
||||
Some(symbol) => format!("Shareholder movements for {}", terminal_safe(symbol)),
|
||||
None => "Shareholder movements across IDX".to_string(),
|
||||
};
|
||||
println!("{}", format!("{title} ({})", list.movements.len()).bold());
|
||||
if list.movements.is_empty() {
|
||||
println!("No movements reported for this query.");
|
||||
} else {
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
"DATE",
|
||||
"SYMBOL",
|
||||
"HOLDER",
|
||||
"ROLE",
|
||||
"ACTION",
|
||||
"SHARES",
|
||||
"STAKE",
|
||||
"PRICE",
|
||||
"VALUE",
|
||||
"SOURCE",
|
||||
"HOLDER ID",
|
||||
]);
|
||||
for movement in &list.movements {
|
||||
let estimated = if movement.price_estimated { "~" } else { "" };
|
||||
table.add_row(vec![
|
||||
cell(movement.date),
|
||||
cell(&movement.symbol),
|
||||
cell(&movement.holder),
|
||||
cell(if movement.roles.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
movement.roles.join(", ")
|
||||
}),
|
||||
painted(movement_action_label(movement.action, no_color)),
|
||||
cell(format_signed_shares(movement.shares_change)),
|
||||
cell(movement_stake(movement)),
|
||||
cell(
|
||||
movement
|
||||
.price
|
||||
.map(|p| format!("{estimated}{}", format_idr(p)))
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
cell(
|
||||
movement
|
||||
.value
|
||||
.map(|v| format!("{estimated}{}", format_compact(v)))
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
cell(match movement.source {
|
||||
Some(MovementSource::Idx) => "IDX",
|
||||
Some(MovementSource::Ksei) => "KSEI",
|
||||
None => "-",
|
||||
}),
|
||||
cell(&movement.holder_id),
|
||||
]);
|
||||
}
|
||||
println!("{table}");
|
||||
println!("{MOVEMENT_NOTE}");
|
||||
}
|
||||
if list.more {
|
||||
println!("More rows exist: raise --pages (up to 10) or narrow --from/--to.");
|
||||
}
|
||||
println!("{STOCKBIT_SESSION_NOTE}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
|
|
|||
|
|
@ -420,6 +420,15 @@ args = ["stocks", "orderbook", "BBCA"]
|
|||
name = "error-actions-no-session"
|
||||
args = ["stocks", "actions", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "error-movements-no-session"
|
||||
args = ["ownership", "movements", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "error-movements-holder"
|
||||
args = ["ownership", "movements", "--holder", "RUDI"]
|
||||
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||
|
||||
[[case]]
|
||||
name = "error-calendar-range"
|
||||
args = ["stocks", "calendar", "--from", "2026-11-10", "--to", "2026-11-01"]
|
||||
|
|
@ -432,6 +441,21 @@ env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0OD
|
|||
|
||||
# --- ownership --------------------------------------------------------------------
|
||||
|
||||
[[case]]
|
||||
name = "ownership-movements"
|
||||
args = ["ownership", "movements", "--pages", "2"]
|
||||
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||
|
||||
[[case]]
|
||||
name = "ownership-movements-bbca"
|
||||
args = ["ownership", "movements", "BBCA"]
|
||||
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||
|
||||
[[case]]
|
||||
name = "ownership-movements-ksei-sells"
|
||||
args = ["ownership", "movements", "--action", "sell", "--source", "ksei", "--pages", "3"]
|
||||
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||
|
||||
[[case]]
|
||||
name = "ownership-releases-empty"
|
||||
args = ["ownership", "releases"]
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@ _idx() {
|
|||
idx__help__ownership,import)
|
||||
cmd="idx__help__ownership__import"
|
||||
;;
|
||||
idx__help__ownership,movements)
|
||||
cmd="idx__help__ownership__movements"
|
||||
;;
|
||||
idx__help__ownership,releases)
|
||||
cmd="idx__help__ownership__releases"
|
||||
;;
|
||||
|
|
@ -282,6 +285,9 @@ _idx() {
|
|||
idx__ownership,import)
|
||||
cmd="idx__ownership__import"
|
||||
;;
|
||||
idx__ownership,movements)
|
||||
cmd="idx__ownership__movements"
|
||||
;;
|
||||
idx__ownership,releases)
|
||||
cmd="idx__ownership__releases"
|
||||
;;
|
||||
|
|
@ -324,6 +330,9 @@ _idx() {
|
|||
idx__ownership__help,import)
|
||||
cmd="idx__ownership__help__import"
|
||||
;;
|
||||
idx__ownership__help,movements)
|
||||
cmd="idx__ownership__help__movements"
|
||||
;;
|
||||
idx__ownership__help,releases)
|
||||
cmd="idx__ownership__help__releases"
|
||||
;;
|
||||
|
|
@ -1095,7 +1104,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__help__ownership)
|
||||
opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases"
|
||||
opts="discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -1220,6 +1229,20 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__ownership__movements)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__ownership__releases)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
|
|
@ -1725,7 +1748,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__ownership)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help"
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -1973,7 +1996,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__ownership__help)
|
||||
opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help"
|
||||
opts="discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -2112,6 +2135,20 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__ownership__help__movements)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__ownership__help__releases)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
|
|
@ -2262,6 +2299,56 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__ownership__movements)
|
||||
opts="-o -q -v -h --from --to --holder --action --source --pages --output --no-color --quiet --verbose --provider --offline --no-cache --help [SYMBOL]"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
--from)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--to)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--holder)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--action)
|
||||
COMPREPLY=($(compgen -W "buy sell transfer cross corporate-action" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--source)
|
||||
COMPREPLY=($(compgen -W "idx ksei" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--pages)
|
||||
COMPREPLY=($(compgen -f "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--output)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--provider)
|
||||
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__ownership__releases)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
|
|
|
|||
|
|
@ -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
|
||||
flow Show Bing institutional flow for a ticker
|
||||
changes Diff two KSEI releases
|
||||
movements Insider and major holder buys and sells, as reported (Stockbit login)
|
||||
graph Ownership network graph
|
||||
resolve Manual entity resolution workflow
|
||||
releases List imported KSEI releases
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
||||
|
||||
use super::stockbit::{corpaction, exodus};
|
||||
use super::stockbit::{corpaction, exodus, movement};
|
||||
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||
use super::{
|
||||
BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider,
|
||||
IntradayProvider, OrderBookProvider, QuoteProvider, TradingSummaryProvider, idx, ipot, msn,
|
||||
stockbit, yahoo,
|
||||
IntradayProvider, OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
|
||||
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
||||
};
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||
use crate::config::ProviderKind;
|
||||
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||
use crate::error::IdxError;
|
||||
use crate::movements::types::{MovementPage, MovementQuery};
|
||||
|
||||
pub(crate) fn enabled() -> bool {
|
||||
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
|
||||
|
|
@ -254,6 +255,36 @@ impl CorporateActionProvider for MockStockbitProvider {
|
|||
}
|
||||
}
|
||||
|
||||
/// `stockbit_exodus_majorholder[_<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");
|
||||
let mut name = "majorholder".to_string();
|
||||
if let Some(symbol) = &query.symbol {
|
||||
name.push('_');
|
||||
name.push_str(&stockbit::stockbit_code(symbol)?.to_ascii_lowercase());
|
||||
}
|
||||
if page > 1 {
|
||||
name.push_str(&format!("_p{page}"));
|
||||
}
|
||||
let body = match self.exodus(subject, &format!("{name}.json")) {
|
||||
Err(IdxError::SymbolNotFound(_)) if query.symbol.is_some() || page > 1 => {
|
||||
r#"{"data":{"is_more":false,"movement":[]}}"#.to_string()
|
||||
}
|
||||
other => other?,
|
||||
};
|
||||
let mut parsed = movement::parse_movements(&body)?;
|
||||
parsed.movements.retain(|m| query.admits(m));
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryProvider for MockStockbitProvider {
|
||||
fn history(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,17 @@ pub trait CorporateActionProvider {
|
|||
) -> 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 {
|
||||
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())
|
||||
}
|
||||
|
||||
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.
|
||||
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||
#[cfg(feature = "testing")]
|
||||
|
|
|
|||
|
|
@ -25,12 +25,13 @@ use chrono::{Months, NaiveDate};
|
|||
use serde_json::Value;
|
||||
|
||||
use super::auth::SessionStore;
|
||||
use super::corpaction;
|
||||
use super::parse::number;
|
||||
use super::{USER_AGENT, pace, stockbit_code};
|
||||
use super::{corpaction, movement};
|
||||
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
|
||||
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||
use crate::error::IdxError;
|
||||
use crate::movements::types::{MovementPage, MovementQuery};
|
||||
|
||||
const BASE_URL: &str = "https://exodus.stockbit.com";
|
||||
const REFRESH_PATH: &str = "/login/refresh";
|
||||
|
|
@ -140,6 +141,25 @@ impl Exodus {
|
|||
corpaction::parse_corporate_actions(kind, &body)
|
||||
}
|
||||
|
||||
/// Page `page` of shareholder movements: one stock's (`query.symbol`,
|
||||
/// resolved) or the market's.
|
||||
pub(crate) fn shareholder_movements(
|
||||
&self,
|
||||
query: &MovementQuery,
|
||||
page: u32,
|
||||
) -> Result<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.
|
||||
/// Unknown on any failure, which hides bid/offer.
|
||||
fn market_status(&self) -> Option<String> {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub mod browser;
|
|||
pub mod corpaction;
|
||||
pub mod exodus;
|
||||
pub mod loopback;
|
||||
pub mod movement;
|
||||
mod parse;
|
||||
pub mod signin;
|
||||
|
||||
|
|
@ -33,10 +34,11 @@ pub use parse::{parse_symbol_page, status_error, stockbit_code};
|
|||
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||
use super::{
|
||||
CorporateActionProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
|
||||
OrderBookProvider, QuoteProvider,
|
||||
OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
|
||||
};
|
||||
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||
use crate::error::IdxError;
|
||||
use crate::movements::types::{MovementPage, MovementQuery};
|
||||
use auth::SessionStore;
|
||||
use exodus::Exodus;
|
||||
|
||||
|
|
@ -216,6 +218,16 @@ impl CorporateActionProvider for StockbitProvider {
|
|||
}
|
||||
}
|
||||
|
||||
impl ShareholderMovementProvider for StockbitProvider {
|
||||
fn shareholder_movements(
|
||||
&self,
|
||||
query: &MovementQuery,
|
||||
page: u32,
|
||||
) -> Result<MovementPage, IdxError> {
|
||||
self.require_session()?.shareholder_movements(query, page)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryProvider for StockbitProvider {
|
||||
fn history(
|
||||
&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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +98,10 @@ impl Idx {
|
|||
crate::corporate::Corporate { idx: self }
|
||||
}
|
||||
|
||||
pub fn movements(&self) -> crate::movements::Movements<'_> {
|
||||
crate::movements::Movements { idx: self }
|
||||
}
|
||||
|
||||
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
||||
crate::financial::Financial { idx: self }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub mod flow;
|
|||
#[cfg(feature = "ownership")]
|
||||
pub mod investor;
|
||||
pub mod market;
|
||||
pub mod movements;
|
||||
#[cfg(feature = "ownership")]
|
||||
pub mod ownership;
|
||||
pub mod text;
|
||||
|
|
|
|||
124
crates/idx-core/src/movements/mod.rs
Normal file
124
crates/idx-core/src/movements/mod.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
//! Shareholder movements from the user's Stockbit session: reported changes
|
||||
//! in insiders' and major holders' stakes (IDX insider disclosures and KSEI's
|
||||
//! 5% holder changes), one stock's or the market's, newest first. Each page
|
||||
//! is 50 rows and one request (paced 1 s apart), cached for an hour.
|
||||
//!
|
||||
//! These are the reports as filed. Whether they predict anything is not
|
||||
//! claimed here: share plans read as a cluster of buys at one price, and a
|
||||
//! row appears days after the trade (see `docs/STOCKBIT.md`).
|
||||
|
||||
pub mod types;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::api::{shareholder_movement_provider, stockbit};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::error::IdxError;
|
||||
use types::{MovementList, MovementQuery};
|
||||
|
||||
/// The feed gains rows through the day; an hour keeps a session's repeated
|
||||
/// queries cheap without hiding a day's filings for long.
|
||||
const TTL_SECS: u64 = 3600;
|
||||
const BUCKET: &str = "stockbit-movements";
|
||||
|
||||
pub struct Movements<'a> {
|
||||
pub(crate) idx: &'a Idx,
|
||||
}
|
||||
|
||||
impl Movements<'_> {
|
||||
/// Up to `query.pages` pages of the feed, newest first; `more` says
|
||||
/// whether rows remain past them. The result is as old as its oldest page
|
||||
/// and stale if any page is.
|
||||
pub fn list(
|
||||
&self,
|
||||
query: &MovementQuery,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<MovementList>, IdxError> {
|
||||
let mut query = query.clone();
|
||||
if let Some(symbol) = &query.symbol {
|
||||
let (resolved, _) = self.idx.idx_code(symbol, "shareholder movements")?;
|
||||
query.symbol = Some(resolved);
|
||||
}
|
||||
let provider = shareholder_movement_provider();
|
||||
let subject = query
|
||||
.symbol
|
||||
.clone()
|
||||
.unwrap_or_else(|| "shareholder movements".to_string());
|
||||
let mut list = Fetched {
|
||||
data: MovementList::default(),
|
||||
source: stockbit::SOURCE,
|
||||
fetched_at: Utc::now(),
|
||||
stale: false,
|
||||
};
|
||||
for page in 1..=query.pages.max(1) {
|
||||
let key = cache_key(&query, page);
|
||||
let part = cached_fetch(
|
||||
&self.idx.cache,
|
||||
CacheSpec {
|
||||
bucket: BUCKET,
|
||||
key: &key,
|
||||
ttl_secs: TTL_SECS,
|
||||
subject: &subject,
|
||||
source: stockbit::SOURCE,
|
||||
},
|
||||
policy,
|
||||
|| provider.shareholder_movements(&query, page),
|
||||
Ok,
|
||||
)?;
|
||||
list.fetched_at = list.fetched_at.min(part.fetched_at);
|
||||
list.stale |= part.stale;
|
||||
list.data.movements.extend(part.data.movements);
|
||||
list.data.more = part.data.more;
|
||||
if !part.data.more {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every filter and the page, so no two queries share an entry.
|
||||
fn cache_key(query: &MovementQuery, page: u32) -> String {
|
||||
let opt = |value: Option<String>| value.unwrap_or_else(|| "-".to_string());
|
||||
format!(
|
||||
"{}:{}:{}:{}:{}:{}:p{page}",
|
||||
query.symbol.as_deref().unwrap_or("market"),
|
||||
opt(query.from.map(|d| d.to_string())),
|
||||
opt(query.to.map(|d| d.to_string())),
|
||||
query.holder_id.as_deref().unwrap_or("-"),
|
||||
query.action.map(|a| a.as_str()).unwrap_or("-"),
|
||||
query.source.map(|s| s.as_str()).unwrap_or("-"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use types::{ActionFilter, MovementSource};
|
||||
|
||||
#[test]
|
||||
fn cache_keys_differ_by_every_filter() {
|
||||
let base = MovementQuery::from_args(None, None, None, None, 1).unwrap();
|
||||
let mut keys = vec![cache_key(&base, 1), cache_key(&base, 2)];
|
||||
let mut q = base.clone();
|
||||
q.symbol = Some("BBCA.JK".to_string());
|
||||
keys.push(cache_key(&q, 1));
|
||||
let mut q = base.clone();
|
||||
q.from = chrono::NaiveDate::from_ymd_opt(2026, 10, 1);
|
||||
keys.push(cache_key(&q, 1));
|
||||
let mut q = base.clone();
|
||||
q.to = chrono::NaiveDate::from_ymd_opt(2026, 10, 1);
|
||||
keys.push(cache_key(&q, 1));
|
||||
let mut q = base.clone();
|
||||
q.holder_id = Some("14074".to_string());
|
||||
keys.push(cache_key(&q, 1));
|
||||
let mut q = base.clone();
|
||||
q.action = Some(ActionFilter::Sell);
|
||||
keys.push(cache_key(&q, 1));
|
||||
let mut q = base;
|
||||
q.source = Some(MovementSource::Ksei);
|
||||
keys.push(cache_key(&q, 1));
|
||||
let unique: std::collections::HashSet<_> = keys.iter().collect();
|
||||
assert_eq!(unique.len(), keys.len(), "{keys:?}");
|
||||
}
|
||||
}
|
||||
334
crates/idx-core/src/movements/types.rs
Normal file
334
crates/idx-core/src/movements/types.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::IdxError;
|
||||
|
||||
/// What a holder's row records, as Stockbit labels it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MovementAction {
|
||||
Buy,
|
||||
Sell,
|
||||
/// Shares moved between accounts or parties, not a market trade.
|
||||
Transfer,
|
||||
/// A crossing: a negotiated trade between two parties.
|
||||
Cross,
|
||||
RightsIssue,
|
||||
PrivatePlacement,
|
||||
TenderOffer,
|
||||
BonusStock,
|
||||
StockDividend,
|
||||
CashDividend,
|
||||
/// A label Stockbit added after this release.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl MovementAction {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Buy => "buy",
|
||||
Self::Sell => "sell",
|
||||
Self::Transfer => "transfer",
|
||||
Self::Cross => "cross",
|
||||
Self::RightsIssue => "rights_issue",
|
||||
Self::PrivatePlacement => "private_placement",
|
||||
Self::TenderOffer => "tender_offer",
|
||||
Self::BonusStock => "bonus_stock",
|
||||
Self::StockDividend => "stock_dividend",
|
||||
Self::CashDividend => "cash_dividend",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `filter` (Stockbit's `action_type`) selects this action.
|
||||
pub fn matches(self, filter: ActionFilter) -> bool {
|
||||
match filter {
|
||||
ActionFilter::Buy => self == Self::Buy,
|
||||
ActionFilter::Sell => self == Self::Sell,
|
||||
ActionFilter::Transfer => self == Self::Transfer,
|
||||
ActionFilter::Cross => self == Self::Cross,
|
||||
ActionFilter::CorporateAction => matches!(
|
||||
self,
|
||||
Self::RightsIssue
|
||||
| Self::PrivatePlacement
|
||||
| Self::TenderOffer
|
||||
| Self::BonusStock
|
||||
| Self::StockDividend
|
||||
| Self::CashDividend
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The action filters Stockbit's feed takes (`action_type`). Corporate
|
||||
/// action covers rights issues, private placements and the like.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ActionFilter {
|
||||
Buy,
|
||||
Sell,
|
||||
Transfer,
|
||||
Cross,
|
||||
#[cfg_attr(feature = "clap", value(name = "corporate-action"))]
|
||||
CorporateAction,
|
||||
}
|
||||
|
||||
impl ActionFilter {
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Buy,
|
||||
Self::Sell,
|
||||
Self::Transfer,
|
||||
Self::Cross,
|
||||
Self::CorporateAction,
|
||||
];
|
||||
|
||||
/// The CLI's name (`--action`), also used in MCP inputs.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Buy => "buy",
|
||||
Self::Sell => "sell",
|
||||
Self::Transfer => "transfer",
|
||||
Self::Cross => "cross",
|
||||
Self::CorporateAction => "corporate-action",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ActionFilter {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<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,
|
||||
}
|
||||
|
||||
#[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::flow::types::FlowSource;
|
||||
use idx_core::market::{ScreenFilter, ScreenRegion};
|
||||
use idx_core::movements::types::{ActionFilter, MAX_PAGES};
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -253,6 +254,34 @@ pub struct CorporateCalendarParams {
|
|||
pub kinds: Option<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.
|
||||
pub fn action_kinds(
|
||||
kinds: Option<Vec<String>>,
|
||||
|
|
@ -346,6 +375,18 @@ fn action_kinds_schema(_: &mut SchemaGenerator) -> Schema {
|
|||
json_schema!({ "type": "array", "items": { "type": "string", "enum": kinds } })
|
||||
}
|
||||
|
||||
fn movement_action_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(ActionFilter::ALL.map(ActionFilter::as_str).to_vec())
|
||||
}
|
||||
|
||||
fn movement_source_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(vec!["idx", "ksei"])
|
||||
}
|
||||
|
||||
fn movement_pages_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({ "type": "integer", "minimum": 1, "maximum": MAX_PAGES })
|
||||
}
|
||||
|
||||
fn login_method_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(LoginMethod::ALL.map(LoginMethod::as_str).to_vec())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use idx_core::financial::{EarningsFilter, StatementFilter};
|
|||
use idx_core::flow::types::FlowRange;
|
||||
use idx_core::investor::{HolderSource, Investor};
|
||||
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
||||
use idx_core::movements::types::MovementQuery;
|
||||
use idx_core::ownership::types::ChangeRow;
|
||||
use idx_core::{CachePolicy, Fetched, Idx};
|
||||
use rmcp::handler::server::router::tool::ToolRouter;
|
||||
|
|
@ -29,8 +30,8 @@ use tokio::sync::Semaphore;
|
|||
use crate::params::{
|
||||
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
||||
CompanySection, CorporateActionsParams, CorporateCalendarParams, EntityParams, FlowParams,
|
||||
HistoryParams, IntradayParams, LoginMethod, LoginParams, OrderBookParams, ScreenParams,
|
||||
SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds,
|
||||
HistoryParams, IntradayParams, LoginMethod, LoginParams, MovementsParams, OrderBookParams,
|
||||
ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds,
|
||||
};
|
||||
|
||||
const SYNC_TOOL: &str = "ownership_sync";
|
||||
|
|
@ -401,6 +402,42 @@ AUTHERROR means there is no session: call stockbit_status, then stockbit_login."
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Reported changes in insiders' and major holders' stakes in IDX stocks, \
|
||||
from the user's own Stockbit session, newest first: IDX insider disclosures (directors, \
|
||||
commissioners and other insiders, any size, with the trade price) and KSEI changes of holders \
|
||||
at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without \
|
||||
`symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis \
|
||||
points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain \
|
||||
past `pages`. These are filings, not signals: many insiders buying at one price on one day is \
|
||||
usually a share plan, and rows appear days after the change. AUTHERROR means there is no \
|
||||
session: call stockbit_status, then stockbit_login."
|
||||
)]
|
||||
async fn get_shareholder_movements(&self, params: Parameters<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(
|
||||
description = "Company data for one IDX ticker: profile, financial statements, \
|
||||
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
||||
|
|
|
|||
|
|
@ -555,6 +555,76 @@
|
|||
},
|
||||
"name": "get_quotes"
|
||||
},
|
||||
{
|
||||
"description": "Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`. These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"action": {
|
||||
"default": null,
|
||||
"description": "Only this kind of movement; \"corporate-action\" covers rights issues, private placements and the like.",
|
||||
"enum": [
|
||||
"buy",
|
||||
"sell",
|
||||
"transfer",
|
||||
"cross",
|
||||
"corporate-action"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"from": {
|
||||
"default": null,
|
||||
"description": "First day, YYYY-MM-DD.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"holder_id": {
|
||||
"default": null,
|
||||
"description": "One holder's movements: the `holder_id` of a row.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pages": {
|
||||
"default": null,
|
||||
"description": "Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit.",
|
||||
"maximum": 10,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"source": {
|
||||
"default": null,
|
||||
"description": "\"idx\" (insider disclosures, any size) or \"ksei\" (holders at 5% and above).",
|
||||
"enum": [
|
||||
"idx",
|
||||
"ksei"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"default": null,
|
||||
"description": "IDX ticker, e.g. \"BBCA\"; omit for the whole market.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"default": null,
|
||||
"description": "Last day, YYYY-MM-DD.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_shareholder_movements"
|
||||
},
|
||||
{
|
||||
"description": "Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).",
|
||||
"inputSchema": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use idx_core::config::CoreConfig;
|
|||
use idx_core::investor::{ImportSource, Investor};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const TOOLS: [&str; 20] = [
|
||||
const TOOLS: [&str; 21] = [
|
||||
"analyze_bandar",
|
||||
"analyze_stock",
|
||||
"compare_stocks",
|
||||
|
|
@ -24,6 +24,7 @@ const TOOLS: [&str; 20] = [
|
|||
"get_ownership_changes",
|
||||
"get_price_history",
|
||||
"get_quotes",
|
||||
"get_shareholder_movements",
|
||||
"get_shareholders",
|
||||
"ownership_status",
|
||||
"screen_stocks",
|
||||
|
|
@ -551,6 +552,54 @@ fn corporate_actions_need_a_session_and_keep_kinds_apart() {
|
|||
assert_eq!(body["code"], "INVALIDINPUT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shareholder_movements_page_and_filter() {
|
||||
let mut client = Client::start(&[], false);
|
||||
let (is_error, body) = client.call("get_shareholder_movements", json!({}));
|
||||
assert!(is_error, "{body}");
|
||||
assert_eq!(body["code"], "AUTHERROR");
|
||||
|
||||
let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]);
|
||||
let market = client.ok("get_shareholder_movements", json!({"pages": 2}));
|
||||
assert_fetched(&market);
|
||||
assert_eq!(market["source"], "stockbit");
|
||||
let rows = market["data"]["movements"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 14, "8 rows on page 1, 6 on the last page");
|
||||
assert_eq!(market["data"]["more"], false);
|
||||
assert!(
|
||||
rows.windows(2)
|
||||
.all(|w| w[0]["date"].as_str() >= w[1]["date"].as_str()),
|
||||
"newest first"
|
||||
);
|
||||
|
||||
let sells = client.ok(
|
||||
"get_shareholder_movements",
|
||||
json!({"action": "sell", "source": "ksei", "holder_id": "1000004481", "pages": 3}),
|
||||
);
|
||||
let rows = sells["data"]["movements"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows.iter().all(|r| r["symbol"] == "ATLA"
|
||||
&& r["action"] == "sell"
|
||||
&& r["shares_change"].as_i64() < Some(0)
|
||||
&& r["price_estimated"] == true));
|
||||
|
||||
let bbca = client.ok("get_shareholder_movements", json!({"symbol": "bbca"}));
|
||||
assert_eq!(bbca["data"]["more"], true, "BBCA has a second page");
|
||||
assert_eq!(bbca["data"]["movements"][0]["roles"], json!(["director"]));
|
||||
|
||||
for bad in [
|
||||
json!({"action": "dividend"}),
|
||||
json!({"source": "bing"}),
|
||||
json!({"holder_id": "RUDI"}),
|
||||
json!({"pages": 11}),
|
||||
json!({"from": "2026-11-02", "to": "2026-11-01"}),
|
||||
] {
|
||||
let (is_error, body) = client.call("get_shareholder_movements", bad.clone());
|
||||
assert!(is_error, "{bad} -> {body}");
|
||||
assert_eq!(body["code"], "INVALIDINPUT", "{bad} -> {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_carry_cli_codes() {
|
||||
let mut client = Client::start(&[], false);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue