mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
Merge pull request #78 from 0xrsydn/feat/stockbit-corpaction
feat: corporate actions from the Stockbit session (stocks actions, stocks calendar)
This commit is contained in:
commit
f74c271c1f
85 changed files with 5918 additions and 59 deletions
|
|
@ -33,6 +33,7 @@ Docs:
|
||||||
- `docs/OWNERSHIP_SYNC.md` / `docs/OWNERSHIP_PUBLISH.md`: snapshot consumer contract / maintainer publishing
|
- `docs/OWNERSHIP_SYNC.md` / `docs/OWNERSHIP_PUBLISH.md`: snapshot consumer contract / maintainer publishing
|
||||||
- `docs/SMOKE.md`: Nix checks, test layout, and the live drift check
|
- `docs/SMOKE.md`: Nix checks, test layout, and the live drift check
|
||||||
- `docs/DESIGN.md`: idx-core / idx-mcp design and parity gates
|
- `docs/DESIGN.md`: idx-core / idx-mcp design and parity gates
|
||||||
|
- `docs/STOCKBIT.md`: Stockbit session endpoints and the design of features on them
|
||||||
- `docs/IDX_NEWS.md`: news sources research (IDX disclosures, Google News, Yahoo) and a proposed publisher design
|
- `docs/IDX_NEWS.md`: news sources research (IDX disclosures, Google News, Yahoo) and a proposed publisher design
|
||||||
- `ROADMAP.md`: open work
|
- `ROADMAP.md`: open work
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31
|
||||||
minute) and `--provider stockbit` quotes with the best bid/offer while the
|
minute) and `--provider stockbit` quotes with the best bid/offer while the
|
||||||
market is open, delayed at least 10 minutes. With your own account
|
market is open, delayed at least 10 minutes. With your own account
|
||||||
(`idx stockbit login`): `stocks orderbook` depth, live
|
(`idx stockbit login`): `stocks orderbook` depth, live
|
||||||
quotes and Stockbit daily history. Personal use under Stockbit's terms; see
|
quotes, Stockbit daily history, and corporate actions (`stocks actions`,
|
||||||
|
`stocks calendar`: dividends, rights, splits, meetings, IPOs and more), and
|
||||||
|
insider and major holder buys and sells (`ownership movements`). Personal use under Stockbit's terms; see
|
||||||
the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
|
the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
|
||||||
- Local file cache with TTL, and `--offline` mode with stale-cache fallback
|
- Local file cache with TTL, and `--offline` mode with stale-cache fallback
|
||||||
|
|
||||||
|
|
@ -165,8 +167,9 @@ change it to `auto` with `idx config set general.output auto`.
|
||||||
|
|
||||||
## MCP server
|
## MCP server
|
||||||
|
|
||||||
`idx-mcp` serves the same data to MCP clients over stdio, as 17 tools:
|
`idx-mcp` serves the same data to MCP clients over stdio, as 21 tools:
|
||||||
`get_quotes`, `get_intraday`, `get_orderbook`, `stockbit_status`, `get_price_history`, `screen_stocks`, `get_company`,
|
`get_quotes`, `get_intraday`, `get_orderbook`, `get_corporate_actions`,
|
||||||
|
`get_corporate_calendar`, `get_shareholder_movements`, `stockbit_status`, `stockbit_login`, `get_price_history`, `screen_stocks`, `get_company`,
|
||||||
`analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`,
|
`analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`,
|
||||||
`get_foreign_flow`, `get_shareholders`, `get_investor_holdings`,
|
`get_foreign_flow`, `get_shareholders`, `get_investor_holdings`,
|
||||||
`search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as
|
`search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as
|
||||||
|
|
|
||||||
15
ROADMAP.md
15
ROADMAP.md
|
|
@ -14,10 +14,17 @@ history lives in git.
|
||||||
- Multi-period financial statements (today `financials` is single-period)
|
- Multi-period financial statements (today `financials` is single-period)
|
||||||
- Order book depth without a login: no free source exists (IDX sells delayed
|
- Order book depth without a login: no free source exists (IDX sells delayed
|
||||||
depth to vendors); `stocks orderbook` needs the user's Stockbit login.
|
depth to vendors); `stocks orderbook` needs the user's Stockbit login.
|
||||||
- Stockbit login follow-ups: intraday OHLCV from the session's `1d` chart;
|
- Shareholder movement follow-ups (`docs/STOCKBIT.md`): `ownership movements
|
||||||
confirming session 1 is `STATE_NAME_SESSION_1` (session 2, `BREAK` and
|
--summary`, a per-symbol score (net value, distinct buyers, share-plan
|
||||||
`MARKET_CLOSED` were recorded on 2026-11-03), and when `has_foreign_bs`
|
flag), then an event study before calling any of it a signal; joining
|
||||||
turns on with the day's foreign figures; an
|
holders to ownership entities by name. Corporate-action follow-ups:
|
||||||
|
ex-dates of splits, bonuses and rights noted in `stocks history`, and the
|
||||||
|
`stock_dividend` / `reversesplit` row shapes once Stockbit lists one.
|
||||||
|
- Stockbit login follow-ups: hourly OHLCV from the session's `1w` chart (the
|
||||||
|
`1d` chart is per-minute closes only); confirming session 1 is
|
||||||
|
`STATE_NAME_SESSION_1` (session 2, `BREAK` and `MARKET_CLOSED` were
|
||||||
|
recorded on 2026-11-03), and when `has_foreign_bs` turns on with the day's
|
||||||
|
foreign figures; an
|
||||||
opt-in daily refresh so a session outlives the refresh token's week, once
|
opt-in daily refresh so a session outlives the refresh token's week, once
|
||||||
it is known whether Stockbit caps a session's total life; and the sign-in
|
it is known whether Stockbit caps a session's total life; and the sign-in
|
||||||
response (`/login/v6/*`, read over DevTools' `Fetch`) as a second signal
|
response (`/login/v6/*`, read over DevTools' `Fetch`) as a second signal
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ use idx_core::error::IdxError;
|
||||||
use idx_core::investor::{
|
use idx_core::investor::{
|
||||||
ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family,
|
ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family,
|
||||||
};
|
};
|
||||||
|
use idx_core::movements::types::{ActionFilter, MovementQuery, MovementSource};
|
||||||
use idx_core::ownership::graph;
|
use idx_core::ownership::graph;
|
||||||
use idx_core::ownership::snapshot;
|
use idx_core::ownership::snapshot;
|
||||||
use idx_core::ownership::types::{ChangeType, FlowSignal, HolderRow, OwnershipSource};
|
use idx_core::ownership::types::{ChangeType, FlowSignal, HolderRow, OwnershipSource};
|
||||||
|
use idx_core::{CachePolicy, Idx};
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
#[command(
|
#[command(
|
||||||
|
|
@ -72,6 +74,17 @@ pub enum OwnershipCommand {
|
||||||
after_help = "Examples:\n idx ownership releases\n idx ownership changes --from 2026-08-31 --to 2026-09-30"
|
after_help = "Examples:\n idx ownership releases\n idx ownership changes --from 2026-08-31 --to 2026-09-30"
|
||||||
)]
|
)]
|
||||||
Changes(ChangesArgs),
|
Changes(ChangesArgs),
|
||||||
|
#[command(
|
||||||
|
about = "Insider and major holder buys and sells, as reported (Stockbit login)",
|
||||||
|
long_about = "Reported changes in holders' stakes from your own Stockbit session (`idx \
|
||||||
|
stockbit login`), newest first: IDX insider disclosures (directors, commissioners and other \
|
||||||
|
insiders, any size, with the trade price) and KSEI's changes of holders at 5% and above (Stockbit \
|
||||||
|
estimates their price). One stock's, or the whole market's without SYMBOL. Each page is 50 rows \
|
||||||
|
and one request; answers are cached for an hour.\n\nThese are filings, not signals: a share \
|
||||||
|
plan shows as many insiders buying at one price, and a row appears days after the change.",
|
||||||
|
after_help = "Examples:\n idx ownership movements BBCA\n idx ownership movements --action sell --source ksei\n idx ownership movements --from 2026-10-01 --to 2026-10-31 --pages 5\n idx ownership movements --holder 1000004481 # one holder's history (HOLDER ID column)"
|
||||||
|
)]
|
||||||
|
Movements(MovementsArgs),
|
||||||
/// Ownership network graph.
|
/// Ownership network graph.
|
||||||
Graph(GraphArgs),
|
Graph(GraphArgs),
|
||||||
/// Manual entity resolution workflow.
|
/// Manual entity resolution workflow.
|
||||||
|
|
@ -168,6 +181,30 @@ pub struct ChangesArgs {
|
||||||
pub to: String,
|
pub to: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct MovementsArgs {
|
||||||
|
/// Single ticker symbol (e.g. BBCA); omit for the whole market.
|
||||||
|
pub symbol: Option<String>,
|
||||||
|
/// First day, YYYY-MM-DD.
|
||||||
|
#[arg(long)]
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// Last day, YYYY-MM-DD.
|
||||||
|
#[arg(long)]
|
||||||
|
pub to: Option<String>,
|
||||||
|
/// One holder's movements, by the id in the HOLDER ID column.
|
||||||
|
#[arg(long = "holder", value_name = "ID")]
|
||||||
|
pub holder_id: Option<String>,
|
||||||
|
/// Only this kind of movement.
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
pub action: Option<ActionFilter>,
|
||||||
|
/// Only IDX insider disclosures or KSEI 5% holder changes.
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
pub source: Option<MovementSource>,
|
||||||
|
/// Pages of 50 rows to read (1-10).
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
pub pages: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
pub struct GraphArgs {
|
pub struct GraphArgs {
|
||||||
/// Ticker code or entity name to start from.
|
/// Ticker code or entity name to start from.
|
||||||
|
|
@ -209,7 +246,12 @@ pub enum ResolveCommand {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError> {
|
pub fn handle(
|
||||||
|
cmd: &OwnershipCommand,
|
||||||
|
config: &IdxConfig,
|
||||||
|
offline: bool,
|
||||||
|
no_cache: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
let investor = Investor::new(&config.core);
|
let investor = Investor::new(&config.core);
|
||||||
let inv = &investor;
|
let inv = &investor;
|
||||||
match cmd {
|
match cmd {
|
||||||
|
|
@ -223,6 +265,9 @@ pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError
|
||||||
OwnershipCommand::Concentration(args) => handle_concentration(inv, args, config),
|
OwnershipCommand::Concentration(args) => handle_concentration(inv, args, config),
|
||||||
OwnershipCommand::Flow(args) => handle_flow(inv, args, config),
|
OwnershipCommand::Flow(args) => handle_flow(inv, args, config),
|
||||||
OwnershipCommand::Changes(args) => handle_changes(inv, args, config),
|
OwnershipCommand::Changes(args) => handle_changes(inv, args, config),
|
||||||
|
OwnershipCommand::Movements(args) => {
|
||||||
|
handle_movements(args, config, CachePolicy::from_flags(offline, no_cache)?)
|
||||||
|
}
|
||||||
OwnershipCommand::Graph(args) => handle_graph(inv, args, config),
|
OwnershipCommand::Graph(args) => handle_graph(inv, args, config),
|
||||||
OwnershipCommand::Resolve(args) => handle_resolve(inv, args, config),
|
OwnershipCommand::Resolve(args) => handle_resolve(inv, args, config),
|
||||||
OwnershipCommand::Releases => handle_releases(inv, config),
|
OwnershipCommand::Releases => handle_releases(inv, config),
|
||||||
|
|
@ -678,6 +723,38 @@ fn handle_changes(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_movements(
|
||||||
|
args: &MovementsArgs,
|
||||||
|
config: &IdxConfig,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
let mut query = MovementQuery::from_args(
|
||||||
|
args.symbol.as_deref(),
|
||||||
|
args.from.as_deref(),
|
||||||
|
args.to.as_deref(),
|
||||||
|
args.holder_id.as_deref(),
|
||||||
|
args.pages,
|
||||||
|
)?;
|
||||||
|
query.action = args.action;
|
||||||
|
query.source = args.source;
|
||||||
|
let idx = Idx::new(config.core.clone())?;
|
||||||
|
let list = idx.movements().list(&query, policy)?;
|
||||||
|
let symbol = match &query.symbol {
|
||||||
|
Some(symbol) => Some(idx_core::api::resolve_symbol(
|
||||||
|
symbol,
|
||||||
|
&config.core.exchange,
|
||||||
|
)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
crate::output::render_shareholder_movements(
|
||||||
|
symbol.as_deref(),
|
||||||
|
&list.data,
|
||||||
|
query.pages,
|
||||||
|
&config.output,
|
||||||
|
config.no_color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_resolve(
|
fn handle_resolve(
|
||||||
investor: &Investor,
|
investor: &Investor,
|
||||||
args: &ResolveArgs,
|
args: &ResolveArgs,
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,17 @@ use clap::{Args, Subcommand};
|
||||||
|
|
||||||
use crate::config::{HistoryProviderKind, IdxConfig};
|
use crate::config::{HistoryProviderKind, IdxConfig};
|
||||||
use crate::output::{
|
use crate::output::{
|
||||||
render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
|
render_bandar, render_broker_summary, render_compare, render_corporate_actions,
|
||||||
render_foreign_flow, render_fundamental, render_growth, render_history, render_insights,
|
render_corporate_calendar, render_earnings, render_financials, render_foreign_flow,
|
||||||
render_intraday, render_news, render_orderbook, render_profile, render_quotes, render_risk,
|
render_fundamental, render_growth, render_history, render_insights, render_intraday,
|
||||||
render_screener, render_sentiment, render_technical, render_valuation,
|
render_news, render_orderbook, render_profile, render_quotes, render_risk, render_screener,
|
||||||
|
render_sentiment, render_technical, render_valuation,
|
||||||
};
|
};
|
||||||
use idx_core::analysis::BandarQuery;
|
use idx_core::analysis::BandarQuery;
|
||||||
use idx_core::analysis::bandar::DEFAULT_TOP;
|
use idx_core::analysis::bandar::DEFAULT_TOP;
|
||||||
use idx_core::api::types::{Interval, Period};
|
use idx_core::api::types::{Interval, Period};
|
||||||
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||||
|
use idx_core::corporate::types::{ActionKind, CalendarRange};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
|
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
|
||||||
use idx_core::flow::types::{FlowRange, FlowSource};
|
use idx_core::flow::types::{FlowRange, FlowSource};
|
||||||
|
|
@ -163,6 +165,43 @@ the output says so.",
|
||||||
#[arg(long, default_value_t = 10)]
|
#[arg(long, default_value_t = 10)]
|
||||||
depth: usize,
|
depth: usize,
|
||||||
},
|
},
|
||||||
|
#[command(
|
||||||
|
about = "Dividends, rights, splits and other corporate actions of a stock (Stockbit login)",
|
||||||
|
long_about = "One IDX stock's corporate actions from your own Stockbit session (`idx \
|
||||||
|
stockbit login`), newest first: cash and stock dividends, bonus shares, rights issues, \
|
||||||
|
warrants, splits and reverse splits, shareholders' meetings, public exposes and tender offers \
|
||||||
|
(`--kind ipo` adds its IPO). Stockbit serves one kind per request, 1 s apart, so the first \
|
||||||
|
call takes about a second per kind; answers are cached for 6 hours.",
|
||||||
|
after_help = "Examples:\n idx stocks actions BBCA\n idx stocks actions TLKM --kind dividend\n idx -o json stocks actions BBRI --kind dividend,split | jq '.[0]'"
|
||||||
|
)]
|
||||||
|
Actions {
|
||||||
|
/// Single ticker symbol (e.g. BBCA).
|
||||||
|
symbol: String,
|
||||||
|
/// Kinds to list (default: all but ipo).
|
||||||
|
#[arg(long = "kind", value_enum, value_delimiter = ',', num_args = 1..)]
|
||||||
|
kinds: Vec<ActionKind>,
|
||||||
|
},
|
||||||
|
#[command(
|
||||||
|
about = "Upcoming and recent corporate actions across the market (Stockbit login)",
|
||||||
|
long_about = "Corporate actions across IDX from your own Stockbit session (`idx stockbit \
|
||||||
|
login`), oldest first, dated within --from and --to (default: today and the next 30 days), \
|
||||||
|
plus rights trading, tender offers and IPO offers still open then. An action's date is its \
|
||||||
|
cum date for dividends, bonuses, rights and splits (its ex date when Stockbit gives no cum \
|
||||||
|
date), else the event's first day. Stockbit lists about a year back and what is announced ahead. One request per \
|
||||||
|
kind, 1 s apart; answers are cached for 6 hours.",
|
||||||
|
after_help = "Examples:\n idx stocks calendar\n idx stocks calendar --kind dividend,rights\n idx stocks calendar --from 2026-12-01 --to 2026-12-31 --kind meeting"
|
||||||
|
)]
|
||||||
|
Calendar {
|
||||||
|
/// First day, YYYY-MM-DD. Default: today.
|
||||||
|
#[arg(long)]
|
||||||
|
from: Option<String>,
|
||||||
|
/// Last day, YYYY-MM-DD. Default: 30 days after --from (at most 366).
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
/// Kinds to list (default: all).
|
||||||
|
#[arg(long = "kind", value_enum, value_delimiter = ',', num_args = 1..)]
|
||||||
|
kinds: Vec<ActionKind>,
|
||||||
|
},
|
||||||
#[command(
|
#[command(
|
||||||
about = "Get historical OHLC data",
|
about = "Get historical OHLC data",
|
||||||
after_help = "Examples:\n idx stocks history BBCA --period 3mo\n idx stocks history BBCA --period 1y --interval 1wk"
|
after_help = "Examples:\n idx stocks history BBCA --period 3mo\n idx stocks history BBCA --period 1y --interval 1wk"
|
||||||
|
|
@ -360,6 +399,27 @@ pub fn handle(
|
||||||
book.offers.truncate(*depth);
|
book.offers.truncate(*depth);
|
||||||
render_orderbook(&book, format, no_color)
|
render_orderbook(&book, format, no_color)
|
||||||
}
|
}
|
||||||
|
StocksSubcommand::Actions { symbol, kinds } => {
|
||||||
|
let kinds = if kinds.is_empty() {
|
||||||
|
ActionKind::STOCK_DEFAULT.to_vec()
|
||||||
|
} else {
|
||||||
|
kinds.clone()
|
||||||
|
};
|
||||||
|
let actions = idx.corporate().actions(symbol, &kinds, policy)?;
|
||||||
|
let resolved = idx_core::api::resolve_symbol(symbol, &config.core.exchange)?;
|
||||||
|
render_corporate_actions(&resolved, &actions.data, format)
|
||||||
|
}
|
||||||
|
StocksSubcommand::Calendar { from, to, kinds } => {
|
||||||
|
let range =
|
||||||
|
CalendarRange::from_args(from.as_deref(), to.as_deref(), idx.corporate().today())?;
|
||||||
|
let kinds = if kinds.is_empty() {
|
||||||
|
ActionKind::ALL.to_vec()
|
||||||
|
} else {
|
||||||
|
kinds.clone()
|
||||||
|
};
|
||||||
|
let actions = idx.corporate().calendar(range, &kinds, policy)?;
|
||||||
|
render_corporate_calendar(&range, &actions.data, format)
|
||||||
|
}
|
||||||
StocksSubcommand::History {
|
StocksSubcommand::History {
|
||||||
symbol,
|
symbol,
|
||||||
period,
|
period,
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,9 @@ fn run() -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
Commands::Ownership(cmd) => {
|
Commands::Ownership(cmd) => {
|
||||||
if let Err(err) = cli::ownership::handle(&cmd.command, &config) {
|
if let Err(err) =
|
||||||
|
cli::ownership::handle(&cmd.command, &config, cli.offline, cli.no_cache)
|
||||||
|
{
|
||||||
emit_error(&err, &config.output);
|
emit_error(&err, &config.output);
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,10 @@ use idx_core::api::types::{
|
||||||
};
|
};
|
||||||
use idx_core::broker::types::BrokerSummary;
|
use idx_core::broker::types::BrokerSummary;
|
||||||
use idx_core::config::ProviderKind;
|
use idx_core::config::ProviderKind;
|
||||||
|
use idx_core::corporate::types::{CalendarRange, CorporateAction};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::flow::types::ForeignFlow;
|
use idx_core::flow::types::ForeignFlow;
|
||||||
|
use idx_core::movements::types::MovementList;
|
||||||
|
|
||||||
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
||||||
|
|
@ -103,6 +105,53 @@ pub fn render_orderbook(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_corporate_actions(
|
||||||
|
symbol: &str,
|
||||||
|
actions: &[CorporateAction],
|
||||||
|
format: &OutputFormat,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => table::print_corporate_actions(symbol, actions),
|
||||||
|
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(actions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_corporate_calendar(
|
||||||
|
range: &CalendarRange,
|
||||||
|
actions: &[CorporateAction],
|
||||||
|
format: &OutputFormat,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => table::print_corporate_calendar(range, actions),
|
||||||
|
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(actions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON is `{movements, more}` (`more`: rows remain past the pages read), plus
|
||||||
|
/// `incomplete` when a later page failed and only the rows before it came back;
|
||||||
|
/// JSONL is one movement per line.
|
||||||
|
/// `pages` is how many pages the query asked for, for the hint when more
|
||||||
|
/// rows exist. JSONL streams the rows only, so the hint goes to stderr.
|
||||||
|
pub fn render_shareholder_movements(
|
||||||
|
symbol: Option<&str>,
|
||||||
|
list: &MovementList,
|
||||||
|
pages: u32,
|
||||||
|
format: &OutputFormat,
|
||||||
|
no_color: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => table::print_shareholder_movements(symbol, list, pages, no_color),
|
||||||
|
OutputFormat::Json => json::print_json(list),
|
||||||
|
OutputFormat::Jsonl => {
|
||||||
|
json::print_json(&list.movements)?;
|
||||||
|
if list.more {
|
||||||
|
idx_core::runtime::info(table::more_movements_hint(pages));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_broker_summary(
|
pub fn render_broker_summary(
|
||||||
summary: &BrokerSummary,
|
summary: &BrokerSummary,
|
||||||
format: &OutputFormat,
|
format: &OutputFormat,
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,12 @@ use idx_core::api::types::{
|
||||||
Intraday, NewsItem, Ohlc, OrderBook, Quote, SentimentData,
|
Intraday, NewsItem, Ohlc, OrderBook, Quote, SentimentData,
|
||||||
};
|
};
|
||||||
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
||||||
|
use idx_core::corporate::types::{ActionKind, CalendarRange, CorporateAction, DateRange};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::flow::types::{FlowSource, ForeignFlow};
|
use idx_core::flow::types::{FlowSource, ForeignFlow};
|
||||||
|
use idx_core::movements::types::{
|
||||||
|
MovementAction, MovementList, MovementSource, ShareholderMovement,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn format_idr(value: i64) -> String {
|
pub fn format_idr(value: i64) -> String {
|
||||||
let sign = if value.is_negative() { "-" } else { "" };
|
let sign = if value.is_negative() { "-" } else { "" };
|
||||||
|
|
@ -1673,15 +1677,382 @@ outside the regular board (negotiated and cash)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn action_kind_label(kind: ActionKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
ActionKind::Dividend => "dividend",
|
||||||
|
ActionKind::StockDividend => "stock dividend",
|
||||||
|
ActionKind::Bonus => "bonus",
|
||||||
|
ActionKind::Rights => "rights",
|
||||||
|
ActionKind::Warrant => "warrant",
|
||||||
|
ActionKind::Split => "split",
|
||||||
|
ActionKind::ReverseSplit => "reverse split",
|
||||||
|
ActionKind::Meeting => "meeting",
|
||||||
|
ActionKind::PublicExpose => "public expose",
|
||||||
|
ActionKind::Ipo => "IPO",
|
||||||
|
ActionKind::TenderOffer => "tender offer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IDR with thousands separators and up to two decimals: `1,191.09`, `3.8`.
|
||||||
|
fn format_rupiah(value: f64) -> String {
|
||||||
|
let cents = (value * 100.0).round() as i64;
|
||||||
|
let (whole, frac) = (cents / 100, (cents % 100).abs());
|
||||||
|
match frac {
|
||||||
|
0 => format_idr(whole),
|
||||||
|
f if f % 10 == 0 => format!("{}.{}", format_idr(whole), f / 10),
|
||||||
|
f => format!("{}.{f:02}", format_idr(whole)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another currency's amount with up to six decimals, since a dollar
|
||||||
|
/// dividend can be a fraction of a cent: `0.0012`, `1,250.5`.
|
||||||
|
fn format_foreign_amount(value: f64) -> String {
|
||||||
|
let micros = (value.abs() * 1_000_000.0).round() as u64;
|
||||||
|
let (whole, frac) = (micros / 1_000_000, micros % 1_000_000);
|
||||||
|
let sign = if value < 0.0 && micros > 0 { "-" } else { "" };
|
||||||
|
let whole = format_u64(whole);
|
||||||
|
if frac == 0 {
|
||||||
|
return format!("{sign}{whole}");
|
||||||
|
}
|
||||||
|
let frac = format!("{frac:06}");
|
||||||
|
format!("{sign}{whole}.{}", frac.trim_end_matches('0'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an action gives or asks, in one line.
|
||||||
|
fn action_detail(action: &CorporateAction) -> String {
|
||||||
|
let span = |range: Option<DateRange>| range.map(|r| format!("{} to {}", r.start, r.end));
|
||||||
|
let price = action.price.map(|p| format!("Rp {}", format_idr(p)));
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
match action.kind {
|
||||||
|
ActionKind::Dividend => {
|
||||||
|
let (unit, amount): (String, fn(f64) -> String) = match action.currency.as_deref() {
|
||||||
|
Some(currency) => (terminal_safe(currency), format_foreign_amount),
|
||||||
|
None => ("Rp".to_string(), format_rupiah),
|
||||||
|
};
|
||||||
|
parts.extend(
|
||||||
|
action
|
||||||
|
.cash_per_share
|
||||||
|
.map(|c| format!("{unit} {} per share", amount(c))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ActionKind::StockDividend | ActionKind::Bonus => {
|
||||||
|
parts.extend(
|
||||||
|
action
|
||||||
|
.ratio
|
||||||
|
.map(|r| format!("{} new per {} held", format_u64(r.new), format_u64(r.old))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ActionKind::Split | ActionKind::ReverseSplit => {
|
||||||
|
parts.extend(
|
||||||
|
action
|
||||||
|
.ratio
|
||||||
|
.map(|r| format!("{} becomes {}", format_u64(r.old), format_u64(r.new))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ActionKind::Rights => {
|
||||||
|
parts.extend(action.ratio.map(|r| {
|
||||||
|
format!(
|
||||||
|
"{} rights per {} held",
|
||||||
|
format_u64(r.new),
|
||||||
|
format_u64(r.old)
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
parts.extend(price.map(|p| format!("at {p}")));
|
||||||
|
parts.extend(span(action.period).map(|s| format!("trading {s}")));
|
||||||
|
}
|
||||||
|
ActionKind::Warrant => {
|
||||||
|
parts.extend(price.map(|p| format!("exercise at {p}")));
|
||||||
|
parts.extend(span(action.exercise).map(|s| format!("exercisable {s}")));
|
||||||
|
parts.extend(action.period.map(|r| format!("trades until {}", r.end)));
|
||||||
|
}
|
||||||
|
ActionKind::Meeting | ActionKind::PublicExpose => {
|
||||||
|
parts.extend(
|
||||||
|
action
|
||||||
|
.time
|
||||||
|
.as_deref()
|
||||||
|
.map(|t| format!("{} WIB", terminal_safe(t))),
|
||||||
|
);
|
||||||
|
parts.extend(
|
||||||
|
action
|
||||||
|
.venue
|
||||||
|
.as_deref()
|
||||||
|
.map(|v| truncate_chars(&terminal_safe(v), 60)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ActionKind::Ipo => {
|
||||||
|
parts.extend(price);
|
||||||
|
parts.extend(action.shares.map(|s| format!("{} shares", format_u64(s))));
|
||||||
|
parts.extend(span(action.period).map(|s| format!("offered {s}")));
|
||||||
|
}
|
||||||
|
ActionKind::TenderOffer => {
|
||||||
|
parts.extend(price);
|
||||||
|
parts.extend(action.shares.map(|s| {
|
||||||
|
let share = action
|
||||||
|
.percentage_bps
|
||||||
|
.map(|bps| format!(" ({}.{:02}%)", bps / 100, bps % 100))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("for {} shares{share}", format_u64(s))
|
||||||
|
}));
|
||||||
|
parts.extend(span(action.period).map(|s| format!("open {s}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
parts.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORPORATE_DATE_NOTE: &str = "DATE is the cum date for dividends, bonuses, rights and \
|
||||||
|
splits (buy by that day to be entitled); for other actions, the event's first day.";
|
||||||
|
const CORPORATE_EX_NOTE: &str = "A DATE marked (ex) is the ex date: Stockbit gives no cum \
|
||||||
|
date for it, and buying that day is too late to be entitled.";
|
||||||
|
|
||||||
|
/// An entitlement Stockbit gave no cum date for sits on its ex date.
|
||||||
|
fn on_ex_date(action: &CorporateAction) -> bool {
|
||||||
|
action.kind.is_entitlement() && action.cum_date.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_date_cell(action: &CorporateAction) -> Cell {
|
||||||
|
if on_ex_date(action) {
|
||||||
|
cell(format!("{} (ex)", action.date))
|
||||||
|
} else {
|
||||||
|
cell(action.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_corporate_notes(actions: &[CorporateAction]) {
|
||||||
|
println!("{CORPORATE_DATE_NOTE}");
|
||||||
|
if actions.iter().any(on_ex_date) {
|
||||||
|
println!("{CORPORATE_EX_NOTE}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opt_date(date: Option<chrono::NaiveDate>) -> String {
|
||||||
|
date.map(|d| d.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_corporate_actions(symbol: &str, actions: &[CorporateAction]) -> Result<(), IdxError> {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("Corporate actions for {}", terminal_safe(symbol)).bold()
|
||||||
|
);
|
||||||
|
if actions.is_empty() {
|
||||||
|
println!("None of these kinds on record.");
|
||||||
|
} else {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec!["DATE", "KIND", "EX", "RECORD", "PAY", "DETAIL"]);
|
||||||
|
for action in actions {
|
||||||
|
table.add_row(vec![
|
||||||
|
action_date_cell(action),
|
||||||
|
cell(action_kind_label(action.kind)),
|
||||||
|
cell(opt_date(action.ex_date)),
|
||||||
|
cell(opt_date(action.record_date)),
|
||||||
|
cell(opt_date(action.pay_date)),
|
||||||
|
cell(action_detail(action)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
print_corporate_notes(actions);
|
||||||
|
}
|
||||||
|
println!("{STOCKBIT_SESSION_NOTE}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_corporate_calendar(
|
||||||
|
range: &CalendarRange,
|
||||||
|
actions: &[CorporateAction],
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
// Offers still open are listed by the day they opened, which can be
|
||||||
|
// before `from`; the title says so rather than claim the range bounds
|
||||||
|
// every row.
|
||||||
|
let earlier = if actions.iter().any(|a| a.date < range.from) {
|
||||||
|
", with offers opened earlier and still open"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(
|
||||||
|
"Corporate actions from {} to {}{earlier} ({})",
|
||||||
|
range.from,
|
||||||
|
range.to,
|
||||||
|
actions.len()
|
||||||
|
)
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
if actions.is_empty() {
|
||||||
|
println!("None of these kinds in that range.");
|
||||||
|
} else {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec!["DATE", "SYMBOL", "KIND", "EX", "DETAIL"]);
|
||||||
|
for action in actions {
|
||||||
|
table.add_row(vec![
|
||||||
|
action_date_cell(action),
|
||||||
|
cell(action.symbol.trim_end_matches(".JK")),
|
||||||
|
cell(action_kind_label(action.kind)),
|
||||||
|
cell(opt_date(action.ex_date)),
|
||||||
|
cell(action_detail(action)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
print_corporate_notes(actions);
|
||||||
|
}
|
||||||
|
println!("{STOCKBIT_SESSION_NOTE}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn movement_action_label(action: MovementAction, no_color: bool) -> String {
|
||||||
|
let label = action.as_str().replace('_', " ");
|
||||||
|
match action {
|
||||||
|
_ if no_color => label,
|
||||||
|
MovementAction::Buy => label.green().to_string(),
|
||||||
|
MovementAction::Sell => label.red().to_string(),
|
||||||
|
_ => label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signed exact shares: `+147,933`, `-10,000,000`.
|
||||||
|
fn format_signed_shares(value: i64) -> String {
|
||||||
|
let text = format_idr(value);
|
||||||
|
if value > 0 { format!("+{text}") } else { text }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stake after the change and the change in percentage points:
|
||||||
|
/// `40.65% (-0.16)`. A stake that rounds to 0 basis points reads `<0.01%`.
|
||||||
|
fn movement_stake(movement: &ShareholderMovement) -> String {
|
||||||
|
let after = match movement.percentage_after_bps {
|
||||||
|
0 if movement.shares_after > 0 => "<0.01%".to_string(),
|
||||||
|
bps => format!("{:.2}%", bps as f64 / 100.0),
|
||||||
|
};
|
||||||
|
match movement.percentage_change_bps {
|
||||||
|
0 => after,
|
||||||
|
bps => format!("{after} ({:+.2})", bps as f64 / 100.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOVEMENT_NOTE: &str = "IDX rows are insider disclosures (directors, commissioners and \
|
||||||
|
other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated \
|
||||||
|
price. Rows appear days after the change.";
|
||||||
|
|
||||||
|
/// What to do when rows remain past the pages read.
|
||||||
|
pub fn more_movements_hint(pages: u32) -> String {
|
||||||
|
if pages >= idx_core::movements::types::MAX_PAGES {
|
||||||
|
"More rows exist: narrow --from/--to (10 pages is the most one query reads).".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"More rows exist: raise --pages (up to {}) or narrow --from/--to.",
|
||||||
|
idx_core::movements::types::MAX_PAGES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_shareholder_movements(
|
||||||
|
symbol: Option<&str>,
|
||||||
|
list: &MovementList,
|
||||||
|
pages: u32,
|
||||||
|
no_color: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
let title = match symbol {
|
||||||
|
Some(symbol) => format!("Shareholder movements for {}", terminal_safe(symbol)),
|
||||||
|
None => "Shareholder movements across IDX".to_string(),
|
||||||
|
};
|
||||||
|
println!("{}", format!("{title} ({})", list.movements.len()).bold());
|
||||||
|
if list.movements.is_empty() {
|
||||||
|
println!("No movements reported for this query.");
|
||||||
|
} else {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec![
|
||||||
|
"DATE",
|
||||||
|
"SYMBOL",
|
||||||
|
"HOLDER",
|
||||||
|
"ROLE",
|
||||||
|
"ACTION",
|
||||||
|
"SHARES",
|
||||||
|
"STAKE",
|
||||||
|
"PRICE",
|
||||||
|
"VALUE",
|
||||||
|
"SOURCE",
|
||||||
|
"HOLDER ID",
|
||||||
|
]);
|
||||||
|
for movement in &list.movements {
|
||||||
|
let estimated = if movement.price_estimated { "~" } else { "" };
|
||||||
|
table.add_row(vec![
|
||||||
|
cell(movement.date),
|
||||||
|
cell(&movement.symbol),
|
||||||
|
cell(&movement.holder),
|
||||||
|
cell(if movement.roles.is_empty() {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
movement.roles.join(", ")
|
||||||
|
}),
|
||||||
|
painted(movement_action_label(movement.action, no_color)),
|
||||||
|
cell(format_signed_shares(movement.shares_change)),
|
||||||
|
cell(movement_stake(movement)),
|
||||||
|
cell(
|
||||||
|
movement
|
||||||
|
.price
|
||||||
|
.map(|p| format!("{estimated}{}", format_idr(p)))
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
|
cell(
|
||||||
|
movement
|
||||||
|
.value
|
||||||
|
.map(|v| format!("{estimated}{}", format_compact(v)))
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
|
cell(match movement.source {
|
||||||
|
Some(MovementSource::Idx) => "IDX",
|
||||||
|
Some(MovementSource::Ksei) => "KSEI",
|
||||||
|
None => "-",
|
||||||
|
}),
|
||||||
|
cell(&movement.holder_id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
println!("{MOVEMENT_NOTE}");
|
||||||
|
}
|
||||||
|
if let Some(reason) = &list.incomplete {
|
||||||
|
println!(
|
||||||
|
"Reading stopped early ({}); these are the rows read before it.",
|
||||||
|
terminal_safe(reason)
|
||||||
|
);
|
||||||
|
} else if list.more {
|
||||||
|
println!("{}", more_movements_hint(pages));
|
||||||
|
}
|
||||||
|
println!("{STOCKBIT_SESSION_NOTE}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
format_earnings_period, format_idr, format_signal, format_table_date, format_u64,
|
format_earnings_period, format_foreign_amount, format_idr, format_rupiah, format_signal,
|
||||||
humanize_metric_key, stale_quote_notes,
|
format_table_date, format_u64, humanize_metric_key, stale_quote_notes,
|
||||||
};
|
};
|
||||||
use idx_core::analysis::signals::Signal;
|
use idx_core::analysis::signals::Signal;
|
||||||
use idx_core::api::types::Quote;
|
use idx_core::api::types::Quote;
|
||||||
|
|
||||||
|
/// Rupiah to the sen; another currency keeps a fraction of a cent.
|
||||||
|
#[test]
|
||||||
|
fn dividend_amounts_keep_their_decimals() {
|
||||||
|
assert_eq!(format_rupiah(1191.09), "1,191.09");
|
||||||
|
assert_eq!(format_rupiah(3.8), "3.8");
|
||||||
|
assert_eq!(format_foreign_amount(0.0012), "0.0012");
|
||||||
|
assert_eq!(format_foreign_amount(1250.5), "1,250.5");
|
||||||
|
assert_eq!(format_foreign_amount(2.0), "2");
|
||||||
|
}
|
||||||
|
|
||||||
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
|
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
|
||||||
Quote {
|
Quote {
|
||||||
symbol: symbol.into(),
|
symbol: symbol.into(),
|
||||||
|
|
|
||||||
|
|
@ -2239,6 +2239,33 @@ fn auto_history_falls_back_to_yahoo_when_the_session_is_refused() {
|
||||||
let _ = fs::remove_dir_all(root);
|
let _ = fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A page after the first that fails keeps the rows read before it, says
|
||||||
|
/// why, and is not cached; a query's pages are cached as one answer.
|
||||||
|
#[test]
|
||||||
|
fn movements_keep_the_pages_read_when_a_later_one_fails() {
|
||||||
|
let root = test_env_dir("movements-partial");
|
||||||
|
let run = |fail_page: Option<&str>| {
|
||||||
|
let mut cmd = mock_bin_with_root(&root);
|
||||||
|
cmd.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
|
||||||
|
.args(["-o", "json", "ownership", "movements", "--pages", "2"]);
|
||||||
|
if let Some(page) = fail_page {
|
||||||
|
cmd.env("IDX_MOCK_MOVEMENTS_FAIL_PAGE", page);
|
||||||
|
}
|
||||||
|
run_success_json(&mut cmd)
|
||||||
|
};
|
||||||
|
let partial = run(Some("2"));
|
||||||
|
assert_eq!(partial["movements"].as_array().unwrap().len(), 8);
|
||||||
|
assert_eq!(partial["more"], true);
|
||||||
|
assert!(partial["incomplete"].is_string(), "{partial}");
|
||||||
|
// Not cached: the next run reads both pages.
|
||||||
|
let full = run(None);
|
||||||
|
assert_eq!(full["movements"].as_array().unwrap().len(), 14);
|
||||||
|
assert!(full.get("incomplete").is_none());
|
||||||
|
// Cached as one answer: a failing page 2 now changes nothing.
|
||||||
|
assert_eq!(run(Some("2")), full);
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
/// A test session, signed by nobody, that expires in 2100.
|
/// A test session, signed by nobody, that expires in 2100.
|
||||||
const FAKE_STOCKBIT_TOKEN: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln";
|
const FAKE_STOCKBIT_TOKEN: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln";
|
||||||
|
|
||||||
|
|
@ -3266,3 +3293,24 @@ fn bandar_never_reads_a_broker_summary_cached_in_the_old_shape() {
|
||||||
assert!(!table.contains("No trades."), "{table}");
|
assert!(!table.contains("No trades."), "{table}");
|
||||||
assert!(table.contains("big distribution"), "{table}");
|
assert!(table.contains("big distribution"), "{table}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `--kind` takes the names JSON carried before they matched it, so a kind
|
||||||
|
/// copied from an older answer works here as it does over MCP.
|
||||||
|
#[test]
|
||||||
|
fn actions_kind_takes_the_old_json_names() {
|
||||||
|
let root = test_env_dir("actions-old-kinds");
|
||||||
|
let rows = run_success_json(
|
||||||
|
mock_bin_with_root(&root)
|
||||||
|
.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
|
||||||
|
.args(["-o", "json", "stocks", "actions", "BBCA", "--kind"])
|
||||||
|
.arg("stock_dividend,reverse_split,public_expose,tender_offer"),
|
||||||
|
);
|
||||||
|
let kinds: Vec<&str> = rows
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|row| row["kind"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert!(kinds.contains(&"expose"), "{kinds:?}");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,30 @@ env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0OD
|
||||||
name = "stockbit-status-logged-out"
|
name = "stockbit-status-logged-out"
|
||||||
args = ["stockbit", "status"]
|
args = ["stockbit", "status"]
|
||||||
|
|
||||||
|
# Corporate actions: BBCA's recorded dividends, splits, meetings and exposes
|
||||||
|
# (no fixture for a kind means none, as Stockbit answers); the calendar reads
|
||||||
|
# the market-wide recordings of 2026-11-03.
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-actions"
|
||||||
|
args = ["stocks", "actions", "BBCA", "--kind", "split,meeting,expose"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-actions-dividends"
|
||||||
|
args = ["stocks", "actions", "BBCA", "--kind", "dividend"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-calendar"
|
||||||
|
args = ["stocks", "calendar", "--to", "2026-11-12", "--kind", "dividend,bonus,rights,warrant,split,ipo,tender"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", IDX_MOCK_TODAY = "2026-11-03" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-calendar-meetings"
|
||||||
|
args = ["stocks", "calendar", "--from", "2026-11-05", "--to", "2026-11-06", "--kind", "meeting,expose"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", IDX_MOCK_TODAY = "2026-11-03" }
|
||||||
|
|
||||||
# --- stocks: broker summaries (IPOT) and foreign flow (IDX) --------------------
|
# --- stocks: broker summaries (IPOT) and foreign flow (IDX) --------------------
|
||||||
|
|
||||||
[[case]]
|
[[case]]
|
||||||
|
|
@ -392,6 +416,24 @@ args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"]
|
||||||
name = "error-orderbook-no-session"
|
name = "error-orderbook-no-session"
|
||||||
args = ["stocks", "orderbook", "BBCA"]
|
args = ["stocks", "orderbook", "BBCA"]
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
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"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", IDX_MOCK_TODAY = "2026-11-03" }
|
||||||
|
|
||||||
[[case]]
|
[[case]]
|
||||||
name = "error-stockbit-history-window"
|
name = "error-stockbit-history-window"
|
||||||
args = ["stocks", "history", "BBCA", "--period", "5y", "--history-provider", "stockbit"]
|
args = ["stocks", "history", "BBCA", "--period", "5y", "--history-provider", "stockbit"]
|
||||||
|
|
@ -399,6 +441,21 @@ env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0OD
|
||||||
|
|
||||||
# --- ownership --------------------------------------------------------------------
|
# --- ownership --------------------------------------------------------------------
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "ownership-movements"
|
||||||
|
args = ["ownership", "movements", "--pages", "2"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "ownership-movements-bbca"
|
||||||
|
args = ["ownership", "movements", "BBCA"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "ownership-movements-ksei-sells"
|
||||||
|
args = ["ownership", "movements", "--action", "sell", "--source", "ksei", "--pages", "3"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
[[case]]
|
[[case]]
|
||||||
name = "ownership-releases-empty"
|
name = "ownership-releases-empty"
|
||||||
args = ["ownership", "releases"]
|
args = ["ownership", "releases"]
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,9 @@ _idx() {
|
||||||
idx__help__ownership,import)
|
idx__help__ownership,import)
|
||||||
cmd="idx__help__ownership__import"
|
cmd="idx__help__ownership__import"
|
||||||
;;
|
;;
|
||||||
|
idx__help__ownership,movements)
|
||||||
|
cmd="idx__help__ownership__movements"
|
||||||
|
;;
|
||||||
idx__help__ownership,releases)
|
idx__help__ownership,releases)
|
||||||
cmd="idx__help__ownership__releases"
|
cmd="idx__help__ownership__releases"
|
||||||
;;
|
;;
|
||||||
|
|
@ -189,12 +192,18 @@ _idx() {
|
||||||
idx__help__stockbit,status)
|
idx__help__stockbit,status)
|
||||||
cmd="idx__help__stockbit__status"
|
cmd="idx__help__stockbit__status"
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks,actions)
|
||||||
|
cmd="idx__help__stocks__actions"
|
||||||
|
;;
|
||||||
idx__help__stocks,bandar)
|
idx__help__stocks,bandar)
|
||||||
cmd="idx__help__stocks__bandar"
|
cmd="idx__help__stocks__bandar"
|
||||||
;;
|
;;
|
||||||
idx__help__stocks,broker)
|
idx__help__stocks,broker)
|
||||||
cmd="idx__help__stocks__broker"
|
cmd="idx__help__stocks__broker"
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks,calendar)
|
||||||
|
cmd="idx__help__stocks__calendar"
|
||||||
|
;;
|
||||||
idx__help__stocks,compare)
|
idx__help__stocks,compare)
|
||||||
cmd="idx__help__stocks__compare"
|
cmd="idx__help__stocks__compare"
|
||||||
;;
|
;;
|
||||||
|
|
@ -276,6 +285,9 @@ _idx() {
|
||||||
idx__ownership,import)
|
idx__ownership,import)
|
||||||
cmd="idx__ownership__import"
|
cmd="idx__ownership__import"
|
||||||
;;
|
;;
|
||||||
|
idx__ownership,movements)
|
||||||
|
cmd="idx__ownership__movements"
|
||||||
|
;;
|
||||||
idx__ownership,releases)
|
idx__ownership,releases)
|
||||||
cmd="idx__ownership__releases"
|
cmd="idx__ownership__releases"
|
||||||
;;
|
;;
|
||||||
|
|
@ -318,6 +330,9 @@ _idx() {
|
||||||
idx__ownership__help,import)
|
idx__ownership__help,import)
|
||||||
cmd="idx__ownership__help__import"
|
cmd="idx__ownership__help__import"
|
||||||
;;
|
;;
|
||||||
|
idx__ownership__help,movements)
|
||||||
|
cmd="idx__ownership__help__movements"
|
||||||
|
;;
|
||||||
idx__ownership__help,releases)
|
idx__ownership__help,releases)
|
||||||
cmd="idx__ownership__help__releases"
|
cmd="idx__ownership__help__releases"
|
||||||
;;
|
;;
|
||||||
|
|
@ -390,12 +405,18 @@ _idx() {
|
||||||
idx__stockbit__help,status)
|
idx__stockbit__help,status)
|
||||||
cmd="idx__stockbit__help__status"
|
cmd="idx__stockbit__help__status"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks,actions)
|
||||||
|
cmd="idx__stocks__actions"
|
||||||
|
;;
|
||||||
idx__stocks,bandar)
|
idx__stocks,bandar)
|
||||||
cmd="idx__stocks__bandar"
|
cmd="idx__stocks__bandar"
|
||||||
;;
|
;;
|
||||||
idx__stocks,broker)
|
idx__stocks,broker)
|
||||||
cmd="idx__stocks__broker"
|
cmd="idx__stocks__broker"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks,calendar)
|
||||||
|
cmd="idx__stocks__calendar"
|
||||||
|
;;
|
||||||
idx__stocks,compare)
|
idx__stocks,compare)
|
||||||
cmd="idx__stocks__compare"
|
cmd="idx__stocks__compare"
|
||||||
;;
|
;;
|
||||||
|
|
@ -453,12 +474,18 @@ _idx() {
|
||||||
idx__stocks,valuation)
|
idx__stocks,valuation)
|
||||||
cmd="idx__stocks__valuation"
|
cmd="idx__stocks__valuation"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help,actions)
|
||||||
|
cmd="idx__stocks__help__actions"
|
||||||
|
;;
|
||||||
idx__stocks__help,bandar)
|
idx__stocks__help,bandar)
|
||||||
cmd="idx__stocks__help__bandar"
|
cmd="idx__stocks__help__bandar"
|
||||||
;;
|
;;
|
||||||
idx__stocks__help,broker)
|
idx__stocks__help,broker)
|
||||||
cmd="idx__stocks__help__broker"
|
cmd="idx__stocks__help__broker"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help,calendar)
|
||||||
|
cmd="idx__stocks__help__calendar"
|
||||||
|
;;
|
||||||
idx__stocks__help,compare)
|
idx__stocks__help,compare)
|
||||||
cmd="idx__stocks__help__compare"
|
cmd="idx__stocks__help__compare"
|
||||||
;;
|
;;
|
||||||
|
|
@ -1077,7 +1104,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__help__ownership)
|
idx__help__ownership)
|
||||||
opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases"
|
opts="discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1202,6 +1229,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__help__ownership__movements)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__help__ownership__releases)
|
idx__help__ownership__releases)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -1371,7 +1412,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__help__stocks)
|
idx__help__stocks)
|
||||||
opts="quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare"
|
opts="quote intraday orderbook actions calendar history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1384,6 +1425,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks__actions)
|
||||||
|
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__stocks__bandar)
|
idx__help__stocks__bandar)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -1412,6 +1467,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks__calendar)
|
||||||
|
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__stocks__compare)
|
idx__help__stocks__compare)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -1679,7 +1748,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__ownership)
|
idx__ownership)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1927,7 +1996,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__ownership__help)
|
idx__ownership__help)
|
||||||
opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help"
|
opts="discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2066,6 +2135,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__ownership__help__movements)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__ownership__help__releases)
|
idx__ownership__help__releases)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -2216,6 +2299,56 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__ownership__movements)
|
||||||
|
opts="-o -q -v -h --from --to --holder --action --source --pages --output --no-color --quiet --verbose --provider --offline --no-cache --help [SYMBOL]"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--from)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--to)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--holder)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--action)
|
||||||
|
COMPREPLY=($(compgen -W "buy sell transfer cross corporate-action" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--source)
|
||||||
|
COMPREPLY=($(compgen -W "idx ksei" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--pages)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--provider)
|
||||||
|
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__ownership__releases)
|
idx__ownership__releases)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
@ -2689,7 +2822,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__stocks)
|
idx__stocks)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday orderbook actions calendar history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2714,6 +2847,36 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__actions)
|
||||||
|
opts="-o -q -v -h --kind --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
|
||||||
|
--kind)
|
||||||
|
COMPREPLY=($(compgen -W "dividend stock-dividend bonus rights warrant split reverse-split meeting expose ipo tender" -- "${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__stocks__bandar)
|
idx__stocks__bandar)
|
||||||
opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
|
opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
@ -2810,6 +2973,44 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__calendar)
|
||||||
|
opts="-o -q -v -h --from --to --kind --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||||
|
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
|
||||||
|
;;
|
||||||
|
--kind)
|
||||||
|
COMPREPLY=($(compgen -W "dividend stock-dividend bonus rights warrant split reverse-split meeting expose ipo tender" -- "${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__stocks__compare)
|
idx__stocks__compare)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
@ -2987,7 +3188,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__stocks__help)
|
idx__stocks__help)
|
||||||
opts="quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
opts="quote intraday orderbook actions calendar history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -3000,6 +3201,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help__actions)
|
||||||
|
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__stocks__help__bandar)
|
idx__stocks__help__bandar)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -3028,6 +3243,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help__calendar)
|
||||||
|
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__stocks__help__compare)
|
idx__stocks__help__compare)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 2
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
{"code":"INVALIDINPUT","error":true,"message":"invalid input: to (2026-11-01) is before from (2026-11-10)"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 2
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: invalid input: to (2026-11-01) is before from (2026-11-10)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 4
|
exit: 4
|
||||||
--- stdout
|
--- stdout
|
||||||
--- stderr
|
--- stderr
|
||||||
{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit"}
|
{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 4
|
exit: 4
|
||||||
--- stdout
|
--- stdout
|
||||||
--- stderr
|
--- stderr
|
||||||
Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit
|
Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 2
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
{"code":"INVALIDINPUT","error":true,"message":"invalid input: holder 'RUDI' is not a holder id; ids are digits, shown in the HOLDER ID column and as holder_id in JSON"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 2
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: invalid input: holder 'RUDI' is not a holder id; ids are digits, shown in the HOLDER ID column and as holder_id in JSON
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 4
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
{"code":"AUTHERROR","error":true,"message":"auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 4
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)
|
||||||
|
|
@ -20,6 +20,7 @@ Commands:
|
||||||
concentration Rank tickers by ownership concentration
|
concentration Rank tickers by ownership concentration
|
||||||
flow Show Bing institutional flow for a ticker
|
flow Show Bing institutional flow for a ticker
|
||||||
changes Diff two KSEI releases
|
changes Diff two KSEI releases
|
||||||
|
movements Insider and major holder buys and sells, as reported (Stockbit login)
|
||||||
graph Ownership network graph
|
graph Ownership network graph
|
||||||
resolve Manual entity resolution workflow
|
resolve Manual entity resolution workflow
|
||||||
releases List imported KSEI releases
|
releases List imported KSEI releases
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ Commands:
|
||||||
quote Get real-time stock quotes
|
quote Get real-time stock quotes
|
||||||
intraday Today's session price by the minute (Stockbit)
|
intraday Today's session price by the minute (Stockbit)
|
||||||
orderbook Order book depth and foreign flow (Stockbit login)
|
orderbook Order book depth and foreign flow (Stockbit login)
|
||||||
|
actions Dividends, rights, splits and other corporate actions of a stock (Stockbit login)
|
||||||
|
calendar Upcoming and recent corporate actions across the market (Stockbit login)
|
||||||
history Get historical OHLC data
|
history Get historical OHLC data
|
||||||
technical Run technical analysis on a stock
|
technical Run technical analysis on a stock
|
||||||
growth Run growth analysis on a stock
|
growth Run growth analysis on a stock
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,83 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Corporate actions for BBCA.JK
|
||||||
|
┌────────────┬──────────┬────────────┬────────────┬────────────┬────────────────────┐
|
||||||
|
│ DATE ┆ KIND ┆ EX ┆ RECORD ┆ PAY ┆ DETAIL │
|
||||||
|
╞════════════╪══════════╪════════════╪════════════╪════════════╪════════════════════╡
|
||||||
|
│ 2026-08-28 ┆ dividend ┆ 2026-08-31 ┆ 2026-09-01 ┆ 2026-09-16 ┆ Rp 25 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-06-15 ┆ dividend ┆ 2026-06-17 ┆ 2026-06-18 ┆ 2026-06-26 ┆ Rp 20 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-27 ┆ dividend ┆ 2026-03-30 ┆ 2026-03-31 ┆ 2026-04-08 ┆ Rp 281 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2025-12-02 ┆ dividend ┆ 2025-12-03 ┆ 2025-12-04 ┆ 2025-12-22 ┆ Rp 55 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2025-03-20 ┆ dividend ┆ 2025-03-21 ┆ 2025-03-24 ┆ 2025-04-11 ┆ Rp 250 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2024-11-20 ┆ dividend ┆ 2024-11-21 ┆ 2024-11-22 ┆ 2024-12-11 ┆ Rp 50 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2024-03-22 ┆ dividend ┆ 2024-03-25 ┆ 2024-03-26 ┆ 2024-04-04 ┆ Rp 227.5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2023-12-01 ┆ dividend ┆ 2023-12-04 ┆ 2023-12-05 ┆ 2023-12-20 ┆ Rp 42.5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2023-03-28 ┆ dividend ┆ 2023-03-29 ┆ 2023-03-30 ┆ 2023-04-14 ┆ Rp 170 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2022-12-01 ┆ dividend ┆ 2022-12-02 ┆ 2022-12-05 ┆ 2022-12-20 ┆ Rp 35 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2022-03-25 ┆ dividend ┆ 2022-03-28 ┆ 2022-03-29 ┆ 2022-04-19 ┆ Rp 120 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-11-16 ┆ dividend ┆ 2021-11-17 ┆ 2021-11-18 ┆ 2021-12-07 ┆ Rp 25 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-04-07 ┆ dividend ┆ 2021-04-08 ┆ 2021-04-09 ┆ 2021-04-28 ┆ Rp 432 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2020-12-07 ┆ dividend ┆ 2020-12-08 ┆ 2020-12-10 ┆ 2020-12-22 ┆ Rp 98 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2020-04-20 ┆ dividend ┆ 2020-04-21 ┆ 2020-04-22 ┆ 2020-05-11 ┆ Rp 455 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2019-12-05 ┆ dividend ┆ 2019-12-06 ┆ 2019-12-09 ┆ 2019-12-20 ┆ Rp 100 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2019-04-23 ┆ dividend ┆ 2019-04-24 ┆ 2019-04-25 ┆ 2019-04-30 ┆ Rp 255 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2018-11-30 ┆ dividend ┆ 2018-12-03 ┆ 2018-12-04 ┆ 2018-12-21 ┆ Rp 85 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2018-04-12 ┆ dividend ┆ 2018-04-13 ┆ 2018-04-17 ┆ 2018-04-30 ┆ Rp 175 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2017-11-28 ┆ dividend ┆ 2017-11-29 ┆ 2017-12-04 ┆ 2017-12-20 ┆ Rp 80 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2017-04-13 ┆ dividend ┆ 2017-04-17 ┆ 2017-04-20 ┆ 2017-04-28 ┆ Rp 130 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2016-11-30 ┆ dividend ┆ 2016-12-01 ┆ 2016-12-05 ┆ 2016-12-22 ┆ Rp 70 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2016-04-14 ┆ dividend ┆ 2016-04-15 ┆ 2016-04-19 ┆ 2016-04-29 ┆ Rp 105 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2015-11-12 ┆ dividend ┆ 2015-11-13 ┆ 2015-11-17 ┆ 2015-12-09 ┆ Rp 55 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2015-04-16 ┆ dividend ┆ 2015-04-17 ┆ 2015-04-21 ┆ 2015-05-13 ┆ Rp 98 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2014-12-04 ┆ dividend ┆ 2014-12-05 ┆ 2014-12-09 ┆ 2014-12-23 ┆ Rp 50 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2014-04-29 ┆ dividend ┆ 2014-04-30 ┆ 2014-05-05 ┆ 2014-05-20 ┆ Rp 75 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2013-11-28 ┆ dividend ┆ 2013-11-29 ┆ 2013-12-03 ┆ 2013-12-17 ┆ Rp 45 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2013-05-28 ┆ dividend ┆ 2013-05-29 ┆ 2013-05-31 ┆ 2013-06-17 ┆ Rp 71 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2012-12-03 ┆ dividend ┆ 2012-12-04 ┆ 2012-12-06 ┆ 2012-12-20 ┆ Rp 43.5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2012-06-08 ┆ dividend ┆ 2012-06-11 ┆ 2012-06-13 ┆ 2012-06-27 ┆ Rp 70 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2011-12-06 ┆ dividend ┆ 2011-12-07 ┆ 2011-12-09 ┆ 2011-12-23 ┆ Rp 43.5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2011-06-06 ┆ dividend ┆ 2011-06-07 ┆ 2011-06-09 ┆ 2011-06-23 ┆ Rp 70 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2010-11-19 ┆ dividend ┆ 2010-11-22 ┆ 2010-11-24 ┆ 2010-12-09 ┆ Rp 42.5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2010-05-31 ┆ dividend ┆ 2010-06-01 ┆ 2010-06-03 ┆ 2010-06-17 ┆ Rp 70 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2009-11-12 ┆ dividend ┆ 2009-11-13 ┆ 2009-11-17 ┆ 2009-12-02 ┆ Rp 40 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2009-06-09 ┆ dividend ┆ 2009-06-10 ┆ 2009-06-12 ┆ 2009-06-26 ┆ Rp 65 per share │
|
||||||
|
└────────────┴──────────┴────────────┴────────────┴────────────┴────────────────────┘
|
||||||
|
DATE is the cum date for dividends, bonuses, rights and splits (buy by that day to be entitled); for other actions, the event's first day.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
4
crates/idx-cli/tests/golden/stockbit-actions.json.golden
Normal file
4
crates/idx-cli/tests/golden/stockbit-actions.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
[{"symbol":"BBCA.JK","kind":"expose","date":"2026-09-09","time":"16:00","venue":"Public Expose Live 2026 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2026-03-12","record_date":"2026-02-09","time":"14:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 10310"},{"symbol":"BBCA.JK","kind":"expose","date":"2025-09-11","time":"16:00","venue":"Public Expose Live 2025 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2025-03-12","record_date":"2025-02-07","time":"09:30","venue":"Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta, 10310"},{"symbol":"BBCA.JK","kind":"expose","date":"2024-08-28","time":"15:00","venue":"Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan pendaftaran peserta di website www.idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2024-03-14","record_date":"2024-02-07","time":"09:30","venue":"Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta Selatan"},{"symbol":"BBCA.JK","kind":"expose","date":"2023-11-29","time":"10:00","venue":"Public Expose Live 2023"},{"symbol":"BBCA.JK","kind":"meeting","date":"2023-03-16","record_date":"2023-02-13","time":"09:30","venue":"Menara BCA"},{"symbol":"BBCA.JK","kind":"expose","date":"2022-09-14","time":"10:00","venue":"Public Expose Live 2022"},{"symbol":"BBCA.JK","kind":"meeting","date":"2022-03-17","record_date":"2022-02-14","time":"09:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2021-10-12","cum_date":"2021-10-12","ex_date":"2021-10-13","record_date":"2021-10-14","ratio":{"old":1,"new":5},"factor":5.0},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-09-23","record_date":"2021-08-27","time":"10:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"expose","date":"2021-09-08","time":"15:00","venue":"Public Expose Live 2021"},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-03-29","record_date":"2021-02-24","time":"09:00","venue":"Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Jakarta"},{"symbol":"BBCA.JK","kind":"meeting","date":"2020-07-30","record_date":"2020-07-03","time":"10:00","venue":"Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2008-01-25","cum_date":"2008-01-25","ex_date":"2008-01-28","record_date":"2008-01-30","ratio":{"old":1,"new":2},"factor":2.0},{"symbol":"BBCA.JK","kind":"split","date":"2004-06-07","cum_date":"2004-06-07","ex_date":"2004-06-08","record_date":"2004-06-10","ratio":{"old":1,"new":2},"factor":2.0}]
|
||||||
|
--- stderr
|
||||||
43
crates/idx-cli/tests/golden/stockbit-actions.table.golden
Normal file
43
crates/idx-cli/tests/golden/stockbit-actions.table.golden
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Corporate actions for BBCA.JK
|
||||||
|
┌────────────┬───────────────┬────────────┬────────────┬─────┬────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DATE ┆ KIND ┆ EX ┆ RECORD ┆ PAY ┆ DETAIL │
|
||||||
|
╞════════════╪═══════════════╪════════════╪════════════╪═════╪════════════════════════════════════════════════════════════════════════════╡
|
||||||
|
│ 2026-09-09 ┆ public expose ┆ - ┆ - ┆ - ┆ 16:00 WIB, Public Expose Live 2026 yang tercantum pada website idx.co.i... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-12 ┆ meeting ┆ - ┆ 2026-02-09 ┆ - ┆ 14:00 WIB, Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 1... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2025-09-11 ┆ public expose ┆ - ┆ - ┆ - ┆ 16:00 WIB, Public Expose Live 2025 yang tercantum pada website idx.co.i... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2025-03-12 ┆ meeting ┆ - ┆ 2025-02-07 ┆ - ┆ 09:30 WIB, Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2024-08-28 ┆ public expose ┆ - ┆ - ┆ - ┆ 15:00 WIB, Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2024-03-14 ┆ meeting ┆ - ┆ 2024-02-07 ┆ - ┆ 09:30 WIB, Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2023-11-29 ┆ public expose ┆ - ┆ - ┆ - ┆ 10:00 WIB, Public Expose Live 2023 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2023-03-16 ┆ meeting ┆ - ┆ 2023-02-13 ┆ - ┆ 09:30 WIB, Menara BCA │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2022-09-14 ┆ public expose ┆ - ┆ - ┆ - ┆ 10:00 WIB, Public Expose Live 2022 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2022-03-17 ┆ meeting ┆ - ┆ 2022-02-14 ┆ - ┆ 09:00 WIB, Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-10-12 ┆ split ┆ 2021-10-13 ┆ 2021-10-14 ┆ - ┆ 1 becomes 5 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-09-23 ┆ meeting ┆ - ┆ 2021-08-27 ┆ - ┆ 10:00 WIB, Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-09-08 ┆ public expose ┆ - ┆ - ┆ - ┆ 15:00 WIB, Public Expose Live 2021 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-03-29 ┆ meeting ┆ - ┆ 2021-02-24 ┆ - ┆ 09:00 WIB, Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Ja... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2020-07-30 ┆ meeting ┆ - ┆ 2020-07-03 ┆ - ┆ 10:00 WIB, Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. ... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2008-01-25 ┆ split ┆ 2008-01-28 ┆ 2008-01-30 ┆ - ┆ 1 becomes 2 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2004-06-07 ┆ split ┆ 2004-06-08 ┆ 2004-06-10 ┆ - ┆ 1 becomes 2 │
|
||||||
|
└────────────┴───────────────┴────────────┴────────────┴─────┴────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
DATE is the cum date for dividends, bonuses, rights and splits (buy by that day to be entitled); for other actions, the event's first day.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
[{"symbol":"AUTO.JK","kind":"expose","date":"2026-11-05","time":"10:00","venue":"Zoom Webinar"},{"symbol":"BATA.JK","kind":"meeting","date":"2026-11-05","record_date":"2026-10-09","time":"14:00","venue":"Gedung Ventura, lantai 7. Jl. Raden Ajeng Kartini No.26, Cilandak Barat, Jakarta Selatan"},{"symbol":"AMAG.JK","kind":"expose","date":"2026-11-06","time":"15:00","venue":"Virtual Zoom Webinar, di PT Asuransi Multi Artha Guna Tbk, Gedung TCC Batavia Tower One, Jalan KH Mas Mansyur Kav 126, Jakarta Pusat"},{"symbol":"BRIS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Jakarta Pusat, melalui fasilitas Electronic General Meeting System KSEI (eASY.KSEI) dalam tautan https//akses.ksei.co.id"},{"symbol":"GIAA.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"Secara elektronik melalui sistem eASY.KSEI (https//akses.ksei.co.id/)"},{"symbol":"INPS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-07","time":"14:00","venue":"Gedung Inprase, Jl. Sunter Garden Raya Blok D8 No. 3G-3H, Sunter Agung, Tanjung Priok, Jakarta Utara, DKI Jakarta 14350"},{"symbol":"KDTN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"PT Puri Sentul Permai Tbk diumumkan dan sesuai iklan)"},{"symbol":"MTFN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5, Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"MTFN.JK","kind":"expose","date":"2026-11-06","time":"11:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5 Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"ULTJ.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-08-31","time":"10:00","venue":"Kantor Perseroan, Jalan Raya Cimareme No 131 Bandung Barat, Jawa Barat Indonesia"}]
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Corporate actions from 2026-11-05 to 2026-11-06 (10)
|
||||||
|
┌────────────┬────────┬───────────────┬────┬────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DATE ┆ SYMBOL ┆ KIND ┆ EX ┆ DETAIL │
|
||||||
|
╞════════════╪════════╪═══════════════╪════╪════════════════════════════════════════════════════════════════════════════╡
|
||||||
|
│ 2026-11-05 ┆ AUTO ┆ public expose ┆ - ┆ 10:00 WIB, Zoom Webinar │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-05 ┆ BATA ┆ meeting ┆ - ┆ 14:00 WIB, Gedung Ventura, lantai 7. Jl. Raden Ajeng Kartini No.26, Cil... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ AMAG ┆ public expose ┆ - ┆ 15:00 WIB, Virtual Zoom Webinar, di PT Asuransi Multi Artha Guna Tbk, G... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ BRIS ┆ meeting ┆ - ┆ 10:00 WIB, Jakarta Pusat, melalui fasilitas Electronic General Meeting ... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ GIAA ┆ meeting ┆ - ┆ 14:00 WIB, Secara elektronik melalui sistem eASY.KSEI (https//akses.kse... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ INPS ┆ meeting ┆ - ┆ 14:00 WIB, Gedung Inprase, Jl. Sunter Garden Raya Blok D8 No. 3G-3H, Su... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ KDTN ┆ meeting ┆ - ┆ 14:00 WIB, PT Puri Sentul Permai Tbk diumumkan dan sesuai iklan) │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ MTFN ┆ meeting ┆ - ┆ 10:00 WIB, Kantor Taman E3.3, Unit D3A, Lantai 5, Jl. DR. Ide Anak Agun... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ MTFN ┆ public expose ┆ - ┆ 11:00 WIB, Kantor Taman E3.3, Unit D3A, Lantai 5 Jl. DR. Ide Anak Agung... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ ULTJ ┆ meeting ┆ - ┆ 10:00 WIB, Kantor Perseroan, Jalan Raya Cimareme No 131 Bandung Barat, ... │
|
||||||
|
└────────────┴────────┴───────────────┴────┴────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
DATE is the cum date for dividends, bonuses, rights and splits (buy by that day to be entitled); for other actions, the event's first day.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
[{"symbol":"DOOH.JK","kind":"tender","date":"2026-10-05","pay_date":"2026-11-09","price":148,"period":{"start":"2026-10-05","end":"2026-11-03"},"shares":2624548624,"percentage_bps":3391,"name":"Era Media Sejahtera Tbk."},{"symbol":"NAYZ.JK","kind":"tender","date":"2026-10-05","pay_date":"2026-11-10","price":75,"period":{"start":"2026-10-05","end":"2026-11-03"},"shares":1169011472,"percentage_bps":4584,"name":"Hassana Boga Sejahtera Tbk."},{"symbol":"AUTO.JK","kind":"tender","date":"2026-10-13","pay_date":"2026-11-18","price":3600,"period":{"start":"2026-10-13","end":"2026-11-11"},"shares":238442293,"percentage_bps":495,"name":"Astra Otoparts Tbk."},{"symbol":"MGLV.JK","kind":"rights","date":"2026-11-04","cum_date":"2026-11-04","ex_date":"2026-11-05","record_date":"2026-11-06","ratio":{"old":100,"new":15},"factor":1.15,"price":8880,"period":{"start":"2026-11-10","end":"2026-11-23"}},{"symbol":"JPFA.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-12","cash_per_share":90.0},{"symbol":"PTPS.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-18","cash_per_share":2.0},{"symbol":"UNTR.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-23","cash_per_share":430.0},{"symbol":"CNMA.JK","kind":"dividend","date":"2026-11-06","cum_date":"2026-11-06","ex_date":"2026-11-09","record_date":"2026-11-10","pay_date":"2026-11-19","cash_per_share":4.0},{"symbol":"SMSM.JK","kind":"dividend","date":"2026-11-09","cum_date":"2026-11-09","ex_date":"2026-11-10","record_date":"2026-11-11","pay_date":"2026-11-26","cash_per_share":45.0},{"symbol":"AVIA.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-19","cash_per_share":12.0},{"symbol":"BWPT.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-25","cash_per_share":1.0},{"symbol":"ITMG.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-18","cash_per_share":1191.09},{"symbol":"MEDC.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-12-02","cash_per_share":21.65},{"symbol":"SSMS.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-12-02","cash_per_share":52.49},{"symbol":"TAPG.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-27","cash_per_share":70.0},{"symbol":"IFII.JK","kind":"dividend","date":"2026-11-11","cum_date":"2026-11-11","ex_date":"2026-11-12","record_date":"2026-11-13","pay_date":"2026-11-26","cash_per_share":5.0},{"symbol":"MIDI.JK","kind":"dividend","date":"2026-11-11","cum_date":"2026-11-11","ex_date":"2026-11-12","record_date":"2026-11-13","pay_date":"2026-12-01","cash_per_share":3.8}]
|
||||||
|
--- stderr
|
||||||
43
crates/idx-cli/tests/golden/stockbit-calendar.table.golden
Normal file
43
crates/idx-cli/tests/golden/stockbit-calendar.table.golden
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Corporate actions from 2026-11-03 to 2026-11-12, with offers opened earlier and still open (17)
|
||||||
|
┌────────────┬────────┬──────────────┬────────────┬──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DATE ┆ SYMBOL ┆ KIND ┆ EX ┆ DETAIL │
|
||||||
|
╞════════════╪════════╪══════════════╪════════════╪══════════════════════════════════════════════════════════════════════════╡
|
||||||
|
│ 2026-10-05 ┆ DOOH ┆ tender offer ┆ - ┆ Rp 148, for 2,624,548,624 shares (33.91%), open 2026-10-05 to 2026-11-03 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-05 ┆ NAYZ ┆ tender offer ┆ - ┆ Rp 75, for 1,169,011,472 shares (45.84%), open 2026-10-05 to 2026-11-03 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-13 ┆ AUTO ┆ tender offer ┆ - ┆ Rp 3,600, for 238,442,293 shares (4.95%), open 2026-10-13 to 2026-11-11 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-04 ┆ MGLV ┆ rights ┆ 2026-11-05 ┆ 15 rights per 100 held, at Rp 8,880, trading 2026-11-10 to 2026-11-23 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-05 ┆ JPFA ┆ dividend ┆ 2026-11-06 ┆ Rp 90 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-05 ┆ PTPS ┆ dividend ┆ 2026-11-06 ┆ Rp 2 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-05 ┆ UNTR ┆ dividend ┆ 2026-11-06 ┆ Rp 430 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ CNMA ┆ dividend ┆ 2026-11-09 ┆ Rp 4 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-09 ┆ SMSM ┆ dividend ┆ 2026-11-10 ┆ Rp 45 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ AVIA ┆ dividend ┆ 2026-11-11 ┆ Rp 12 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ BWPT ┆ dividend ┆ 2026-11-11 ┆ Rp 1 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ ITMG ┆ dividend ┆ 2026-11-11 ┆ Rp 1,191.09 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ MEDC ┆ dividend ┆ 2026-11-11 ┆ Rp 21.65 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ SSMS ┆ dividend ┆ 2026-11-11 ┆ Rp 52.49 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ TAPG ┆ dividend ┆ 2026-11-11 ┆ Rp 70 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-11 ┆ IFII ┆ dividend ┆ 2026-11-12 ┆ Rp 5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-11 ┆ MIDI ┆ dividend ┆ 2026-11-12 ┆ Rp 3.8 per share │
|
||||||
|
└────────────┴────────┴──────────────┴────────────┴──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
DATE is the cum date for dividends, bonuses, rights and splits (buy by that day to be entitled); for other actions, the event's first day.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
||||||
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
||||||
|
|
||||||
use super::stockbit::exodus;
|
use super::stockbit::{corpaction, exodus, movement};
|
||||||
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||||
use super::{
|
use super::{
|
||||||
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
|
BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider,
|
||||||
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::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||||
use crate::config::ProviderKind;
|
use crate::config::ProviderKind;
|
||||||
|
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use crate::movements::types::{MovementPage, MovementQuery};
|
||||||
|
|
||||||
pub(crate) fn enabled() -> bool {
|
pub(crate) fn enabled() -> bool {
|
||||||
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
|
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
|
||||||
|
|
@ -184,6 +187,10 @@ impl MockStockbitProvider {
|
||||||
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
|
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// The session API's own block, as `exodus::fetch` reports it.
|
||||||
|
if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("403") {
|
||||||
|
return Err(stockbit::exodus::blocked());
|
||||||
|
}
|
||||||
if let Some(err) = self.failure(symbol) {
|
if let Some(err) = self.failure(symbol) {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +237,68 @@ impl OrderBookProvider for MockStockbitProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `stockbit_exodus_corpaction_<type>.json` for the market,
|
||||||
|
/// `..._<type>_<code>.json` for one stock; a stock without a fixture has none
|
||||||
|
/// of that kind, as Stockbit answers.
|
||||||
|
impl CorporateActionProvider for MockStockbitProvider {
|
||||||
|
fn corporate_actions(
|
||||||
|
&self,
|
||||||
|
kind: ActionKind,
|
||||||
|
symbol: Option<&str>,
|
||||||
|
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||||
|
let subject = symbol.unwrap_or("corporate actions");
|
||||||
|
let name = match symbol {
|
||||||
|
Some(symbol) => format!(
|
||||||
|
"corpaction_{}_{}.json",
|
||||||
|
corpaction::path_type(kind),
|
||||||
|
stockbit::stockbit_code(symbol)?.to_ascii_lowercase()
|
||||||
|
),
|
||||||
|
None => format!("corpaction_{}.json", corpaction::path_type(kind)),
|
||||||
|
};
|
||||||
|
let body = match self.exodus(subject, &name) {
|
||||||
|
Err(IdxError::SymbolNotFound(_)) if symbol.is_some() => {
|
||||||
|
r#"{"data":{"none":[]}}"#.to_string()
|
||||||
|
}
|
||||||
|
other => other?,
|
||||||
|
};
|
||||||
|
corpaction::parse_corporate_actions(kind, &body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `stockbit_exodus_majorholder[_<code>][_p<page>].json` (no suffix for page
|
||||||
|
/// 1); a missing fixture is an empty page, as Stockbit answers past the end.
|
||||||
|
/// The query's filters apply to the fixture's rows, as Stockbit applies them.
|
||||||
|
impl ShareholderMovementProvider for MockStockbitProvider {
|
||||||
|
fn shareholder_movements(
|
||||||
|
&self,
|
||||||
|
query: &MovementQuery,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<MovementPage, IdxError> {
|
||||||
|
let subject = query.symbol.as_deref().unwrap_or("shareholder movements");
|
||||||
|
// `IDX_MOCK_MOVEMENTS_FAIL_PAGE=N` rate-limits page N alone.
|
||||||
|
if std::env::var("IDX_MOCK_MOVEMENTS_FAIL_PAGE").ok() == Some(page.to_string()) {
|
||||||
|
return Err(IdxError::RateLimited);
|
||||||
|
}
|
||||||
|
let mut name = "majorholder".to_string();
|
||||||
|
if let Some(symbol) = &query.symbol {
|
||||||
|
name.push('_');
|
||||||
|
name.push_str(&stockbit::stockbit_code(symbol)?.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
name.push_str(&format!("_p{page}"));
|
||||||
|
}
|
||||||
|
let body = match self.exodus(subject, &format!("{name}.json")) {
|
||||||
|
Err(IdxError::SymbolNotFound(_)) if query.symbol.is_some() || page > 1 => {
|
||||||
|
r#"{"data":{"is_more":false,"movement":[]}}"#.to_string()
|
||||||
|
}
|
||||||
|
other => other?,
|
||||||
|
};
|
||||||
|
let mut parsed = movement::parse_movements(&body)?;
|
||||||
|
parsed.movements.retain(|m| query.admits(m));
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl HistoryProvider for MockStockbitProvider {
|
impl HistoryProvider for MockStockbitProvider {
|
||||||
fn history(
|
fn history(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,28 @@ pub trait OrderBookProvider {
|
||||||
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError>;
|
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Corporate actions of one kind: needs the user's Stockbit session.
|
||||||
|
pub trait CorporateActionProvider {
|
||||||
|
/// One stock's history (`symbol` resolved, e.g. `BBCA.JK`), or the
|
||||||
|
/// market's announced and recent actions with `None`.
|
||||||
|
fn corporate_actions(
|
||||||
|
&self,
|
||||||
|
kind: crate::corporate::types::ActionKind,
|
||||||
|
symbol: Option<&str>,
|
||||||
|
) -> Result<Vec<crate::corporate::types::CorporateAction>, IdxError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shareholder movements: needs the user's Stockbit session.
|
||||||
|
pub trait ShareholderMovementProvider {
|
||||||
|
/// Page `page` (from 1) of `query`, whose `symbol` is resolved
|
||||||
|
/// (`BBCA.JK`) or `None` for the market.
|
||||||
|
fn shareholder_movements(
|
||||||
|
&self,
|
||||||
|
query: &crate::movements::types::MovementQuery,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<crate::movements::types::MovementPage, IdxError>;
|
||||||
|
}
|
||||||
|
|
||||||
pub trait FundamentalsProvider {
|
pub trait FundamentalsProvider {
|
||||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
@ -345,6 +367,22 @@ pub fn orderbook_provider() -> Box<dyn OrderBookProvider> {
|
||||||
Box::new(stockbit::StockbitProvider::new())
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn corporate_action_provider() -> Box<dyn CorporateActionProvider> {
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
if mock::enabled() {
|
||||||
|
return Box::new(mock::MockStockbitProvider);
|
||||||
|
}
|
||||||
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shareholder_movement_provider() -> Box<dyn ShareholderMovementProvider> {
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
if mock::enabled() {
|
||||||
|
return Box::new(mock::MockStockbitProvider);
|
||||||
|
}
|
||||||
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
|
}
|
||||||
|
|
||||||
/// Today in WIB, the date IDX trades on.
|
/// Today in WIB, the date IDX trades on.
|
||||||
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
|
|
|
||||||
437
crates/idx-core/src/api/stockbit/corpaction.rs
Normal file
437
crates/idx-core/src/api/stockbit/corpaction.rs
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
//! Corporate actions from Stockbit's session API:
|
||||||
|
//! `GET /corpaction/{type}?symbol={CODE}` (one stock's history) or without
|
||||||
|
//! `symbol` (the market, about a year back and the announced ones ahead; the
|
||||||
|
//! `limit` the web app sends is not honoured by every type).
|
||||||
|
//!
|
||||||
|
//! Each type names its fields with its own prefix (`dividend_`,
|
||||||
|
//! `rightissue_`, `wrant_`, `rups_`, `puexp_`, `tender_`, ...), bonus rows
|
||||||
|
//! reuse `stocksplit_*`, and the list sits under a key that is not always the
|
||||||
|
//! path's (`tenderoffer` → `tender`, `reversesplit` → `stock_reverse`). So
|
||||||
|
//! the entitlement kinds are read by field suffix (`_cumdate`, `_exdate`,
|
||||||
|
//! ...), which also covers the two kinds no row was recorded for yet
|
||||||
|
//! (`stock_dividend`, `reversesplit`, both empty on 2026-11-03). Placeholders
|
||||||
|
//! (`""`, `0`, `0001-01-01 00:00:00`) read as absent; a row without a symbol
|
||||||
|
//! or a date is dropped.
|
||||||
|
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
use crate::corporate::types::{ActionKind, CorporateAction, DateRange, Ratio};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
/// Stockbit's path segment for `kind`.
|
||||||
|
pub fn path_type(kind: ActionKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
ActionKind::Dividend => "dividend",
|
||||||
|
ActionKind::StockDividend => "stock_dividend",
|
||||||
|
ActionKind::Bonus => "bonus",
|
||||||
|
ActionKind::Rights => "rightissue",
|
||||||
|
ActionKind::Warrant => "warrant",
|
||||||
|
ActionKind::Split => "stocksplit",
|
||||||
|
ActionKind::ReverseSplit => "reversesplit",
|
||||||
|
ActionKind::Meeting => "rups",
|
||||||
|
ActionKind::PublicExpose => "pubex",
|
||||||
|
ActionKind::Ipo => "ipo",
|
||||||
|
ActionKind::TenderOffer => "tenderoffer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actions of one `kind` in a `/corpaction/{type}` response, in the
|
||||||
|
/// response's order.
|
||||||
|
pub fn parse_corporate_actions(
|
||||||
|
kind: ActionKind,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||||
|
let bad = |what: String| IdxError::ParseError(format!("stockbit corporate actions: {what}"));
|
||||||
|
let root: Value = serde_json::from_str(body).map_err(|e| bad(e.to_string()))?;
|
||||||
|
let rows = match root.get("data") {
|
||||||
|
Some(Value::Object(data)) => match data.values().find_map(Value::as_array) {
|
||||||
|
Some(rows) => rows.as_slice(),
|
||||||
|
// No rows reads `{"dividend": null, "today": "..."}`: a null list
|
||||||
|
// beside scalars. An object in their place is a new layout.
|
||||||
|
None if !data.values().any(Value::is_object) => &[],
|
||||||
|
None => return Err(bad("no list in data".to_string())),
|
||||||
|
},
|
||||||
|
Some(Value::Array(rows)) => rows.as_slice(),
|
||||||
|
Some(Value::Null) => &[],
|
||||||
|
_ => return Err(bad("no data".to_string())),
|
||||||
|
};
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_object)
|
||||||
|
.filter_map(|row| parse_row(kind, row))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_row(kind: ActionKind, row: &Map<String, Value>) -> Option<CorporateAction> {
|
||||||
|
let code = text(row.get("company_symbol"))?.to_ascii_uppercase();
|
||||||
|
if !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let symbol = format!("{code}.JK");
|
||||||
|
let mut action = if kind.is_entitlement() {
|
||||||
|
entitlement(kind, symbol, row)?
|
||||||
|
} else {
|
||||||
|
event(kind, symbol, row)?
|
||||||
|
};
|
||||||
|
if matches!(kind, ActionKind::Ipo | ActionKind::TenderOffer) {
|
||||||
|
action.name = text(row.get("company_name")).map(str::to_string);
|
||||||
|
}
|
||||||
|
Some(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dividends, bonuses, rights and splits: cum/ex/record/pay dates, a ratio
|
||||||
|
/// and an adjustment factor.
|
||||||
|
fn entitlement(
|
||||||
|
kind: ActionKind,
|
||||||
|
symbol: String,
|
||||||
|
row: &Map<String, Value>,
|
||||||
|
) -> Option<CorporateAction> {
|
||||||
|
let cum = date(suffixed(row, "cumdate"));
|
||||||
|
let ex = date(suffixed(row, "exdate"));
|
||||||
|
let mut action = CorporateAction::new(symbol, kind, cum.or(ex)?);
|
||||||
|
action.cum_date = cum;
|
||||||
|
action.ex_date = ex;
|
||||||
|
action.record_date = date(suffixed(row, "recdate"));
|
||||||
|
action.pay_date = date(suffixed(row, "paydate")).or_else(|| date(suffixed(row, "paymentdate")));
|
||||||
|
action.ratio = ratio(row);
|
||||||
|
action.factor = number(suffixed(row, "factor"));
|
||||||
|
match kind {
|
||||||
|
ActionKind::Dividend => {
|
||||||
|
action.currency = text(suffixed(row, "currency"))
|
||||||
|
.map(|c| c.trim_start_matches("CURRENCY_").to_ascii_uppercase())
|
||||||
|
.filter(|c| c != "IDR");
|
||||||
|
// Rupiah to the sen; a dollar dividend can be a fraction of a cent.
|
||||||
|
let places = if action.currency.is_some() { 6 } else { 2 };
|
||||||
|
action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, places));
|
||||||
|
}
|
||||||
|
ActionKind::Rights => {
|
||||||
|
action.price = whole(suffixed(row, "price"));
|
||||||
|
action.period = range(suffixed(row, "trading_start"), suffixed(row, "trading_end"));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Some(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warrants, meetings, exposes, IPOs and tender offers.
|
||||||
|
fn event(kind: ActionKind, symbol: String, row: &Map<String, Value>) -> Option<CorporateAction> {
|
||||||
|
let get = |key: &str| row.get(key);
|
||||||
|
let mut action;
|
||||||
|
match kind {
|
||||||
|
ActionKind::Warrant => {
|
||||||
|
let trading = range(get("wrant_trading_from"), get("wrant_trading_end"));
|
||||||
|
let exercise = range(get("wrant_exc_from"), get("wrant_exc_end"));
|
||||||
|
let first = date(get("wrant_trading_from")).or(exercise.map(|r| r.start))?;
|
||||||
|
action = CorporateAction::new(symbol, kind, first);
|
||||||
|
action.period = trading;
|
||||||
|
action.exercise = exercise;
|
||||||
|
action.price = whole(get("wrant_exc_price"));
|
||||||
|
}
|
||||||
|
ActionKind::Meeting | ActionKind::PublicExpose => {
|
||||||
|
let prefix = if kind == ActionKind::Meeting {
|
||||||
|
"rups"
|
||||||
|
} else {
|
||||||
|
"puexp"
|
||||||
|
};
|
||||||
|
let field = |name: &str| row.get(&format!("{prefix}_{name}"));
|
||||||
|
action = CorporateAction::new(symbol, kind, date(field("date"))?);
|
||||||
|
action.time = text(field("time")).map(clock);
|
||||||
|
action.venue = text(field("venue")).map(squeeze);
|
||||||
|
if kind == ActionKind::Meeting {
|
||||||
|
action.record_date = date(field("eligible_date"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ActionKind::Ipo => {
|
||||||
|
action = CorporateAction::new(symbol, kind, date(get("ipo_listing_date"))?);
|
||||||
|
action.price = whole(row.get("ipo_price").and_then(|p| p.get("final")));
|
||||||
|
let detail = row.get("ipo_data_detail");
|
||||||
|
let detail = |key: &str| detail.and_then(|d| d.get(key));
|
||||||
|
action.shares = count(detail("shares"));
|
||||||
|
action.period = range(detail("offering_start"), detail("offering_end"));
|
||||||
|
}
|
||||||
|
ActionKind::TenderOffer => {
|
||||||
|
let period = range(get("tender_start"), get("tender_end"));
|
||||||
|
let first = date(get("tender_start")).or(date(get("tender_end")))?;
|
||||||
|
action = CorporateAction::new(symbol, kind, first);
|
||||||
|
action.period = period;
|
||||||
|
action.pay_date = date(get("tender_paydate"));
|
||||||
|
action.price = whole(get("tender_price"));
|
||||||
|
action.shares = count(get("tender_shares"));
|
||||||
|
action.percentage_bps =
|
||||||
|
number(get("tender_percentage")).map(|p| (p * 100.0).round() as i64);
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
Some(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of the one field named `<prefix>_<suffix>`; the shortest name
|
||||||
|
/// wins should several end so.
|
||||||
|
fn suffixed<'a>(row: &'a Map<String, Value>, suffix: &str) -> Option<&'a Value> {
|
||||||
|
let tail = format!("_{suffix}");
|
||||||
|
row.iter()
|
||||||
|
.filter(|(key, _)| key.ends_with(&tail))
|
||||||
|
.min_by_key(|(key, _)| key.len())
|
||||||
|
.map(|(_, value)| value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `old`/`new` fields, else the `"6 : 4"` text.
|
||||||
|
fn ratio(row: &Map<String, Value>) -> Option<Ratio> {
|
||||||
|
let pair = |old: Option<u64>, new: Option<u64>| {
|
||||||
|
Some(Ratio {
|
||||||
|
old: old?,
|
||||||
|
new: new?,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
pair(count(suffixed(row, "old")), count(suffixed(row, "new"))).or_else(|| {
|
||||||
|
let text = text(suffixed(row, "ratio"))?;
|
||||||
|
let (old, new) = text.split_once(':')?;
|
||||||
|
pair(
|
||||||
|
old.trim().replace(',', "").parse().ok().filter(|n| *n > 0),
|
||||||
|
new.trim().replace(',', "").parse().ok().filter(|n| *n > 0),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(value: Option<&Value>) -> Option<&str> {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `YYYY-MM-DD`, optionally followed by a time; year 1 is a placeholder.
|
||||||
|
fn date(value: Option<&Value>) -> Option<NaiveDate> {
|
||||||
|
let text = text(value)?;
|
||||||
|
let day = text.get(..10)?;
|
||||||
|
NaiveDate::parse_from_str(day, "%Y-%m-%d")
|
||||||
|
.ok()
|
||||||
|
.filter(|d| d.year() >= 1900)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range(start: Option<&Value>, end: Option<&Value>) -> Option<DateRange> {
|
||||||
|
let (start, end) = (date(start)?, date(end)?);
|
||||||
|
(start <= end).then_some(DateRange { start, end })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A positive number from a JSON number or a display string (`"1,191.09"`,
|
||||||
|
/// `"Rp 25"`); zero is a placeholder.
|
||||||
|
fn number(value: Option<&Value>) -> Option<f64> {
|
||||||
|
let n = match value? {
|
||||||
|
Value::Number(n) => n.as_f64()?,
|
||||||
|
Value::String(s) => {
|
||||||
|
let cleaned: String = s
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches("Rp")
|
||||||
|
.chars()
|
||||||
|
.filter(|c| *c != ',' && !c.is_whitespace())
|
||||||
|
.collect();
|
||||||
|
cleaned.parse().ok()?
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
(n.is_finite() && n > 0.0).then_some(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whole(value: Option<&Value>) -> Option<i64> {
|
||||||
|
number(value).map(|n| n.round() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(value: Option<&Value>) -> Option<u64> {
|
||||||
|
number(value).map(|n| n.round() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn round_to(value: f64, places: i32) -> f64 {
|
||||||
|
let scale = 10f64.powi(places);
|
||||||
|
(value * scale).round() / scale
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `13:00:00` → `13:00`; anything else as given.
|
||||||
|
fn clock(raw: &str) -> String {
|
||||||
|
match raw.split(':').collect::<Vec<_>>().as_slice() {
|
||||||
|
[h, m, s] if s.len() == 2 && h.len() <= 2 && m.len() == 2 => format!("{h}:{m}"),
|
||||||
|
_ => raw.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn squeeze(raw: &str) -> String {
|
||||||
|
raw.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> String {
|
||||||
|
std::fs::read_to_string(format!(
|
||||||
|
"{}/tests/fixtures/stockbit_exodus_corpaction_{name}.json",
|
||||||
|
env!("CARGO_MANIFEST_DIR")
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn day(s: &str) -> NaiveDate {
|
||||||
|
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find<'a>(actions: &'a [CorporateAction], symbol: &str) -> &'a CorporateAction {
|
||||||
|
actions.iter().find(|a| a.symbol == symbol).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_dividends_keep_cents_and_dates() {
|
||||||
|
let bbca =
|
||||||
|
parse_corporate_actions(ActionKind::Dividend, &fixture("dividend_bbca")).unwrap();
|
||||||
|
assert_eq!(bbca.len(), 37);
|
||||||
|
let latest = &bbca[0];
|
||||||
|
assert_eq!(latest.symbol, "BBCA.JK");
|
||||||
|
assert_eq!(latest.date, day("2026-08-28"));
|
||||||
|
assert_eq!(
|
||||||
|
(latest.ex_date, latest.record_date, latest.pay_date),
|
||||||
|
(
|
||||||
|
Some(day("2026-08-31")),
|
||||||
|
Some(day("2026-09-01")),
|
||||||
|
Some(day("2026-09-16"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(latest.cash_per_share, Some(25.0));
|
||||||
|
assert_eq!(latest.ratio, None);
|
||||||
|
|
||||||
|
let market = parse_corporate_actions(ActionKind::Dividend, &fixture("dividend")).unwrap();
|
||||||
|
assert_eq!(find(&market, "ITMG.JK").cash_per_share, Some(1191.09));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_bonus_reads_split_named_fields() {
|
||||||
|
let bonus = parse_corporate_actions(ActionKind::Bonus, &fixture("bonus")).unwrap();
|
||||||
|
let ppgl = find(&bonus, "PPGL.JK");
|
||||||
|
assert_eq!(ppgl.date, day("2026-11-02"));
|
||||||
|
assert_eq!(ppgl.ex_date, Some(day("2026-11-03")));
|
||||||
|
assert_eq!(ppgl.pay_date, Some(day("2026-11-25")));
|
||||||
|
assert_eq!(ppgl.ratio, Some(Ratio { old: 6, new: 4 }));
|
||||||
|
assert_eq!(ppgl.factor, Some(1.6667));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_rights_and_split() {
|
||||||
|
let rights = parse_corporate_actions(ActionKind::Rights, &fixture("rightissue")).unwrap();
|
||||||
|
let bmas = find(&rights, "BMAS.JK");
|
||||||
|
assert_eq!(bmas.date, day("2026-11-23"));
|
||||||
|
assert_eq!(bmas.ratio, Some(Ratio { old: 655, new: 104 }));
|
||||||
|
assert_eq!(bmas.price, Some(350));
|
||||||
|
assert_eq!(
|
||||||
|
bmas.period,
|
||||||
|
Some(DateRange {
|
||||||
|
start: day("2026-11-27"),
|
||||||
|
end: day("2026-12-03")
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let split =
|
||||||
|
parse_corporate_actions(ActionKind::Split, &fixture("stocksplit_bbca")).unwrap();
|
||||||
|
assert_eq!(split[0].date, day("2021-10-12"));
|
||||||
|
assert_eq!(split[0].ratio, Some(Ratio { old: 1, new: 5 }));
|
||||||
|
assert_eq!(split[0].factor, Some(5.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_events() {
|
||||||
|
let warrants = parse_corporate_actions(ActionKind::Warrant, &fixture("warrant")).unwrap();
|
||||||
|
let brna = find(&warrants, "BRNA.JK");
|
||||||
|
assert_eq!(brna.date, day("2026-10-16"));
|
||||||
|
assert_eq!(brna.price, Some(800));
|
||||||
|
assert_eq!(brna.exercise.map(|r| r.end), Some(day("2029-10-15")));
|
||||||
|
|
||||||
|
let meetings = parse_corporate_actions(ActionKind::Meeting, &fixture("rups")).unwrap();
|
||||||
|
let arti = find(&meetings, "ARTI.JK");
|
||||||
|
assert_eq!(arti.date, day("2026-12-09"));
|
||||||
|
assert_eq!(arti.time.as_deref(), Some("10:30"));
|
||||||
|
assert_eq!(arti.record_date, Some(day("2026-11-12")));
|
||||||
|
|
||||||
|
let exposes = parse_corporate_actions(ActionKind::PublicExpose, &fixture("pubex")).unwrap();
|
||||||
|
assert_eq!(find(&exposes, "MEJA.JK").time.as_deref(), Some("13:00"));
|
||||||
|
|
||||||
|
let tenders =
|
||||||
|
parse_corporate_actions(ActionKind::TenderOffer, &fixture("tenderoffer")).unwrap();
|
||||||
|
let auto = find(&tenders, "AUTO.JK");
|
||||||
|
assert_eq!(auto.date, day("2026-10-13"));
|
||||||
|
assert_eq!(auto.price, Some(3600));
|
||||||
|
assert_eq!(auto.shares, Some(238_442_293));
|
||||||
|
assert_eq!(auto.percentage_bps, Some(495));
|
||||||
|
assert_eq!(auto.pay_date, Some(day("2026-11-18")));
|
||||||
|
assert_eq!(auto.name.as_deref(), Some("Astra Otoparts Tbk."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_ipo_drops_placeholders() {
|
||||||
|
let ipos = parse_corporate_actions(ActionKind::Ipo, &fixture("ipo")).unwrap();
|
||||||
|
let rans = find(&ipos, "RANS.JK");
|
||||||
|
assert_eq!(rans.price, Some(170));
|
||||||
|
assert_eq!(rans.shares, Some(2_525_000_000));
|
||||||
|
assert_eq!(rans.period.map(|r| r.start), Some(day("2026-07-02")));
|
||||||
|
// An ETF listed without offer terms: no price, no period.
|
||||||
|
let xkgs = find(&ipos, "XKGS.JK");
|
||||||
|
assert_eq!((xkgs.price, xkgs.period, xkgs.shares), (None, None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_empty_lists_and_odd_keys() {
|
||||||
|
for (kind, body) in [
|
||||||
|
(
|
||||||
|
ActionKind::StockDividend,
|
||||||
|
r#"{"data":{"stock_dividend":[]}}"#,
|
||||||
|
),
|
||||||
|
(ActionKind::ReverseSplit, r#"{"data":{"stock_reverse":[]}}"#),
|
||||||
|
(ActionKind::Dividend, r#"{"data":null}"#),
|
||||||
|
(
|
||||||
|
ActionKind::Dividend,
|
||||||
|
r#"{"data":{"dividend":null,"today":"2026-11-04"}}"#,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(parse_corporate_actions(kind, body).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
assert!(parse_corporate_actions(ActionKind::Dividend, r#"{"message":"x"}"#).is_err());
|
||||||
|
let moved = r#"{"data":{"dividend":{"rows":[]},"today":"2026-11-04"}}"#;
|
||||||
|
assert!(parse_corporate_actions(ActionKind::Dividend, moved).is_err());
|
||||||
|
// A reverse split named like a split, read by suffix.
|
||||||
|
let body = r#"{"data":{"stock_reverse":[{"company_symbol":"ABCD",
|
||||||
|
"reverse_cumdate":"2026-12-01","reverse_exdate":"2026-12-02",
|
||||||
|
"reverse_old":"10","reverse_new":"1","reverse_factor":"0.1"}]}}"#;
|
||||||
|
let rows = parse_corporate_actions(ActionKind::ReverseSplit, body).unwrap();
|
||||||
|
assert_eq!(rows[0].ratio, Some(Ratio { old: 10, new: 1 }));
|
||||||
|
assert_eq!(rows[0].factor, Some(0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dividend not in IDR names its currency; one in IDR leaves it out.
|
||||||
|
/// Without a cum date, the action sits on its ex date.
|
||||||
|
#[test]
|
||||||
|
fn test_parse_dividend_currency_and_missing_cum_date() {
|
||||||
|
let body = r#"{"data":{"dividend":[
|
||||||
|
{"company_symbol":"ABCD","dividend_cumdate":"2026-11-11",
|
||||||
|
"dividend_exdate":"2026-11-12","dividend_value":"0.0012",
|
||||||
|
"dividend_currency":"CURRENCY_USD"},
|
||||||
|
{"company_symbol":"EFGH","dividend_cumdate":"0001-01-01 00:00:00",
|
||||||
|
"dividend_exdate":"2026-11-12","dividend_value":"5",
|
||||||
|
"dividend_currency":"CURRENCY_IDR"}]}}"#;
|
||||||
|
let rows = parse_corporate_actions(ActionKind::Dividend, body).unwrap();
|
||||||
|
assert_eq!(rows[0].currency.as_deref(), Some("USD"));
|
||||||
|
assert_eq!(rows[0].cash_per_share, Some(0.0012), "not rounded to cents");
|
||||||
|
assert_eq!(rows[1].currency, None);
|
||||||
|
assert_eq!(rows[1].cash_per_share, Some(5.0));
|
||||||
|
assert_eq!((rows[1].date, rows[1].cum_date), (day("2026-11-12"), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_numbers_and_placeholders() {
|
||||||
|
assert_eq!(number(Some(&Value::from("1,191.09"))), Some(1191.09));
|
||||||
|
assert_eq!(number(Some(&Value::from("Rp 25"))), Some(25.0));
|
||||||
|
assert_eq!(number(Some(&Value::from(0))), None);
|
||||||
|
assert_eq!(date(Some(&Value::from("0001-01-01 00:00:00"))), None);
|
||||||
|
assert_eq!(
|
||||||
|
date(Some(&Value::from("2026-07-02 00:00:00"))),
|
||||||
|
Some(day("2026-07-02"))
|
||||||
|
);
|
||||||
|
assert_eq!(clock("09:00:00"), "09:00");
|
||||||
|
assert_eq!(clock("10:30"), "10:30");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,11 +27,24 @@ use serde_json::Value;
|
||||||
use super::auth::SessionStore;
|
use super::auth::SessionStore;
|
||||||
use super::parse::number;
|
use super::parse::number;
|
||||||
use super::{USER_AGENT, pace, stockbit_code};
|
use super::{USER_AGENT, pace, stockbit_code};
|
||||||
|
use super::{corpaction, movement};
|
||||||
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
|
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
|
||||||
|
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use crate::movements::types::{MovementPage, MovementQuery};
|
||||||
|
|
||||||
const BASE_URL: &str = "https://exodus.stockbit.com";
|
const BASE_URL: &str = "https://exodus.stockbit.com";
|
||||||
const REFRESH_PATH: &str = "/login/refresh";
|
const REFRESH_PATH: &str = "/login/refresh";
|
||||||
|
/// The only paths idx GETs from the session API. The same API places orders,
|
||||||
|
/// withdraws cash and changes the PIN; a path outside these fails before any
|
||||||
|
/// request (or token refresh) leaves the process. A feature that reads a new
|
||||||
|
/// endpoint adds its prefix here.
|
||||||
|
const READ_PATHS: &[&str] = &[
|
||||||
|
"/company-price-feed/",
|
||||||
|
"/charts/",
|
||||||
|
"/corpaction/",
|
||||||
|
"/insider/company/majorholder?",
|
||||||
|
];
|
||||||
/// The prefix of the states in which the regular market trades:
|
/// The prefix of the states in which the regular market trades:
|
||||||
/// `STATE_NAME_SESSION_2` was recorded on 2026-11-03 from 13:30; session 1 is
|
/// `STATE_NAME_SESSION_2` was recorded on 2026-11-03 from 13:30; session 1 is
|
||||||
/// assumed to follow the same naming. Every other state (`STATE_NAME_BREAK`,
|
/// assumed to follow the same naming. Every other state (`STATE_NAME_BREAK`,
|
||||||
|
|
@ -120,6 +133,43 @@ impl Exodus {
|
||||||
daily_for_period(&body, period)
|
daily_for_period(&body, period)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Corporate actions of `kind`: one stock's (`resolved`) or the market's.
|
||||||
|
pub(crate) fn corporate_actions(
|
||||||
|
&self,
|
||||||
|
kind: ActionKind,
|
||||||
|
resolved: Option<&str>,
|
||||||
|
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||||
|
let path = format!("/corpaction/{}", corpaction::path_type(kind));
|
||||||
|
let (path, subject) = match resolved {
|
||||||
|
Some(resolved) => (
|
||||||
|
format!("{path}?symbol={}", stockbit_code(resolved)?),
|
||||||
|
resolved.to_string(),
|
||||||
|
),
|
||||||
|
None => (path, "corporate actions".to_string()),
|
||||||
|
};
|
||||||
|
let body = self.get(&path, &subject)?;
|
||||||
|
corpaction::parse_corporate_actions(kind, &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page `page` of shareholder movements: one stock's (`query.symbol`,
|
||||||
|
/// resolved) or the market's.
|
||||||
|
pub(crate) fn shareholder_movements(
|
||||||
|
&self,
|
||||||
|
query: &MovementQuery,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<MovementPage, IdxError> {
|
||||||
|
let code = query.symbol.as_deref().map(stockbit_code).transpose()?;
|
||||||
|
let subject = query
|
||||||
|
.symbol
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "shareholder movements".to_string());
|
||||||
|
let body = self.get(
|
||||||
|
&movement::request_path(query, code.as_deref(), page),
|
||||||
|
&subject,
|
||||||
|
)?;
|
||||||
|
movement::parse_movements(&body)
|
||||||
|
}
|
||||||
|
|
||||||
/// The market's state, read at most every `MARKET_STATE_TTL` per process.
|
/// The market's state, read at most every `MARKET_STATE_TTL` per process.
|
||||||
/// Unknown on any failure, which hides bid/offer.
|
/// Unknown on any failure, which hides bid/offer.
|
||||||
fn market_status(&self) -> Option<String> {
|
fn market_status(&self) -> Option<String> {
|
||||||
|
|
@ -139,6 +189,11 @@ impl Exodus {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get(&self, path: &str, subject: &str) -> Result<String, IdxError> {
|
fn get(&self, path: &str, subject: &str) -> Result<String, IdxError> {
|
||||||
|
if !is_read_path(path) {
|
||||||
|
return Err(IdxError::Unsupported(format!(
|
||||||
|
"idx does not call {path} on Stockbit's session API"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let refresher = |refresh: &str| self.refresh(refresh);
|
let refresher = |refresh: &str| self.refresh(refresh);
|
||||||
let token = self.store.access(&refresher, unix_now())?;
|
let token = self.store.access(&refresher, unix_now())?;
|
||||||
let mut reply = self.send(path, &token)?;
|
let mut reply = self.send(path, &token)?;
|
||||||
|
|
@ -217,11 +272,7 @@ fn classify(reply: Reply, subject: &str) -> Result<String, IdxError> {
|
||||||
Err(IdxError::SymbolNotFound(subject.to_string()))
|
Err(IdxError::SymbolNotFound(subject.to_string()))
|
||||||
}
|
}
|
||||||
429 => Err(IdxError::RateLimited),
|
429 => Err(IdxError::RateLimited),
|
||||||
403 => Err(IdxError::UpstreamBlocked(
|
403 => Err(blocked()),
|
||||||
"exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \
|
|
||||||
retry a block. Try again later or from another network"
|
|
||||||
.to_string(),
|
|
||||||
)),
|
|
||||||
status => Err(IdxError::Http(format!(
|
status => Err(IdxError::Http(format!(
|
||||||
"stockbit api: HTTP {status}{}",
|
"stockbit api: HTTP {status}{}",
|
||||||
message(&reply.body)
|
message(&reply.body)
|
||||||
|
|
@ -457,10 +508,59 @@ fn trim_to_period(mut bars: Vec<Ohlc>, period: &Period) -> Vec<Ohlc> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A 403 from the CDN in front of the session API: a block, not a refused
|
||||||
|
/// session, so nothing is retried.
|
||||||
|
pub(crate) fn blocked() -> IdxError {
|
||||||
|
IdxError::UpstreamBlocked(
|
||||||
|
"exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \
|
||||||
|
retry a block. Try again later or from another network"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `path` is one idx reads; `..` never climbs out of a prefix.
|
||||||
|
fn is_read_path(path: &str) -> bool {
|
||||||
|
READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Only the endpoints idx reads pass; trading and account paths, and a
|
||||||
|
/// path that climbs out of a prefix, never leave the process.
|
||||||
|
#[test]
|
||||||
|
fn only_read_paths_are_called() {
|
||||||
|
for path in [
|
||||||
|
"/company-price-feed/market-time/session",
|
||||||
|
"/company-price-feed/v2/orderbook/companies/BBCA",
|
||||||
|
"/charts/BBCA/daily?timeframe=1y&chart_type=PRICE_CHART_TYPE_CANDLE",
|
||||||
|
"/corpaction/dividend?symbol=BBCA",
|
||||||
|
"/insider/company/majorholder?page=1&symbol=BBCA",
|
||||||
|
] {
|
||||||
|
assert!(is_read_path(path), "{path}");
|
||||||
|
}
|
||||||
|
// Paths another module builds come from its own builder, so the
|
||||||
|
// allowlist cannot drift from them.
|
||||||
|
let query = MovementQuery::default();
|
||||||
|
for path in [
|
||||||
|
movement::request_path(&query, None, 1),
|
||||||
|
movement::request_path(&query, Some("BBCA"), 3),
|
||||||
|
] {
|
||||||
|
assert!(is_read_path(&path), "{path}");
|
||||||
|
}
|
||||||
|
for path in [
|
||||||
|
"/order/buy",
|
||||||
|
"/login/refresh",
|
||||||
|
"/corpaction/../order/buy",
|
||||||
|
"/insider/company/majorholder/../../order",
|
||||||
|
"/charts",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(!is_read_path(path), "{path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn fixture(name: &str) -> String {
|
fn fixture(name: &str) -> String {
|
||||||
std::fs::read_to_string(format!(
|
std::fs::read_to_string(format!(
|
||||||
"{}/tests/fixtures/stockbit_exodus_{name}",
|
"{}/tests/fixtures/stockbit_exodus_{name}",
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod browser;
|
pub mod browser;
|
||||||
|
pub mod corpaction;
|
||||||
pub mod exodus;
|
pub mod exodus;
|
||||||
pub mod loopback;
|
pub mod loopback;
|
||||||
|
pub mod movement;
|
||||||
mod parse;
|
mod parse;
|
||||||
pub mod signin;
|
pub mod signin;
|
||||||
|
|
||||||
|
|
@ -31,9 +33,12 @@ pub use parse::{parse_symbol_page, status_error, stockbit_code};
|
||||||
|
|
||||||
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||||
use super::{
|
use super::{
|
||||||
FundamentalsProvider, HistoryProvider, IntradayProvider, OrderBookProvider, QuoteProvider,
|
CorporateActionProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
|
||||||
|
OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
|
||||||
};
|
};
|
||||||
|
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use crate::movements::types::{MovementPage, MovementQuery};
|
||||||
use auth::SessionStore;
|
use auth::SessionStore;
|
||||||
use exodus::Exodus;
|
use exodus::Exodus;
|
||||||
|
|
||||||
|
|
@ -203,6 +208,26 @@ impl OrderBookProvider for StockbitProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl CorporateActionProvider for StockbitProvider {
|
||||||
|
fn corporate_actions(
|
||||||
|
&self,
|
||||||
|
kind: ActionKind,
|
||||||
|
symbol: Option<&str>,
|
||||||
|
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||||
|
self.require_session()?.corporate_actions(kind, symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShareholderMovementProvider for StockbitProvider {
|
||||||
|
fn shareholder_movements(
|
||||||
|
&self,
|
||||||
|
query: &MovementQuery,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<MovementPage, IdxError> {
|
||||||
|
self.require_session()?.shareholder_movements(query, page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl HistoryProvider for StockbitProvider {
|
impl HistoryProvider for StockbitProvider {
|
||||||
fn history(
|
fn history(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
328
crates/idx-core/src/api/stockbit/movement.rs
Normal file
328
crates/idx-core/src/api/stockbit/movement.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
//! Shareholder movements from `GET /insider/company/majorholder`: reported
|
||||||
|
//! changes in a holder's stake, newest first, 50 rows a page.
|
||||||
|
//!
|
||||||
|
//! Two sources share the feed: IDX insider disclosures (directors,
|
||||||
|
//! commissioners and other insiders, any size; a trade price) and KSEI's
|
||||||
|
//! daily changes of holders at or above 5% (no price; Stockbit estimates
|
||||||
|
//! one). Each row (`data.movement[]`) carries `id` (the holder's id, not the
|
||||||
|
//! row's), `name`, `symbol`, `date` (`"25 Mar 26"`), `previous` / `current` /
|
||||||
|
//! `changes` (`value`: shares as `"+147,933"`, `percentage`: `"+0.0001"`),
|
||||||
|
//! `action_type` (`ACTION_TYPE_BUY`, ..._SELL, ..._TRANSFER, ..._CROSS,
|
||||||
|
//! ..._RIGHT_ISSUE, ..._PRIVATE_PLACEMENT, ..._TENDER_OFFER, ...),
|
||||||
|
//! `nationality`, `badges`, `price.raw.value`, `value.raw.value`,
|
||||||
|
//! `is_estimated_price`, `purpose` and `data_source.type`.
|
||||||
|
//!
|
||||||
|
//! Query parameters (all optional): `symbol`, `date_start` / `date_end`
|
||||||
|
//! (YYYY-MM-DD), `insider` (holder id), `action_type`, `source_type`, `page`
|
||||||
|
//! (from 1).
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use crate::movements::types::{
|
||||||
|
ActionFilter, MovementAction, MovementPage, MovementQuery, MovementSource, ShareholderMovement,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const PATH: &str = "/insider/company/majorholder";
|
||||||
|
|
||||||
|
/// Stockbit's `action_type` value for `filter`.
|
||||||
|
pub fn action_type(filter: ActionFilter) -> &'static str {
|
||||||
|
match filter {
|
||||||
|
ActionFilter::Buy => "ACTION_TYPE_BUY",
|
||||||
|
ActionFilter::Sell => "ACTION_TYPE_SELL",
|
||||||
|
ActionFilter::Transfer => "ACTION_TYPE_TRANSFER",
|
||||||
|
ActionFilter::Cross => "ACTION_TYPE_CROSS",
|
||||||
|
ActionFilter::CorporateAction => "ACTION_TYPE_CORPACTION",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stockbit's `source_type` value for `source`.
|
||||||
|
pub fn source_type(source: MovementSource) -> &'static str {
|
||||||
|
match source {
|
||||||
|
MovementSource::Idx => "SOURCE_TYPE_IDX",
|
||||||
|
MovementSource::Ksei => "SOURCE_TYPE_KSEI",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request path for `page` of `query`; `code` is the bare IDX code.
|
||||||
|
/// Every value is a date, a digit id, a code or a fixed enum name, so
|
||||||
|
/// nothing needs escaping.
|
||||||
|
pub fn request_path(query: &MovementQuery, code: Option<&str>, page: u32) -> String {
|
||||||
|
let mut path = format!("{PATH}?page={page}");
|
||||||
|
if let Some(code) = code {
|
||||||
|
path.push_str(&format!("&symbol={code}"));
|
||||||
|
}
|
||||||
|
if let Some(from) = query.from {
|
||||||
|
path.push_str(&format!("&date_start={from}"));
|
||||||
|
}
|
||||||
|
if let Some(to) = query.to {
|
||||||
|
path.push_str(&format!("&date_end={to}"));
|
||||||
|
}
|
||||||
|
if let Some(id) = &query.holder_id {
|
||||||
|
path.push_str(&format!("&insider={id}"));
|
||||||
|
}
|
||||||
|
if let Some(action) = query.action {
|
||||||
|
path.push_str(&format!("&action_type={}", action_type(action)));
|
||||||
|
}
|
||||||
|
if let Some(source) = query.source {
|
||||||
|
path.push_str(&format!("&source_type={}", source_type(source)));
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of the feed. A row without a symbol, holder or readable date is
|
||||||
|
/// skipped; a body without `data` is a parse error.
|
||||||
|
pub fn parse_movements(body: &str) -> Result<MovementPage, IdxError> {
|
||||||
|
let value: Value = serde_json::from_str(body)
|
||||||
|
.map_err(|e| IdxError::ParseError(format!("Stockbit majorholder: {e}")))?;
|
||||||
|
let data = value
|
||||||
|
.get("data")
|
||||||
|
.filter(|data| data.is_object())
|
||||||
|
.ok_or_else(|| IdxError::ParseError("Stockbit majorholder: no data".to_string()))?;
|
||||||
|
let movements = data
|
||||||
|
.get("movement")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|rows| rows.iter().filter_map(parse_row).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(MovementPage {
|
||||||
|
movements,
|
||||||
|
more: data
|
||||||
|
.get("is_more")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_row(row: &Value) -> Option<ShareholderMovement> {
|
||||||
|
let symbol = text(row.get("symbol"))?.to_ascii_uppercase();
|
||||||
|
let holder = text(row.get("name"))?.to_string();
|
||||||
|
let date = NaiveDate::parse_from_str(text(row.get("date"))?, "%d %b %y").ok()?;
|
||||||
|
let shares = |key: &str| signed(row.get(key).and_then(|v| v.get("value"))).unwrap_or(0);
|
||||||
|
let bps = |key: &str| {
|
||||||
|
percent(row.get(key).and_then(|v| v.get("percentage")))
|
||||||
|
.map(|p| (p * 100.0).round() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
let raw = |key: &str| {
|
||||||
|
signed(
|
||||||
|
row.get(key)
|
||||||
|
.and_then(|v| v.get("raw"))
|
||||||
|
.and_then(|v| v.get("value")),
|
||||||
|
)
|
||||||
|
.filter(|n| *n != 0)
|
||||||
|
};
|
||||||
|
Some(ShareholderMovement {
|
||||||
|
symbol,
|
||||||
|
date,
|
||||||
|
holder,
|
||||||
|
holder_id: text(row.get("id")).unwrap_or_default().to_string(),
|
||||||
|
action: action(text(row.get("action_type")).unwrap_or_default()),
|
||||||
|
source: match text(row.get("data_source").and_then(|v| v.get("type"))) {
|
||||||
|
Some("SOURCE_TYPE_IDX") => Some(MovementSource::Idx),
|
||||||
|
Some("SOURCE_TYPE_KSEI") => Some(MovementSource::Ksei),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
locality: match text(row.get("nationality")) {
|
||||||
|
Some("NATIONALITY_TYPE_LOCAL") => Some("Local".to_string()),
|
||||||
|
Some("NATIONALITY_TYPE_FOREIGN") => Some("Foreign".to_string()),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
roles: row
|
||||||
|
.get("badges")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|badges| {
|
||||||
|
badges
|
||||||
|
.iter()
|
||||||
|
.filter_map(|b| text(Some(b)))
|
||||||
|
.map(role)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
shares_before: shares("previous"),
|
||||||
|
shares_after: shares("current"),
|
||||||
|
shares_change: shares("changes"),
|
||||||
|
percentage_before_bps: bps("previous"),
|
||||||
|
percentage_after_bps: bps("current"),
|
||||||
|
percentage_change_bps: bps("changes"),
|
||||||
|
price: raw("price"),
|
||||||
|
price_estimated: row
|
||||||
|
.get("is_estimated_price")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
value: raw("value"),
|
||||||
|
purpose: text(row.get("purpose"))
|
||||||
|
.filter(|p| *p != "-")
|
||||||
|
.map(str::to_string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action(raw: &str) -> MovementAction {
|
||||||
|
match raw.strip_prefix("ACTION_TYPE_").unwrap_or(raw) {
|
||||||
|
"BUY" => MovementAction::Buy,
|
||||||
|
"SELL" => MovementAction::Sell,
|
||||||
|
"TRANSFER" => MovementAction::Transfer,
|
||||||
|
"CROSS" => MovementAction::Cross,
|
||||||
|
"RIGHT_ISSUE" => MovementAction::RightsIssue,
|
||||||
|
"PRIVATE_PLACEMENT" => MovementAction::PrivatePlacement,
|
||||||
|
"TENDER_OFFER" => MovementAction::TenderOffer,
|
||||||
|
"BONUS_STOCK" => MovementAction::BonusStock,
|
||||||
|
"DIVIDEND_STOCK" => MovementAction::StockDividend,
|
||||||
|
"DIVIDEND_CASH" => MovementAction::CashDividend,
|
||||||
|
_ => MovementAction::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SHAREHOLDER_BADGE_DIREKTUR` -> `director`; an unknown badge keeps its
|
||||||
|
/// own name, lowercased.
|
||||||
|
fn role(badge: &str) -> String {
|
||||||
|
match badge.strip_prefix("SHAREHOLDER_BADGE_").unwrap_or(badge) {
|
||||||
|
"DIREKTUR" => "director".to_string(),
|
||||||
|
"KOMISARIS" => "commissioner".to_string(),
|
||||||
|
other => other.to_ascii_lowercase(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(value: Option<&Value>) -> Option<&str> {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `"+147,933"`, `"-10,000,000"`, `"6982"` or a JSON number.
|
||||||
|
fn signed(value: Option<&Value>) -> Option<i64> {
|
||||||
|
match value? {
|
||||||
|
Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f.round() as i64)),
|
||||||
|
Value::String(s) => s.trim().replace(',', "").parse().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `"+0.0001"`, `"-0.16"`, `"54.94"`.
|
||||||
|
fn percent(value: Option<&Value>) -> Option<f64> {
|
||||||
|
let n = match value? {
|
||||||
|
Value::Number(n) => n.as_f64()?,
|
||||||
|
Value::String(s) => s.trim().replace(',', "").parse().ok()?,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
n.is_finite().then_some(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> String {
|
||||||
|
std::fs::read_to_string(format!(
|
||||||
|
"{}/tests/fixtures/stockbit_exodus_{name}.json",
|
||||||
|
env!("CARGO_MANIFEST_DIR")
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insider_buy_reads_shares_price_and_roles() {
|
||||||
|
let page = parse_movements(&fixture("majorholder_bbca")).unwrap();
|
||||||
|
assert!(page.more);
|
||||||
|
let row = page
|
||||||
|
.movements
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.holder == "HENDRA TANUMIHARDJA")
|
||||||
|
.expect("BBCA director row");
|
||||||
|
assert_eq!(row.symbol, "BBCA");
|
||||||
|
assert_eq!(row.date, NaiveDate::from_ymd_opt(2026, 3, 25).unwrap());
|
||||||
|
assert_eq!(row.action, MovementAction::Buy);
|
||||||
|
assert_eq!(row.source, Some(MovementSource::Idx));
|
||||||
|
assert_eq!(row.roles, ["director"]);
|
||||||
|
assert_eq!(
|
||||||
|
(row.shares_before, row.shares_after, row.shares_change),
|
||||||
|
(193_206, 341_139, 147_933)
|
||||||
|
);
|
||||||
|
// 0.0003% of BBCA rounds to 0 basis points; the shares stay exact.
|
||||||
|
assert_eq!(row.percentage_after_bps, 0);
|
||||||
|
assert_eq!(row.price, Some(6982));
|
||||||
|
assert_eq!(row.value, Some(1_032_868_206));
|
||||||
|
assert!(!row.price_estimated);
|
||||||
|
assert_eq!(row.locality.as_deref(), Some("Local"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ksei_sale_is_negative_with_an_estimated_price() {
|
||||||
|
let page = parse_movements(&fixture("majorholder")).unwrap();
|
||||||
|
let row = page
|
||||||
|
.movements
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.holder == "RUDI R. SUTANTRA")
|
||||||
|
.expect("ATLA KSEI sale");
|
||||||
|
assert_eq!(row.action, MovementAction::Sell);
|
||||||
|
assert_eq!(row.source, Some(MovementSource::Ksei));
|
||||||
|
assert_eq!(row.holder_id, "1000004481");
|
||||||
|
assert_eq!(row.shares_change, -10_000_000);
|
||||||
|
assert_eq!(row.percentage_before_bps, 4081);
|
||||||
|
assert_eq!(row.percentage_change_bps, -16);
|
||||||
|
assert!(row.price_estimated);
|
||||||
|
assert!(row.roles.is_empty());
|
||||||
|
assert_eq!(row.purpose, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_labels_are_kept_not_dropped() {
|
||||||
|
let body = r#"{"data":{"is_more":false,"movement":[
|
||||||
|
{"id":"7","name":"X","symbol":"abcd","date":"02 Nov 26",
|
||||||
|
"action_type":"ACTION_TYPE_SOMETHING_NEW","badges":["SHAREHOLDER_BADGE_CEO"],
|
||||||
|
"data_source":{"type":"SOURCE_TYPE_NEW"},"purpose":"-",
|
||||||
|
"previous":{"value":"0","percentage":"0"},
|
||||||
|
"current":{"value":"1,000","percentage":"0.01"},
|
||||||
|
"changes":{"value":"+1,000","percentage":"+0.01"},
|
||||||
|
"price":{"raw":{"value":"0"}},"value":{"raw":{"value":"0"}}},
|
||||||
|
{"id":"8","name":"","symbol":"ABCD","date":"02 Nov 26"},
|
||||||
|
{"id":"9","name":"Y","symbol":"ABCD","date":"2026-11-02"}
|
||||||
|
]}}"#;
|
||||||
|
let page = parse_movements(body).unwrap();
|
||||||
|
assert!(!page.more);
|
||||||
|
assert_eq!(page.movements.len(), 1);
|
||||||
|
let row = &page.movements[0];
|
||||||
|
assert_eq!(row.symbol, "ABCD");
|
||||||
|
assert_eq!(row.action, MovementAction::Other);
|
||||||
|
assert_eq!(row.source, None);
|
||||||
|
assert_eq!(row.roles, ["ceo"]);
|
||||||
|
assert_eq!(
|
||||||
|
(row.price, row.value, row.purpose.as_deref()),
|
||||||
|
(None, None, None)
|
||||||
|
);
|
||||||
|
assert_eq!(row.percentage_after_bps, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_broken_bodies() {
|
||||||
|
let empty = parse_movements(r#"{"data":{"is_more":false,"movement":null}}"#).unwrap();
|
||||||
|
assert_eq!(empty, MovementPage::default());
|
||||||
|
assert!(parse_movements(r#"{"message":"unauthorized"}"#).is_err());
|
||||||
|
assert!(parse_movements("<html>").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_path_carries_every_filter() {
|
||||||
|
let mut query = MovementQuery::from_args(
|
||||||
|
None,
|
||||||
|
Some("2026-10-01"),
|
||||||
|
Some("2026-10-15"),
|
||||||
|
Some("14074"),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
query.action = Some(ActionFilter::CorporateAction);
|
||||||
|
query.source = Some(MovementSource::Ksei);
|
||||||
|
assert_eq!(
|
||||||
|
request_path(&query, Some("BBCA"), 2),
|
||||||
|
"/insider/company/majorholder?page=2&symbol=BBCA&date_start=2026-10-01\
|
||||||
|
&date_end=2026-10-15&insider=14074&action_type=ACTION_TYPE_CORPACTION\
|
||||||
|
&source_type=SOURCE_TYPE_KSEI"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_path(&MovementQuery::default(), None, 1),
|
||||||
|
"/insider/company/majorholder?page=1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -124,8 +124,8 @@ pub fn status_error(status: u16, resolved: &str) -> IdxError {
|
||||||
match status {
|
match status {
|
||||||
403 => IdxError::UpstreamBlocked(
|
403 => IdxError::UpstreamBlocked(
|
||||||
"stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \
|
"stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \
|
||||||
block. Try again later or from another network; `--provider msn` serves quotes \
|
block. Try again later or from another network; the msn provider (`--provider msn`, \
|
||||||
without Stockbit"
|
or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
404 => IdxError::SymbolNotFound(resolved.to_string()),
|
404 => IdxError::SymbolNotFound(resolved.to_string()),
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,14 @@ impl Idx {
|
||||||
crate::flow::Flow { idx: self }
|
crate::flow::Flow { idx: self }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn corporate(&self) -> crate::corporate::Corporate<'_> {
|
||||||
|
crate::corporate::Corporate { idx: self }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn movements(&self) -> crate::movements::Movements<'_> {
|
||||||
|
crate::movements::Movements { idx: self }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
||||||
crate::financial::Financial { idx: self }
|
crate::financial::Financial { idx: self }
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +199,7 @@ where
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if use_cache && let Some(stale) = cache.any::<T>(spec.bucket, spec.key)? {
|
if use_cache && let Some(stale) = cache.any::<T>(spec.bucket, spec.key)? {
|
||||||
runtime::warn(format!(
|
runtime::warn(format!(
|
||||||
"network failed, serving stale cache for {}",
|
"network failed ({err}), serving stale cache for {}",
|
||||||
spec.subject
|
spec.subject
|
||||||
));
|
));
|
||||||
return Ok(hit(stale, true));
|
return Ok(hit(stale, true));
|
||||||
|
|
|
||||||
109
crates/idx-core/src/corporate/mod.rs
Normal file
109
crates/idx-core/src/corporate/mod.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
//! Corporate actions (cash and stock dividends, bonus shares, rights issues,
|
||||||
|
//! warrants, splits, shareholders' meetings, public exposes, IPOs, tender
|
||||||
|
//! offers) from the user's Stockbit session: one stock's history, or the
|
||||||
|
//! market's calendar. Stockbit serves one kind per request, so a query costs
|
||||||
|
//! one request per kind (paced 1 s apart), each cached for 6 hours.
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use chrono::{NaiveDate, Utc};
|
||||||
|
|
||||||
|
use crate::api::{corporate_action_provider, stockbit, today_wib};
|
||||||
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use types::{ActionKind, CalendarRange, CorporateAction};
|
||||||
|
|
||||||
|
/// Announcements change a few times a day at most.
|
||||||
|
const TTL_SECS: u64 = 6 * 3600;
|
||||||
|
const BUCKET: &str = "stockbit-corpaction";
|
||||||
|
|
||||||
|
pub struct Corporate<'a> {
|
||||||
|
pub(crate) idx: &'a Idx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Corporate<'_> {
|
||||||
|
/// Today in WIB.
|
||||||
|
pub fn today(&self) -> NaiveDate {
|
||||||
|
today_wib()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One IDX stock's corporate actions of `kinds`, newest first.
|
||||||
|
pub fn actions(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
kinds: &[ActionKind],
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||||
|
let (resolved, _) = self.idx.idx_code(symbol, "corporate actions")?;
|
||||||
|
let mut fetched = self.fetch(kinds, Some(&resolved), policy)?;
|
||||||
|
fetched
|
||||||
|
.data
|
||||||
|
.sort_by(|a, b| b.date.cmp(&a.date).then(a.kind.cmp(&b.kind)));
|
||||||
|
Ok(fetched)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The market's corporate actions of `kinds` dated within `range` (see
|
||||||
|
/// `CorporateAction::date`), plus offers still open in it (see
|
||||||
|
/// `CalendarRange::includes`), oldest first.
|
||||||
|
pub fn calendar(
|
||||||
|
&self,
|
||||||
|
range: CalendarRange,
|
||||||
|
kinds: &[ActionKind],
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||||
|
let fetched = self.fetch(kinds, None, policy)?;
|
||||||
|
Ok(fetched.map(|mut actions| {
|
||||||
|
actions.retain(|a| range.includes(a));
|
||||||
|
actions.sort_by(|a, b| (a.date, &a.symbol, a.kind).cmp(&(b.date, &b.symbol, b.kind)));
|
||||||
|
actions
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every kind in turn, through the cache; the result is as old as its
|
||||||
|
/// oldest part and stale if any part is.
|
||||||
|
fn fetch(
|
||||||
|
&self,
|
||||||
|
kinds: &[ActionKind],
|
||||||
|
resolved: Option<&str>,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||||
|
if kinds.is_empty() {
|
||||||
|
return Err(IdxError::InvalidInput(
|
||||||
|
"name at least one kind of corporate action".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let provider = corporate_action_provider();
|
||||||
|
let subject = resolved.unwrap_or("corporate actions");
|
||||||
|
let mut merged = Fetched {
|
||||||
|
data: Vec::new(),
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
fetched_at: Utc::now(),
|
||||||
|
stale: false,
|
||||||
|
};
|
||||||
|
let mut seen = Vec::with_capacity(kinds.len());
|
||||||
|
for &kind in kinds {
|
||||||
|
if seen.contains(&kind) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.push(kind);
|
||||||
|
let key = format!("{}:{}", kind.as_str(), resolved.unwrap_or("market"));
|
||||||
|
let part = cached_fetch(
|
||||||
|
&self.idx.cache,
|
||||||
|
CacheSpec {
|
||||||
|
bucket: BUCKET,
|
||||||
|
key: &key,
|
||||||
|
ttl_secs: TTL_SECS,
|
||||||
|
subject,
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
|| provider.corporate_actions(kind, resolved),
|
||||||
|
Ok,
|
||||||
|
)?;
|
||||||
|
merged.fetched_at = merged.fetched_at.min(part.fetched_at);
|
||||||
|
merged.stale |= part.stale;
|
||||||
|
merged.data.extend(part.data);
|
||||||
|
}
|
||||||
|
Ok(merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
419
crates/idx-core/src/corporate/types.rs
Normal file
419
crates/idx-core/src/corporate/types.rs
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
use chrono::{Duration, NaiveDate};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
/// The kinds of corporate action Stockbit lists, one request each. JSON
|
||||||
|
/// names them as `--kind` does (`as_str`), so an output kind can be passed
|
||||||
|
/// back in.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum ActionKind {
|
||||||
|
/// Cash dividend.
|
||||||
|
Dividend,
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "clap",
|
||||||
|
value(name = "stock-dividend", alias = "stock_dividend")
|
||||||
|
)]
|
||||||
|
#[serde(alias = "stock_dividend")]
|
||||||
|
StockDividend,
|
||||||
|
/// Bonus shares.
|
||||||
|
Bonus,
|
||||||
|
/// Rights issue (HMETD).
|
||||||
|
Rights,
|
||||||
|
Warrant,
|
||||||
|
/// Stock split.
|
||||||
|
Split,
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "clap",
|
||||||
|
value(name = "reverse-split", alias = "reverse_split")
|
||||||
|
)]
|
||||||
|
#[serde(alias = "reverse_split")]
|
||||||
|
ReverseSplit,
|
||||||
|
/// Shareholders' meeting (RUPS).
|
||||||
|
Meeting,
|
||||||
|
/// Public expose.
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "clap",
|
||||||
|
value(name = "expose", alias = "public-expose", alias = "public_expose")
|
||||||
|
)]
|
||||||
|
#[serde(rename = "expose", alias = "public_expose")]
|
||||||
|
PublicExpose,
|
||||||
|
Ipo,
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "clap",
|
||||||
|
value(name = "tender", alias = "tender-offer", alias = "tender_offer")
|
||||||
|
)]
|
||||||
|
#[serde(rename = "tender", alias = "tender_offer")]
|
||||||
|
TenderOffer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionKind {
|
||||||
|
pub const ALL: [Self; 11] = [
|
||||||
|
Self::Dividend,
|
||||||
|
Self::StockDividend,
|
||||||
|
Self::Bonus,
|
||||||
|
Self::Rights,
|
||||||
|
Self::Warrant,
|
||||||
|
Self::Split,
|
||||||
|
Self::ReverseSplit,
|
||||||
|
Self::Meeting,
|
||||||
|
Self::PublicExpose,
|
||||||
|
Self::Ipo,
|
||||||
|
Self::TenderOffer,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// What one stock's history covers by default: everything but its IPO.
|
||||||
|
pub const STOCK_DEFAULT: [Self; 10] = [
|
||||||
|
Self::Dividend,
|
||||||
|
Self::StockDividend,
|
||||||
|
Self::Bonus,
|
||||||
|
Self::Rights,
|
||||||
|
Self::Warrant,
|
||||||
|
Self::Split,
|
||||||
|
Self::ReverseSplit,
|
||||||
|
Self::Meeting,
|
||||||
|
Self::PublicExpose,
|
||||||
|
Self::TenderOffer,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The CLI's name (`--kind`), also used in MCP inputs.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Dividend => "dividend",
|
||||||
|
Self::StockDividend => "stock-dividend",
|
||||||
|
Self::Bonus => "bonus",
|
||||||
|
Self::Rights => "rights",
|
||||||
|
Self::Warrant => "warrant",
|
||||||
|
Self::Split => "split",
|
||||||
|
Self::ReverseSplit => "reverse-split",
|
||||||
|
Self::Meeting => "meeting",
|
||||||
|
Self::PublicExpose => "expose",
|
||||||
|
Self::Ipo => "ipo",
|
||||||
|
Self::TenderOffer => "tender",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the action entitles holders on a cum date (and so moves the
|
||||||
|
/// price on the ex date).
|
||||||
|
pub fn is_entitlement(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::Dividend
|
||||||
|
| Self::StockDividend
|
||||||
|
| Self::Bonus
|
||||||
|
| Self::Rights
|
||||||
|
| Self::Split
|
||||||
|
| Self::ReverseSplit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for ActionKind {
|
||||||
|
type Err = IdxError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
let wanted = value.trim().to_ascii_lowercase().replace('_', "-");
|
||||||
|
// The names JSON carried before they matched `--kind`, which an
|
||||||
|
// agent may pass back from an earlier answer.
|
||||||
|
let wanted = match wanted.as_str() {
|
||||||
|
"public-expose" => "expose",
|
||||||
|
"tender-offer" => "tender",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
Self::ALL
|
||||||
|
.into_iter()
|
||||||
|
.find(|kind| kind.as_str() == wanted)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect();
|
||||||
|
IdxError::InvalidInput(format!(
|
||||||
|
"invalid kind '{value}' (expected one of: {})",
|
||||||
|
names.join(", ")
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `old : new` as Stockbit states it: a 1:5 split turns 1 share into 5; a
|
||||||
|
/// 655:104 rights issue gives 104 rights per 655 shares held; a 6:4 bonus
|
||||||
|
/// gives 4 bonus shares per 6 held.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Ratio {
|
||||||
|
pub old: u64,
|
||||||
|
pub new: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inclusive dates.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DateRange {
|
||||||
|
pub start: NaiveDate,
|
||||||
|
pub end: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One corporate action of one stock. Which fields are present depends on
|
||||||
|
/// `kind`; absent ones are left out of JSON.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CorporateAction {
|
||||||
|
/// `BBCA.JK`.
|
||||||
|
pub symbol: String,
|
||||||
|
pub kind: ActionKind,
|
||||||
|
/// Where the action sits on a calendar: the cum date for entitlements
|
||||||
|
/// (the last day to buy and be entitled), else the meeting or expose
|
||||||
|
/// day, the listing day, a warrant's first trading day, or a tender
|
||||||
|
/// offer's first day. An entitlement Stockbit gives no cum date for is
|
||||||
|
/// placed on its ex date, and `cum_date` is then absent.
|
||||||
|
pub date: NaiveDate,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cum_date: Option<NaiveDate>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ex_date: Option<NaiveDate>,
|
||||||
|
/// Recording date; for a meeting, the date that decides who may attend.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub record_date: Option<NaiveDate>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pay_date: Option<NaiveDate>,
|
||||||
|
/// Cash dividend per share as Stockbit gives it: IDR to the sen (e.g.
|
||||||
|
/// 3.8), another `currency` to 6 decimals (e.g. USD 0.0012).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cash_per_share: Option<f64>,
|
||||||
|
/// The dividend's currency (ISO code, e.g. `USD`) when it is not IDR;
|
||||||
|
/// absent for IDR, which every recorded dividend was.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub currency: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ratio: Option<Ratio>,
|
||||||
|
/// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a
|
||||||
|
/// 6:4 bonus.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub factor: Option<f64>,
|
||||||
|
/// Whole IDR: the rights exercise price, a warrant's exercise price, the
|
||||||
|
/// tender offer price or the final IPO price.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub price: Option<i64>,
|
||||||
|
/// When rights or warrants trade, a tender offer runs, or an IPO is
|
||||||
|
/// offered.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub period: Option<DateRange>,
|
||||||
|
/// When warrants can be exercised.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub exercise: Option<DateRange>,
|
||||||
|
/// Shares offered (IPO) or sought (tender offer).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub shares: Option<u64>,
|
||||||
|
/// Share of the company a tender offer seeks, basis points.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub percentage_bps: Option<i64>,
|
||||||
|
/// Meeting or expose time, as Stockbit writes it (WIB).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub time: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub venue: Option<String>,
|
||||||
|
/// The issuer's name, when Stockbit gives it (IPO, tender offer).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CorporateAction {
|
||||||
|
pub fn new(symbol: String, kind: ActionKind, date: NaiveDate) -> Self {
|
||||||
|
Self {
|
||||||
|
symbol,
|
||||||
|
kind,
|
||||||
|
date,
|
||||||
|
cum_date: None,
|
||||||
|
ex_date: None,
|
||||||
|
record_date: None,
|
||||||
|
pay_date: None,
|
||||||
|
cash_per_share: None,
|
||||||
|
currency: None,
|
||||||
|
ratio: None,
|
||||||
|
factor: None,
|
||||||
|
price: None,
|
||||||
|
period: None,
|
||||||
|
exercise: None,
|
||||||
|
shares: None,
|
||||||
|
percentage_bps: None,
|
||||||
|
time: None,
|
||||||
|
venue: None,
|
||||||
|
name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default calendar span, days from today.
|
||||||
|
pub const CALENDAR_DEFAULT_DAYS: i64 = 30;
|
||||||
|
/// Longest calendar range, days.
|
||||||
|
pub const CALENDAR_MAX_DAYS: i64 = 366;
|
||||||
|
|
||||||
|
/// The dates a market-wide calendar covers, inclusive.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CalendarRange {
|
||||||
|
pub from: NaiveDate,
|
||||||
|
pub to: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarRange {
|
||||||
|
/// `from` defaults to today, `to` to 30 days after `from`; at most
|
||||||
|
/// `CALENDAR_MAX_DAYS` days, counting both ends.
|
||||||
|
pub fn from_args(
|
||||||
|
from: Option<&str>,
|
||||||
|
to: Option<&str>,
|
||||||
|
today: NaiveDate,
|
||||||
|
) -> 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()?.unwrap_or(today);
|
||||||
|
let to = match to {
|
||||||
|
Some(v) => parse("to", v)?,
|
||||||
|
None => from
|
||||||
|
.checked_add_signed(Duration::days(CALENDAR_DEFAULT_DAYS))
|
||||||
|
.ok_or_else(|| IdxError::InvalidInput(format!("from ({from}) is out of range")))?,
|
||||||
|
};
|
||||||
|
if to < from {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"to ({to}) is before from ({from})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if (to - from).num_days() >= CALENDAR_MAX_DAYS {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"a calendar covers at most {CALENDAR_MAX_DAYS} days"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Self { from, to })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(&self, date: NaiveDate) -> bool {
|
||||||
|
(self.from..=self.to).contains(&date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `action` belongs on this calendar: dated in it, or an offer
|
||||||
|
/// still open in it (rights trading, a tender offer, an IPO offer), so a
|
||||||
|
/// calendar shows what can still be acted on. A warrant's windows run
|
||||||
|
/// for years, so a warrant counts by its date alone.
|
||||||
|
pub fn includes(&self, action: &CorporateAction) -> bool {
|
||||||
|
let open_offer = matches!(
|
||||||
|
action.kind,
|
||||||
|
ActionKind::Rights | ActionKind::TenderOffer | ActionKind::Ipo
|
||||||
|
) && action
|
||||||
|
.period
|
||||||
|
.is_some_and(|period| period.start <= self.to && period.end >= self.from);
|
||||||
|
self.contains(action.date) || open_offer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn day(s: &str) -> NaiveDate {
|
||||||
|
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A calendar shows what is dated in it and the offers still open in it;
|
||||||
|
/// a warrant's years-long windows do not put it on every calendar.
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_includes_offers_still_open() {
|
||||||
|
let range = CalendarRange {
|
||||||
|
from: day("2026-11-03"),
|
||||||
|
to: day("2026-11-12"),
|
||||||
|
};
|
||||||
|
let with_period = |kind, date: &str, start: &str, end: &str| {
|
||||||
|
let mut action = CorporateAction::new("AUTO.JK".into(), kind, day(date));
|
||||||
|
action.period = Some(DateRange {
|
||||||
|
start: day(start),
|
||||||
|
end: day(end),
|
||||||
|
});
|
||||||
|
action
|
||||||
|
};
|
||||||
|
let tender = with_period(
|
||||||
|
ActionKind::TenderOffer,
|
||||||
|
"2026-10-13",
|
||||||
|
"2026-10-13",
|
||||||
|
"2026-11-11",
|
||||||
|
);
|
||||||
|
assert!(range.includes(&tender), "open until 11-11");
|
||||||
|
let rights = with_period(ActionKind::Rights, "2026-10-20", "2026-10-28", "2026-11-03");
|
||||||
|
assert!(range.includes(&rights), "trades on the first day");
|
||||||
|
let closed = with_period(
|
||||||
|
ActionKind::TenderOffer,
|
||||||
|
"2026-09-01",
|
||||||
|
"2026-09-01",
|
||||||
|
"2026-11-02",
|
||||||
|
);
|
||||||
|
assert!(!range.includes(&closed), "closed the day before");
|
||||||
|
let warrant = with_period(
|
||||||
|
ActionKind::Warrant,
|
||||||
|
"2025-01-10",
|
||||||
|
"2025-01-10",
|
||||||
|
"2028-01-10",
|
||||||
|
);
|
||||||
|
assert!(!range.includes(&warrant), "a warrant counts by its date");
|
||||||
|
let dated = CorporateAction::new("BBCA.JK".into(), ActionKind::Dividend, day("2026-11-12"));
|
||||||
|
assert!(range.includes(&dated));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_range_defaults_and_bounds() {
|
||||||
|
let today = day("2026-11-03");
|
||||||
|
let range = CalendarRange::from_args(None, None, today).unwrap();
|
||||||
|
assert_eq!((range.from, range.to), (today, day("2026-12-03")));
|
||||||
|
let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap();
|
||||||
|
assert_eq!(range.to, day("2026-12-10"));
|
||||||
|
// 366 days counting both ends is the most.
|
||||||
|
let range = CalendarRange::from_args(Some("2028-01-01"), Some("2028-12-31"), today);
|
||||||
|
assert!(range.is_ok());
|
||||||
|
for (from, to) in [
|
||||||
|
(Some("2026-11-10"), Some("2026-11-09")),
|
||||||
|
(Some("2026-01-01"), Some("2027-06-01")),
|
||||||
|
(Some("2028-01-01"), Some("2029-01-01")),
|
||||||
|
(Some("11/10/2026"), None),
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
CalendarRange::from_args(from, to, today),
|
||||||
|
Err(IdxError::InvalidInput(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last date there is: 30 days on is an error, not a panic.
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_range_at_the_end_of_time() {
|
||||||
|
let last = NaiveDate::MAX.format("%Y-%m-%d").to_string();
|
||||||
|
let err = CalendarRange::from_args(Some(&last), None, day("2026-11-03")).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("out of range"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_kind_names_round_trip() {
|
||||||
|
for kind in ActionKind::ALL {
|
||||||
|
assert_eq!(kind.as_str().parse::<ActionKind>().unwrap(), kind);
|
||||||
|
}
|
||||||
|
assert!("rups".parse::<ActionKind>().is_err());
|
||||||
|
assert_eq!(
|
||||||
|
"stock_dividend".parse::<ActionKind>().unwrap(),
|
||||||
|
ActionKind::StockDividend
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON's `kind` is the name `--kind` and MCP take.
|
||||||
|
#[test]
|
||||||
|
fn test_action_kind_json_is_the_input_name() {
|
||||||
|
for kind in ActionKind::ALL {
|
||||||
|
let json = serde_json::to_value(kind).unwrap();
|
||||||
|
assert_eq!(json, kind.as_str(), "{kind:?}");
|
||||||
|
assert_eq!(serde_json::from_value::<ActionKind>(json).unwrap(), kind);
|
||||||
|
}
|
||||||
|
// Entries cached before the names changed still read.
|
||||||
|
let old: ActionKind = serde_json::from_str("\"public_expose\"").unwrap();
|
||||||
|
assert_eq!(old, ActionKind::PublicExpose);
|
||||||
|
// And as input, so a kind copied from an older answer works.
|
||||||
|
assert_eq!("public_expose".parse::<ActionKind>().unwrap(), old);
|
||||||
|
assert_eq!(
|
||||||
|
"tender_offer".parse::<ActionKind>().unwrap(),
|
||||||
|
ActionKind::TenderOffer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ pub mod broker;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
mod client;
|
mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod corporate;
|
||||||
pub mod curl_impersonate;
|
pub mod curl_impersonate;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod financial;
|
pub mod financial;
|
||||||
|
|
@ -15,6 +16,7 @@ pub mod flow;
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
pub mod investor;
|
pub mod investor;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
|
pub mod movements;
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
pub mod ownership;
|
pub mod ownership;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
|
|
|
||||||
165
crates/idx-core/src/movements/mod.rs
Normal file
165
crates/idx-core/src/movements/mod.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
//! Shareholder movements from the user's Stockbit session: reported changes
|
||||||
|
//! in insiders' and major holders' stakes (IDX insider disclosures and KSEI's
|
||||||
|
//! 5% holder changes), one stock's or the market's, newest first. Each page
|
||||||
|
//! is 50 rows and one request (paced 1 s apart). A query's pages are read
|
||||||
|
//! together and cached together for an hour, so they come from one moment
|
||||||
|
//! of a feed that gains rows at the top.
|
||||||
|
//!
|
||||||
|
//! These are the reports as filed. Whether they predict anything is not
|
||||||
|
//! claimed here: share plans read as a cluster of buys at one price, and a
|
||||||
|
//! row appears days after the trade (see `docs/STOCKBIT.md`).
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::api::{ShareholderMovementProvider, shareholder_movement_provider, stockbit};
|
||||||
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use crate::runtime;
|
||||||
|
use types::{MAX_PAGES, MovementList, MovementQuery};
|
||||||
|
|
||||||
|
/// The feed gains rows through the day; an hour keeps a session's repeated
|
||||||
|
/// queries cheap without hiding a day's filings for long.
|
||||||
|
const TTL_SECS: u64 = 3600;
|
||||||
|
const BUCKET: &str = "stockbit-movements";
|
||||||
|
|
||||||
|
pub struct Movements<'a> {
|
||||||
|
pub(crate) idx: &'a Idx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Movements<'_> {
|
||||||
|
/// Up to `query.pages` pages of the feed (at most `MAX_PAGES`), newest
|
||||||
|
/// first; `more` says whether rows remain past them.
|
||||||
|
///
|
||||||
|
/// When a page after the first fails and no cached answer is at hand,
|
||||||
|
/// the rows read before it are returned with `incomplete` saying why,
|
||||||
|
/// and are not cached.
|
||||||
|
pub fn list(
|
||||||
|
&self,
|
||||||
|
query: &MovementQuery,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<MovementList>, IdxError> {
|
||||||
|
let mut query = query.clone();
|
||||||
|
query.pages = query.pages.clamp(1, MAX_PAGES);
|
||||||
|
if let Some(symbol) = &query.symbol {
|
||||||
|
let (resolved, _) = self.idx.idx_code(symbol, "shareholder movements")?;
|
||||||
|
query.symbol = Some(resolved);
|
||||||
|
}
|
||||||
|
let provider = shareholder_movement_provider();
|
||||||
|
let subject = query
|
||||||
|
.symbol
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "shareholder movements".to_string());
|
||||||
|
let key = cache_key(&query);
|
||||||
|
let partial = RefCell::new(None);
|
||||||
|
let result = cached_fetch(
|
||||||
|
&self.idx.cache,
|
||||||
|
CacheSpec {
|
||||||
|
bucket: BUCKET,
|
||||||
|
key: &key,
|
||||||
|
ttl_secs: TTL_SECS,
|
||||||
|
subject: &subject,
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
|| read_pages(provider.as_ref(), &query, &partial),
|
||||||
|
Ok,
|
||||||
|
);
|
||||||
|
match (result, partial.into_inner()) {
|
||||||
|
(Err(err), Some(mut list)) => {
|
||||||
|
runtime::warn(format!(
|
||||||
|
"{err}; showing the {} rows read before it",
|
||||||
|
list.movements.len()
|
||||||
|
));
|
||||||
|
list.incomplete = Some(err.to_string());
|
||||||
|
Ok(Fetched {
|
||||||
|
data: list,
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
fetched_at: Utc::now(),
|
||||||
|
stale: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(result, _) => result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the query's pages in turn. A failure after the first page leaves
|
||||||
|
/// the rows read so far in `partial` and fails, so they are not cached.
|
||||||
|
fn read_pages(
|
||||||
|
provider: &dyn ShareholderMovementProvider,
|
||||||
|
query: &MovementQuery,
|
||||||
|
partial: &RefCell<Option<MovementList>>,
|
||||||
|
) -> Result<MovementList, IdxError> {
|
||||||
|
let mut list = MovementList::default();
|
||||||
|
for page in 1..=query.pages {
|
||||||
|
match provider.shareholder_movements(query, page) {
|
||||||
|
Ok(part) => {
|
||||||
|
list.movements.extend(part.movements);
|
||||||
|
list.more = part.more;
|
||||||
|
if !part.more {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) if page > 1 => {
|
||||||
|
list.more = true;
|
||||||
|
*partial.borrow_mut() = Some(list);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every filter and the page count, so no two queries share an entry.
|
||||||
|
fn cache_key(query: &MovementQuery) -> String {
|
||||||
|
let opt = |value: Option<String>| value.unwrap_or_else(|| "-".to_string());
|
||||||
|
format!(
|
||||||
|
"{}:{}:{}:{}:{}:{}:{}p",
|
||||||
|
query.symbol.as_deref().unwrap_or("market"),
|
||||||
|
opt(query.from.map(|d| d.to_string())),
|
||||||
|
opt(query.to.map(|d| d.to_string())),
|
||||||
|
query.holder_id.as_deref().unwrap_or("-"),
|
||||||
|
query.action.map(|a| a.as_str()).unwrap_or("-"),
|
||||||
|
query.source.map(|s| s.as_str()).unwrap_or("-"),
|
||||||
|
query.pages,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use types::{ActionFilter, MovementSource};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_keys_differ_by_every_filter() {
|
||||||
|
let base = MovementQuery::from_args(None, None, None, None, 1).unwrap();
|
||||||
|
let mut two = base.clone();
|
||||||
|
two.pages = 2;
|
||||||
|
let mut keys = vec![cache_key(&base), cache_key(&two)];
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.symbol = Some("BBCA.JK".to_string());
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.from = chrono::NaiveDate::from_ymd_opt(2026, 10, 1);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.to = chrono::NaiveDate::from_ymd_opt(2026, 10, 1);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.holder_id = Some("14074".to_string());
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.action = Some(ActionFilter::Sell);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base;
|
||||||
|
q.source = Some(MovementSource::Ksei);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let unique: std::collections::HashSet<_> = keys.iter().collect();
|
||||||
|
assert_eq!(unique.len(), keys.len(), "{keys:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
339
crates/idx-core/src/movements/types.rs
Normal file
339
crates/idx-core/src/movements/types.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
/// What a holder's row records, as Stockbit labels it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MovementAction {
|
||||||
|
Buy,
|
||||||
|
Sell,
|
||||||
|
/// Shares moved between accounts or parties, not a market trade.
|
||||||
|
Transfer,
|
||||||
|
/// A crossing: a negotiated trade between two parties.
|
||||||
|
Cross,
|
||||||
|
RightsIssue,
|
||||||
|
PrivatePlacement,
|
||||||
|
TenderOffer,
|
||||||
|
BonusStock,
|
||||||
|
StockDividend,
|
||||||
|
CashDividend,
|
||||||
|
/// A label Stockbit added after this release.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovementAction {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Buy => "buy",
|
||||||
|
Self::Sell => "sell",
|
||||||
|
Self::Transfer => "transfer",
|
||||||
|
Self::Cross => "cross",
|
||||||
|
Self::RightsIssue => "rights_issue",
|
||||||
|
Self::PrivatePlacement => "private_placement",
|
||||||
|
Self::TenderOffer => "tender_offer",
|
||||||
|
Self::BonusStock => "bonus_stock",
|
||||||
|
Self::StockDividend => "stock_dividend",
|
||||||
|
Self::CashDividend => "cash_dividend",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `filter` (Stockbit's `action_type`) selects this action.
|
||||||
|
pub fn matches(self, filter: ActionFilter) -> bool {
|
||||||
|
match filter {
|
||||||
|
ActionFilter::Buy => self == Self::Buy,
|
||||||
|
ActionFilter::Sell => self == Self::Sell,
|
||||||
|
ActionFilter::Transfer => self == Self::Transfer,
|
||||||
|
ActionFilter::Cross => self == Self::Cross,
|
||||||
|
ActionFilter::CorporateAction => matches!(
|
||||||
|
self,
|
||||||
|
Self::RightsIssue
|
||||||
|
| Self::PrivatePlacement
|
||||||
|
| Self::TenderOffer
|
||||||
|
| Self::BonusStock
|
||||||
|
| Self::StockDividend
|
||||||
|
| Self::CashDividend
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The action filters Stockbit's feed takes (`action_type`). Corporate
|
||||||
|
/// action covers rights issues, private placements and the like.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum ActionFilter {
|
||||||
|
Buy,
|
||||||
|
Sell,
|
||||||
|
Transfer,
|
||||||
|
Cross,
|
||||||
|
#[cfg_attr(feature = "clap", value(name = "corporate-action"))]
|
||||||
|
CorporateAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionFilter {
|
||||||
|
pub const ALL: [Self; 5] = [
|
||||||
|
Self::Buy,
|
||||||
|
Self::Sell,
|
||||||
|
Self::Transfer,
|
||||||
|
Self::Cross,
|
||||||
|
Self::CorporateAction,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The CLI's name (`--action`), also used in MCP inputs.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Buy => "buy",
|
||||||
|
Self::Sell => "sell",
|
||||||
|
Self::Transfer => "transfer",
|
||||||
|
Self::Cross => "cross",
|
||||||
|
Self::CorporateAction => "corporate-action",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for ActionFilter {
|
||||||
|
type Err = IdxError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
let wanted = value.trim().to_ascii_lowercase().replace('_', "-");
|
||||||
|
Self::ALL
|
||||||
|
.into_iter()
|
||||||
|
.find(|filter| filter.as_str() == wanted)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
IdxError::InvalidInput(format!(
|
||||||
|
"unknown action '{value}'; use one of: {}",
|
||||||
|
Self::ALL.map(Self::as_str).join(", ")
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who reported the change: IDX insider disclosures (directors,
|
||||||
|
/// commissioners and other insiders, any size), or KSEI's daily changes of
|
||||||
|
/// holders at or above 5%.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MovementSource {
|
||||||
|
Idx,
|
||||||
|
Ksei,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovementSource {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Idx => "idx",
|
||||||
|
Self::Ksei => "ksei",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for MovementSource {
|
||||||
|
type Err = IdxError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"idx" => Ok(Self::Idx),
|
||||||
|
"ksei" => Ok(Self::Ksei),
|
||||||
|
_ => Err(IdxError::InvalidInput(format!(
|
||||||
|
"unknown source '{value}'; use idx or ksei"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One reported change in a holder's stake. Share counts are exact;
|
||||||
|
/// percentages are of the company's shares, in basis points as Stockbit
|
||||||
|
/// rounds them, so a small insider's stake can read 0.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ShareholderMovement {
|
||||||
|
pub symbol: String,
|
||||||
|
/// The day of the change (the trade or the register date).
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub holder: String,
|
||||||
|
/// Stockbit's id for the holder; `--holder` takes it.
|
||||||
|
pub holder_id: String,
|
||||||
|
pub action: MovementAction,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source: Option<MovementSource>,
|
||||||
|
/// `Local` or `Foreign`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub locality: Option<String>,
|
||||||
|
/// Insider roles at the company: `director`, `commissioner`.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
pub shares_before: i64,
|
||||||
|
pub shares_after: i64,
|
||||||
|
/// Signed: negative for a sale.
|
||||||
|
pub shares_change: i64,
|
||||||
|
pub percentage_before_bps: i64,
|
||||||
|
pub percentage_after_bps: i64,
|
||||||
|
pub percentage_change_bps: i64,
|
||||||
|
/// Whole IDR per share.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub price: Option<i64>,
|
||||||
|
/// The price is Stockbit's estimate (KSEI rows report no price).
|
||||||
|
pub price_estimated: bool,
|
||||||
|
/// Whole IDR: shares changed times price.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub value: Option<i64>,
|
||||||
|
/// The stated purpose, free text (`Investasi`, `Divestasi`, ...).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub purpose: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of Stockbit's feed (50 rows), newest first.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MovementPage {
|
||||||
|
pub movements: Vec<ShareholderMovement>,
|
||||||
|
/// Whether a further page exists.
|
||||||
|
pub more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most pages one query reads (500 rows).
|
||||||
|
pub const MAX_PAGES: u32 = 10;
|
||||||
|
|
||||||
|
/// What to list. `symbol` is resolved (`BBCA.JK`) once it reaches a
|
||||||
|
/// provider; `None` is the whole market.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct MovementQuery {
|
||||||
|
pub symbol: Option<String>,
|
||||||
|
pub from: Option<NaiveDate>,
|
||||||
|
pub to: Option<NaiveDate>,
|
||||||
|
pub holder_id: Option<String>,
|
||||||
|
pub action: Option<ActionFilter>,
|
||||||
|
pub source: Option<MovementSource>,
|
||||||
|
/// Pages to read, 1 to `MAX_PAGES`.
|
||||||
|
pub pages: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovementQuery {
|
||||||
|
/// Validates user input: dates as YYYY-MM-DD with `from` not after
|
||||||
|
/// `to`, a numeric holder id, and 1 to `MAX_PAGES` pages.
|
||||||
|
pub fn from_args(
|
||||||
|
symbol: Option<&str>,
|
||||||
|
from: Option<&str>,
|
||||||
|
to: Option<&str>,
|
||||||
|
holder_id: Option<&str>,
|
||||||
|
pages: u32,
|
||||||
|
) -> Result<Self, IdxError> {
|
||||||
|
let parse = |name: &str, value: &str| {
|
||||||
|
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| {
|
||||||
|
IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD"))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let from = from.map(|v| parse("from", v)).transpose()?;
|
||||||
|
let to = to.map(|v| parse("to", v)).transpose()?;
|
||||||
|
if let (Some(from), Some(to)) = (from, to)
|
||||||
|
&& to < from
|
||||||
|
{
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"to ({to}) is before from ({from})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let holder_id = holder_id.map(str::trim).filter(|id| !id.is_empty());
|
||||||
|
if let Some(id) = holder_id
|
||||||
|
&& !id.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"holder '{id}' is not a holder id; ids are digits, shown in the HOLDER ID \
|
||||||
|
column and as holder_id in JSON"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !(1..=MAX_PAGES).contains(&pages) {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"pages must be 1 to {MAX_PAGES}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
symbol: symbol
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
holder_id: holder_id.map(str::to_string),
|
||||||
|
action: None,
|
||||||
|
source: None,
|
||||||
|
pages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `movement` passes the query's filters (what Stockbit applies
|
||||||
|
/// on its side; the mock provider uses it).
|
||||||
|
pub fn admits(&self, movement: &ShareholderMovement) -> bool {
|
||||||
|
self.from.is_none_or(|from| movement.date >= from)
|
||||||
|
&& self.to.is_none_or(|to| movement.date <= to)
|
||||||
|
&& self
|
||||||
|
.holder_id
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|id| &movement.holder_id == id)
|
||||||
|
&& self
|
||||||
|
.action
|
||||||
|
.is_none_or(|filter| movement.action.matches(filter))
|
||||||
|
&& self
|
||||||
|
.source
|
||||||
|
.is_none_or(|source| movement.source == Some(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rows a query read, newest first.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MovementList {
|
||||||
|
pub movements: Vec<ShareholderMovement>,
|
||||||
|
/// More rows exist past the pages read (raise `--pages`, or narrow the
|
||||||
|
/// dates).
|
||||||
|
pub more: bool,
|
||||||
|
/// Why reading stopped before the pages asked for: a later page failed,
|
||||||
|
/// and `movements` holds the rows read before it. Absent when every page
|
||||||
|
/// asked for was read.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incomplete: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_validates_dates_holder_and_pages() {
|
||||||
|
let query =
|
||||||
|
MovementQuery::from_args(Some(" bbca "), Some("2026-10-01"), None, Some("14074"), 2)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(query.symbol.as_deref(), Some("bbca"));
|
||||||
|
assert_eq!(query.from, NaiveDate::from_ymd_opt(2026, 10, 1));
|
||||||
|
assert_eq!(query.holder_id.as_deref(), Some("14074"));
|
||||||
|
|
||||||
|
for (from, to, holder, pages) in [
|
||||||
|
(Some("01-10-2026"), None, None, 1),
|
||||||
|
(Some("2026-10-02"), Some("2026-10-01"), None, 1),
|
||||||
|
(None, None, Some("RUDI"), 1),
|
||||||
|
(None, None, Some("1&page=9"), 1),
|
||||||
|
(None, None, None, 0),
|
||||||
|
(None, None, None, MAX_PAGES + 1),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
MovementQuery::from_args(None, from, to, holder, pages).is_err(),
|
||||||
|
"{from:?} {to:?} {holder:?} {pages}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn action_filter_names_round_trip() {
|
||||||
|
for filter in ActionFilter::ALL {
|
||||||
|
assert_eq!(filter.as_str().parse::<ActionFilter>().unwrap(), filter);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
"corporate_action".parse::<ActionFilter>().unwrap(),
|
||||||
|
ActionFilter::CorporateAction
|
||||||
|
);
|
||||||
|
assert!("dividend".parse::<ActionFilter>().is_err());
|
||||||
|
assert!(MovementAction::RightsIssue.matches(ActionFilter::CorporateAction));
|
||||||
|
assert!(!MovementAction::Transfer.matches(ActionFilter::CorporateAction));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_bonus.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_bonus.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"bonus":[{"company_id":"1000003000","company_symbol":"PPGL","corp_action_active":true,"sahambonus_id":"7732","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"6 : 4","stocksplit_created":"","stocksplit_cumdate":"2026-11-02","stocksplit_exdate":"2026-11-03","stocksplit_factor":"1.6667","stocksplit_new":"4","stocksplit_old":"6","stocksplit_paymentdate":"2026-11-25","stocksplit_recdate":"2026-11-04","event_note":""},{"company_id":"397","company_symbol":"SPMA","corp_action_active":false,"sahambonus_id":"7730","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"100 : 30","stocksplit_created":"","stocksplit_cumdate":"2026-07-08","stocksplit_exdate":"2026-07-09","stocksplit_factor":"1.3","stocksplit_new":"30","stocksplit_old":"100","stocksplit_paymentdate":"2026-07-30","stocksplit_recdate":"2026-07-10","event_note":""},{"company_id":"457","company_symbol":"WINS","corp_action_active":false,"sahambonus_id":"7731","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"71 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-05-25","stocksplit_exdate":"2026-05-26","stocksplit_factor":"1.0141","stocksplit_new":"1","stocksplit_old":"71","stocksplit_paymentdate":"2026-06-18","stocksplit_recdate":"2026-05-29","event_note":""},{"company_id":"1000004517","company_symbol":"MEJA","corp_action_active":false,"sahambonus_id":"7727","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"6 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-16","stocksplit_exdate":"2026-04-17","stocksplit_factor":"1.1667","stocksplit_new":"1","stocksplit_old":"6","stocksplit_paymentdate":"2026-05-08","stocksplit_recdate":"2026-04-20","event_note":""},{"company_id":"1000004196","company_symbol":"CNMA","corp_action_active":false,"sahambonus_id":"7728","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"50 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-14","stocksplit_exdate":"2026-04-15","stocksplit_factor":"1.02","stocksplit_new":"1","stocksplit_old":"50","stocksplit_paymentdate":"2026-04-28","stocksplit_recdate":"2026-04-16","event_note":""},{"company_id":"284","company_symbol":"MEGA","corp_action_active":false,"sahambonus_id":"7723","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"1 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-09","stocksplit_exdate":"2026-04-10","stocksplit_factor":"2","stocksplit_new":"1","stocksplit_old":"1","stocksplit_paymentdate":"2026-04-30","stocksplit_recdate":"2026-04-13","event_note":""},{"company_id":"1000003506","company_symbol":"WGSH","corp_action_active":false,"sahambonus_id":"7722","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"1 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-02","stocksplit_exdate":"2026-04-06","stocksplit_factor":"2","stocksplit_new":"1","stocksplit_old":"1","stocksplit_paymentdate":"2026-04-24","stocksplit_recdate":"2026-04-07","event_note":""},{"company_id":"1000000034","company_symbol":"RISE","corp_action_active":false,"sahambonus_id":"7721","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"25 : 12","stocksplit_created":"","stocksplit_cumdate":"2026-01-19","stocksplit_exdate":"2026-01-20","stocksplit_factor":"1.48","stocksplit_new":"12","stocksplit_old":"25","stocksplit_paymentdate":"2026-02-09","stocksplit_recdate":"2026-01-21","event_note":""},{"company_id":"43","company_symbol":"ASRM","corp_action_active":false,"sahambonus_id":"7720","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"20 : 1","stocksplit_created":"","stocksplit_cumdate":"2025-11-12","stocksplit_exdate":"2025-11-13","stocksplit_factor":"1.0500","stocksplit_new":"1","stocksplit_old":"20","stocksplit_paymentdate":"2025-12-05","stocksplit_recdate":"2025-11-14","event_note":""},{"company_id":"397","company_symbol":"SPMA","corp_action_active":false,"sahambonus_id":"7719","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"100 : 30","stocksplit_created":"","stocksplit_cumdate":"2025-11-07","stocksplit_exdate":"2025-11-10","stocksplit_factor":"1.3000","stocksplit_new":"30","stocksplit_old":"100","stocksplit_paymentdate":"2025-11-25","stocksplit_recdate":"2025-11-11","event_note":""}]},"message":"Successfully retrieved stock bonus data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend_bbca.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend_bbca.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_ipo.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_ipo.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex_bbca.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex_bbca.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"pubex":[{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2026-09-09","puexp_id":"156753","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"16:00:00","puexp_venue":"Public Expose Live 2026 yang tercantum pada website idx.co.id"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2025-09-11","puexp_id":"87667","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"16:00:00","puexp_venue":"Public Expose Live 2025 yang tercantum pada website idx.co.id"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2024-08-28","puexp_id":"36803","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"15:00:00","puexp_venue":"Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan pendaftaran peserta di website www.idx.co.id"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2023-11-29","puexp_id":"20156","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"10:00:00","puexp_venue":"Public Expose Live 2023"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2022-09-14","puexp_id":"19470","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"10:00:00","puexp_venue":"Public Expose Live 2022"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2021-09-08","puexp_id":"965","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"15:00:00","puexp_venue":"Public Expose Live 2021\t\t\t"}]},"message":"Successfully retrieved Public Expose data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_reversesplit.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_reversesplit.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"stock_reverse":[]},"message":"Successfully retrieved reverse split data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rightissue.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rightissue.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups_bbca.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups_bbca.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"rups":[{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-03-12","rups_id":"1460182","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 10310","rups_eligible_date":"2026-02-09","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2025-03-12","rups_id":"860258","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta, 10310","rups_eligible_date":"2025-02-07","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2024-03-14","rups_id":"385363","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta Selatan","rups_eligible_date":"2024-02-07","company_name":"","company_icon_url":""},{"company_id":"0","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2023-03-16","rups_id":"368621","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA","rups_eligible_date":"2023-02-13","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2022-03-17","rups_id":"365637","rups_time":"09:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia\nJl. M.H. Thamrin No. 1\nJakarta","rups_eligible_date":"2022-02-14","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2021-09-23","rups_id":"356628","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia\nJl. M.H. Thamrin No. 1\nJakarta","rups_eligible_date":"2021-08-27","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2021-03-29","rups_id":"314659","rups_time":"09:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Jakarta\t\t\t\t ","rups_eligible_date":"2021-02-24","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2020-07-30","rups_id":"314133","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. Jakarta\t\t\t ","rups_eligible_date":"2020-07-03","company_name":"","company_icon_url":""}]},"message":"Successfully retrieved RUPS/Shareholders meeting data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stock_dividend.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stock_dividend.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"stock_dividend":[]},"message":"Successfully retrieved stock dividend data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"stocksplit":[{"company_id":"601","company_symbol":"PRDA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-12-17","stocksplit_exdate":"2026-12-18","stocksplit_factor":"5","stocksplit_id":"6100","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2026-12-21","event_note":""},{"company_id":"600","company_symbol":"PBSA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-10-13","stocksplit_exdate":"2026-10-14","stocksplit_factor":"2","stocksplit_id":"6099","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2026-10-15","event_note":""},{"company_id":"488","company_symbol":"MLPT","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-07-20","stocksplit_exdate":"2026-07-21","stocksplit_factor":"25","stocksplit_id":"6095","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"25","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 25","stocksplit_recdate":"2026-07-22","event_note":""},{"company_id":"1000003487","company_symbol":"RMKE","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-07-16","stocksplit_exdate":"2026-07-17","stocksplit_factor":"5","stocksplit_id":"6094","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2026-07-20","event_note":""},{"company_id":"354","company_symbol":"RAJA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-07-15","stocksplit_exdate":"2026-07-16","stocksplit_factor":"5","stocksplit_id":"6092","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2026-07-17","event_note":""},{"company_id":"1000004216","company_symbol":"CYBR","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-05-12","stocksplit_exdate":"2026-05-13","stocksplit_factor":"2","stocksplit_id":"6091","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2026-05-18","event_note":""},{"company_id":"1000004217","company_symbol":"CYBR-W","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-05-12","stocksplit_exdate":"2026-05-13","stocksplit_factor":"2","stocksplit_id":"6093","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2026-05-18","event_note":""},{"company_id":"143","company_symbol":"DSSA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-04-08","stocksplit_exdate":"2026-04-09","stocksplit_factor":"25","stocksplit_id":"6089","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"25","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 25","stocksplit_recdate":"2026-04-10","event_note":""}]},"message":"Successfully retrieved stock split data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit_bbca.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit_bbca.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"stocksplit":[{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2021-10-12","stocksplit_exdate":"2021-10-13","stocksplit_factor":"5","stocksplit_id":"3091","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2021-10-14","event_note":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2008-01-25","stocksplit_exdate":"2008-01-28","stocksplit_factor":"2","stocksplit_id":"3","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2008-01-30","event_note":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2004-06-07","stocksplit_exdate":"2004-06-08","stocksplit_factor":"2","stocksplit_id":"1061","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2004-06-10","event_note":""}]},"message":"Successfully retrieved stock split data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_tenderoffer.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_tenderoffer.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"tender":[{"company_id":"46","company_name":"Astra Otoparts Tbk.","company_symbol":"AUTO","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-11-11","tender_id":"434","tender_paydate":"2026-11-18","tender_percentage":"4.95","tender_price":"3600","tender_shares":"238442293","tender_start":"2026-10-13","event_note":"","tender_price_formatted":"3,600"},{"company_id":"1000003921","company_name":"Hassana Boga Sejahtera Tbk.","company_symbol":"NAYZ","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-11-03","tender_id":"433","tender_paydate":"2026-11-10","tender_percentage":"45.84","tender_price":"75","tender_shares":"1169011472","tender_start":"2026-10-05","event_note":"","tender_price_formatted":"75"},{"company_id":"1000004066","company_name":"Era Media Sejahtera Tbk.","company_symbol":"DOOH","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-11-03","tender_id":"432","tender_paydate":"2026-11-09","tender_percentage":"33.91","tender_price":"148","tender_shares":"2624548624","tender_start":"2026-10-05","event_note":"","tender_price_formatted":"148"},{"company_id":"409","company_name":"Solusi Tunas Pratama Tbk.","company_symbol":"SUPR","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-10-21","tender_id":"431","tender_paydate":"2026-11-02","tender_percentage":"74.39","tender_price":"45000","tender_shares":"729102","tender_start":"2026-09-24","event_note":"","tender_price_formatted":"45,000"},{"company_id":"278","company_name":"Mitrabahtera Segara Sejati Tbk.","company_symbol":"MBSS","corp_action_active":false,"tender_created":"","tender_datahash":"","tender_end":"2026-10-08","tender_id":"430","tender_paydate":"2026-10-15","tender_percentage":"17.50","tender_price":"2915","tender_shares":"306259839","tender_start":"2026-09-09","event_note":"","tender_price_formatted":"2,915"},{"company_id":"194","company_name":"Inti Bangun Sejahtera Tbk.","company_symbol":"IBST","corp_action_active":false,"tender_created":"","tender_datahash":"","tender_end":"2026-10-02","tender_id":"429","tender_paydate":"2026-10-14","tender_percentage":"0.05","tender_price":"5400","tender_shares":"650832","tender_start":"2026-09-04","event_note":"","tender_price_formatted":"5,400"}]},"message":"Successfully retrieved tender offer data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_warrant.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_warrant.json
vendored
Normal file
File diff suppressed because one or more lines are too long
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,10 @@
|
||||||
use idx_core::analysis::bandar::TIERS;
|
use idx_core::analysis::bandar::TIERS;
|
||||||
use idx_core::api::types::{Interval, Period};
|
use idx_core::api::types::{Interval, Period};
|
||||||
use idx_core::broker::types::{BrokerBoard, BrokerInvestor};
|
use idx_core::broker::types::{BrokerBoard, BrokerInvestor};
|
||||||
|
use idx_core::corporate::types::ActionKind;
|
||||||
use idx_core::flow::types::FlowSource;
|
use idx_core::flow::types::FlowSource;
|
||||||
use idx_core::market::{ScreenFilter, ScreenRegion};
|
use idx_core::market::{ScreenFilter, ScreenRegion};
|
||||||
|
use idx_core::movements::types::{ActionFilter, MAX_PAGES};
|
||||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
|
@ -228,6 +230,69 @@ pub struct FlowParams {
|
||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct CorporateActionsParams {
|
||||||
|
/// IDX ticker, e.g. "BBCA".
|
||||||
|
pub symbol: String,
|
||||||
|
/// Kinds to list (default: every kind but "ipo"). Each kind is one request to Stockbit.
|
||||||
|
#[serde(default)]
|
||||||
|
#[schemars(schema_with = "action_kinds_schema")]
|
||||||
|
pub kinds: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct CorporateCalendarParams {
|
||||||
|
/// First day, YYYY-MM-DD (default today).
|
||||||
|
#[serde(default)]
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// Last day, YYYY-MM-DD (default 30 days after `from`; at most 366 days).
|
||||||
|
#[serde(default)]
|
||||||
|
pub to: Option<String>,
|
||||||
|
/// Kinds to list (default: every kind). Each kind is one request to Stockbit.
|
||||||
|
#[serde(default)]
|
||||||
|
#[schemars(schema_with = "action_kinds_schema")]
|
||||||
|
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>>,
|
||||||
|
default: &[ActionKind],
|
||||||
|
) -> Result<Vec<ActionKind>, idx_core::error::IdxError> {
|
||||||
|
match kinds {
|
||||||
|
None => Ok(default.to_vec()),
|
||||||
|
Some(kinds) => kinds.iter().map(|k| k.parse()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct ShareholdersParams {
|
pub struct ShareholdersParams {
|
||||||
/// IDX ticker, e.g. "BBCA".
|
/// IDX ticker, e.g. "BBCA".
|
||||||
|
|
@ -305,6 +370,23 @@ fn flow_source_schema(_: &mut SchemaGenerator) -> Schema {
|
||||||
string_enum(FlowSource::ALL.map(FlowSource::as_str).to_vec())
|
string_enum(FlowSource::ALL.map(FlowSource::as_str).to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn action_kinds_schema(_: &mut SchemaGenerator) -> Schema {
|
||||||
|
let kinds: Vec<&str> = ActionKind::ALL.iter().map(|k| k.as_str()).collect();
|
||||||
|
json_schema!({ "type": "array", "items": { "type": "string", "enum": kinds } })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn movement_action_schema(_: &mut SchemaGenerator) -> Schema {
|
||||||
|
string_enum(ActionFilter::ALL.map(ActionFilter::as_str).to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn movement_source_schema(_: &mut SchemaGenerator) -> Schema {
|
||||||
|
string_enum(vec!["idx", "ksei"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn movement_pages_schema(_: &mut SchemaGenerator) -> Schema {
|
||||||
|
json_schema!({ "type": "integer", "minimum": 1, "maximum": MAX_PAGES })
|
||||||
|
}
|
||||||
|
|
||||||
fn login_method_schema(_: &mut SchemaGenerator) -> Schema {
|
fn login_method_schema(_: &mut SchemaGenerator) -> Schema {
|
||||||
string_enum(LoginMethod::ALL.map(LoginMethod::as_str).to_vec())
|
string_enum(LoginMethod::ALL.map(LoginMethod::as_str).to_vec())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk
|
//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk
|
||||||
//! cache and ownership database.
|
//! cache and ownership database.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use idx_core::analysis::BandarQuery;
|
use idx_core::analysis::BandarQuery;
|
||||||
use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP};
|
use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP};
|
||||||
|
|
@ -10,13 +12,15 @@ use idx_core::analysis::fundamental::FundamentalReport;
|
||||||
use idx_core::api::stockbit::{self, signin};
|
use idx_core::api::stockbit::{self, signin};
|
||||||
use idx_core::broker::types::BrokerRange;
|
use idx_core::broker::types::BrokerRange;
|
||||||
use idx_core::config::CoreConfig;
|
use idx_core::config::CoreConfig;
|
||||||
|
use idx_core::corporate::types::{ActionKind, CalendarRange};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::financial::{EarningsFilter, StatementFilter};
|
use idx_core::financial::{EarningsFilter, StatementFilter};
|
||||||
use idx_core::flow::types::FlowRange;
|
use idx_core::flow::types::FlowRange;
|
||||||
use idx_core::investor::{HolderSource, Investor};
|
use idx_core::investor::{HolderSource, Investor};
|
||||||
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
||||||
|
use idx_core::movements::types::MovementQuery;
|
||||||
use idx_core::ownership::types::ChangeRow;
|
use idx_core::ownership::types::ChangeRow;
|
||||||
use idx_core::{CachePolicy, Fetched, Idx};
|
use idx_core::{CachePolicy, Fetched, Idx, runtime};
|
||||||
use rmcp::handler::server::router::tool::ToolRouter;
|
use rmcp::handler::server::router::tool::ToolRouter;
|
||||||
use rmcp::handler::server::wrapper::Parameters;
|
use rmcp::handler::server::wrapper::Parameters;
|
||||||
use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig};
|
use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig};
|
||||||
|
|
@ -27,9 +31,9 @@ use tokio::sync::Semaphore;
|
||||||
|
|
||||||
use crate::params::{
|
use crate::params::{
|
||||||
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
||||||
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, LoginMethod,
|
CompanySection, CorporateActionsParams, CorporateCalendarParams, EntityParams, FlowParams,
|
||||||
LoginParams, OrderBookParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams,
|
HistoryParams, IntradayParams, LoginMethod, LoginParams, MovementsParams, OrderBookParams,
|
||||||
SyncParams,
|
ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SYNC_TOOL: &str = "ownership_sync";
|
const SYNC_TOOL: &str = "ownership_sync";
|
||||||
|
|
@ -55,7 +59,9 @@ static CALL_SLOTS: Semaphore = Semaphore::const_new(4);
|
||||||
|
|
||||||
const INSTRUCTIONS: &str = "Indonesian stock market (IDX) data. Market and financial \
|
const INSTRUCTIONS: &str = "Indonesian stock market (IDX) data. Market and financial \
|
||||||
tools fetch live data (cached on disk) and wrap it as {data, source, fetched_at, stale}; \
|
tools fetch live data (cached on disk) and wrap it as {data, source, fetched_at, stale}; \
|
||||||
stale=true means an expired cache entry was served. Ownership tools read a local KSEI \
|
stale=true means an expired cache entry was served. An answer (or error) may carry \
|
||||||
|
`warnings`: what idx worked around, such as a refused Stockbit session that made history \
|
||||||
|
fall back to yahoo, or the failure behind a stale entry; pass on any that need the user. Ownership tools read a local KSEI \
|
||||||
database of holders above 1%: call ownership_status first to see which release dates \
|
database of holders above 1%: call ownership_status first to see which release dates \
|
||||||
exist. Percentages ending in _bps are basis points (5494 = 54.94%). Errors carry a \
|
exist. Percentages ending in _bps are basis points (5494 = 54.94%). Errors carry a \
|
||||||
stable code, e.g. SYMBOLNOTFOUND, RATELIMITED, INVALIDINPUT.";
|
stable code, e.g. SYMBOLNOTFOUND, RATELIMITED, INVALIDINPUT.";
|
||||||
|
|
@ -355,6 +361,94 @@ average price, the close, `change_bps` and `non_regular_bps`."
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Corporate actions of one IDX stock from the user's own Stockbit \
|
||||||
|
session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, \
|
||||||
|
reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, \
|
||||||
|
rights and splits, i.e. the last day to buy and be entitled, or the ex date when `cum_date` \
|
||||||
|
is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind \
|
||||||
|
`cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), \
|
||||||
|
`ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), \
|
||||||
|
`factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), \
|
||||||
|
`period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent \
|
||||||
|
fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached \
|
||||||
|
6 hours). AUTHERROR means there is no session: call stockbit_status, then stockbit_login."
|
||||||
|
)]
|
||||||
|
async fn get_corporate_actions(
|
||||||
|
&self,
|
||||||
|
params: Parameters<CorporateActionsParams>,
|
||||||
|
) -> ToolResult {
|
||||||
|
let CorporateActionsParams { symbol, kinds } = params.0;
|
||||||
|
self.live(move |idx| {
|
||||||
|
let kinds = action_kinds(kinds, &ActionKind::STOCK_DEFAULT)?;
|
||||||
|
idx.corporate()
|
||||||
|
.actions(&symbol, &kinds, CachePolicy::Default)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Corporate actions across IDX from the user's own Stockbit session, \
|
||||||
|
oldest first, dated from `from` to `to` (default today and the next 30 days), plus rights \
|
||||||
|
trading, tender offers and IPO offers still open in that range (their `period` overlaps it, \
|
||||||
|
though `date` may be earlier): upcoming cum dates, meetings, exposes, rights, warrants, IPOs \
|
||||||
|
and tender offers, and the past year. Each \
|
||||||
|
item has the same fields as get_corporate_actions, with `symbol`. One Stockbit request per \
|
||||||
|
kind (about 1 s each on a cold cache, then cached 6 hours); pass `kinds` to ask for less. \
|
||||||
|
AUTHERROR means there is no session: call stockbit_status, then stockbit_login."
|
||||||
|
)]
|
||||||
|
async fn get_corporate_calendar(
|
||||||
|
&self,
|
||||||
|
params: Parameters<CorporateCalendarParams>,
|
||||||
|
) -> ToolResult {
|
||||||
|
let CorporateCalendarParams { from, to, kinds } = params.0;
|
||||||
|
self.live(move |idx| {
|
||||||
|
let corporate = idx.corporate();
|
||||||
|
let range =
|
||||||
|
CalendarRange::from_args(from.as_deref(), to.as_deref(), corporate.today())?;
|
||||||
|
let kinds = action_kinds(kinds, &ActionKind::ALL)?;
|
||||||
|
corporate.calendar(range, &kinds, CachePolicy::Default)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Reported changes in insiders' and major holders' stakes in IDX stocks, \
|
||||||
|
from the user's own Stockbit session, newest first: IDX insider disclosures (directors, \
|
||||||
|
commissioners and other insiders, any size, with the trade price) and KSEI changes of holders \
|
||||||
|
at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without \
|
||||||
|
`symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis \
|
||||||
|
points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain \
|
||||||
|
past `pages`; `incomplete`, when present, says a later page failed and the rows are those read \
|
||||||
|
before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is \
|
||||||
|
usually a share plan, and rows appear days after the change. AUTHERROR means there is no \
|
||||||
|
session: call stockbit_status, then stockbit_login."
|
||||||
|
)]
|
||||||
|
async fn get_shareholder_movements(&self, params: Parameters<MovementsParams>) -> ToolResult {
|
||||||
|
let MovementsParams {
|
||||||
|
symbol,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
holder_id,
|
||||||
|
action,
|
||||||
|
source,
|
||||||
|
pages,
|
||||||
|
} = params.0;
|
||||||
|
self.live(move |idx| {
|
||||||
|
let mut query = MovementQuery::from_args(
|
||||||
|
symbol.as_deref(),
|
||||||
|
from.as_deref(),
|
||||||
|
to.as_deref(),
|
||||||
|
holder_id.as_deref(),
|
||||||
|
pages.unwrap_or(1),
|
||||||
|
)?;
|
||||||
|
query.action = action.as_deref().map(str::parse).transpose()?;
|
||||||
|
query.source = source.as_deref().map(str::parse).transpose()?;
|
||||||
|
idx.movements().list(&query, CachePolicy::Default)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "Company data for one IDX ticker: profile, financial statements, \
|
description = "Company data for one IDX ticker: profile, financial statements, \
|
||||||
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
||||||
|
|
@ -657,13 +751,43 @@ where
|
||||||
.acquire()
|
.acquire()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
||||||
let result = tokio::task::spawn_blocking(move || f().and_then(to_json))
|
let (result, warnings) = tokio::task::spawn_blocking(move || {
|
||||||
.await
|
// idx-core warns on stderr, which the agent never sees; what this
|
||||||
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
// call warns about (a fallback, a stale cache, a partial answer)
|
||||||
Ok(match result {
|
// goes into its answer instead. The call runs on this thread.
|
||||||
Ok(value) => CallToolResult::structured(value),
|
let warnings = Rc::new(RefCell::new(Vec::<String>::new()));
|
||||||
Err(err) => CallToolResult::structured_error(Value::Object(error_body(&err))),
|
let sink = {
|
||||||
|
let warnings = warnings.clone();
|
||||||
|
runtime::on_warning(move |message| {
|
||||||
|
let mut warnings = warnings.borrow_mut();
|
||||||
|
if !warnings.iter().any(|seen| seen == message) {
|
||||||
|
warnings.push(message.to_string());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let result = f().and_then(to_json);
|
||||||
|
drop(sink);
|
||||||
|
(result, warnings.take())
|
||||||
})
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
||||||
|
Ok(match result {
|
||||||
|
Ok(value) => CallToolResult::structured(with_warnings(value, warnings)),
|
||||||
|
Err(err) => CallToolResult::structured_error(with_warnings(
|
||||||
|
Value::Object(error_body(&err)),
|
||||||
|
warnings,
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds `warnings` to an answer when the call had any.
|
||||||
|
fn with_warnings(mut value: Value, warnings: Vec<String>) -> Value {
|
||||||
|
if !warnings.is_empty()
|
||||||
|
&& let Some(object) = value.as_object_mut()
|
||||||
|
{
|
||||||
|
object.insert("warnings".into(), json!(warnings));
|
||||||
|
}
|
||||||
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-symbol failures as error bodies carrying their symbol.
|
/// Per-symbol failures as error bodies carrying their symbol.
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,91 @@
|
||||||
},
|
},
|
||||||
"name": "get_company"
|
"name": "get_company"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Corporate actions of one IDX stock from the user's own Stockbit session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, rights and splits, i.e. the last day to buy and be entitled, or the ex date when `cum_date` is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), `ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), `factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), `period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours). AUTHERROR means there is no session: call stockbit_status, then stockbit_login.",
|
||||||
|
"inputSchema": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"properties": {
|
||||||
|
"kinds": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Kinds to list (default: every kind but \"ipo\"). Each kind is one request to Stockbit.",
|
||||||
|
"items": {
|
||||||
|
"enum": [
|
||||||
|
"dividend",
|
||||||
|
"stock-dividend",
|
||||||
|
"bonus",
|
||||||
|
"rights",
|
||||||
|
"warrant",
|
||||||
|
"split",
|
||||||
|
"reverse-split",
|
||||||
|
"meeting",
|
||||||
|
"expose",
|
||||||
|
"ipo",
|
||||||
|
"tender"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"symbol": {
|
||||||
|
"description": "IDX ticker, e.g. \"BBCA\".",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"symbol"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"name": "get_corporate_actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Corporate actions across IDX from the user's own Stockbit session, oldest first, dated from `from` to `to` (default today and the next 30 days), plus rights trading, tender offers and IPO offers still open in that range (their `period` overlaps it, though `date` may be earlier): upcoming cum dates, meetings, exposes, rights, warrants, IPOs and tender offers, and the past year. Each item has the same fields as get_corporate_actions, with `symbol`. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours); pass `kinds` to ask for less. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.",
|
||||||
|
"inputSchema": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"properties": {
|
||||||
|
"from": {
|
||||||
|
"default": null,
|
||||||
|
"description": "First day, YYYY-MM-DD (default today).",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"kinds": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Kinds to list (default: every kind). Each kind is one request to Stockbit.",
|
||||||
|
"items": {
|
||||||
|
"enum": [
|
||||||
|
"dividend",
|
||||||
|
"stock-dividend",
|
||||||
|
"bonus",
|
||||||
|
"rights",
|
||||||
|
"warrant",
|
||||||
|
"split",
|
||||||
|
"reverse-split",
|
||||||
|
"meeting",
|
||||||
|
"expose",
|
||||||
|
"ipo",
|
||||||
|
"tender"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Last day, YYYY-MM-DD (default 30 days after `from`; at most 366 days).",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"name": "get_corporate_calendar"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` \"ipot\" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With \"idx\": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`.",
|
"description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` \"ipot\" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With \"idx\": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
|
|
@ -470,6 +555,76 @@
|
||||||
},
|
},
|
||||||
"name": "get_quotes"
|
"name": "get_quotes"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`; `incomplete`, when present, says a later page failed and the rows are those read before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.",
|
||||||
|
"inputSchema": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Only this kind of movement; \"corporate-action\" covers rights issues, private placements and the like.",
|
||||||
|
"enum": [
|
||||||
|
"buy",
|
||||||
|
"sell",
|
||||||
|
"transfer",
|
||||||
|
"cross",
|
||||||
|
"corporate-action"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"default": null,
|
||||||
|
"description": "First day, YYYY-MM-DD.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"holder_id": {
|
||||||
|
"default": null,
|
||||||
|
"description": "One holder's movements: the `holder_id` of a row.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit.",
|
||||||
|
"maximum": 10,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"default": null,
|
||||||
|
"description": "\"idx\" (insider disclosures, any size) or \"ksei\" (holders at 5% and above).",
|
||||||
|
"enum": [
|
||||||
|
"idx",
|
||||||
|
"ksei"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"symbol": {
|
||||||
|
"default": null,
|
||||||
|
"description": "IDX ticker, e.g. \"BBCA\"; omit for the whole market.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Last day, YYYY-MM-DD.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"name": "get_shareholder_movements"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).",
|
"description": "Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ use idx_core::config::CoreConfig;
|
||||||
use idx_core::investor::{ImportSource, Investor};
|
use idx_core::investor::{ImportSource, Investor};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
const TOOLS: [&str; 18] = [
|
const TOOLS: [&str; 21] = [
|
||||||
"analyze_bandar",
|
"analyze_bandar",
|
||||||
"analyze_stock",
|
"analyze_stock",
|
||||||
"compare_stocks",
|
"compare_stocks",
|
||||||
"get_broker_summary",
|
"get_broker_summary",
|
||||||
"get_company",
|
"get_company",
|
||||||
|
"get_corporate_actions",
|
||||||
|
"get_corporate_calendar",
|
||||||
"get_foreign_flow",
|
"get_foreign_flow",
|
||||||
"get_intraday",
|
"get_intraday",
|
||||||
"get_investor_holdings",
|
"get_investor_holdings",
|
||||||
|
|
@ -22,6 +24,7 @@ const TOOLS: [&str; 18] = [
|
||||||
"get_ownership_changes",
|
"get_ownership_changes",
|
||||||
"get_price_history",
|
"get_price_history",
|
||||||
"get_quotes",
|
"get_quotes",
|
||||||
|
"get_shareholder_movements",
|
||||||
"get_shareholders",
|
"get_shareholders",
|
||||||
"ownership_status",
|
"ownership_status",
|
||||||
"screen_stocks",
|
"screen_stocks",
|
||||||
|
|
@ -371,6 +374,72 @@ fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What idx works around reaches the agent as `warnings`, since it never
|
||||||
|
/// sees idx-mcp's stderr: a refused session that made history fall back to
|
||||||
|
/// yahoo, and a movements query cut short. A clean answer has none.
|
||||||
|
#[test]
|
||||||
|
fn workarounds_reach_the_agent_as_warnings() {
|
||||||
|
let mut client = Client::start_with_env(
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
&[
|
||||||
|
TEST_SESSION,
|
||||||
|
("IDX_PROVIDER", "stockbit"),
|
||||||
|
("IDX_MOCK_STOCKBIT_STATUS", "401"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let history = client.ok(
|
||||||
|
"get_price_history",
|
||||||
|
json!({"symbol": "BBCA", "period": "1mo"}),
|
||||||
|
);
|
||||||
|
assert_eq!(history["source"], "yahoo");
|
||||||
|
let warnings = history["warnings"].as_array().expect("warnings");
|
||||||
|
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||||
|
assert!(
|
||||||
|
warnings[0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Stockbit rejected the session")
|
||||||
|
&& warnings[0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("falls back to yahoo"),
|
||||||
|
"{warnings:?}"
|
||||||
|
);
|
||||||
|
let analysis = client.ok("analyze_stock", json!({"symbol": "BBCA"}));
|
||||||
|
assert!(
|
||||||
|
analysis["warnings"][0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("falls back to yahoo")
|
||||||
|
);
|
||||||
|
// The next call starts with none of the last one's warnings.
|
||||||
|
let (is_error, body) = client.call("get_orderbook", json!({"symbol": "BBCA"}));
|
||||||
|
assert!(is_error && body["code"] == "AUTHERROR", "{body}");
|
||||||
|
assert!(body.get("warnings").is_none(), "{body}");
|
||||||
|
|
||||||
|
let mut client = Client::start_with_env(
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
&[TEST_SESSION, ("IDX_MOCK_MOVEMENTS_FAIL_PAGE", "2")],
|
||||||
|
);
|
||||||
|
let partial = client.ok(
|
||||||
|
"get_shareholder_movements",
|
||||||
|
json!({"symbol": "BBCA", "pages": 3}),
|
||||||
|
);
|
||||||
|
assert_eq!(partial["data"]["incomplete"], "provider rate limited");
|
||||||
|
assert!(
|
||||||
|
partial["warnings"][0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("showing the 8 rows read before it")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]);
|
||||||
|
let clean = client.ok("get_shareholder_movements", json!({"symbol": "BBCA"}));
|
||||||
|
assert!(clean.get("warnings").is_none(), "{clean}");
|
||||||
|
}
|
||||||
|
|
||||||
/// The sign-in tool answers at once. With no browser to open (the mock
|
/// The sign-in tool answers at once. With no browser to open (the mock
|
||||||
/// provider never starts one), auto falls back to the page and says why; the
|
/// provider never starts one), auto falls back to the page and says why; the
|
||||||
/// page takes the cookie the way the browser would, and stockbit_status
|
/// page takes the cookie the way the browser would, and stockbit_status
|
||||||
|
|
@ -502,6 +571,101 @@ fn the_order_book_needs_a_session_and_status_never_shows_tokens() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corporate_actions_need_a_session_and_keep_kinds_apart() {
|
||||||
|
let mut client = Client::start(&[], false);
|
||||||
|
let (is_error, body) = client.call("get_corporate_actions", json!({"symbol": "BBCA"}));
|
||||||
|
assert!(is_error, "{body}");
|
||||||
|
assert_eq!(body["code"], "AUTHERROR");
|
||||||
|
|
||||||
|
let mut client = Client::start_with_env(
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
&[TEST_SESSION, ("IDX_MOCK_TODAY", "2026-11-03")],
|
||||||
|
);
|
||||||
|
let actions = client.ok(
|
||||||
|
"get_corporate_actions",
|
||||||
|
json!({"symbol": "BBCA", "kinds": ["dividend", "split"]}),
|
||||||
|
);
|
||||||
|
assert_fetched(&actions);
|
||||||
|
assert_eq!(actions["source"], "stockbit");
|
||||||
|
let rows = actions["data"].as_array().unwrap();
|
||||||
|
assert_eq!(rows.len(), 40, "37 dividends and 3 splits");
|
||||||
|
assert_eq!(rows[0]["kind"], "dividend");
|
||||||
|
assert_eq!(rows[0]["date"], "2026-08-28");
|
||||||
|
assert_eq!(rows[0]["cash_per_share"], 25.0);
|
||||||
|
assert!(rows[0].get("ratio").is_none(), "absent fields are omitted");
|
||||||
|
let split = rows.iter().find(|r| r["kind"] == "split").unwrap();
|
||||||
|
assert_eq!(split["ratio"], json!({"old": 1, "new": 5}));
|
||||||
|
|
||||||
|
let calendar = client.ok(
|
||||||
|
"get_corporate_calendar",
|
||||||
|
json!({"to": "2026-11-12", "kinds": ["rights", "dividend"]}),
|
||||||
|
);
|
||||||
|
let rows = calendar["data"].as_array().unwrap();
|
||||||
|
assert_eq!(rows[0]["symbol"], "MGLV.JK");
|
||||||
|
assert_eq!(rows[0]["price"], 8880);
|
||||||
|
assert!(
|
||||||
|
rows.windows(2)
|
||||||
|
.all(|w| w[0]["date"].as_str() <= w[1]["date"].as_str())
|
||||||
|
);
|
||||||
|
|
||||||
|
let (is_error, body) = client.call(
|
||||||
|
"get_corporate_actions",
|
||||||
|
json!({"symbol": "BBCA", "kinds": ["rups"]}),
|
||||||
|
);
|
||||||
|
assert!(is_error, "{body}");
|
||||||
|
assert_eq!(body["code"], "INVALIDINPUT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shareholder_movements_page_and_filter() {
|
||||||
|
let mut client = Client::start(&[], false);
|
||||||
|
let (is_error, body) = client.call("get_shareholder_movements", json!({}));
|
||||||
|
assert!(is_error, "{body}");
|
||||||
|
assert_eq!(body["code"], "AUTHERROR");
|
||||||
|
|
||||||
|
let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]);
|
||||||
|
let market = client.ok("get_shareholder_movements", json!({"pages": 2}));
|
||||||
|
assert_fetched(&market);
|
||||||
|
assert_eq!(market["source"], "stockbit");
|
||||||
|
let rows = market["data"]["movements"].as_array().unwrap();
|
||||||
|
assert_eq!(rows.len(), 14, "8 rows on page 1, 6 on the last page");
|
||||||
|
assert_eq!(market["data"]["more"], false);
|
||||||
|
assert!(
|
||||||
|
rows.windows(2)
|
||||||
|
.all(|w| w[0]["date"].as_str() >= w[1]["date"].as_str()),
|
||||||
|
"newest first"
|
||||||
|
);
|
||||||
|
|
||||||
|
let sells = client.ok(
|
||||||
|
"get_shareholder_movements",
|
||||||
|
json!({"action": "sell", "source": "ksei", "holder_id": "1000004481", "pages": 3}),
|
||||||
|
);
|
||||||
|
let rows = sells["data"]["movements"].as_array().unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert!(rows.iter().all(|r| r["symbol"] == "ATLA"
|
||||||
|
&& r["action"] == "sell"
|
||||||
|
&& r["shares_change"].as_i64() < Some(0)
|
||||||
|
&& r["price_estimated"] == true));
|
||||||
|
|
||||||
|
let bbca = client.ok("get_shareholder_movements", json!({"symbol": "bbca"}));
|
||||||
|
assert_eq!(bbca["data"]["more"], true, "BBCA has a second page");
|
||||||
|
assert_eq!(bbca["data"]["movements"][0]["roles"], json!(["director"]));
|
||||||
|
|
||||||
|
for bad in [
|
||||||
|
json!({"action": "dividend"}),
|
||||||
|
json!({"source": "bing"}),
|
||||||
|
json!({"holder_id": "RUDI"}),
|
||||||
|
json!({"pages": 11}),
|
||||||
|
json!({"from": "2026-11-02", "to": "2026-11-01"}),
|
||||||
|
] {
|
||||||
|
let (is_error, body) = client.call("get_shareholder_movements", bad.clone());
|
||||||
|
assert!(is_error, "{bad} -> {body}");
|
||||||
|
assert_eq!(body["code"], "INVALIDINPUT", "{bad} -> {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn errors_carry_cli_codes() {
|
fn errors_carry_cli_codes() {
|
||||||
let mut client = Client::start(&[], false);
|
let mut client = Client::start(&[], false);
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,30 @@ WIB as the fallback.
|
||||||
provider uses Stockbit's daily windows (`5d` to `1y`, trimmed from `1m`,
|
provider uses Stockbit's daily windows (`5d` to `1y`, trimmed from `1m`,
|
||||||
`3m` or `1y`), Yahoo otherwise. Without one, quotes use the public page and
|
`3m` or `1y`), Yahoo otherwise. Without one, quotes use the public page and
|
||||||
the order book is `AUTHERROR`.
|
the order book is `AUTHERROR`.
|
||||||
|
- Corporate actions (`corporate/`, `api/stockbit/corpaction.rs`):
|
||||||
|
`/corpaction/{type}` per kind (`dividend`, `stock_dividend`, `bonus`,
|
||||||
|
`rightissue`, `warrant`, `stocksplit`, `reversesplit`, `rups`, `pubex`,
|
||||||
|
`ipo`, `tenderoffer`), with `?symbol=` for one stock's history or without
|
||||||
|
for the market (about a year back and what is announced ahead). Each type
|
||||||
|
has its own field prefix and the list key is not always the path's, so
|
||||||
|
entitlements are read by field suffix (`_cumdate`, `_exdate`, ...). One
|
||||||
|
request per kind, cached 6 hours under `stockbit-corpaction`
|
||||||
|
(`<kind>:<SYMBOL.JK>` or `<kind>:market`); `stocks actions` /
|
||||||
|
`get_corporate_actions` sort newest first, `stocks calendar` /
|
||||||
|
`get_corporate_calendar` keep what `CalendarRange::includes`: a
|
||||||
|
`CorporateAction::date` (the cum date for entitlements) in the range, or a
|
||||||
|
rights, tender or IPO `period` that overlaps it (not a warrant's, which
|
||||||
|
runs for years), sorted oldest first. Endpoint notes and the wider
|
||||||
|
design: `docs/STOCKBIT.md`.
|
||||||
|
- Shareholder movements (`movements`, `api/stockbit/movement.rs`) read
|
||||||
|
`/insider/company/majorholder`: IDX insider disclosures and KSEI 5% holder
|
||||||
|
changes, 50 rows a page, filtered by Stockbit (`symbol`, `date_start`,
|
||||||
|
`date_end`, `insider` (holder id), `action_type`, `source_type`).
|
||||||
|
`ownership movements` and `get_shareholder_movements` read up to 10 pages
|
||||||
|
as one answer, cached an hour under `stockbit-movements`, keyed by every
|
||||||
|
filter and the page count. If a page after the first fails, the rows read
|
||||||
|
before it come back with `incomplete` set and nothing is cached. The KSEI
|
||||||
|
register in SQLite is not involved.
|
||||||
|
|
||||||
### Capability Gating
|
### Capability Gating
|
||||||
```
|
```
|
||||||
|
|
@ -299,6 +323,8 @@ Factory functions:
|
||||||
trading_summary_provider() → Box<dyn TradingSummaryProvider>
|
trading_summary_provider() → Box<dyn TradingSummaryProvider>
|
||||||
intraday_provider() → Box<dyn IntradayProvider>
|
intraday_provider() → Box<dyn IntradayProvider>
|
||||||
orderbook_provider() → Box<dyn OrderBookProvider>
|
orderbook_provider() → Box<dyn OrderBookProvider>
|
||||||
|
corporate_action_provider() → Box<dyn CorporateActionProvider>
|
||||||
|
shareholder_movement_provider() → Box<dyn ShareholderMovementProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
`ProviderKind::Stockbit` serves quotes (and, with a session, daily history):
|
`ProviderKind::Stockbit` serves quotes (and, with a session, daily history):
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,9 @@ Key decisions:
|
||||||
- **`Fetched<T>` envelope** for live data: `{ data, source, fetched_at, stale }`,
|
- **`Fetched<T>` envelope** for live data: `{ data, source, fetched_at, stale }`,
|
||||||
so agents and API clients can see freshness. idx-core still emits the
|
so agents and API clients can see freshness. idx-core still emits the
|
||||||
stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI
|
stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI
|
||||||
stderr stays identical.
|
stderr stays identical. idx-mcp also catches a call's warnings with
|
||||||
|
`runtime::on_warning` (the call runs on one blocking thread) and returns
|
||||||
|
them as `warnings`, since an agent never reads the server's stderr.
|
||||||
- **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` /
|
- **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` /
|
||||||
`--no-cache` flag handling now spread through `cli/stocks.rs`.
|
`--no-cache` flag handling now spread through `cli/stocks.rs`.
|
||||||
- **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads
|
- **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,12 @@ scripts/live-smoke.sh --dry-run
|
||||||
`--provider stockbit stocks quote`, on the new binary only. A run outside the
|
`--provider stockbit stocks quote`, on the new binary only. A run outside the
|
||||||
session sees `market_status: close` and no bid/offer. These run without a
|
session sees `market_status: close` and no bid/offer. These run without a
|
||||||
Stockbit login: each side has its own config, and seeding it with a real
|
Stockbit login: each side has its own config, and seeding it with a real
|
||||||
refresh token would rotate (and so retire) that token. Check the login
|
refresh token would rotate (and so retire) that token. When
|
||||||
features by hand with your own session: `idx stockbit status`,
|
`idx stockbit status` reports a session, `stocks orderbook`, `stocks actions`,
|
||||||
`idx stocks orderbook BBCA`, `idx --provider stockbit stocks quote BBCA` and
|
`stocks calendar` and `ownership movements` (market-wide, one page) also run, on the new binary with your real config
|
||||||
|
directory (the session file is shared under its lock, never copied; cache
|
||||||
|
and data stay isolated). Check the rest by hand:
|
||||||
|
`idx --provider stockbit stocks quote BBCA` and
|
||||||
`idx --provider stockbit stocks history BBCA --period 1y`.
|
`idx --provider stockbit stocks history BBCA --period 1y`.
|
||||||
- `ownership`: `discover`, then imports the newest supported report into a temp
|
- `ownership`: `discover`, then imports the newest supported report into a temp
|
||||||
DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many
|
DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many
|
||||||
|
|
|
||||||
287
docs/STOCKBIT.md
Normal file
287
docs/STOCKBIT.md
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
# Stockbit Session Features: Research and Design
|
||||||
|
|
||||||
|
What the user's Stockbit session (`idx stockbit login`, see the Stockbit
|
||||||
|
sections of `docs/ARCHITECTURE.md`) can serve beyond the order book, live
|
||||||
|
quotes and daily history, and how idx should add it. Endpoints were found in
|
||||||
|
Stockbit's web bundle (`stockbit.com/_next/static/chunks/*.js`, the API client
|
||||||
|
module) and probed with a real session on Tue 2026-11-03 during trading hours,
|
||||||
|
one GET at a time at least 1 s apart. Nothing here ships yet unless marked.
|
||||||
|
|
||||||
|
## Rules for every session feature
|
||||||
|
|
||||||
|
1. **Read-only by construction.** The session client (`api/stockbit/exodus.rs`)
|
||||||
|
issues GET only on an allowlist of path prefixes (`READ_PATHS`), plus
|
||||||
|
`POST /login/refresh`.
|
||||||
|
The bundle also holds trading (orders, day trade, PIN, cash withdrawal,
|
||||||
|
portfolio), chat, posting and account endpoints; idx never calls them, and
|
||||||
|
the allowlist makes a stray call fail before it leaves the process.
|
||||||
|
2. **One client.** Every endpoint goes through `Exodus::get`: rotating refresh
|
||||||
|
under the file lock, one retry after a 401 / JSON 403, the shared 1 s
|
||||||
|
pacing, `AUTHERROR` / `UPSTREAMBLOCKED` / `RATELIMITED` mapping, and no
|
||||||
|
token in logs, errors or cache keys.
|
||||||
|
3. **Stockbit-only commands use Stockbit whatever `--provider` says**, like
|
||||||
|
`stocks intraday`; without a session they answer `AUTHERROR` (exit code as
|
||||||
|
today). All endpoints below answer `401` without a session.
|
||||||
|
4. **Pure parsers, typed output.** Stockbit sends numbers as display strings
|
||||||
|
(`"193,206"`, `"+0.0001"`, `"1.03B"`), dates as `"25 Mar 26"` or
|
||||||
|
`YYYY-MM-DD`, and placeholders (`""`, `0`, `"0001-01-01 00:00:00"`). Parsers
|
||||||
|
turn them into integers, decimals, `NaiveDate` and `None`, and are tested
|
||||||
|
against recorded fixtures (`tests/fixtures/stockbit_exodus_*`).
|
||||||
|
5. **Units checked live** per endpoint before release (chart volume is lots,
|
||||||
|
the order book is shares).
|
||||||
|
6. **Cache per endpoint**: book and quote 5 s, market state 30 s, movements
|
||||||
|
1 h, corporate actions 6 h.
|
||||||
|
7. **Terms**: Stockbit's data is for personal, non-commercial use; the user
|
||||||
|
docs (`web/src/content/docs/cli/stockbit.md`) say so for every feature.
|
||||||
|
|
||||||
|
## Endpoint inventory
|
||||||
|
|
||||||
|
All on `https://exodus.stockbit.com`, bearer access token, HTTP 200 on
|
||||||
|
2026-11-03 unless noted.
|
||||||
|
|
||||||
|
| Endpoint | Serves | Plan |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/company-price-feed/v2/orderbook/companies/{SYM}` | quote, OHLC, foreign flow, depth | shipped (#74) |
|
||||||
|
| `/company-price-feed/market-time/session` | market state | shipped (#74) |
|
||||||
|
| `/charts/{SYM}/daily?timeframe=1m\|3m\|1y` | daily candles | shipped (#74) |
|
||||||
|
| `/charts/{SYM}/daily?timeframe=1w` | hourly candles, 5 sessions | P2 |
|
||||||
|
| `/corpaction/{type}[?symbol=SYM]` | corporate actions and calendar | shipped: `stocks actions`, `stocks calendar` |
|
||||||
|
| `/insider/company/majorholder` | insider / major-holder movements | **P1** |
|
||||||
|
| `/insider/shareholding/composition/companies/{SYM}` | holder composition by period | P2 |
|
||||||
|
| `/marketdetectors/{SYM}` | broker summary, Stockbit's bandar detector | P2 |
|
||||||
|
| `/analyst-ratings/{SYM}/consensus`, `/analyst-ratings/{SYM}` | estimates, ratings | P3 |
|
||||||
|
| `/order-trade/running-trade`, `/order-trade/trade-book` | trade tape | P3 |
|
||||||
|
| `/emitten-metadata/shareholders/{SYM}/chart` | composition chart | not probed: needs a second token from `POST /emitten-metadata/shareholders/token` |
|
||||||
|
| `/keystats/ratio/v1/{SYM}`, `/findata-view/company/financial` | ratios, statements | skip for now (MSN covers them) |
|
||||||
|
|
||||||
|
### Market state (`market-time/session`)
|
||||||
|
|
||||||
|
`data.detail` has two boards, `regular` and `fca` (full call auction), each
|
||||||
|
with `session`, `state_name`, `state_start_time`, `state_end_time`,
|
||||||
|
`time_left`, `is_last_session`, `is_end_of_day`. Observed:
|
||||||
|
|
||||||
|
| Time (WIB) | `regular` | `fca` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sat 2026-10-18 20:15 | `STATE_NAME_MARKET_CLOSED`, session 0, end of day | same |
|
||||||
|
| Tue 2026-11-03 12:35 | `STATE_NAME_BREAK`, session 1, 12:00:00-13:29:59 | `STATE_NAME_BREAK`, session 3, 12:00:00-13:59:59 |
|
||||||
|
| Tue 2026-11-03 13:30 | `STATE_NAME_SESSION_2`, session 2, 13:30:00-15:49:59 | still `STATE_NAME_BREAK` |
|
||||||
|
| Tue 2026-11-03 14:00 | `STATE_NAME_SESSION_2` | `STATE_NAME_SESSION_4` |
|
||||||
|
|
||||||
|
The order book and session quotes treat any numbered `STATE_NAME_SESSION_<n>`
|
||||||
|
as trading (#74; the assumed `STATE_NAME_MARKET_OPEN` never appeared). idx
|
||||||
|
reads `regular` only, so FCA-board stocks can show the regular board's state
|
||||||
|
around the break.
|
||||||
|
|
||||||
|
In session the order book's `fbuy`/`fsell`/`fnet` were the previous
|
||||||
|
session's (equal to IPOT's 2026-11-02 net for BBCA, BBRI, TLKM) with
|
||||||
|
`has_foreign_bs: false`; idx keeps them only when the flag is true (#74).
|
||||||
|
Other order-book fields not used yet: `total_bid_offer` (queued lots and
|
||||||
|
orders on each side), `market_data` (volume and value by board: regular,
|
||||||
|
negotiated, cash), `iepiev` (indicative equilibrium price and volume, for the
|
||||||
|
auctions), `corp_action.active`.
|
||||||
|
|
||||||
|
### Charts
|
||||||
|
|
||||||
|
- `timeframe=1d`, `PRICE_CHART_TYPE_CANDLE`: empty `prices` during the break.
|
||||||
|
- `timeframe=1d`, `PRICE_CHART_TYPE_LINE`: a rolling ~350 minutes of
|
||||||
|
per-minute closes across two sessions, no OHLC or volume
|
||||||
|
(`xaxisopt: "intraday"`). Adds nothing over the public `stocks intraday`.
|
||||||
|
- `timeframe=1w`, candle: hourly bars for 5 sessions, `interval_in_minutes:
|
||||||
|
60`, volume in lots. The newest bar is stamped with its last update
|
||||||
|
(`11:59:00`), not the hour. BBCA's three bars on 2026-11-03 summed to
|
||||||
|
101.2M shares against 105.1M in the order book (pre-open auction or the
|
||||||
|
negotiated market, unconfirmed).
|
||||||
|
|
||||||
|
Hourly history needs a datetime bar (`Ohlc.date` is a `NaiveDate`) and an
|
||||||
|
hourly `Interval`, across Yahoo, MSN, output, goldens and the MCP schema; that
|
||||||
|
is its own change.
|
||||||
|
|
||||||
|
## P1: corporate actions (shipped)
|
||||||
|
|
||||||
|
Shipped as `idx stocks actions`, `idx stocks calendar` (not a top-level
|
||||||
|
`calendar`: it sits beside `stocks screen`) and the MCP tools
|
||||||
|
`get_corporate_actions`, `get_corporate_calendar`; the notes below are the
|
||||||
|
endpoint as recorded. Before, idx had none (only the `high-dividend` screen and Yahoo's dividend
|
||||||
|
yield). They explain price gaps in history (splits, bonuses, rights) and give
|
||||||
|
a calendar of cum dates.
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
`GET /corpaction/{type}?symbol=SYM` gives one stock's history (BBCA: 37 cash dividends, cum dates 2009-06-09 to 2026-08-28); `GET /corpaction/{type}` without `symbol` is the
|
||||||
|
market-wide list, newest cum/event date first, upcoming events included. The
|
||||||
|
bundle passes `limit` (default 30); `rightissue` honoured it loosely (28 rows
|
||||||
|
for `limit=5`), `warrant` ignored it (1329 rows for `limit=3`), so idx filters
|
||||||
|
by date itself.
|
||||||
|
|
||||||
|
| `type` | Response key | Field prefix | Fields that matter |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `dividend` | `dividend` | `dividend_` | `cumdate`, `exdate`, `recdate`, `paydate`, `value` (per share), `currency` |
|
||||||
|
| `stock_dividend` | `stock_dividend` | — | empty on 2026-11-03; record a row before parsing |
|
||||||
|
| `bonus` | `bonus` | `sahambonus_`, **`stocksplit_`** | `ratio` (`"6 : 4"`), `stocksplit_{cumdate,exdate,recdate,paymentdate,old,new,factor}` |
|
||||||
|
| `rightissue` | `rightissue` | `rightissue_` | `cumdate`, `exdate`, `recdate`, `old`, `new`, `ratio`, `price`, `factor`, `trading_start`, `trading_end` |
|
||||||
|
| `warrant` | `warrant` | `wrant_` | `trading_from`, `trading_end`, `exc_from`, `exc_end`, `exc_price` |
|
||||||
|
| `stocksplit` | `stocksplit` | `stocksplit_` | `cumdate`, `exdate`, `recdate`, `old`, `new`, `ratio`, `factor` |
|
||||||
|
| `reversesplit` | **`stock_reverse`** | — | empty on 2026-11-03; record a row before parsing |
|
||||||
|
| `rups` | `rups` | `rups_` | `date`, `time`, `venue`, `eligible_date` |
|
||||||
|
| `pubex` | `pubex` | `puexp_` | `date`, `time`, `venue` |
|
||||||
|
| `ipo` | `ipo` | `ipo_` | `listing_date`, `company_name`, `ipo_price.{minimum,maximum,final}`, `ipo_data_detail` (placeholders common) |
|
||||||
|
| `tenderoffer` | **`tender`** | `tender_` | `start`, `end`, `paydate`, `price`, `shares`, `percentage` |
|
||||||
|
| `economic` | — | — | macro calendar, not a corporate action; out of scope |
|
||||||
|
|
||||||
|
Examples from 2026-11-03: PPGL bonus 6:4 ex 2026-11-03; PRDA split 1:5 cum
|
||||||
|
2026-12-17; MGLV rights 100:15 at 8,880 cum 2026-11-04; BMAS rights 655:104 at
|
||||||
|
350 cum 2026-11-23, rights trading 2026-11-27 to 12-03; AUTO tender offer at
|
||||||
|
3,600 until 2026-11-11.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
As shipped (`corporate/types.rs`), one flat record with a calendar `date` and
|
||||||
|
optional per-kind fields; JSON leaves absent ones out:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CorporateAction {
|
||||||
|
pub symbol: String, // BBCA.JK
|
||||||
|
pub kind: ActionKind, // JSON: dividend, stock-dividend, bonus, rights, warrant,
|
||||||
|
// split, reverse-split, meeting, expose, ipo, tender
|
||||||
|
pub date: NaiveDate, // cum date for entitlements (ex date without one),
|
||||||
|
// else the event's first day
|
||||||
|
pub cum_date: Option<NaiveDate>,
|
||||||
|
pub ex_date: Option<NaiveDate>,
|
||||||
|
pub record_date: Option<NaiveDate>,
|
||||||
|
pub pay_date: Option<NaiveDate>,
|
||||||
|
pub cash_per_share: Option<f64>, // dividends; IDR to the sen, else up to 6 decimals
|
||||||
|
pub currency: Option<String>, // dividends not paid in IDR
|
||||||
|
pub ratio: Option<Ratio>, // old : new
|
||||||
|
pub factor: Option<f64>, // Stockbit's price adjustment
|
||||||
|
pub price: Option<i64>, // rights, warrant, tender or IPO price (IDR)
|
||||||
|
pub period: Option<DateRange>, // rights or warrant trading, tender, IPO offer
|
||||||
|
pub exercise: Option<DateRange>, // warrant exercise
|
||||||
|
pub shares: Option<u64>, // offered (IPO) or sought (tender)
|
||||||
|
pub percentage_bps: Option<i64>, // tender
|
||||||
|
pub time: Option<String>, // meeting or expose
|
||||||
|
pub venue: Option<String>,
|
||||||
|
pub name: Option<String>, // IPO or tender issuer
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
One parser per type maps its prefix into this record; unknown types and empty
|
||||||
|
lists are not errors. A row whose dates are all placeholders is dropped.
|
||||||
|
|
||||||
|
### Surface
|
||||||
|
|
||||||
|
- `idx stocks actions SYM [--kind dividend,rights,...]`: one stock's
|
||||||
|
history, newest first; every kind but `ipo` by default.
|
||||||
|
- `idx stocks calendar [--kind ...] [--from DATE] [--to DATE]`: market-wide,
|
||||||
|
default today to +30 days, oldest first by `date`; rights trading, tender
|
||||||
|
offers and IPO offers still open in the range are included too.
|
||||||
|
- MCP: `get_corporate_actions {symbol, kinds?}`,
|
||||||
|
`get_corporate_calendar {kinds?, from?, to?}`.
|
||||||
|
- Later: `stocks history` notes ex-dates of splits, bonuses and rights in the
|
||||||
|
window, so a gap is not read as a crash.
|
||||||
|
|
||||||
|
## P1: shareholder movements
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
Rows shipped as `idx ownership movements` and `get_shareholder_movements`;
|
||||||
|
the summary score is next.
|
||||||
|
|
||||||
|
`GET /insider/company/majorholder?symbol=&insider=&date_start=&date_end=&action_type=&source_type=&page=`;
|
||||||
|
all parameters optional, all filtered by Stockbit (checked 2026-11-03).
|
||||||
|
`date_start` / `date_end` are YYYY-MM-DD; `insider` is the holder id, which is
|
||||||
|
the row's `id` (not unique per row); `action_type` takes `ACTION_TYPE_BUY`,
|
||||||
|
`_SELL`, `_TRANSFER`, `_CROSS` or `_CORPACTION` (the app's filter list);
|
||||||
|
`source_type` takes `SOURCE_TYPE_IDX` or `_KSEI`. `page` counts from 1. 50 rows
|
||||||
|
a page, `data.is_more` for the next page; a busy day has more than 50 rows.
|
||||||
|
Without `symbol` it is market-wide, newest first: on 2026-11-03 the newest rows
|
||||||
|
were from 2026-11-02. BBCA's 50 rows span 2024-03-22 to 2026-03-25.
|
||||||
|
|
||||||
|
Each row (`data.movement[]`): `name`, `symbol`, `date` (`"25 Mar 26"`),
|
||||||
|
`previous` / `current` / `changes` `{value: shares as "193,206", percentage}`,
|
||||||
|
`action_type` (`ACTION_TYPE_BUY`, `_SELL`, `_TRANSFER`, `_CROSS`,
|
||||||
|
`_RIGHT_ISSUE`, `_PRIVATE_PLACEMENT`, `_TENDER_OFFER`; the bundle also names
|
||||||
|
`_BONUS_STOCK`, `_DIVIDEND_STOCK`, `_DIVIDEND_CASH`), `nationality`
|
||||||
|
(`NATIONALITY_TYPE_LOCAL|FOREIGN`), `badges` (`SHAREHOLDER_BADGE_DIREKTUR`,
|
||||||
|
`..._KOMISARIS`), `price.raw.value`, `value.raw.value` (IDR),
|
||||||
|
`is_estimated_price`, `purpose` (free text such as "Pembelian", "Investasi"),
|
||||||
|
`data_source.type` (`SOURCE_TYPE_IDX` for insider disclosures,
|
||||||
|
`SOURCE_TYPE_KSEI` for 5% holder changes), `broker_detail` (empty so far).
|
||||||
|
KSEI rows carry an estimated price (`is_estimated_price`: true); IDX rows
|
||||||
|
mostly the trade price, though crossings can be estimated too. `CROSS` rows
|
||||||
|
name securities firms as the holder. A rights issue can change the stake
|
||||||
|
with no change in shares (dilution).
|
||||||
|
|
||||||
|
### Relation to `ownership`
|
||||||
|
|
||||||
|
The KSEI register idx imports is a monthly snapshot of holders above 1%. This
|
||||||
|
feed is the individual changes, days after the trade, including insiders far
|
||||||
|
below 1%. It joins the ownership entities by name (the same resolution
|
||||||
|
`ownership resolve` maintains), but needs no SQLite import: it is live.
|
||||||
|
|
||||||
|
### Signal quality
|
||||||
|
|
||||||
|
Discretionary insider buying, above all by several insiders together, is the
|
||||||
|
form of this data with predictive power in the literature; selling much less.
|
||||||
|
The raw feed is noisy:
|
||||||
|
|
||||||
|
- Program-like rows: BBCA's 2026-03-25 rows are every director and
|
||||||
|
commissioner "buying" at the same 6,982, which reads as a share plan, not
|
||||||
|
open-market conviction. Flag many holders on one date at one price.
|
||||||
|
- `TRANSFER` rows and estimated prices (`is_estimated_price`).
|
||||||
|
- The reporting lag: part of the move can be priced before the row appears.
|
||||||
|
|
||||||
|
So the command shows the rows faithfully, and a separate summary scores them:
|
||||||
|
net value by symbol, the number of distinct buyers (cluster), value against
|
||||||
|
average daily value, holders crossing 5% / 10%, and the program flag. Calling
|
||||||
|
it alpha waits for an event study (feed history back to 2024 per symbol,
|
||||||
|
Yahoo prices, forward 5/20/60-session returns against IHSG).
|
||||||
|
|
||||||
|
### Surface
|
||||||
|
|
||||||
|
- `idx ownership movements [SYM] [--from DATE] [--to DATE] [--holder ID]
|
||||||
|
[--action buy|sell|transfer|cross|corporate-action] [--source idx|ksei]
|
||||||
|
[--pages N]`: rows, newest first (shipped). `--holder` takes the holder id
|
||||||
|
rather than a name: Stockbit filters by id, and a name search would only see
|
||||||
|
the pages already read.
|
||||||
|
- MCP: `get_shareholder_movements {symbol?, from?, to?, holder_id?, action?,
|
||||||
|
source?, pages?}` (shipped).
|
||||||
|
- Next: `idx ownership movements --summary [--from DATE]`, the per-symbol
|
||||||
|
score.
|
||||||
|
|
||||||
|
## P2 and P3
|
||||||
|
|
||||||
|
- **Composition by period** (`/insider/shareholding/composition/companies/{SYM}`,
|
||||||
|
optional `period_start`, `period_end`, `view`): per `report_date` (latest
|
||||||
|
2026-10-30 on 2026-11-03), total shares and each holder's shares,
|
||||||
|
percentage and previous shares. Fresher than waiting on the IDX register;
|
||||||
|
could feed `ownership changes` as a source.
|
||||||
|
- **Market detector** (`/marketdetectors/{SYM}`): `bandar_detector` with
|
||||||
|
`accdist` labels per top-N, broker counts, average price. A second source
|
||||||
|
for `stocks broker` / `bandar` beside IPOT (`--source ipot|stockbit`), as
|
||||||
|
`flow` has `--source ipot|idx`; IPOT answered 502 on 2026-10-31.
|
||||||
|
- **Hourly candles** (`timeframe=1w`), see "Charts".
|
||||||
|
- **Analyst consensus**: revenue and operating profit actuals and estimates
|
||||||
|
by year; ratings per broker.
|
||||||
|
- **Trade tape**: running trade and trade book; large and only meaningful in
|
||||||
|
session; wait for `--watch`.
|
||||||
|
|
||||||
|
## Order of work
|
||||||
|
|
||||||
|
1. Close #74: the open-state name in trading hours, fixtures for the observed
|
||||||
|
states (done 2026-11-03).
|
||||||
|
2. Corporate actions (parsers, `stocks actions`, `stocks calendar`, MCP,
|
||||||
|
goldens) (done).
|
||||||
|
3. Shareholder movements, rows first (done), then `--summary`.
|
||||||
|
4. Event study for the movement score; P2 by demand.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- The `regular` state in session 1 (assumed `STATE_NAME_SESSION_1`), the
|
||||||
|
pre-open (08:45-09:00) and the closing auction (15:50-16:00).
|
||||||
|
- When `has_foreign_bs` turns true, and whether the figures are then the
|
||||||
|
day's.
|
||||||
|
- `stock_dividend` and `reversesplit` row shapes (both empty on 2026-11-03).
|
||||||
|
- Whether `/corpaction/{type}` takes a date range (the bundle sends only
|
||||||
|
`limit` and `symbol`).
|
||||||
|
- The reporting lag between a movement's `date` and its appearance in the
|
||||||
|
feed (sample the market-wide first page daily).
|
||||||
|
|
@ -71,10 +71,14 @@ LOG_DIR="$WORKDIR/logs"
|
||||||
COMMON_ENV=("IDX_PROVIDER=msn" "IDX_HISTORY_PROVIDER=auto" "IDX_NO_COLOR=1")
|
COMMON_ENV=("IDX_PROVIDER=msn" "IDX_HISTORY_PROVIDER=auto" "IDX_NO_COLOR=1")
|
||||||
|
|
||||||
# Each binary gets its own config, cache, and data so both really fetch.
|
# Each binary gets its own config, cache, and data so both really fetch.
|
||||||
|
# Stockbit session cases keep the user's real config directory, which holds
|
||||||
|
# the session: a copy would refresh on its own and spend the user's rotating
|
||||||
|
# refresh token, while the real file is shared under its lock.
|
||||||
|
REAL_CONFIG=0
|
||||||
side_env() {
|
side_env() {
|
||||||
local home="$WORKDIR/$1"
|
local home="$WORKDIR/$1"
|
||||||
printf '%s\n' "XDG_CONFIG_HOME=$home/config" "XDG_CACHE_HOME=$home/cache" \
|
(( REAL_CONFIG )) || printf '%s\n' "XDG_CONFIG_HOME=$home/config"
|
||||||
"XDG_DATA_HOME=$home/data" "${COMMON_ENV[@]}"
|
printf '%s\n' "XDG_CACHE_HOME=$home/cache" "XDG_DATA_HOME=$home/data" "${COMMON_ENV[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Values of these commands should not move between two back-to-back runs.
|
# Values of these commands should not move between two back-to-back runs.
|
||||||
|
|
@ -128,6 +132,16 @@ contract() {
|
||||||
echo '(.highlights | type == "array") and (.risks | type == "array")' ;;
|
echo '(.highlights | type == "array") and (.risks | type == "array")' ;;
|
||||||
"stocks news "*)
|
"stocks news "*)
|
||||||
echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;;
|
echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;;
|
||||||
|
"stocks orderbook "*)
|
||||||
|
echo '(.symbol | type == "string") and (.price | type == "number") and (.market_status | type == "string") and (.bids | type == "array") and (.offers | type == "array")' ;;
|
||||||
|
# The smoke symbol always has dividends on record; a quiet month can
|
||||||
|
# leave the calendar empty.
|
||||||
|
"stocks actions "*)
|
||||||
|
echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;;
|
||||||
|
"stocks calendar"*)
|
||||||
|
echo 'type == "array" and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;;
|
||||||
|
"ownership movements"*)
|
||||||
|
echo '(.more | type == "boolean") and (.movements | length > 0) and all(.movements[]; (.symbol | type == "string") and (.date | type == "string") and (.holder_id | type == "string") and (.action | type == "string") and (.shares_change | type == "number"))' ;;
|
||||||
"stocks broker "*)
|
"stocks broker "*)
|
||||||
echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;;
|
echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;;
|
||||||
"stocks bandar "*)
|
"stocks bandar "*)
|
||||||
|
|
@ -253,6 +267,17 @@ if selected stockbit; then
|
||||||
run_new_case intraday "stocks intraday $SYMBOL"
|
run_new_case intraday "stocks intraday $SYMBOL"
|
||||||
run_new_case intraday-index "stocks intraday ^JKSE"
|
run_new_case intraday-index "stocks intraday ^JKSE"
|
||||||
run_new_case quote-stockbit "--provider stockbit stocks quote $SYMBOL"
|
run_new_case quote-stockbit "--provider stockbit stocks quote $SYMBOL"
|
||||||
|
# With the user's own session (`idx stockbit login`), if there is one.
|
||||||
|
if (( DRY_RUN )) || "${BIN_PATH:-idx}" -o json stockbit status 2>/dev/null | jq -e '.logged_in' >/dev/null; then
|
||||||
|
REAL_CONFIG=1
|
||||||
|
run_new_case orderbook "stocks orderbook $SYMBOL --depth 5"
|
||||||
|
run_new_case actions "stocks actions $SYMBOL --kind dividend,split"
|
||||||
|
run_new_case calendar "stocks calendar --kind dividend,rights"
|
||||||
|
run_new_case movements "ownership movements"
|
||||||
|
REAL_CONFIG=0
|
||||||
|
else
|
||||||
|
echo " no Stockbit session: orderbook, actions, calendar and movements not run"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if selected ownership; then
|
if selected ownership; then
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ description: Every idx-mcp tool and its inputs, generated from the server's tool
|
||||||
idx-mcp serves these tools. `ownership_sync` is listed only when the server
|
idx-mcp serves these tools. `ownership_sync` is listed only when the server
|
||||||
starts with `--allow-sync`. Results that come from a live provider are wrapped
|
starts with `--allow-sync`. Results that come from a live provider are wrapped
|
||||||
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
||||||
message}` with `isError` set.
|
message}` with `isError` set. Either may carry `warnings`, what idx worked
|
||||||
|
around (such as a history fallback or a stale cache entry), when there was
|
||||||
|
any.
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,34 @@ two releases. `ticker` also reports concentration: the top holder's share,
|
||||||
the top three, the Herfindahl index (HHI) and the free float (shares held by
|
the top three, the Herfindahl index (HHI) and the free float (shares held by
|
||||||
holders below 1%).
|
holders below 1%).
|
||||||
|
|
||||||
|
## Insider and major holder movements
|
||||||
|
|
||||||
|
With your own Stockbit login (`idx stockbit login`, see [Stockbit](/cli/stockbit/)),
|
||||||
|
`movements` lists reported changes in holders' stakes, newest first. It reads
|
||||||
|
Stockbit live and needs no import:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
idx ownership movements BBCA # one stock, 50 rows
|
||||||
|
idx ownership movements # the whole market
|
||||||
|
idx ownership movements --action sell --source ksei --pages 3
|
||||||
|
idx ownership movements --from 2026-10-01 --to 2026-10-31
|
||||||
|
idx ownership movements --holder 1000004481 # one holder's history
|
||||||
|
```
|
||||||
|
|
||||||
|
Two sources share the list. `IDX` rows are insider disclosures: directors,
|
||||||
|
commissioners and other insiders, at any size, with the trade price. `KSEI`
|
||||||
|
rows are changes of holders at 5% and above, which report no price; Stockbit
|
||||||
|
estimates one, shown with `~`. `--holder` takes the number in the HOLDER ID
|
||||||
|
column. Shares are exact. Stakes are as Stockbit rounds them, so a director's
|
||||||
|
small holding reads `<0.01%`.
|
||||||
|
|
||||||
|
These are filings, not signals. Many insiders buying at one price on one day
|
||||||
|
is usually a share plan, transfers are not trades, and a row appears days
|
||||||
|
after the change. Each page is 50 rows and one request to Stockbit (`--pages`
|
||||||
|
up to 10); a query's pages are read and cached together, for an hour. JSON's
|
||||||
|
`more` says rows remain past them. If a later page fails, you get the rows
|
||||||
|
read before it, with `incomplete` saying why, and nothing is cached.
|
||||||
|
|
||||||
## Import reports yourself
|
## Import reports yourself
|
||||||
|
|
||||||
The snapshot covers most needs. To load a month it lacks, or older history,
|
The snapshot covers most needs. To load a month it lacks, or older history,
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,8 @@ Commands:
|
||||||
quote Get real-time stock quotes
|
quote Get real-time stock quotes
|
||||||
intraday Today's session price by the minute (Stockbit)
|
intraday Today's session price by the minute (Stockbit)
|
||||||
orderbook Order book depth and foreign flow (Stockbit login)
|
orderbook Order book depth and foreign flow (Stockbit login)
|
||||||
|
actions Dividends, rights, splits and other corporate actions of a stock (Stockbit login)
|
||||||
|
calendar Upcoming and recent corporate actions across the market (Stockbit login)
|
||||||
history Get historical OHLC data
|
history Get historical OHLC data
|
||||||
technical Run technical analysis on a stock
|
technical Run technical analysis on a stock
|
||||||
growth Run growth analysis on a stock
|
growth Run growth analysis on a stock
|
||||||
|
|
@ -223,6 +225,132 @@ Examples:
|
||||||
idx -o json stocks orderbook TLKM | jq '.offers[0]'
|
idx -o json stocks orderbook TLKM | jq '.offers[0]'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `idx stocks actions`
|
||||||
|
|
||||||
|
```text
|
||||||
|
One IDX stock's corporate actions from your own Stockbit session (`idx stockbit login`), newest first: cash and stock dividends, bonus shares, rights issues, warrants, splits and reverse splits, shareholders' meetings, public exposes and tender offers (`--kind ipo` adds its IPO). Stockbit serves one kind per request, 1 s apart, so the first call takes about a second per kind; answers are cached for 6 hours.
|
||||||
|
|
||||||
|
Usage: idx stocks actions [OPTIONS] <SYMBOL>
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
<SYMBOL>
|
||||||
|
Single ticker symbol (e.g. BBCA)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--kind <KINDS>...
|
||||||
|
Kinds to list (default: all but ipo)
|
||||||
|
|
||||||
|
Possible values:
|
||||||
|
- dividend: Cash dividend
|
||||||
|
- stock-dividend
|
||||||
|
- bonus: Bonus shares
|
||||||
|
- rights: Rights issue (HMETD)
|
||||||
|
- warrant
|
||||||
|
- split: Stock split
|
||||||
|
- reverse-split
|
||||||
|
- meeting: Shareholders' meeting (RUPS)
|
||||||
|
- expose: Public expose
|
||||||
|
- ipo
|
||||||
|
- tender
|
||||||
|
|
||||||
|
-o, --output <OUTPUT>
|
||||||
|
Output format [default: auto = table on a terminal, json when piped]
|
||||||
|
|
||||||
|
[possible values: auto, table, json, jsonl]
|
||||||
|
|
||||||
|
--no-color
|
||||||
|
Disable colored output
|
||||||
|
|
||||||
|
-q, --quiet
|
||||||
|
Suppress non-essential output
|
||||||
|
|
||||||
|
-v, --verbose...
|
||||||
|
Increase verbosity (-v, -vv)
|
||||||
|
|
||||||
|
--provider <PROVIDER>
|
||||||
|
Market data provider [default: general.provider, else msn]
|
||||||
|
|
||||||
|
[possible values: yahoo, msn, stockbit]
|
||||||
|
|
||||||
|
--offline
|
||||||
|
Run without network requests
|
||||||
|
|
||||||
|
--no-cache
|
||||||
|
Bypass local cache
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
idx stocks actions BBCA
|
||||||
|
idx stocks actions TLKM --kind dividend
|
||||||
|
idx -o json stocks actions BBRI --kind dividend,split | jq '.[0]'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `idx stocks calendar`
|
||||||
|
|
||||||
|
```text
|
||||||
|
Corporate actions across IDX from your own Stockbit session (`idx stockbit login`), oldest first, dated within --from and --to (default: today and the next 30 days), plus rights trading, tender offers and IPO offers still open then. An action's date is its cum date for dividends, bonuses, rights and splits (its ex date when Stockbit gives no cum date), else the event's first day. Stockbit lists about a year back and what is announced ahead. One request per kind, 1 s apart; answers are cached for 6 hours.
|
||||||
|
|
||||||
|
Usage: idx stocks calendar [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--from <FROM>
|
||||||
|
First day, YYYY-MM-DD. Default: today
|
||||||
|
|
||||||
|
-o, --output <OUTPUT>
|
||||||
|
Output format [default: auto = table on a terminal, json when piped]
|
||||||
|
|
||||||
|
[possible values: auto, table, json, jsonl]
|
||||||
|
|
||||||
|
--no-color
|
||||||
|
Disable colored output
|
||||||
|
|
||||||
|
--to <TO>
|
||||||
|
Last day, YYYY-MM-DD. Default: 30 days after --from (at most 366)
|
||||||
|
|
||||||
|
--kind <KINDS>...
|
||||||
|
Kinds to list (default: all)
|
||||||
|
|
||||||
|
Possible values:
|
||||||
|
- dividend: Cash dividend
|
||||||
|
- stock-dividend
|
||||||
|
- bonus: Bonus shares
|
||||||
|
- rights: Rights issue (HMETD)
|
||||||
|
- warrant
|
||||||
|
- split: Stock split
|
||||||
|
- reverse-split
|
||||||
|
- meeting: Shareholders' meeting (RUPS)
|
||||||
|
- expose: Public expose
|
||||||
|
- ipo
|
||||||
|
- tender
|
||||||
|
|
||||||
|
-q, --quiet
|
||||||
|
Suppress non-essential output
|
||||||
|
|
||||||
|
-v, --verbose...
|
||||||
|
Increase verbosity (-v, -vv)
|
||||||
|
|
||||||
|
--provider <PROVIDER>
|
||||||
|
Market data provider [default: general.provider, else msn]
|
||||||
|
|
||||||
|
[possible values: yahoo, msn, stockbit]
|
||||||
|
|
||||||
|
--offline
|
||||||
|
Run without network requests
|
||||||
|
|
||||||
|
--no-cache
|
||||||
|
Bypass local cache
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
idx stocks calendar
|
||||||
|
idx stocks calendar --kind dividend,rights
|
||||||
|
idx stocks calendar --from 2026-12-01 --to 2026-12-31 --kind meeting
|
||||||
|
```
|
||||||
|
|
||||||
### `idx stocks history`
|
### `idx stocks history`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -1030,6 +1158,7 @@ Commands:
|
||||||
concentration Rank tickers by ownership concentration
|
concentration Rank tickers by ownership concentration
|
||||||
flow Show Bing institutional flow for a ticker
|
flow Show Bing institutional flow for a ticker
|
||||||
changes Diff two KSEI releases
|
changes Diff two KSEI releases
|
||||||
|
movements Insider and major holder buys and sells, as reported (Stockbit login)
|
||||||
graph Ownership network graph
|
graph Ownership network graph
|
||||||
resolve Manual entity resolution workflow
|
resolve Manual entity resolution workflow
|
||||||
releases List imported KSEI releases
|
releases List imported KSEI releases
|
||||||
|
|
@ -1419,6 +1548,79 @@ Examples:
|
||||||
idx ownership changes --from 2026-08-31 --to 2026-09-30
|
idx ownership changes --from 2026-08-31 --to 2026-09-30
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `idx ownership movements`
|
||||||
|
|
||||||
|
```text
|
||||||
|
Reported changes in holders' stakes from your own Stockbit session (`idx stockbit login`), newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI's changes of holders at 5% and above (Stockbit estimates their price). One stock's, or the whole market's without SYMBOL. Each page is 50 rows and one request; answers are cached for an hour.
|
||||||
|
|
||||||
|
These are filings, not signals: a share plan shows as many insiders buying at one price, and a row appears days after the change.
|
||||||
|
|
||||||
|
Usage: idx ownership movements [OPTIONS] [SYMBOL]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
[SYMBOL]
|
||||||
|
Single ticker symbol (e.g. BBCA); omit for the whole market
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--from <FROM>
|
||||||
|
First day, YYYY-MM-DD
|
||||||
|
|
||||||
|
-o, --output <OUTPUT>
|
||||||
|
Output format [default: auto = table on a terminal, json when piped]
|
||||||
|
|
||||||
|
[possible values: auto, table, json, jsonl]
|
||||||
|
|
||||||
|
--no-color
|
||||||
|
Disable colored output
|
||||||
|
|
||||||
|
--to <TO>
|
||||||
|
Last day, YYYY-MM-DD
|
||||||
|
|
||||||
|
--holder <ID>
|
||||||
|
One holder's movements, by the id in the HOLDER ID column
|
||||||
|
|
||||||
|
-q, --quiet
|
||||||
|
Suppress non-essential output
|
||||||
|
|
||||||
|
--action <ACTION>
|
||||||
|
Only this kind of movement
|
||||||
|
|
||||||
|
[possible values: buy, sell, transfer, cross, corporate-action]
|
||||||
|
|
||||||
|
-v, --verbose...
|
||||||
|
Increase verbosity (-v, -vv)
|
||||||
|
|
||||||
|
--provider <PROVIDER>
|
||||||
|
Market data provider [default: general.provider, else msn]
|
||||||
|
|
||||||
|
[possible values: yahoo, msn, stockbit]
|
||||||
|
|
||||||
|
--source <SOURCE>
|
||||||
|
Only IDX insider disclosures or KSEI 5% holder changes
|
||||||
|
|
||||||
|
[possible values: idx, ksei]
|
||||||
|
|
||||||
|
--offline
|
||||||
|
Run without network requests
|
||||||
|
|
||||||
|
--pages <PAGES>
|
||||||
|
Pages of 50 rows to read (1-10)
|
||||||
|
|
||||||
|
[default: 1]
|
||||||
|
|
||||||
|
--no-cache
|
||||||
|
Bypass local cache
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
idx ownership movements BBCA
|
||||||
|
idx ownership movements --action sell --source ksei
|
||||||
|
idx ownership movements --from 2026-10-01 --to 2026-10-31 --pages 5
|
||||||
|
idx ownership movements --holder 1000004481 # one holder's history (HOLDER ID column)
|
||||||
|
```
|
||||||
|
|
||||||
### `idx ownership graph`
|
### `idx ownership graph`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ idx stockbit login # paste the credentialStorage cookie
|
||||||
idx stockbit status # logged in? when do the tokens expire?
|
idx stockbit status # logged in? when do the tokens expire?
|
||||||
idx stocks orderbook BBCA # depth, queued orders, foreign flow
|
idx stocks orderbook BBCA # depth, queued orders, foreign flow
|
||||||
idx stocks orderbook BBRI --depth 5
|
idx stocks orderbook BBRI --depth 5
|
||||||
|
idx stocks actions BBCA # dividends, splits, meetings, ...
|
||||||
|
idx stocks calendar --kind dividend # cum dates in the next 30 days
|
||||||
|
idx ownership movements BBCA # insider and major holder buys and sells
|
||||||
idx --provider stockbit stocks quote BBCA # live, not delayed
|
idx --provider stockbit stocks quote BBCA # live, not delayed
|
||||||
idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily candles
|
idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily candles
|
||||||
idx stockbit logout
|
idx stockbit logout
|
||||||
|
|
@ -201,6 +204,8 @@ What the login adds:
|
||||||
| `stocks orderbook` | `AUTHERROR` | every level with lots and queued orders, OHLC, value, trades; foreign buy/sell/net once Stockbit marks them current |
|
| `stocks orderbook` | `AUTHERROR` | every level with lots and queued orders, OHLC, value, trades; foreign buy/sell/net once Stockbit marks them current |
|
||||||
| `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows |
|
| `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows |
|
||||||
| `stocks intraday` | public page | public page |
|
| `stocks intraday` | public page | public page |
|
||||||
|
| `stocks actions`, `stocks calendar` | `AUTHERROR` | corporate actions: one stock's history, or the market's calendar |
|
||||||
|
| `ownership movements` | `AUTHERROR` | insider and major holder buys and sells, as reported to IDX and KSEI |
|
||||||
|
|
||||||
The order book is what Stockbit's own app shows. Outside trading it is the
|
The order book is what Stockbit's own app shows. Outside trading it is the
|
||||||
book as the market left it, and the output says so. Foreign buy, sell and net
|
book as the market left it, and the output says so. Foreign buy, sell and net
|
||||||
|
|
@ -228,7 +233,7 @@ Stockbit. Use this for looking at a stock now and then, not for crawling the
|
||||||
market or republishing prices. With your login, automated use is tied to your
|
market or republishing prices. With your login, automated use is tied to your
|
||||||
account: keep it human-paced.
|
account: keep it human-paced.
|
||||||
|
|
||||||
Over MCP, the same data comes from `get_intraday`, `get_orderbook` and
|
Over MCP, the same data comes from `get_intraday`, `get_orderbook`,
|
||||||
`stockbit_status`, and from `get_quotes` and `get_price_history` when the
|
`get_corporate_actions`, `get_corporate_calendar`, `get_shareholder_movements` and `stockbit_status`, and from `get_quotes` and `get_price_history` when the
|
||||||
server runs with `IDX_PROVIDER=stockbit`. The server never logs in; run
|
server runs with `IDX_PROVIDER=stockbit`. The server never logs in; run
|
||||||
`idx stockbit login` in a terminal.
|
`idx stockbit login` in a terminal.
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,33 @@ value and trades, and foreign buy, sell and net once Stockbit marks them
|
||||||
current (not during trading, when they lag a day). Without one it answers
|
current (not during trading, when they lag a day). Without one it answers
|
||||||
`AUTHERROR`. See [Stockbit](/cli/stockbit/#with-your-stockbit-login).
|
`AUTHERROR`. See [Stockbit](/cli/stockbit/#with-your-stockbit-login).
|
||||||
|
|
||||||
|
## Corporate actions (Stockbit login)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
idx stocks actions BBCA # newest first
|
||||||
|
idx stocks actions TLKM --kind dividend
|
||||||
|
idx stocks calendar # today and the next 30 days
|
||||||
|
idx stocks calendar --kind dividend,rights --to 2026-12-31
|
||||||
|
```
|
||||||
|
|
||||||
|
`actions` lists one stock's cash and stock dividends, bonus shares, rights
|
||||||
|
issues, warrants, splits and reverse splits, shareholders' meetings, public
|
||||||
|
exposes and tender offers (`--kind ipo` adds its IPO). `calendar` lists them
|
||||||
|
across the market between `--from` and `--to` (at most 366 days), including
|
||||||
|
what is announced ahead, plus rights trading, tender offers and IPO offers
|
||||||
|
still open in that range, so an offer that began earlier still shows while
|
||||||
|
you can act on it. An action's date is its cum date for dividends,
|
||||||
|
bonuses, rights and splits: buy by then to be entitled. For the others it is
|
||||||
|
the event's first day. When Stockbit gives no cum date, the action sits on
|
||||||
|
its ex date, marked `(ex)` in the table, and JSON leaves `cum_date` out.
|
||||||
|
Dividends per share keep their decimals (`Rp 3.8`); one not paid in Rupiah
|
||||||
|
carries `currency` (e.g. `USD`). JSON's `kind` is the name `--kind` takes.
|
||||||
|
|
||||||
|
Both need your Stockbit session and answer `AUTHERROR` without one. Stockbit
|
||||||
|
serves one kind per request, 1 s apart, so a first call with every kind takes
|
||||||
|
about ten seconds; answers are then cached for 6 hours. `--kind` asks for
|
||||||
|
less.
|
||||||
|
|
||||||
## Company data
|
## Company data
|
||||||
|
|
||||||
| Command | What it shows |
|
| Command | What it shows |
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ description: idx-mcp serves idx's stock and ownership data to AI agents over MCP
|
||||||
|
|
||||||
`idx-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io)
|
`idx-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io)
|
||||||
server over stdio. It gives an agent the same data as the `idx` CLI, as
|
server over stdio. It gives an agent the same data as the `idx` CLI, as
|
||||||
[17 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's
|
[21 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's
|
||||||
session by the minute, the order book (with a Stockbit login), price
|
session by the minute, the order book, corporate actions and insider movements (with a Stockbit
|
||||||
history, screening, company data, analysis, comparison, broker summaries,
|
login), price history, screening, company data, analysis, comparison, broker summaries,
|
||||||
bandarmology, foreign flow, and the KSEI ownership queries.
|
bandarmology, foreign flow, and the KSEI ownership queries.
|
||||||
|
|
||||||
It shares idx's [config, cache and ownership database](/start/config/#files-idx-keeps),
|
It shares idx's [config, cache and ownership database](/start/config/#files-idx-keeps),
|
||||||
|
|
@ -33,6 +33,21 @@ Tools that fetch live data return:
|
||||||
failed. Ownership tools return their data directly, from the local
|
failed. Ownership tools return their data directly, from the local
|
||||||
database.
|
database.
|
||||||
|
|
||||||
|
What `idx` prints as a warning in a terminal comes back in `warnings`, on an
|
||||||
|
answer or an error, when there was any. For example, history that fell back to
|
||||||
|
Yahoo because Stockbit refused your session says so, and a stale answer names
|
||||||
|
the failure behind it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [{ "date": "2026-11-03", "close": 9875, "...": "..." }],
|
||||||
|
"source": "yahoo",
|
||||||
|
"fetched_at": "2026-11-04T03:20:11Z",
|
||||||
|
"stale": false,
|
||||||
|
"warnings": ["Stockbit rejected the session; run `idx stockbit login` again; history falls back to yahoo meanwhile"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Errors come back as a tool result with `isError` set and the CLI's error
|
Errors come back as a tool result with `isError` set and the CLI's error
|
||||||
body, so the agent can decide whether to retry:
|
body, so the agent can decide whether to retry:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ description: Every idx-mcp tool and its inputs, generated from the server's tool
|
||||||
idx-mcp serves these tools. `ownership_sync` is listed only when the server
|
idx-mcp serves these tools. `ownership_sync` is listed only when the server
|
||||||
starts with `--allow-sync`. Results that come from a live provider are wrapped
|
starts with `--allow-sync`. Results that come from a live provider are wrapped
|
||||||
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
as `{data, source, fetched_at, stale}`; failures return `{error, code,
|
||||||
message}` with `isError` set.
|
message}` with `isError` set. Either may carry `warnings`, what idx worked
|
||||||
|
around (such as a history fallback or a stale cache entry), when there was
|
||||||
|
any.
|
||||||
|
|
||||||
## `analyze_bandar`
|
## `analyze_bandar`
|
||||||
|
|
||||||
|
|
@ -63,6 +65,25 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys
|
||||||
| `sections` | array of `profile` / `financials` / `earnings` / `sentiment` / `insights` / `news` | no | Sections to fetch (default: all). |
|
| `sections` | array of `profile` / `financials` / `earnings` / `sentiment` / `insights` / `news` | no | Sections to fetch (default: all). |
|
||||||
| `symbol` | string | yes | IDX ticker, e.g. "BBCA". |
|
| `symbol` | string | yes | IDX ticker, e.g. "BBCA". |
|
||||||
|
|
||||||
|
## `get_corporate_actions`
|
||||||
|
|
||||||
|
Corporate actions of one IDX stock from the user's own Stockbit session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, rights and splits, i.e. the last day to buy and be entitled, or the ex date when `cum_date` is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), `ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), `factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), `period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours). AUTHERROR means there is no session: call stockbit_status, then stockbit_login.
|
||||||
|
|
||||||
|
| Input | Type | Required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `kinds` | array of `dividend` / `stock-dividend` / `bonus` / `rights` / `warrant` / `split` / `reverse-split` / `meeting` / `expose` / `ipo` / `tender` | no | Kinds to list (default: every kind but "ipo"). Each kind is one request to Stockbit. |
|
||||||
|
| `symbol` | string | yes | IDX ticker, e.g. "BBCA". |
|
||||||
|
|
||||||
|
## `get_corporate_calendar`
|
||||||
|
|
||||||
|
Corporate actions across IDX from the user's own Stockbit session, oldest first, dated from `from` to `to` (default today and the next 30 days), plus rights trading, tender offers and IPO offers still open in that range (their `period` overlaps it, though `date` may be earlier): upcoming cum dates, meetings, exposes, rights, warrants, IPOs and tender offers, and the past year. Each item has the same fields as get_corporate_actions, with `symbol`. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours); pass `kinds` to ask for less. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.
|
||||||
|
|
||||||
|
| Input | Type | Required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `from` | string | no | First day, YYYY-MM-DD (default today). |
|
||||||
|
| `kinds` | array of `dividend` / `stock-dividend` / `bonus` / `rights` / `warrant` / `split` / `reverse-split` / `meeting` / `expose` / `ipo` / `tender` | no | Kinds to list (default: every kind). Each kind is one request to Stockbit. |
|
||||||
|
| `to` | string | no | Last day, YYYY-MM-DD (default 30 days after `from`; at most 366 days). |
|
||||||
|
|
||||||
## `get_foreign_flow`
|
## `get_foreign_flow`
|
||||||
|
|
||||||
Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` "ipot" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With "idx": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`.
|
Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` "ipot" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With "idx": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`.
|
||||||
|
|
@ -131,6 +152,20 @@ Latest quotes (price, change, volume, 52-week range, market cap) for one or more
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. |
|
| `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. |
|
||||||
|
|
||||||
|
## `get_shareholder_movements`
|
||||||
|
|
||||||
|
Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`; `incomplete`, when present, says a later page failed and the rows are those read before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.
|
||||||
|
|
||||||
|
| Input | Type | Required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `action` | `buy` / `sell` / `transfer` / `cross` / `corporate-action` | no | Only this kind of movement; "corporate-action" covers rights issues, private placements and the like. |
|
||||||
|
| `from` | string | no | First day, YYYY-MM-DD. |
|
||||||
|
| `holder_id` | string | no | One holder's movements: the `holder_id` of a row. |
|
||||||
|
| `pages` | integer | no | Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit. |
|
||||||
|
| `source` | `idx` / `ksei` | no | "idx" (insider disclosures, any size) or "ksei" (holders at 5% and above). |
|
||||||
|
| `symbol` | string | no | IDX ticker, e.g. "BBCA"; omit for the whole market. |
|
||||||
|
| `to` | string | no | Last day, YYYY-MM-DD. |
|
||||||
|
|
||||||
## `get_shareholders`
|
## `get_shareholders`
|
||||||
|
|
||||||
Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).
|
Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue