Merge pull request #74 from 0xrsydn/feat/stockbit-login

feat: Stockbit login for the order book, live quotes and daily history
This commit is contained in:
dogfiles 2026-11-05 17:53:18 +07:00 • committed by GitHub
commit 400381df2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
129 changed files with 12450 additions and 187 deletions

3
.gitignore vendored
View file

@ -24,3 +24,6 @@ out.txt
# Cloudflare CLI account cache # Cloudflare CLI account cache
.cloudflare/ .cloudflare/
# macOS Finder metadata
.DS_Store

View file

@ -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

26
Cargo.lock generated
View file

@ -979,6 +979,7 @@ dependencies = [
name = "idx-core" name = "idx-core"
version = "0.4.0" version = "0.4.0"
dependencies = [ dependencies = [
"base64",
"chrono", "chrono",
"clap", "clap",
"directories", "directories",
@ -1134,6 +1135,17 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "new_debug_unreachable" name = "new_debug_unreachable"
version = "1.0.6" version = "1.0.6"
@ -1764,6 +1776,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.8" version = "0.3.8"
@ -1966,8 +1988,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc",
"mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2",
] ]
[[package]] [[package]]

View file

@ -91,12 +91,16 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31
- Analysis: `technical`, `growth`, `valuation`, `risk`, and a combined - Analysis: `technical`, `growth`, `valuation`, `risk`, and a combined
`fundamental` report; multi-symbol `compare`; MSN stock `screen` `fundamental` report; multi-symbol `compare`; MSN stock `screen`
- Providers: MSN is the default for quotes, fundamentals, and screening; - Providers: MSN is the default for quotes, fundamentals, and screening;
Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo` and Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo|stockbit`
`IDX_HISTORY_PROVIDER=auto|yahoo|msn` and `IDX_HISTORY_PROVIDER=auto|yahoo|msn|stockbit`
- Stockbit, opt-in and without a login: `stocks intraday` (today's price by - Stockbit, opt-in: without a login, `stocks intraday` (today's price by the
the minute) and `--provider stockbit` quotes with the best bid/offer while minute) and `--provider stockbit` quotes with the best bid/offer while the
the market is open. Delayed at least 10 minutes, for personal use under market is open, delayed at least 10 minutes. With your own account
Stockbit's terms; see the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/) (`idx stockbit login`): `stocks orderbook` depth, live
quotes, Stockbit daily history, and corporate actions (`stocks actions`,
`stocks calendar`: dividends, rights, splits, meetings, IPOs and more), and
insider and major holder buys and sells (`ownership movements`). Personal use under Stockbit's terms; see
the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
- Local file cache with TTL, and `--offline` mode with stale-cache fallback - Local file cache with TTL, and `--offline` mode with stale-cache fallback
### Ownership (KSEI shareholders above 1%) ### Ownership (KSEI shareholders above 1%)
@ -163,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 15 tools: `idx-mcp` serves the same data to MCP clients over stdio, as 21 tools:
`get_quotes`, `get_intraday`, `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

View file

@ -12,11 +12,28 @@ history lives in git.
- `screen presets` / `screen run <PRESET>`: built-in screener presets - `screen presets` / `screen run <PRESET>`: built-in screener presets
(graduate `screen` out of `stocks` only if these grow into a richer workflow) (graduate `screen` out of `stocks` only if these grow into a richer workflow)
- Multi-period financial statements (today `financials` is single-period) - Multi-period financial statements (today `financials` is single-period)
- Order book depth: no free, login-free source exists (IDX sells delayed depth - Order book depth without a login: no free source exists (IDX sells delayed
to vendors). Planned as the Stockbit login follow-on: opt-in with the depth to vendors); `stocks orderbook` needs the user's Stockbit login.
user's own account, the depth ladder, intraday OHLCV and token refresh from - Shareholder movement follow-ups (`docs/STOCKBIT.md`): `ownership movements
`exodus.stockbit.com`; fzn0x/idx (MIT) maps the endpoints. The public page --summary`, a per-symbol score (net value, distinct buyers, share-plan
already gives the best bid/offer (L1) while the market is open. flag), then an event study before calling any of it a signal; joining
holders to ownership entities by name. Corporate-action follow-ups:
ex-dates of splits, bonuses and rights noted in `stocks history`, and the
`stock_dividend` / `reversesplit` row shapes once Stockbit lists one.
- Stockbit login follow-ups: hourly OHLCV from the session's `1w` chart (the
`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
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
beside the `credentialStorage` cookie, should Stockbit rename or move it.
- Google sign-in in idx's browser window: Google refused it on 2026-10-31,
most likely over `--remote-debugging-port`. First confirm by trying Google
in a plain window on a throwaway profile; if that works, sign in with no
debugging flag and reopen the profile with it, on a blank page, to read the
cookie.
- Observe Stockbit's `market_hour.status` in the pre-open (08:45-09:00) and - Observe Stockbit's `market_hour.status` in the pre-open (08:45-09:00) and
the closing auction (15:50-16:00) before `--watch` or alerts build on it; the closing auction (15:50-16:00) before `--watch` or alerts build on it;
`open`, `break` and `close` are known, and the bid/offer gate hides the book `open`, `break` and `close` are known, and the bid/offer gate hides the book
@ -50,8 +67,8 @@ history lives in git.
- Stockbit's terms call its data delayed at least 10 minutes and for - Stockbit's terms call its data delayed at least 10 minutes and for
personal, non-commercial use (on 2026-10-23 the last trade was within a personal, non-commercial use (on 2026-10-23 the last trade was within a
minute of the clock); its CDN refuses some user agents with a 403 minute of the clock); its CDN refuses some user agents with a 403
(`UPSTREAMBLOCKED`). Its order-book sizes are not shown: the page does not (`UPSTREAMBLOCKED`). The public page's best bid/offer sizes are not shown
say whether they are lots or shares. yet; they are shares (equal to the session API's book on 2026-10-23).
- Yahoo can return `429` from datacenter IPs; requests retry with backoff but - Yahoo can return `429` from datacenter IPs; requests retry with backoff but
can still fail. can still fail.
- `stocks technical` reports the SMA200 trend as unavailable when fewer than - `stocks technical` reports the SMA200 trend as unavailable when fewer than

View file

@ -2,6 +2,7 @@ pub mod cache;
pub mod config; pub mod config;
#[cfg(feature = "ownership")] #[cfg(feature = "ownership")]
pub mod ownership; pub mod ownership;
pub mod stockbit;
pub mod stocks; pub mod stocks;
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand, ValueEnum};
@ -69,6 +70,8 @@ pub enum Commands {
#[cfg(feature = "ownership")] #[cfg(feature = "ownership")]
#[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")] #[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")]
Ownership(ownership::OwnershipCmd), Ownership(ownership::OwnershipCmd),
#[command(about = "Your Stockbit session (opt-in: order book, live quotes)")]
Stockbit(stockbit::StockbitCmd),
#[command(about = "Generate shell completions")] #[command(about = "Generate shell completions")]
Completions { shell: Shell }, Completions { shell: Shell },
#[command(about = "Show idx-cli version")] #[command(about = "Show idx-cli version")]

View file

@ -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,

View file

@ -0,0 +1,236 @@
use std::io::{IsTerminal, Read};
use clap::{Args, Subcommand};
use crate::output::text::terminal_safe;
use crate::output::{OutputFormat, json};
use idx_core::api::stockbit::{self, auth::SessionStatus, auth::TokenInfo, signin};
use idx_core::error::IdxError;
use idx_core::runtime;
#[derive(Debug, Args)]
#[command(
about = "Your Stockbit session (opt-in: order book, live quotes)",
long_about = "Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` \
quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, \
stored in the config directory with owner-only permissions, and never logs them. Stockbit \
rotates the refresh token on every refresh, so one session has one owner: `login` signs in \
through a browser profile only idx uses. Stockbit's terms allow personal, non-commercial use.",
after_help = "Examples:\n idx stockbit login # sign in through a browser\n idx stockbit login --no-browser # local page: bookmarklet or paste\n pbpaste | idx stockbit login # the credentialStorage cookie on stdin\n idx stockbit status\n idx stockbit logout"
)]
pub struct StockbitCmd {
#[command(subcommand)]
pub command: StockbitSubcommand,
}
#[derive(Debug, Subcommand)]
pub enum StockbitSubcommand {
#[command(
about = "Sign in through your browser, or from stdin",
long_about = "In a terminal, idx opens Chrome (or Chromium, Edge, Brave, Helium) on \
Stockbit's sign-in page with a profile only idx uses. Sign in there: idx reads the session \
cookie over the DevTools protocol, checks it with one request, takes it out of that profile, \
and closes the window. Your password, one-time codes and any reCAPTCHA stay in the browser. \
A stored session that still works means nothing to do: idx exits at once. Over SSH, without \
a display, without such a browser, or with --no-browser, idx serves a local page on \
127.0.0.1 instead, with a bookmarklet that reads the cookie in your own browser and a \
clipboard button. With stdin piped, it reads the value of stockbit.com's \
`credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin. \
Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead; IDX_STOCKBIT_BROWSER picks the browser."
)]
Login {
/// Use the local page instead of a browser sign-in
#[arg(long)]
no_browser: bool,
/// Port for the local page (for `ssh -L`); default 7878, or any free one
#[arg(long)]
port: Option<u16>,
},
#[command(about = "Show whether a session is stored and when its tokens expire")]
Status,
#[command(about = "Delete the stored session")]
Logout,
}
pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> {
match &cmd.command {
StockbitSubcommand::Login { no_browser, port } => {
let interactive = std::io::stdin().is_terminal();
// A session that still works means there is nothing to sign in to.
let reuse = interactive && stockbit::verify_session().is_ok();
let status = if reuse {
stockbit::session_status()?
} else if interactive {
sign_in(*no_browser, *port, format)?
} else {
stockbit::login(&read_paste()?)?
};
if format.is_json() {
return json::print_json(&status);
}
if reuse {
println!(
"Already signed in to Stockbit; use `idx stockbit logout` to switch accounts."
);
} else {
println!("Stored and checked your Stockbit session.");
}
print_status(&status);
}
StockbitSubcommand::Status => {
let status = stockbit::session_status()?;
if format.is_json() {
return json::print_json(&status);
}
print_status(&status);
}
StockbitSubcommand::Logout => {
let (removed, status) = stockbit::logout()?;
if status.logged_in {
runtime::warn(
"IDX_STOCKBIT_REFRESH_TOKEN or IDX_STOCKBIT_ACCESS_TOKEN still provides a session",
);
}
if format.is_json() {
return json::print_json(&serde_json::json!({
"removed": removed,
"logged_in": status.logged_in,
"session_file": status.session_file,
}));
}
if removed {
println!("Removed the stored Stockbit session.");
} else {
println!("No Stockbit session was stored.");
}
}
}
Ok(())
}
/// Runs idx-core's sign-in in the foreground, telling the user on stderr
/// where to go. When neither the browser nor the page can start, the cookie
/// can still be pasted.
fn sign_in(
no_browser: bool,
port: Option<u16>,
format: &OutputFormat,
) -> Result<SessionStatus, IdxError> {
let options = signin::Options {
method: if no_browser {
signin::Method::Page
} else {
signin::Method::Auto
},
port,
open_page: !no_browser,
};
let sign_in = match signin::SignIn::start(&options) {
Ok(sign_in) => sign_in,
Err(err) => {
runtime::warn(format!("cannot start a sign-in ({err}); paste instead"));
return stockbit::login(&read_paste()?);
}
};
describe(sign_in.started(), format);
sign_in.wait()
}
/// Where to sign in. These lines are the instructions, so they print even
/// with `--quiet`; on stderr, so stdout keeps only the result.
fn describe(started: &signin::Started, format: &OutputFormat) {
let say = |message: String| {
if format.is_json() {
eprintln!(
"{}",
serde_json::json!({ "level": "info", "message": message })
);
} else {
eprintln!("{}", terminal_safe(&message));
}
};
match started {
signin::Started::Browser { browser } => say(format!(
"Opened {browser} on Stockbit's sign-in page. Sign in there; idx closes the window \
when you are done."
)),
signin::Started::Page {
url,
forward,
remote,
reason,
..
} => {
if let Some(reason) = reason
&& !remote
{
runtime::warn(format!("{reason}; using the local page"));
}
say(format!("Open this page to connect Stockbit: {url}"));
if *remote {
say(format!(
"This shell is over SSH, so the page is on the remote machine. From the \
machine with your browser, run (and keep it open):\n {forward}\nthen open \
the URL above there."
));
}
}
signin::Started::Existing => {}
}
}
/// The pasted session: one line from a terminal, else all of stdin.
fn read_paste() -> Result<String, IdxError> {
let stdin = std::io::stdin();
let mut raw = String::new();
if stdin.is_terminal() {
eprintln!(
"Paste the value of stockbit.com's `credentialStorage` cookie (DevTools > \
Application > Cookies) or a refresh token, then press Enter:"
);
stdin
.read_line(&mut raw)
.map_err(|e| IdxError::Io(e.to_string()))?;
} else {
stdin
.lock()
.read_to_string(&mut raw)
.map_err(|e| IdxError::Io(e.to_string()))?;
}
if raw.trim().is_empty() {
return Err(IdxError::InvalidInput(
"nothing on stdin: run `idx stockbit login` in a terminal to sign in through a \
browser, pipe the credentialStorage cookie (`pbpaste | ssh <host> idx stockbit \
login`), or set IDX_STOCKBIT_REFRESH_TOKEN"
.to_string(),
));
}
Ok(raw)
}
fn print_status(status: &SessionStatus) {
println!(
"logged in: {}",
if status.logged_in { "yes" } else { "no" }
);
println!("access token: {}", token_line(&status.access));
println!("refresh token: {}", token_line(&status.refresh));
println!("session file: {}", terminal_safe(&status.session_file));
if !status.logged_in {
println!("Run `idx stockbit login` to add one.");
}
}
fn token_line(token: &TokenInfo) -> String {
match (token.present, token.expires_at, token.expires_in_secs) {
(false, _, _) => "none".to_string(),
(true, Some(at), Some(secs)) if secs > 0 => format!(
"expires {} UTC (in {}h {}m)",
at.format("%Y-%m-%d %H:%M"),
secs / 3600,
secs % 3600 / 60
),
(true, Some(at), _) => format!("expired {} UTC", at.format("%Y-%m-%d %H:%M")),
(true, None, _) => "present (expiry unknown)".to_string(),
}
}

View file

@ -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_profile, render_quotes, render_risk, render_screener, render_fundamental, render_growth, render_history, render_insights, render_intraday,
render_news, render_orderbook, render_profile, render_quotes, render_risk, render_screener,
render_sentiment, render_technical, render_valuation, 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};
@ -23,6 +25,8 @@ use idx_core::{CachePolicy, Idx, runtime};
/// separated by whitespace or commas. /// separated by whitespace or commas.
/// Most symbols one `bandar` screen fetches, one request each. /// Most symbols one `bandar` screen fetches, one request each.
const MAX_BANDAR_SYMBOLS: usize = 100; const MAX_BANDAR_SYMBOLS: usize = 100;
/// Most price levels shown per side of an order book.
const MAX_BOOK_DEPTH: usize = 50;
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> { fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
let mut symbols = Vec::new(); let mut symbols = Vec::new();
@ -146,6 +150,58 @@ Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45).",
/// Single ticker symbol (e.g. BBCA). /// Single ticker symbol (e.g. BBCA).
symbol: String, symbol: String,
}, },
#[command(
about = "Order book depth and foreign flow (Stockbit login)",
long_about = "The order book of one IDX stock from your own Stockbit session (`idx stockbit \
login`): bid and offer levels with lots and queued orders, the session's OHLC, volume, value \
and foreign buy, sell and net. Outside trading it is the book as the market left it, and \
the output says so.",
after_help = "Examples:\n idx stocks orderbook BBCA\n idx stocks orderbook BBRI --depth 5\n idx -o json stocks orderbook TLKM | jq '.offers[0]'"
)]
Orderbook {
/// Single ticker symbol (e.g. BBCA).
symbol: String,
/// Price levels per side (1-50).
#[arg(long, default_value_t = 10)]
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"
@ -332,13 +388,47 @@ pub fn handle(
let day = idx.market().intraday(symbol, policy)?; let day = idx.market().intraday(symbol, policy)?;
render_intraday(&day.data, format, no_color) render_intraday(&day.data, format, no_color)
} }
StocksSubcommand::Orderbook { symbol, depth } => {
if !(1..=MAX_BOOK_DEPTH).contains(depth) {
return Err(IdxError::InvalidInput(format!(
"--depth must be 1 to {MAX_BOOK_DEPTH} (got {depth})"
)));
}
let mut book = idx.market().orderbook(symbol, policy)?.data;
book.bids.truncate(*depth);
book.offers.truncate(*depth);
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,
interval, interval,
history_provider, history_provider,
} => { } => {
let source = idx.market().history_source(*history_provider); let source = idx
.market()
.history_source(*history_provider, period, interval);
announce_history_fallback(&idx, source, config); announce_history_fallback(&idx, source, config);
let history = idx let history = idx
.market() .market()
@ -350,7 +440,7 @@ pub fn handle(
symbol, symbol,
history_provider, history_provider,
} => { } => {
let source = idx.market().history_source(*history_provider); let source = idx.analysis().technical_source(*history_provider);
announce_history_fallback(&idx, source, config); announce_history_fallback(&idx, source, config);
let report = idx.analysis().technical(symbol, source, policy)?; let report = idx.analysis().technical(symbol, source, policy)?;
render_technical(&report.data, format, no_color) render_technical(&report.data, format, no_color)

View file

@ -73,9 +73,17 @@ fn run() -> Result<(), IdxError> {
return Err(err); return Err(err);
} }
} }
Commands::Stockbit(cmd) => {
if let Err(err) = cli::stockbit::handle(cmd, &config.output) {
emit_error(&err, &config.output);
return Err(err);
}
}
#[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);
} }

View file

@ -15,12 +15,14 @@ use idx_core::analysis::fundamental::{
use idx_core::analysis::report::TechnicalReport; use idx_core::analysis::report::TechnicalReport;
use idx_core::api::types::{ use idx_core::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc, CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc,
Quote, SentimentData, OrderBook, Quote, SentimentData,
}; };
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)]
@ -92,6 +94,64 @@ pub fn render_intraday(
} }
} }
pub fn render_orderbook(
book: &OrderBook,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_orderbook(book, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(book),
}
}
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,

View file

@ -11,12 +11,16 @@ use idx_core::analysis::fundamental::{
use idx_core::analysis::report::TechnicalReport; use idx_core::analysis::report::TechnicalReport;
use idx_core::analysis::signals::Signal; use idx_core::analysis::signals::Signal;
use idx_core::api::types::{ use idx_core::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, Intraday, BookLevel, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData,
NewsItem, Ohlc, 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 { "" };
@ -128,6 +132,13 @@ fn pct_cell(change_pct: Option<f64>, no_color: bool) -> Cell {
/// Printed under every Stockbit table: what the data is and is not. /// Printed under every Stockbit table: what the data is and is not.
const STOCKBIT_SOURCE_NOTE: &str = "Source: Stockbit's public page, delayed at least 10 \ const STOCKBIT_SOURCE_NOTE: &str = "Source: Stockbit's public page, delayed at least 10 \
minutes (Stockbit's terms); bid/offer only while the market is open."; minutes (Stockbit's terms); bid/offer only while the market is open.";
/// Under `--provider stockbit` quotes, which may come from either source.
const STOCKBIT_QUOTE_NOTE: &str = "Source: Stockbit, your session when logged in (`idx \
stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); \
bid/offer only while the market is open.";
/// Under the order book, which comes from the user's own session.
const STOCKBIT_SESSION_NOTE: &str =
"Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).";
fn opt_idr(value: Option<i64>) -> String { fn opt_idr(value: Option<i64>) -> String {
value.map(format_idr).unwrap_or_else(|| "-".to_string()) value.map(format_idr).unwrap_or_else(|| "-".to_string())
@ -160,7 +171,12 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx
cell(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())), cell(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())),
cell(opt_idr(q.bid)), cell(opt_idr(q.bid)),
cell(opt_idr(q.offer)), cell(opt_idr(q.offer)),
cell(q.market_status.as_deref().unwrap_or("-")), cell(
q.market_status
.as_deref()
.map(market_words)
.unwrap_or_else(|| "-".to_string()),
),
cell(q.as_of.map(wib_minute).unwrap_or_else(|| "-".to_string())), cell(q.as_of.map(wib_minute).unwrap_or_else(|| "-".to_string())),
]); ]);
} }
@ -177,7 +193,7 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx
for note in stale_quote_notes(quotes, chrono::Utc::now()) { for note in stale_quote_notes(quotes, chrono::Utc::now()) {
println!("{}", terminal_safe(&note)); println!("{}", terminal_safe(&note));
} }
println!("{STOCKBIT_SOURCE_NOTE}"); println!("{STOCKBIT_QUOTE_NOTE}");
Ok(()) Ok(())
} }
@ -262,6 +278,108 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> {
Ok(()) Ok(())
} }
/// Stockbit's market state for people: `STATE_NAME_MARKET_CLOSED` reads
/// `closed`, `STATE_NAME_BREAK` `break`, `STATE_NAME_SESSION_2` `session 2`.
fn market_words(state: &str) -> String {
state
.strip_prefix("STATE_NAME_MARKET_")
.or_else(|| state.strip_prefix("STATE_NAME_"))
.unwrap_or(state)
.replace('_', " ")
.to_lowercase()
}
pub fn print_orderbook(book: &OrderBook, no_color: bool) -> Result<(), IdxError> {
let change = match (book.change, book.change_pct) {
(Some(c), Some(p)) => format!(" {c:+} ({p:+.2}%)"),
(Some(c), None) => format!(" {c:+}"),
_ => String::new(),
};
let change = match book.change {
_ if no_color || change.is_empty() => change,
Some(c) if c >= 0 => change.green(),
_ => change.red(),
};
let name = book
.name
.as_deref()
.map(|n| format!(" {}", terminal_safe(n)))
.unwrap_or_default();
println!(
"{}{change}",
format!(
"{}{name} {}",
terminal_safe(&book.symbol),
format_idr(book.price)
)
.bold()
);
let count = |v: Option<u64>| v.map(format_u64).unwrap_or_else(|| "-".to_string());
println!(
"prev close {} · open {} · high {} · low {} · volume {} · value {} · {} trades",
opt_idr(book.prev_close),
opt_idr(book.open),
opt_idr(book.high),
opt_idr(book.low),
count(book.volume),
count(book.value),
count(book.frequency)
);
if book.foreign_buy.is_some() || book.foreign_sell.is_some() {
println!(
"foreign buy {} · sell {} · net {}",
opt_idr(book.foreign_buy),
opt_idr(book.foreign_sell),
opt_idr(book.foreign_net)
);
}
match book.market_status.as_deref() {
Some(state) if idx_core::api::stockbit::exodus::is_trading(state) => {
println!("market open ({})", terminal_safe(&market_words(state)))
}
Some(state) => println!(
"market {}: the book as the market left it",
terminal_safe(&market_words(state))
),
None => println!("market state unknown: the book may not be live"),
}
if let Some(note) = &book.session_note {
println!("Note: {}", terminal_safe(note));
}
if book.bids.is_empty() && book.offers.is_empty() {
println!("No bids or offers.");
} else {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["ORDERS", "LOTS", "BID", "OFFER", "LOTS", "ORDERS"]);
let side = |level: Option<&BookLevel>| match level {
Some(l) => [
format_u64(l.orders),
format_u64(l.shares / 100),
format_idr(l.price),
],
None => ["".to_string(), "".to_string(), "".to_string()],
};
for i in 0..book.bids.len().max(book.offers.len()) {
let [bid_orders, bid_lots, bid] = side(book.bids.get(i));
let [offer_orders, offer_lots, offer] = side(book.offers.get(i));
table.add_row(vec![
cell(bid_orders),
cell(bid_lots),
cell(bid),
cell(offer),
cell(offer_lots),
cell(offer_orders),
]);
}
println!("{table}");
}
println!("{STOCKBIT_SESSION_NOTE}");
Ok(())
}
/// Whole Rupiah for stocks, two decimals for an index level. /// Whole Rupiah for stocks, two decimals for an index level.
fn format_price(price: f64) -> String { fn format_price(price: f64) -> String {
let cents = (price * 100.0).round() as i64; let cents = (price * 100.0).round() as i64;
@ -1559,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(),

View file

@ -2137,6 +2137,156 @@ fn provider_flag_overrides_env() {
assert_eq!(quotes[0]["market_cap"], Value::Null); assert_eq!(quotes[0]["market_cap"], Value::Null);
} }
#[test]
fn stockbit_login_stores_a_private_session_and_logout_removes_it() {
let root = test_env_dir("stockbit-login");
let cookie = fs::read_to_string(fixture_path(
"stockbit_exodus_credential_storage_cookie_constructed.txt",
))
.expect("cookie fixture");
let session = root.join("config/idx/stockbit-session.json");
let status = run_success_json(
bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stockbit", "login"])
.write_stdin(cookie.clone()),
);
assert_eq!(status["logged_in"], true);
assert_eq!(status["refresh"]["present"], true);
let stored = fs::read_to_string(&session).expect("session file");
assert!(stored.contains("\"refresh\""));
// Status shows expiries, never a token.
assert!(!serde_json::to_string(&status).unwrap().contains("eyJ"));
#[cfg(unix)]
assert_eq!(
fs::metadata(&session).unwrap().permissions().mode() & 0o777,
0o600
);
let book = run_success_json(
bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stocks", "orderbook", "BBCA"]),
);
assert_eq!(book["offers"][0]["shares"], 264_462_500);
let out = run_success_json(
bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stockbit", "logout"]),
);
assert_eq!(out["removed"], true);
assert!(!session.exists());
run_error_json(
bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
// The book fetched above is still cached for a few seconds.
.args(["-o", "json", "--no-cache", "stocks", "orderbook", "BBCA"]),
"AUTHERROR",
);
// Anything that is not a session is refused and stores nothing.
run_error_json(
bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stockbit", "login"])
.write_stdin("not a token"),
"INVALIDINPUT",
);
assert!(!session.exists());
}
/// A session file Stockbit no longer accepts does not break `auto` history:
/// it falls back to Yahoo with a warning. An explicit Stockbit choice fails.
#[test]
fn auto_history_falls_back_to_yahoo_when_the_session_is_refused() {
for args in [
&["stocks", "history", "BBCA", "--period", "1mo"][..],
&["stocks", "technical", "BBCA"][..],
] {
let root = test_env_dir(&format!("stockbit-refused-{}", args[1]));
let output = mock_bin_with_root(&root)
.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
.env("IDX_MOCK_STOCKBIT_STATUS", "401")
.args(["-o", "json", "--provider", "stockbit"])
.args(args)
.output()
.expect("run history");
assert!(output.status.success(), "{args:?}: {output:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("falls back to yahoo"), "{stderr}");
serde_json::from_slice::<Value>(&output.stdout).expect("JSON on stdout");
let _ = fs::remove_dir_all(root);
}
let root = test_env_dir("stockbit-refused-explicit");
run_error_json(
mock_bin_with_root(&root)
.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
.env("IDX_MOCK_STOCKBIT_STATUS", "401")
.args([
"-o",
"json",
"--provider",
"stockbit",
"stocks",
"history",
"BBCA",
])
.args(["--period", "1mo", "--history-provider", "stockbit"]),
"AUTHERROR",
);
let _ = fs::remove_dir_all(root);
}
/// A page after the first that fails keeps the rows read before it, says
/// why, and is not cached; a query's pages are cached as one answer.
#[test]
fn movements_keep_the_pages_read_when_a_later_one_fails() {
let root = test_env_dir("movements-partial");
let run = |fail_page: Option<&str>| {
let mut cmd = mock_bin_with_root(&root);
cmd.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
.args(["-o", "json", "ownership", "movements", "--pages", "2"]);
if let Some(page) = fail_page {
cmd.env("IDX_MOCK_MOVEMENTS_FAIL_PAGE", page);
}
run_success_json(&mut cmd)
};
let partial = run(Some("2"));
assert_eq!(partial["movements"].as_array().unwrap().len(), 8);
assert_eq!(partial["more"], true);
assert!(partial["incomplete"].is_string(), "{partial}");
// Not cached: the next run reads both pages.
let full = run(None);
assert_eq!(full["movements"].as_array().unwrap().len(), 14);
assert!(full.get("incomplete").is_none());
// Cached as one answer: a failing page 2 now changes nothing.
assert_eq!(run(Some("2")), full);
let _ = fs::remove_dir_all(root);
}
/// A test session, signed by nobody, that expires in 2100.
const FAKE_STOCKBIT_TOKEN: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln";
/// An agent on a headless box gets told how to log in, not "nothing pasted".
#[test]
fn stockbit_login_with_empty_stdin_says_how_to_log_in() {
let root = test_env_dir("stockbit-login-empty");
let output = bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stockbit", "login"])
.output()
.expect("run login");
assert!(!output.status.success());
let error: Value = serde_json::from_slice(&output.stderr).expect("JSON error");
assert_eq!(error["code"], "INVALIDINPUT");
let message = error["message"].as_str().unwrap();
for hint in ["idx stockbit login", "IDX_STOCKBIT_REFRESH_TOKEN"] {
assert!(message.contains(hint), "{message}");
}
}
#[test] #[test]
fn invalid_quote_ttl_env_returns_non_zero() { fn invalid_quote_ttl_env_returns_non_zero() {
test_bin("invalid-quote-ttl") test_bin("invalid-quote-ttl")
@ -3143,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);
}

View file

@ -263,6 +263,52 @@ args = ["stocks", "intraday", "MABA"]
name = "stockbit-quote" name = "stockbit-quote"
args = ["--provider", "stockbit", "stocks", "quote", "BBCA,MABA"] args = ["--provider", "stockbit", "stocks", "quote", "BBCA,MABA"]
# With a session: IDX_STOCKBIT_REFRESH_TOKEN seeds an unsigned test token that
# expires in 2100; the mock serves the recorded exodus responses.
[[case]]
name = "stockbit-session-orderbook"
args = ["stocks", "orderbook", "BBCA", "--depth", "3"]
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
[[case]]
name = "stockbit-session-quote"
args = ["--provider", "stockbit", "stocks", "quote", "BBCA"]
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
[[case]]
name = "stockbit-session-history"
args = ["--provider", "stockbit", "stocks", "history", "BBCA", "--period", "1mo"]
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
[[case]]
name = "stockbit-status-logged-out"
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]]
@ -366,8 +412,50 @@ args = ["stocks", "intraday", "ZZZZ"]
name = "error-stockbit-fundamental-unsupported" name = "error-stockbit-fundamental-unsupported"
args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"] args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"]
[[case]]
name = "error-orderbook-no-session"
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]]
name = "error-stockbit-history-window"
args = ["stocks", "history", "BBCA", "--period", "5y", "--history-provider", "stockbit"]
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
# --- 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"]

View file

@ -33,6 +33,9 @@ _idx() {
idx,ownership) idx,ownership)
cmd="idx__ownership" cmd="idx__ownership"
;; ;;
idx,stockbit)
cmd="idx__stockbit"
;;
idx,stocks) idx,stocks)
cmd="idx__stocks" cmd="idx__stocks"
;; ;;
@ -102,6 +105,9 @@ _idx() {
idx__help,ownership) idx__help,ownership)
cmd="idx__help__ownership" cmd="idx__help__ownership"
;; ;;
idx__help,stockbit)
cmd="idx__help__stockbit"
;;
idx__help,stocks) idx__help,stocks)
cmd="idx__help__stocks" cmd="idx__help__stocks"
;; ;;
@ -150,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"
;; ;;
@ -174,12 +183,27 @@ _idx() {
idx__help__ownership__resolve,unresolved) idx__help__ownership__resolve,unresolved)
cmd="idx__help__ownership__resolve__unresolved" cmd="idx__help__ownership__resolve__unresolved"
;; ;;
idx__help__stockbit,login)
cmd="idx__help__stockbit__login"
;;
idx__help__stockbit,logout)
cmd="idx__help__stockbit__logout"
;;
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"
;; ;;
@ -210,6 +234,9 @@ _idx() {
idx__help__stocks,news) idx__help__stocks,news)
cmd="idx__help__stocks__news" cmd="idx__help__stocks__news"
;; ;;
idx__help__stocks,orderbook)
cmd="idx__help__stocks__orderbook"
;;
idx__help__stocks,profile) idx__help__stocks,profile)
cmd="idx__help__stocks__profile" cmd="idx__help__stocks__profile"
;; ;;
@ -258,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"
;; ;;
@ -300,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"
;; ;;
@ -348,12 +381,42 @@ _idx() {
idx__ownership__resolve__help,unresolved) idx__ownership__resolve__help,unresolved)
cmd="idx__ownership__resolve__help__unresolved" cmd="idx__ownership__resolve__help__unresolved"
;; ;;
idx__stockbit,help)
cmd="idx__stockbit__help"
;;
idx__stockbit,login)
cmd="idx__stockbit__login"
;;
idx__stockbit,logout)
cmd="idx__stockbit__logout"
;;
idx__stockbit,status)
cmd="idx__stockbit__status"
;;
idx__stockbit__help,help)
cmd="idx__stockbit__help__help"
;;
idx__stockbit__help,login)
cmd="idx__stockbit__help__login"
;;
idx__stockbit__help,logout)
cmd="idx__stockbit__help__logout"
;;
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"
;; ;;
@ -387,6 +450,9 @@ _idx() {
idx__stocks,news) idx__stocks,news)
cmd="idx__stocks__news" cmd="idx__stocks__news"
;; ;;
idx__stocks,orderbook)
cmd="idx__stocks__orderbook"
;;
idx__stocks,profile) idx__stocks,profile)
cmd="idx__stocks__profile" cmd="idx__stocks__profile"
;; ;;
@ -408,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"
;; ;;
@ -447,6 +519,9 @@ _idx() {
idx__stocks__help,news) idx__stocks__help,news)
cmd="idx__stocks__help__news" cmd="idx__stocks__help__news"
;; ;;
idx__stocks__help,orderbook)
cmd="idx__stocks__help__orderbook"
;;
idx__stocks__help,profile) idx__stocks__help,profile)
cmd="idx__stocks__help__profile" cmd="idx__stocks__help__profile"
;; ;;
@ -475,7 +550,7 @@ _idx() {
case "${cmd}" in case "${cmd}" in
idx) idx)
opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership completions version help" opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership stockbit completions version help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
@ -875,7 +950,7 @@ _idx() {
return 0 return 0
;; ;;
idx__help) idx__help)
opts="stocks config cache ownership completions version help" opts="stocks config cache ownership stockbit completions version 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
@ -1029,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
@ -1154,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
@ -1266,8 +1355,8 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
idx__help__stocks) idx__help__stockbit)
opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare" opts="login status logout"
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
@ -1280,6 +1369,76 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
idx__help__stockbit__login)
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__stockbit__logout)
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__stockbit__status)
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)
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
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
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
@ -1308,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
@ -1448,6 +1621,20 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
idx__help__stocks__orderbook)
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__profile) idx__help__stocks__profile)
opts="" opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
@ -1561,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
@ -1809,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
@ -1948,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
@ -2098,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
@ -2392,8 +2643,8 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
idx__stocks) idx__stockbit)
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday 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 login status logout 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
@ -2418,6 +2669,214 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
idx__stockbit__help)
opts="login status logout help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stockbit__help__help)
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__stockbit__help__login)
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__stockbit__help__logout)
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__stockbit__help__status)
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__stockbit__login)
opts="-o -q -v -h --no-browser --port --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
--port)
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__stockbit__logout)
opts="-o -q -v -h --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
--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__stockbit__status)
opts="-o -q -v -h --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
--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)
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
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--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__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
@ -2514,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
@ -2691,7 +3188,7 @@ _idx() {
return 0 return 0
;; ;;
idx__stocks__help) idx__stocks__help)
opts="quote intraday 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
@ -2704,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
@ -2732,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
@ -2886,6 +3411,20 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
idx__stocks__help__orderbook)
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__profile) idx__stocks__help__profile)
opts="" opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
@ -3000,7 +3539,7 @@ _idx() {
return 0 return 0
;; ;;
--history-provider) --history-provider)
COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}")) COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}"))
return 0 return 0
;; ;;
--output) --output)
@ -3104,6 +3643,36 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0 return 0
;; ;;
idx__stocks__orderbook)
opts="-o -q -v -h --depth --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
--depth)
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__stocks__profile) idx__stocks__profile)
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>" opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
@ -3254,7 +3823,7 @@ _idx() {
fi fi
case "${prev}" in case "${prev}" in
--history-provider) --history-provider)
COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}")) COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}"))
return 0 return 0
;; ;;
--output) --output)

View file

@ -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)"}

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)

View file

@ -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)"}

View file

@ -0,0 +1,4 @@
exit: 2
--- stdout
--- stderr
Error: invalid input: to (2026-11-01) is before from (2026-11-10)

View file

@ -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"}

View file

@ -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

View file

@ -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"}

View file

@ -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

View file

@ -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)"}

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)

View file

@ -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)"}

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
{"code":"UNSUPPORTED","error":true,"message":"unsupported: Stockbit history covers 5d to 1y of daily candles, not 5y at 1d; use --history-provider yahoo"}

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
Error: unsupported: Stockbit history covers 5d to 1y of daily candles, not 5y at 1d; use --history-provider yahoo

View file

@ -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

View file

@ -10,6 +10,7 @@ Commands:
config Manage configuration config Manage configuration
cache Manage local cache cache Manage local cache
ownership Ownership intelligence (bootstrap with `ownership sync`) ownership Ownership intelligence (bootstrap with `ownership sync`)
stockbit Your Stockbit session (opt-in: order book, live quotes)
completions Generate shell completions completions Generate shell completions
version Show idx-cli version version Show idx-cli version
help Print this message or the help of the given subcommand(s) help Print this message or the help of the given subcommand(s)

View file

@ -7,6 +7,9 @@ Usage: idx stocks [OPTIONS] <COMMAND>
Commands: 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)
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -8,5 +8,5 @@ exit: 0
│ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │ │ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │
└─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘ └─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘
Note: MABA.JK: Stockbit lists the stock as suspended Note: MABA.JK: Stockbit lists the stock as suspended
Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
--- stderr --- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"date":"2026-09-17","open":6400,"high":6400,"low":6325,"close":6350,"volume":61812000},{"date":"2026-09-18","open":6325,"high":6350,"low":6225,"close":6300,"volume":176753700},{"date":"2026-09-21","open":6300,"high":6300,"low":6225,"close":6225,"volume":69914100},{"date":"2026-09-22","open":6225,"high":6250,"low":6175,"close":6200,"volume":93211200},{"date":"2026-09-23","open":6200,"high":6300,"low":6200,"close":6300,"volume":96826100},{"date":"2026-09-24","open":6250,"high":6275,"low":6225,"close":6225,"volume":75648600},{"date":"2026-09-25","open":6225,"high":6275,"low":6200,"close":6250,"volume":89447400},{"date":"2026-09-28","open":6225,"high":6250,"low":6175,"close":6225,"volume":112107600},{"date":"2026-09-29","open":6150,"high":6225,"low":6125,"close":6150,"volume":149783400},{"date":"2026-09-30","open":6100,"high":6150,"low":6050,"close":6075,"volume":221335700},{"date":"2026-10-01","open":6050,"high":6150,"low":6000,"close":6000,"volume":118044900},{"date":"2026-10-02","open":6000,"high":6150,"low":5975,"close":6100,"volume":131920300},{"date":"2026-10-05","open":6100,"high":6175,"low":6075,"close":6175,"volume":89905700},{"date":"2026-10-06","open":6175,"high":6200,"low":6100,"close":6100,"volume":152173400},{"date":"2026-10-07","open":6075,"high":6100,"low":5975,"close":6050,"volume":162690800},{"date":"2026-10-08","open":6100,"high":6150,"low":6000,"close":6000,"volume":113140300},{"date":"2026-10-09","open":6025,"high":6075,"low":5975,"close":6050,"volume":87098600},{"date":"2026-10-12","open":6050,"high":6100,"low":5950,"close":5950,"volume":97965100},{"date":"2026-10-13","open":5925,"high":5950,"low":5850,"close":5950,"volume":122716900},{"date":"2026-10-14","open":6000,"high":6125,"low":5950,"close":6125,"volume":136303100},{"date":"2026-10-15","open":6075,"high":6125,"low":6025,"close":6075,"volume":134763200},{"date":"2026-10-16","open":5825,"high":5825,"low":5175,"close":5175,"volume":1241098200}]
--- stderr

View file

@ -0,0 +1,51 @@
exit: 0
--- stdout
History for BBCA.JK
┌────────────┬───────┬───────┬───────┬───────┬───────────────┐
│ DATE ┆ OPEN ┆ HIGH ┆ LOW ┆ CLOSE ┆ VOLUME │
╞════════════╪═══════╪═══════╪═══════╪═══════╪═══════════════╡
│ 2026-09-17 ┆ 6,400 ┆ 6,400 ┆ 6,325 ┆ 6,350 ┆ 61,812,000 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-18 ┆ 6,325 ┆ 6,350 ┆ 6,225 ┆ 6,300 ┆ 176,753,700 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-21 ┆ 6,300 ┆ 6,300 ┆ 6,225 ┆ 6,225 ┆ 69,914,100 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-22 ┆ 6,225 ┆ 6,250 ┆ 6,175 ┆ 6,200 ┆ 93,211,200 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-23 ┆ 6,200 ┆ 6,300 ┆ 6,200 ┆ 6,300 ┆ 96,826,100 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-24 ┆ 6,250 ┆ 6,275 ┆ 6,225 ┆ 6,225 ┆ 75,648,600 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-25 ┆ 6,225 ┆ 6,275 ┆ 6,200 ┆ 6,250 ┆ 89,447,400 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-28 ┆ 6,225 ┆ 6,250 ┆ 6,175 ┆ 6,225 ┆ 112,107,600 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-29 ┆ 6,150 ┆ 6,225 ┆ 6,125 ┆ 6,150 ┆ 149,783,400 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-30 ┆ 6,100 ┆ 6,150 ┆ 6,050 ┆ 6,075 ┆ 221,335,700 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-01 ┆ 6,050 ┆ 6,150 ┆ 6,000 ┆ 6,000 ┆ 118,044,900 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-02 ┆ 6,000 ┆ 6,150 ┆ 5,975 ┆ 6,100 ┆ 131,920,300 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-05 ┆ 6,100 ┆ 6,175 ┆ 6,075 ┆ 6,175 ┆ 89,905,700 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-06 ┆ 6,175 ┆ 6,200 ┆ 6,100 ┆ 6,100 ┆ 152,173,400 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-07 ┆ 6,075 ┆ 6,100 ┆ 5,975 ┆ 6,050 ┆ 162,690,800 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-08 ┆ 6,100 ┆ 6,150 ┆ 6,000 ┆ 6,000 ┆ 113,140,300 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-09 ┆ 6,025 ┆ 6,075 ┆ 5,975 ┆ 6,050 ┆ 87,098,600 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-12 ┆ 6,050 ┆ 6,100 ┆ 5,950 ┆ 5,950 ┆ 97,965,100 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-13 ┆ 5,925 ┆ 5,950 ┆ 5,850 ┆ 5,950 ┆ 122,716,900 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-14 ┆ 6,000 ┆ 6,125 ┆ 5,950 ┆ 6,125 ┆ 136,303,100 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-15 ┆ 6,075 ┆ 6,125 ┆ 6,025 ┆ 6,075 ┆ 134,763,200 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-16 ┆ 5,825 ┆ 5,825 ┆ 5,175 ┆ 5,175 ┆ 1,241,098,200 │
└────────────┴───────┴───────┴───────┴───────┴───────────────┘
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"symbol":"BBCA.JK","name":"Bank Central Asia Tbk.","price":5175,"change":-900,"change_pct":-14.81,"prev_close":6075,"open":5825,"high":5825,"low":5175,"volume":1241098200,"value":6474934315000,"frequency":185072,"foreign_buy":1616461977500,"foreign_sell":3945955065000,"foreign_net":-2329493087500,"bids":[],"offers":[{"price":5175,"shares":264462500,"orders":3284},{"price":5200,"shares":10053400,"orders":337},{"price":5225,"shares":3711400,"orders":396}],"market_status":"STATE_NAME_MARKET_CLOSED","session_note":null}
--- stderr

View file

@ -0,0 +1,17 @@
exit: 0
--- stdout
BBCA.JK Bank Central Asia Tbk. 5,175 -900 (-14.81%)
prev close 6,075 · open 5,825 · high 5,825 · low 5,175 · volume 1,241,098,200 · value 6,474,934,315,000 · 185,072 trades
foreign buy 1,616,461,977,500 · sell 3,945,955,065,000 · net -2,329,493,087,500
market closed: the book as the market left it
┌────────┬──────┬─────┬───────┬───────────┬────────┐
│ ORDERS ┆ LOTS ┆ BID ┆ OFFER ┆ LOTS ┆ ORDERS │
╞════════╪══════╪═════╪═══════╪═══════════╪════════╡
│ ┆ ┆ ┆ 5,175 ┆ 2,644,625 ┆ 3,284 │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ ┆ ┆ ┆ 5,200 ┆ 100,534 ┆ 337 │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ ┆ ┆ ┆ 5,225 ┆ 37,114 ┆ 396 │
└────────┴──────┴─────┴───────┴───────────┴────────┘
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"symbol":"BBCA.JK","price":5175,"change":-900,"change_pct":-14.81,"volume":1241098200,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":6075,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close"}]
--- stderr

View file

@ -0,0 +1,9 @@
exit: 0
--- stdout
┌─────────┬───────┬──────┬─────────┬───────────────┬─────┬───────┬────────┬───────┐
│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ BID ┆ OFFER ┆ MARKET ┆ AS OF │
╞═════════╪═══════╪══════╪═════════╪═══════════════╪═════╪═══════╪════════╪═══════╡
│ BBCA.JK ┆ 5,175 ┆ -900 ┆ -14.81% ┆ 1,241,098,200 ┆ - ┆ - ┆ close ┆ - │
└─────────┴───────┴──────┴─────────┴───────────────┴─────┴───────┴────────┴───────┘
Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"logged_in":false,"access":{"present":false,"expires_at":null,"expires_in_secs":null},"refresh":{"present":false,"expires_at":null,"expires_in_secs":null},"session_file":"<ROOT>/config/idx/stockbit-session.json"}
--- stderr

View file

@ -0,0 +1,8 @@
exit: 0
--- stdout
logged in: no
access token: none
refresh token: none
session file: <ROOT>/config/idx/stockbit-session.json
Run `idx stockbit login` to add one.
--- stderr

View file

@ -30,6 +30,8 @@ directories = "5"
chrono.workspace = true chrono.workspace = true
thiserror = "2" thiserror = "2"
fastrand = "2" fastrand = "2"
# Decodes a Stockbit session JWT's expiry (`exp`); never verifies or signs.
base64 = "0.22"
rusqlite = { workspace = true, optional = true } rusqlite = { workspace = true, optional = true }
quick-xml = { version = "0.37", optional = true } quick-xml = { version = "0.37", optional = true }
sha2 = { workspace = true, optional = true } sha2 = { workspace = true, optional = true }

View file

@ -11,6 +11,7 @@ use crate::api::history_provider;
use crate::api::types::{Fundamentals, Interval, Period}; use crate::api::types::{Fundamentals, Interval, Period};
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::config::HistoryProviderKind;
use crate::error::IdxError; use crate::error::IdxError;
use crate::market::HistorySource; use crate::market::HistorySource;
use bandar::{BandarReport, analyze_bandar, check_top}; use bandar::{BandarReport, analyze_bandar, check_top};
@ -110,8 +111,16 @@ impl Analysis<'_> {
Ok(BandarScreen { reports, failures }) Ok(BandarScreen { reports, failures })
} }
/// Where `technical` reads its year of daily history from, for an
/// optional `--history-provider` override.
pub fn technical_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
self.idx
.market()
.history_source(mode, &Period::OneYear, &Interval::Day)
}
/// Technical analysis over one year of daily history from `source` /// Technical analysis over one year of daily history from `source`
/// (see `Market::history_source`). /// (see `technical_source`).
pub fn technical( pub fn technical(
&self, &self,
symbol: &str, symbol: &str,
@ -119,23 +128,24 @@ impl Analysis<'_> {
policy: CachePolicy, policy: CachePolicy,
) -> Result<Fetched<TechnicalReport>, IdxError> { ) -> Result<Fetched<TechnicalReport>, IdxError> {
let idx = self.idx; let idx = self.idx;
let (provider_kind, provider) =
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
let bucket = format!("{}-technical", provider_kind.as_str());
let resolved = idx.resolve(symbol)?; let resolved = idx.resolve(symbol)?;
cached_fetch( idx.market().with_history_fallback(source, |source| {
&idx.cache, let provider = history_provider(source.provider, idx.config.verbose);
CacheSpec { let bucket = format!("{}-technical", source.provider.as_str());
bucket: &bucket, cached_fetch(
key: &resolved, &idx.cache,
ttl_secs: idx.config.quote_ttl, CacheSpec {
subject: &resolved, bucket: &bucket,
source: provider_kind.as_str(), key: &resolved,
}, ttl_secs: idx.config.quote_ttl,
policy, subject: &resolved,
|| provider.history(&resolved, &Period::OneYear, &Interval::Day), source: source.provider.as_str(),
|history| build_technical_report(&resolved, &history), },
) policy,
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|history| build_technical_report(&resolved, &history),
)
})
} }
pub fn growth( pub fn growth(

View file

@ -1,14 +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::types::{Bar, Fundamentals, Interval, Intraday, Period, Quote}; use super::stockbit::{corpaction, exodus, movement};
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
use super::{ use super::{
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, QuoteProvider, BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider,
IntradayProvider, OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo, TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
}; };
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
use crate::config::ProviderKind; use crate::config::ProviderKind;
use crate::corporate::types::{ActionKind, CorporateAction};
use crate::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()
@ -135,22 +139,32 @@ impl HistoryProvider for MockProvider {
} }
} }
/// Stockbit symbol pages from `tests/fixtures/stockbit_symbol_<code>.html`; /// Stockbit from fixtures. Without a session (none stored or seeded through
/// a code without a fixture is Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` /// `IDX_STOCKBIT_REFRESH_TOKEN`): the public page,
/// (e.g. `403`) answers that HTTP status instead, and `IDX_MOCK_ERROR` fails /// `tests/fixtures/stockbit_symbol_<code>.html`. With one: the session API,
/// as for the other providers. /// `tests/fixtures/stockbit_exodus_orderbook_<code>.json` and
/// `stockbit_exodus_chart_candle_<code>_<window>.json`, with the market state
/// from `stockbit_exodus_market_session.json`. A code without a fixture is
/// Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` (e.g. `403`) answers that HTTP
/// status instead (`401` on the session API: Stockbit refused the session),
/// and `IDX_MOCK_ERROR` fails as for the other providers.
pub struct MockStockbitProvider; pub struct MockStockbitProvider;
impl MockStockbitProvider { impl MockStockbitProvider {
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> { fn failure(&self, symbol: &str) -> Option<IdxError> {
if std::env::var("IDX_MOCK_ERROR").is_ok() { if std::env::var("IDX_MOCK_ERROR").is_ok() {
return Err(IdxError::ProviderUnavailable); return Some(IdxError::ProviderUnavailable);
} }
if let Ok(status) = std::env::var("IDX_MOCK_STOCKBIT_STATUS") { let status = std::env::var("IDX_MOCK_STOCKBIT_STATUS").ok()?;
return Err(stockbit::status_error( Some(stockbit::status_error(
status.parse().unwrap_or(500), status.parse().unwrap_or(500),
symbol, symbol,
)); ))
}
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
if let Some(err) = self.failure(symbol) {
return Err(err);
} }
let code = stockbit::stockbit_code(symbol)?; let code = stockbit::stockbit_code(symbol)?;
let path = format!( let path = format!(
@ -162,10 +176,41 @@ impl MockStockbitProvider {
Err(_) => Err(stockbit::status_error(404, symbol)), Err(_) => Err(stockbit::status_error(404, symbol)),
} }
} }
/// A session API fixture, or the error a request would meet.
fn exodus(&self, symbol: &str, name: &str) -> Result<String, IdxError> {
if !stockbit::has_session() {
return Err(IdxError::AuthError(stockbit::auth::NO_SESSION.to_string()));
}
if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("401") {
return Err(IdxError::AuthError(
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
));
}
// The session API's own block, as `exodus::fetch` reports it.
if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("403") {
return Err(stockbit::exodus::blocked());
}
if let Some(err) = self.failure(symbol) {
return Err(err);
}
std::fs::read_to_string(format!("tests/fixtures/stockbit_exodus_{name}"))
.map_err(|_| IdxError::SymbolNotFound(symbol.to_string()))
}
fn book(&self, symbol: &str) -> Result<OrderBook, IdxError> {
let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase();
let body = self.exodus(symbol, &format!("orderbook_{code}.json"))?;
let state = self.exodus(symbol, "market_session.json")?;
exodus::parse_orderbook(symbol, &body, exodus::parse_market_status(&state))
}
} }
impl QuoteProvider for MockStockbitProvider { impl QuoteProvider for MockStockbitProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> { fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
if stockbit::has_session() {
return self.book(symbol).map(|book| exodus::quote_from_book(&book));
}
let mut quote = self.snapshot(symbol)?.quote; let mut quote = self.snapshot(symbol)?.quote;
// Quotes are judged against the clock (behind the session, a week // Quotes are judged against the clock (behind the session, a week
// old); a fixed trade time would make their output age. // old); a fixed trade time would make their output age.
@ -186,6 +231,89 @@ impl IntradayProvider for MockStockbitProvider {
} }
} }
impl OrderBookProvider for MockStockbitProvider {
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
self.book(symbol)
}
}
/// `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 {
fn history(
&self,
symbol: &str,
period: &Period,
interval: &Interval,
) -> Result<Vec<Bar>, IdxError> {
let window = exodus::history_window(period, interval)
.ok_or_else(|| exodus::unsupported_window(period, interval))?;
let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase();
let body = self.exodus(symbol, &format!("chart_candle_{code}_{window}.json"))?;
exodus::daily_for_period(&body, period)
}
}
/// Broker summaries from `tests/fixtures/ipot_brokersummary_<code>_<from> /// Broker summaries from `tests/fixtures/ipot_brokersummary_<code>_<from>
/// [_<to>][_<investor>_<board>].html`; a query without a fixture reads as an /// [_<to>][_<investor>_<board>].html`; a query without a fixture reads as an
/// unknown code. /// unknown code.

View file

@ -14,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind};
use crate::error::IdxError; use crate::error::IdxError;
use types::{ use types::{
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
Intraday, NewsItem, Period, Quote, SentimentData, Intraday, NewsItem, OrderBook, Period, Quote, SentimentData,
}; };
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp /// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
@ -72,6 +72,34 @@ pub trait IntradayProvider {
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError>; fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError>;
} }
/// The order book of one IDX stock: needs the user's Stockbit session.
pub trait OrderBookProvider {
/// `symbol` is resolved, e.g. `BBCA.JK`.
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>;
} }
@ -242,9 +270,12 @@ fn mock_market(_provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
#[cfg(feature = "testing")] #[cfg(feature = "testing")]
fn mock_history(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> { fn mock_history(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> {
Some(Box::new( match provider {
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose), ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)),
)) _ => Some(Box::new(
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
)),
}
} }
#[cfg(not(feature = "testing"))] #[cfg(not(feature = "testing"))]
@ -285,7 +316,7 @@ fn build_selected_provider(
news: Some(Box::new(msn::MsnProvider::new(verbose))), news: Some(Box::new(msn::MsnProvider::new(verbose))),
screener: Some(Box::new(msn::MsnProvider::new(verbose))), screener: Some(Box::new(msn::MsnProvider::new(verbose))),
}, },
// Quotes only; history falls back to Yahoo (see `history_source`). // Quotes only; history needs a session (see `history_source`).
ProviderKind::Stockbit => SelectedProvider { ProviderKind::Stockbit => SelectedProvider {
kind: ProviderKind::Stockbit, kind: ProviderKind::Stockbit,
market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())), market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())),
@ -328,6 +359,30 @@ pub fn intraday_provider() -> Box<dyn IntradayProvider> {
Box::new(stockbit::StockbitProvider::new()) Box::new(stockbit::StockbitProvider::new())
} }
pub fn orderbook_provider() -> Box<dyn OrderBookProvider> {
#[cfg(feature = "testing")]
if mock::enabled() {
return Box::new(mock::MockStockbitProvider);
}
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")]
@ -338,40 +393,44 @@ pub(crate) fn today_wib() -> chrono::NaiveDate {
} }
/// The provider that serves history for `provider` under `history_mode`. /// The provider that serves history for `provider` under `history_mode`.
pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind { /// `auto` uses Stockbit when it is the selected provider, the user has a
/// session and the window is one of its daily charts; otherwise Yahoo, whose
/// candles are full OHLCV for any window.
pub fn history_source(
provider: ProviderKind,
history_mode: HistoryProviderKind,
period: &Period,
interval: &Interval,
stockbit_session: bool,
) -> ProviderKind {
match history_mode { match history_mode {
HistoryProviderKind::Yahoo => ProviderKind::Yahoo, HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
HistoryProviderKind::Msn => ProviderKind::Msn, HistoryProviderKind::Msn => ProviderKind::Msn,
HistoryProviderKind::Auto => match provider { HistoryProviderKind::Stockbit => ProviderKind::Stockbit,
ProviderKind::Yahoo | ProviderKind::Msn | ProviderKind::Stockbit => ProviderKind::Yahoo, HistoryProviderKind::Auto => {
}, if provider == ProviderKind::Stockbit
&& stockbit_session
&& stockbit::exodus::history_window(period, interval).is_some()
{
ProviderKind::Stockbit
} else {
ProviderKind::Yahoo
}
}
} }
} }
/// Resolves a history provider based on the selected market data provider and /// The history provider for a source from `history_source`.
/// history provider strategy. pub fn history_provider(source: ProviderKind, verbose: bool) -> Box<dyn HistoryProvider> {
///
/// `history_mode=auto` keeps using Yahoo for IDX history because Yahoo provides
/// full OHLCV candles. Explicit `msn` opts into MSN's price-only chart feed.
pub fn history_provider(
provider: ProviderKind,
history_mode: HistoryProviderKind,
verbose: bool,
) -> Result<(ProviderKind, Box<dyn HistoryProvider>), IdxError> {
let resolved = history_source(provider, history_mode);
if mock_enabled() if mock_enabled()
&& let Some(mock) = mock_history(resolved, verbose) && let Some(mock) = mock_history(source, verbose)
{ {
return Ok((resolved, mock)); return mock;
} }
match source {
match resolved { ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)),
ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))), ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)),
ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))), ProviderKind::Stockbit => Box::new(stockbit::StockbitProvider::new()),
ProviderKind::Stockbit => Err(IdxError::Unsupported(
"Stockbit serves no price history; use --history-provider yahoo or msn".to_string(),
)),
} }
} }
@ -472,4 +531,72 @@ mod tests {
"unsupported: screen: command requires --provider msn" "unsupported: screen: command requires --provider msn"
); );
} }
#[test]
fn auto_history_uses_stockbit_only_with_a_session_and_a_daily_window() {
use super::history_source;
use crate::api::types::{Interval, Period};
use crate::config::HistoryProviderKind::{Auto, Stockbit, Yahoo};
let source = |provider, mode, period, interval, session| {
history_source(provider, mode, &period, &interval, session)
};
let year = (Period::OneYear, Interval::Day);
assert_eq!(
source(
ProviderKind::Stockbit,
Auto,
year.0.clone(),
year.1.clone(),
true
),
ProviderKind::Stockbit
);
// No session, a window Stockbit charts weekly, or another provider.
assert_eq!(
source(
ProviderKind::Stockbit,
Auto,
year.0.clone(),
year.1.clone(),
false
),
ProviderKind::Yahoo
);
assert_eq!(
source(
ProviderKind::Stockbit,
Auto,
Period::FiveYears,
Interval::Day,
true
),
ProviderKind::Yahoo
);
assert_eq!(
source(
ProviderKind::Msn,
Auto,
year.0.clone(),
year.1.clone(),
true
),
ProviderKind::Yahoo
);
// An explicit choice wins either way.
assert_eq!(
source(
ProviderKind::Msn,
Stockbit,
year.0.clone(),
year.1.clone(),
false
),
ProviderKind::Stockbit
);
assert_eq!(
source(ProviderKind::Stockbit, Yahoo, year.0, year.1, true),
ProviderKind::Yahoo
);
}
} }

View file

@ -0,0 +1,698 @@
//! The user's own Stockbit session: two bearer JWTs, where they are kept, and
//! how they are refreshed. Opt-in; nothing here runs without a session.
//!
//! - The **access** token (about a day) authorises every
//! `exodus.stockbit.com` request.
//! - The **refresh** token (about a week) is spent on `POST /login/refresh`,
//! which answers a new access token *and a new refresh token*, retiring the
//! one presented. So exactly one party may refresh a session: two idx
//! processes refresh under a file lock and re-read what the other wrote, and
//! a browser sharing the session gets logged out once idx refreshes.
//!
//! The session lives in `<config dir>/stockbit-session.json` (mode 0600).
//! `IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` seed fields the
//! file lacks, for servers. Tokens never reach logs, errors or cache keys.
//! The token shapes follow fzn0x/idx (MIT).
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::IdxError;
pub const ENV_REFRESH: &str = "IDX_STOCKBIT_REFRESH_TOKEN";
pub const ENV_ACCESS: &str = "IDX_STOCKBIT_ACCESS_TOKEN";
const SESSION_FILE: &str = "stockbit-session.json";
const LOCK_FILE: &str = "stockbit-session.lock";
/// Refresh this long before the access token expires, so no request races
/// the expiry.
const REFRESH_BUFFER_SECS: i64 = 60 * 60;
pub(crate) const NO_SESSION: &str =
"no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)";
/// The persisted token pair. Expiries are read from the tokens themselves.
#[derive(Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Session {
#[serde(default)]
pub access: String,
#[serde(default)]
pub refresh: String,
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Session")
.field("access", &(!self.access.is_empty()).then_some("<redacted>"))
.field(
"refresh",
&(!self.refresh.is_empty()).then_some("<redacted>"),
)
.finish()
}
}
impl Session {
pub fn is_empty(&self) -> bool {
self.access.is_empty() && self.refresh.is_empty()
}
}
/// A token's presence and expiry, never its value.
#[derive(Debug, Clone, Serialize)]
pub struct TokenInfo {
pub present: bool,
/// When the token expires; `null` when absent or not a JWT.
pub expires_at: Option<DateTime<Utc>>,
/// Seconds until expiry, negative once expired.
pub expires_in_secs: Option<i64>,
}
impl TokenInfo {
fn of(token: &str, now: i64) -> Self {
let present = !token.is_empty();
let exp = present.then(|| token_expiry(token)).flatten();
Self {
present,
expires_at: exp.and_then(|e| DateTime::from_timestamp(e, 0)),
expires_in_secs: exp.map(|e| e - now),
}
}
}
/// What `idx stockbit status` and the MCP `stockbit_status` tool report.
#[derive(Debug, Clone, Serialize)]
pub struct SessionStatus {
/// A refresh or access token is stored or seeded (live or not).
pub logged_in: bool,
pub access: TokenInfo,
pub refresh: TokenInfo,
pub session_file: String,
}
/// Where the session and idx's browser profile live: beside `config.toml`,
/// or in the other default location when that is where they are.
///
/// On macOS the config directory depends on the environment: a shell that
/// sets `XDG_CONFIG_HOME=~/.config` and an app started from the Dock, which
/// does not, resolve to different places, and the CLI and an MCP client must
/// still find one session. A custom `XDG_CONFIG_HOME` has no other location,
/// so a run isolated with one never reaches the user's session.
pub fn session_dir() -> Result<PathBuf, IdxError> {
let config = crate::config::config_path()?;
let primary = config
.parent()
.ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))?
.to_path_buf();
let other = if cfg!(target_os = "macos") {
std::env::var_os("HOME").and_then(|home| other_default(&primary, Path::new(&home)))
} else {
None
};
Ok(choose_dir(primary, other))
}
/// The second default config location, when `primary` is one of the two.
fn other_default(primary: &Path, home: &Path) -> Option<PathBuf> {
let dot_config = home.join(".config/idx");
let library = home.join("Library/Application Support/idx");
if primary == dot_config {
Some(library)
} else if primary == library {
Some(dot_config)
} else {
None
}
}
/// The directory holding a session, else a browser profile, else `primary`.
fn choose_dir(primary: PathBuf, other: Option<PathBuf>) -> PathBuf {
let Some(other) = other else {
return primary;
};
for entry in [SESSION_FILE, super::browser::PROFILE_DIR] {
if primary.join(entry).exists() {
return primary;
}
if other.join(entry).exists() {
return other;
}
}
primary
}
/// Reads, writes and refreshes the session.
#[derive(Debug, Clone)]
pub struct SessionStore {
dir: PathBuf,
seed: Session,
}
impl SessionStore {
/// The session in `session_dir`, seeded from the environment.
pub fn open() -> Result<Self, IdxError> {
let env = |name| {
std::env::var(name)
.map(|v| normalize_token(&v))
.unwrap_or_default()
};
Ok(Self::at(
session_dir()?,
Session {
access: env(ENV_ACCESS),
refresh: env(ENV_REFRESH),
},
))
}
pub fn at(dir: PathBuf, seed: Session) -> Self {
Self { dir, seed }
}
pub fn path(&self) -> PathBuf {
self.dir.join(SESSION_FILE)
}
/// The file's tokens, each missing one taken from the seed.
pub fn load(&self) -> Session {
let mut session = std::fs::read_to_string(self.path())
.ok()
.and_then(|raw| serde_json::from_str::<Session>(&raw).ok())
.unwrap_or_default();
session.access = normalize_token(&session.access);
session.refresh = normalize_token(&session.refresh);
if session.access.is_empty() {
session.access.clone_from(&self.seed.access);
}
if session.refresh.is_empty() {
session.refresh.clone_from(&self.seed.refresh);
}
session
}
pub fn has_session(&self) -> bool {
!self.load().is_empty()
}
/// Writes the session atomically, readable by the owner only.
pub fn save(&self, session: &Session) -> Result<(), IdxError> {
let io = |e: std::io::Error| IdxError::Io(format!("stockbit session: {e}"));
std::fs::create_dir_all(&self.dir).map_err(io)?;
let tmp = self.dir.join(format!("{SESSION_FILE}.tmp"));
let body = serde_json::to_vec_pretty(session)
.map_err(|e| IdxError::Io(format!("stockbit session: {e}")))?;
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let mut file = options.open(&tmp).map_err(io)?;
std::io::Write::write_all(&mut file, &body).map_err(io)?;
file.sync_all().map_err(io)?;
drop(file);
std::fs::rename(&tmp, self.path()).map_err(io)
}
/// Deletes the stored session; false when there was none. Seeds from the
/// environment are not affected.
pub fn clear(&self) -> Result<bool, IdxError> {
match std::fs::remove_file(self.path()) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(IdxError::Io(format!("stockbit session: {e}"))),
}
}
pub fn status(&self, now: i64) -> SessionStatus {
let session = self.load();
SessionStatus {
logged_in: !session.is_empty(),
access: TokenInfo::of(&session.access, now),
refresh: TokenInfo::of(&session.refresh, now),
session_file: self.path().display().to_string(),
}
}
/// An access token good for at least `REFRESH_BUFFER_SECS`, refreshing
/// with `refresher` (refresh token in, response body out) when it is not.
pub fn access(
&self,
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
now: i64,
) -> Result<String, IdxError> {
let current = self.load();
if current.is_empty() {
return Err(IdxError::AuthError(NO_SESSION.to_string()));
}
if fresh(&current.access, now) {
return Ok(current.access);
}
self.locked(|| {
// Another process may have refreshed while this one waited.
let session = self.load();
if fresh(&session.access, now) {
return Ok(session.access);
}
match self.rotate(&session, refresher) {
Ok(access) => Ok(access),
// A failed refresh must not block a token that still works.
Err(_) if token_expiry(&session.access).is_some_and(|exp| now < exp) => {
Ok(session.access)
}
Err(e) => Err(e),
}
})
}
/// A new access token after the server rejected `rejected`, unless
/// another process already replaced it.
pub fn after_rejection(
&self,
rejected: &str,
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
) -> Result<String, IdxError> {
self.locked(|| {
let session = self.load();
if !session.access.is_empty() && session.access != rejected {
return Ok(session.access);
}
self.rotate(&session, refresher)
})
}
/// Spends the refresh token and stores the new pair.
fn rotate(
&self,
session: &Session,
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
) -> Result<String, IdxError> {
if session.refresh.is_empty() {
return Err(IdxError::AuthError(
"the Stockbit session has no refresh token; run `idx stockbit login`".to_string(),
));
}
let body = refresher(&session.refresh)?;
let next = parse_refresh(&body, session)?;
self.save(&next)?;
Ok(next.access)
}
/// Runs `f` holding the session lock, so only one process spends the
/// rotating refresh token at a time.
fn locked<T>(&self, f: impl FnOnce() -> Result<T, IdxError>) -> Result<T, IdxError> {
let io = |e: std::io::Error| IdxError::Io(format!("stockbit session lock: {e}"));
std::fs::create_dir_all(&self.dir).map_err(io)?;
let lock = lock_file(&self.dir.join(LOCK_FILE)).map_err(io)?;
lock.lock().map_err(io)?;
let result = f();
drop(lock);
result
}
}
fn lock_file(path: &Path) -> std::io::Result<File> {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
}
fn fresh(access: &str, now: i64) -> bool {
token_expiry(access).is_some_and(|exp| now < exp - REFRESH_BUFFER_SECS)
}
/// Strips whitespace and a leading `Bearer `.
pub fn normalize_token(raw: &str) -> String {
let t = raw.trim();
t.strip_prefix("Bearer ")
.or_else(|| t.strip_prefix("bearer "))
.unwrap_or(t)
.trim()
.to_string()
}
/// A JWT's `exp` (Unix seconds), read without verifying the signature: idx
/// only carries these tokens.
pub fn token_expiry(token: &str) -> Option<i64> {
let payload = token.split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload.trim_end_matches('='))
.ok()?;
serde_json::from_slice::<Value>(&bytes)
.ok()?
.get("exp")?
.as_i64()
}
/// Three dot-separated base64url parts with a JSON header (`eyJ`).
fn looks_like_jwt(value: &str) -> bool {
let mut parts = value.split('.');
matches!(parts.next(), Some(h) if h.starts_with("eyJ"))
&& parts.next().is_some_and(|p| !p.is_empty())
&& parts.next().is_some_and(|p| !p.is_empty())
&& parts.next().is_none()
}
/// The JWT under key `slot` anywhere in `value`: a bare string or an object
/// with `token`. Stockbit's envelopes have moved between versions.
fn find_token(value: &Value, slot: &str) -> Option<String> {
match value {
Value::Object(map) => {
let direct = map.get(slot).and_then(|hit| {
hit.as_str()
.or_else(|| hit.get("token").and_then(Value::as_str))
.filter(|t| looks_like_jwt(t))
.map(str::to_string)
});
direct.or_else(|| map.values().find_map(|child| find_token(child, slot)))
}
Value::Array(items) => items.iter().find_map(|child| find_token(child, slot)),
_ => None,
}
}
/// The new pair from a `/login/refresh` response; a response that rotates
/// only the access token keeps the presented refresh token.
pub fn parse_refresh(body: &str, previous: &Session) -> Result<Session, IdxError> {
let value: Value = serde_json::from_str(body)
.map_err(|e| IdxError::ParseError(format!("stockbit refresh response: {e}")))?;
let access = find_token(&value, "access").ok_or_else(|| {
IdxError::ParseError("stockbit refresh response carried no access token".to_string())
})?;
Ok(Session {
access,
refresh: find_token(&value, "refresh").unwrap_or_else(|| previous.refresh.clone()),
})
}
/// A session from what a user pastes: the `credentialStorage` cookie of
/// stockbit.com (percent-encoded JSON), that JSON decoded, or a bare refresh
/// token.
pub fn session_from_paste(raw: &str) -> Option<Session> {
let raw = raw.trim();
let decoded = percent_decode(raw);
for candidate in [decoded.as_str(), raw] {
if let Ok(value) = serde_json::from_str::<Value>(candidate) {
let session = Session {
access: find_token(&value, "access").unwrap_or_default(),
refresh: find_token(&value, "refresh").unwrap_or_default(),
};
if !session.is_empty() {
return Some(session);
}
}
}
let token = normalize_token(raw);
looks_like_jwt(&token).then(|| Session {
access: String::new(),
refresh: token,
})
}
/// `%XX` escapes decoded; anything else kept as is.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& let Some(hex) = s.get(i + 1..i + 3)
&& let Ok(byte) = u8::from_str_radix(hex, 16)
{
out.push(byte);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
/// The two default locations pair up; anything else stands alone, so
/// a run with its own `XDG_CONFIG_HOME` cannot reach the user's session.
#[test]
fn only_the_two_default_locations_pair_up() {
let home = Path::new("/Users/me");
let dot_config = home.join(".config/idx");
let library = home.join("Library/Application Support/idx");
assert_eq!(other_default(&dot_config, home), Some(library.clone()));
assert_eq!(other_default(&library, home), Some(dot_config));
assert_eq!(other_default(Path::new("/tmp/run/idx"), home), None);
}
/// A session anywhere beats a profile, a profile beats nothing, and with
/// neither the primary location is used.
#[test]
fn the_session_is_found_in_either_location() {
let root = std::env::temp_dir().join(format!(
"idx-session-dir-{}-{}",
std::process::id(),
fastrand::u32(..)
));
let (primary, other) = (root.join("primary"), root.join("other"));
std::fs::create_dir_all(&primary).unwrap();
std::fs::create_dir_all(&other).unwrap();
let choose = || choose_dir(primary.clone(), Some(other.clone()));
assert_eq!(choose(), primary);
assert_eq!(choose_dir(primary.clone(), None), primary);
std::fs::create_dir(other.join(super::super::browser::PROFILE_DIR)).unwrap();
assert_eq!(choose(), other, "a profile says where the user signs in");
std::fs::write(primary.join(SESSION_FILE), "{}").unwrap();
assert_eq!(choose(), primary, "a session beats a profile");
std::fs::remove_file(primary.join(SESSION_FILE)).unwrap();
std::fs::write(other.join(SESSION_FILE), "{}").unwrap();
assert_eq!(choose(), other);
std::fs::write(primary.join(SESSION_FILE), "{}").unwrap();
assert_eq!(choose(), primary, "with two, the primary one wins");
let _ = std::fs::remove_dir_all(&root);
}
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
}
/// An unsigned JWT expiring at `exp`.
fn jwt(exp: i64, tag: &str) -> String {
let enc = |v: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v);
format!(
"{}.{}.{}",
enc(r#"{"alg":"none"}"#),
enc(&format!(r#"{{"exp":{exp},"tag":"{tag}"}}"#)),
enc("sig")
)
}
fn refresh_body(access: &str, refresh: &str) -> String {
format!(
r#"{{"data":{{"access":{{"token":"{access}"}},"refresh":{{"token":"{refresh}"}}}}}}"#
)
}
struct TempDir(PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir()
.join(format!("idx-stockbit-auth-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Self(dir)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
const NOW: i64 = 1_800_000_000;
#[test]
fn reads_the_pair_from_a_refresh_response_and_the_cookie() {
let pair = parse_refresh(
&fixture("stockbit_exodus_login_refresh_constructed.json"),
&Session::default(),
)
.unwrap();
assert_eq!(token_expiry(&pair.access), Some(1_792_385_008));
assert!(looks_like_jwt(&pair.refresh));
let pasted = session_from_paste(&fixture(
"stockbit_exodus_credential_storage_cookie_constructed.txt",
))
.unwrap();
assert!(looks_like_jwt(&pasted.access) && looks_like_jwt(&pasted.refresh));
let bare = jwt(NOW, "r");
assert_eq!(
session_from_paste(&format!(" Bearer {bare}\n")),
Some(Session {
access: String::new(),
refresh: bare
})
);
assert_eq!(session_from_paste("not a token"), None);
}
#[test]
fn a_refresh_without_a_new_refresh_token_keeps_the_old_one() {
let previous = Session {
access: String::new(),
refresh: jwt(NOW, "old"),
};
let access = jwt(NOW + 86_400, "a");
let next =
parse_refresh(&format!(r#"{{"data":{{"access":"{access}"}}}}"#), &previous).unwrap();
assert_eq!(
next,
Session {
access,
refresh: previous.refresh
}
);
}
#[test]
fn a_fresh_access_token_is_used_without_refreshing() {
let dir = TempDir::new("fresh");
let store = SessionStore::at(dir.0.clone(), Session::default());
let access = jwt(NOW + 2 * REFRESH_BUFFER_SECS, "a");
store
.save(&Session {
access: access.clone(),
refresh: jwt(NOW + 7 * 86_400, "r"),
})
.unwrap();
let refresher = |_: &str| -> Result<String, IdxError> { panic!("must not refresh") };
assert_eq!(store.access(&refresher, NOW).unwrap(), access);
}
#[test]
fn a_stale_access_token_rotates_both_tokens_and_persists_them() {
let dir = TempDir::new("rotate");
let store = SessionStore::at(dir.0.clone(), Session::default());
let old_refresh = jwt(NOW + 7 * 86_400, "r1");
store
.save(&Session {
access: jwt(NOW + 60, "a1"),
refresh: old_refresh.clone(),
})
.unwrap();
let (new_access, new_refresh) = (jwt(NOW + 86_400, "a2"), jwt(NOW + 8 * 86_400, "r2"));
let calls = Cell::new(0);
let refresher = |presented: &str| {
calls.set(calls.get() + 1);
assert_eq!(presented, old_refresh);
Ok(refresh_body(&new_access, &new_refresh))
};
assert_eq!(store.access(&refresher, NOW).unwrap(), new_access);
assert_eq!(calls.get(), 1);
// The rotated pair is what the next process reads.
assert_eq!(
store.load(),
Session {
access: new_access,
refresh: new_refresh
}
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(store.path())
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn a_rejected_token_already_replaced_by_another_process_is_not_refreshed_again() {
let dir = TempDir::new("rejected");
let store = SessionStore::at(dir.0.clone(), Session::default());
let replaced = jwt(NOW + 86_400, "theirs");
store
.save(&Session {
access: replaced.clone(),
refresh: jwt(NOW + 86_400, "r"),
})
.unwrap();
let refresher = |_: &str| -> Result<String, IdxError> { panic!("must not refresh") };
assert_eq!(
store
.after_rejection(&jwt(NOW, "mine"), &refresher)
.unwrap(),
replaced
);
}
#[test]
fn no_session_is_an_auth_error_and_the_file_beats_the_seed() {
let dir = TempDir::new("seed");
let empty = SessionStore::at(dir.0.clone(), Session::default());
let refresher = |_: &str| -> Result<String, IdxError> { unreachable!() };
assert!(matches!(
empty.access(&refresher, NOW),
Err(IdxError::AuthError(_))
));
let seeded = SessionStore::at(
dir.0.clone(),
Session {
access: String::new(),
refresh: jwt(NOW, "env"),
},
);
assert!(seeded.has_session());
let stored = jwt(NOW, "file");
seeded
.save(&Session {
access: String::new(),
refresh: stored.clone(),
})
.unwrap();
assert_eq!(seeded.load().refresh, stored);
}
#[test]
fn status_and_debug_never_show_a_token() {
let dir = TempDir::new("status");
let token = jwt(NOW + 100, "secret");
let store = SessionStore::at(
dir.0.clone(),
Session {
access: token.clone(),
refresh: String::new(),
},
);
let status = store.status(NOW);
assert!(status.logged_in);
assert_eq!(status.access.expires_in_secs, Some(100));
assert!(!status.refresh.present);
let shown = format!(
"{} {:?}",
serde_json::to_string(&status).unwrap(),
store.load()
);
assert!(!shown.contains(&token), "{shown}");
}
}

File diff suppressed because it is too large Load diff

View 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");
}
}

View file

@ -0,0 +1,708 @@
//! Stockbit's own market-data API, `https://exodus.stockbit.com`, with the
//! user's session (see `auth`). Opt-in: used only when a session is stored.
//!
//! - `GET /company-price-feed/v2/orderbook/companies/{CODE}`: last price,
//! OHLC, volume (shares), value, frequency, foreign buy/sell/net (IDR, used
//! only when `has_foreign_bs` is true) and the depth ladder
//! `bid[]`/`offer[]` of `{price, que_num, volume}` (shares).
//! - `GET /company-price-feed/market-time/session`: the market's state,
//! `data.detail.regular.state_name`: `STATE_NAME_SESSION_2` while trading,
//! `STATE_NAME_BREAK`, `STATE_NAME_MARKET_CLOSED` (recorded). The `fca`
//! (full call auction) board beside it keeps its own state and is not read.
//! - `GET /charts/{CODE}/daily?timeframe=1m|3m|1y&chart_type=PRICE_CHART_TYPE_CANDLE`:
//! daily candles, `formatted_date` `YYYY-MM-DD`, string OHLC, `value` the
//! close and `volume` in lots.
//! - `POST /login/refresh` with the refresh token as bearer rotates the pair.
//!
//! A 401 (or a JSON 403) spends one refresh and retries once. Requests share
//! the public page's pacing. Endpoints and units were mapped by fzn0x/idx
//! (MIT); `tests/fixtures/stockbit_exodus_*` are its recordings.
use std::sync::Mutex;
use std::time::{Duration, Instant};
use chrono::{Months, NaiveDate};
use serde_json::Value;
use super::auth::SessionStore;
use super::parse::number;
use super::{USER_AGENT, pace, stockbit_code};
use super::{corpaction, movement};
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
use crate::corporate::types::{ActionKind, CorporateAction};
use crate::error::IdxError;
use crate::movements::types::{MovementPage, MovementQuery};
const BASE_URL: &str = "https://exodus.stockbit.com";
const REFRESH_PATH: &str = "/login/refresh";
/// 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:
/// `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`,
/// `STATE_NAME_MARKET_CLOSED`, auctions) hides bid/offer in quotes, as on the
/// public page.
const TRADING_PREFIX: &str = "STATE_NAME_SESSION_";
/// Whether Stockbit's market state is a trading session.
pub fn is_trading(state: &str) -> bool {
state
.strip_prefix(TRADING_PREFIX)
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
/// The market's state changes a few times a day; one read serves this long.
const MARKET_STATE_TTL: Duration = Duration::from_secs(30);
pub(crate) struct Exodus {
agent: ureq::Agent,
store: SessionStore,
}
struct Reply {
status: u16,
body: String,
}
impl Reply {
/// The server refused the access token: 401, or a 403 with a JSON body
/// (a CDN block is an HTML page).
fn rejected(&self) -> bool {
self.status == 401
|| (self.status == 403 && serde_json::from_str::<Value>(&self.body).is_ok())
}
}
impl Exodus {
pub(crate) fn new(store: SessionStore) -> Self {
let agent = ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_recv_body(Some(Duration::from_secs(10)))
.timeout_global(Some(Duration::from_secs(20)))
.http_status_as_error(false)
.build()
.into();
Self { agent, store }
}
pub(crate) fn store(&self) -> &SessionStore {
&self.store
}
/// One authorised request (the market's state), refreshing the session
/// when needed: proves Stockbit accepts it.
pub(crate) fn verify(&self) -> Result<(), IdxError> {
self.get("/company-price-feed/market-time/session", "market session")
.map(|_| ())
}
pub(crate) fn orderbook(&self, resolved: &str) -> Result<OrderBook, IdxError> {
let code = stockbit_code(resolved)?;
let body = self.get(
&format!("/company-price-feed/v2/orderbook/companies/{code}"),
resolved,
)?;
parse_orderbook(resolved, &body, self.market_status())
}
pub(crate) fn quote(&self, resolved: &str) -> Result<Quote, IdxError> {
self.orderbook(resolved).map(|book| quote_from_book(&book))
}
/// Daily candles covering `period`, oldest first.
pub(crate) fn daily(
&self,
resolved: &str,
period: &Period,
interval: &Interval,
) -> Result<Vec<Ohlc>, IdxError> {
let window =
history_window(period, interval).ok_or_else(|| unsupported_window(period, interval))?;
let code = stockbit_code(resolved)?;
let body = self.get(
&format!("/charts/{code}/daily?timeframe={window}&chart_type=PRICE_CHART_TYPE_CANDLE"),
resolved,
)?;
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.
/// Unknown on any failure, which hides bid/offer.
fn market_status(&self) -> Option<String> {
static CACHE: Mutex<Option<(Instant, Option<String>)>> = Mutex::new(None);
let mut cached = CACHE.lock().unwrap_or_else(|p| p.into_inner());
if let Some((at, state)) = cached.as_ref()
&& at.elapsed() < MARKET_STATE_TTL
{
return state.clone();
}
let state = self
.get("/company-price-feed/market-time/session", "market session")
.ok()
.and_then(|body| parse_market_status(&body));
*cached = Some((Instant::now(), state.clone()));
state
}
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 token = self.store.access(&refresher, unix_now())?;
let mut reply = self.send(path, &token)?;
if reply.rejected() {
let token = self.store.after_rejection(&token, &refresher)?;
reply = self.send(path, &token)?;
if reply.rejected() {
return Err(IdxError::AuthError(
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
));
}
}
classify(reply, subject)
}
fn send(&self, path: &str, token: &str) -> Result<Reply, IdxError> {
pace();
let response = self
.agent
.get(format!("{BASE_URL}{path}"))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
.header("Origin", "https://stockbit.com")
.header("Referer", "https://stockbit.com/")
.header("Authorization", format!("Bearer {token}"))
.call()
.map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?;
read_reply(response)
}
/// `POST /login/refresh`; the response body carries the new pair.
fn refresh(&self, refresh: &str) -> Result<String, IdxError> {
pace();
let response = self
.agent
.post(format!("{BASE_URL}{REFRESH_PATH}"))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Origin", "https://stockbit.com")
.header("Referer", "https://stockbit.com/")
.header("Authorization", format!("Bearer {refresh}"))
.send("{}")
.map_err(|e| IdxError::Http(format!("stockbit refresh: {e}")))?;
let reply = read_reply(response)?;
match reply.status {
200..=299 => Ok(reply.body),
401 | 403 => Err(IdxError::AuthError(
"Stockbit rejected the refresh token (expired or revoked); run `idx stockbit \
login` again"
.to_string(),
)),
status => Err(IdxError::Http(format!("stockbit refresh: HTTP {status}"))),
}
}
}
fn read_reply(response: ureq::http::Response<ureq::Body>) -> Result<Reply, IdxError> {
let status = response.status().as_u16();
let body = response
.into_body()
.read_to_string()
.map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?;
Ok(Reply { status, body })
}
fn unix_now() -> i64 {
chrono::Utc::now().timestamp()
}
/// A non-rejected reply as data or the error its status means.
fn classify(reply: Reply, subject: &str) -> Result<String, IdxError> {
match reply.status {
200..=299 => Ok(reply.body),
404 if error_type(&reply.body).as_deref() == Some("NOT_FOUND") => {
Err(IdxError::SymbolNotFound(subject.to_string()))
}
429 => Err(IdxError::RateLimited),
403 => Err(blocked()),
status => Err(IdxError::Http(format!(
"stockbit api: HTTP {status}{}",
message(&reply.body)
.map(|m| format!(": {m}"))
.unwrap_or_default()
))),
}
}
fn error_type(body: &str) -> Option<String> {
serde_json::from_str::<Value>(body)
.ok()?
.get("error_type")?
.as_str()
.map(str::to_string)
}
fn message(body: &str) -> Option<String> {
serde_json::from_str::<Value>(body)
.ok()?
.get("message")?
.as_str()
.map(str::to_string)
}
fn data(body: &str, what: &str) -> Result<Value, IdxError> {
let mut value: Value = serde_json::from_str(body)
.map_err(|e| IdxError::ParseError(format!("stockbit {what}: {e}")))?;
match value.get_mut("data") {
Some(data) => Ok(data.take()),
None => Err(IdxError::ParseError(format!("stockbit {what}: no data"))),
}
}
fn whole(value: Option<&Value>) -> Option<i64> {
value.and_then(number).map(|n| n.round() as i64)
}
fn count(value: Option<&Value>) -> Option<u64> {
value
.and_then(number)
.filter(|n| *n >= 0.0)
.map(|n| n.round() as u64)
}
/// The order book and session figures; `market_status` comes from the
/// market-time endpoint.
pub fn parse_orderbook(
resolved: &str,
body: &str,
market_status: Option<String>,
) -> Result<OrderBook, IdxError> {
let d = data(body, "order book")?;
let price = whole(d.get("lastprice").or_else(|| d.get("close")))
.ok_or_else(|| IdxError::ParseError("stockbit order book: no last price".to_string()))?;
let levels = |key: &str| -> Vec<BookLevel> {
d.get(key)
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|row| {
Some(BookLevel {
price: whole(row.get("price")).filter(|p| *p > 0)?,
shares: count(row.get("volume")).unwrap_or(0),
orders: count(row.get("que_num")).unwrap_or(0),
})
})
.collect()
})
.unwrap_or_default()
};
let listing = d.get("status").and_then(Value::as_str).unwrap_or_default();
let volume = count(d.get("volume"));
let session_note = if !listing.is_empty() && !listing.eq_ignore_ascii_case("active") {
Some(format!(
"Stockbit lists the stock as {}",
listing.to_ascii_lowercase()
))
} else if volume == Some(0) {
Some("no trades this session".to_string())
} else {
None
};
// Stockbit flags whether `fbuy`/`fsell`/`fnet` are current. While the
// flag was off (2026-11-03, in session) they were the previous session's
// figures (equal to IPOT's for 2026-11-02 for BBCA, BBRI and TLKM), so
// they are dropped rather than shown beside today's prices.
let foreign_current = d.get("has_foreign_bs").and_then(Value::as_bool) == Some(true);
let foreign = |key: &str| foreign_current.then(|| whole(d.get(key))).flatten();
Ok(OrderBook {
symbol: resolved.to_string(),
name: d.get("name").and_then(Value::as_str).map(str::to_string),
price,
change: whole(d.get("change")),
change_pct: d.get("percentage_change").and_then(number),
prev_close: whole(d.get("previous")),
open: whole(d.get("open")),
high: whole(d.get("high")),
low: whole(d.get("low")),
volume,
value: count(d.get("value")),
frequency: count(d.get("frequency")),
foreign_buy: foreign("fbuy"),
foreign_sell: foreign("fsell"),
foreign_net: foreign("fnet"),
bids: levels("bid"),
offers: levels("offer"),
market_status,
session_note,
})
}
/// A quote from the order book; bid/offer only while the market is open.
/// Its `market_status` is the public page's vocabulary, `open` or `close`,
/// so a quote reads the same with or without a login; the order book keeps
/// Stockbit's state verbatim.
pub fn quote_from_book(book: &OrderBook) -> Quote {
let open = book.market_status.as_deref().is_some_and(is_trading);
let best = |levels: &[BookLevel]| open.then(|| levels.first().map(|l| l.price)).flatten();
Quote {
symbol: book.symbol.clone(),
price: book.price,
change: book.change,
change_pct: book.change_pct,
volume: book.volume,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close: book.prev_close,
avg_volume: None,
as_of: None,
behind_session: false,
bid: best(&book.bids),
offer: best(&book.offers),
market_status: book
.market_status
.as_ref()
.map(|_| if open { "open" } else { "close" }.to_string()),
session_note: book.session_note.clone(),
}
}
pub fn parse_market_status(body: &str) -> Option<String> {
data(body, "market session")
.ok()?
.pointer("/detail/regular/state_name")?
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
}
/// The chart window holding `period` of daily candles: Stockbit picks the
/// bar size from the window (`1w` is hourly, `3y`/`5y` weekly), so only the
/// daily windows qualify.
pub fn history_window(period: &Period, interval: &Interval) -> Option<&'static str> {
match (interval, period) {
(Interval::Day, Period::FiveDays | Period::OneMonth) => Some("1m"),
(Interval::Day, Period::ThreeMonths) => Some("3m"),
(Interval::Day, Period::SixMonths | Period::OneYear) => Some("1y"),
_ => None,
}
}
pub fn unsupported_window(period: &Period, interval: &Interval) -> IdxError {
IdxError::Unsupported(format!(
"Stockbit history covers 5d to 1y of daily candles, not {} at {}; use \
--history-provider yahoo",
period.as_str(),
interval.as_str()
))
}
/// The candles of `period` from a daily chart response.
pub fn daily_for_period(body: &str, period: &Period) -> Result<Vec<Ohlc>, IdxError> {
Ok(trim_to_period(parse_daily(body)?, period))
}
/// Daily candles, oldest first. Volume arrives in lots and is returned in
/// shares, as from the other providers.
pub fn parse_daily(body: &str) -> Result<Vec<Ohlc>, IdxError> {
let d = data(body, "chart")?;
let rows = d
.get("prices")
.and_then(Value::as_array)
.ok_or_else(|| IdxError::ParseError("stockbit chart: no prices".to_string()))?;
let mut bars = Vec::with_capacity(rows.len());
for row in rows {
let Some(close) = whole(row.get("value")) else {
continue;
};
let Some(date) = row
.get("formatted_date")
.and_then(Value::as_str)
.and_then(|d| NaiveDate::parse_from_str(d.trim(), "%Y-%m-%d").ok())
else {
return Err(IdxError::ParseError(format!(
"stockbit chart: row without a daily date: {row}"
)));
};
bars.push(Ohlc {
date,
open: whole(row.get("open")).unwrap_or(close),
high: whole(row.get("high")).unwrap_or(close),
low: whole(row.get("low")).unwrap_or(close),
close,
volume: count(row.get("volume")).unwrap_or(0).saturating_mul(100),
});
}
bars.sort_by_key(|b| b.date);
Ok(bars)
}
/// The candles within `period` of the latest one (5 sessions for `5d`).
fn trim_to_period(mut bars: Vec<Ohlc>, period: &Period) -> Vec<Ohlc> {
let Some(last) = bars.last().map(|b| b.date) else {
return bars;
};
let months = match period {
Period::FiveDays => {
let start = bars.len().saturating_sub(5);
return bars.split_off(start);
}
Period::OneMonth => 1,
Period::ThreeMonths => 3,
Period::SixMonths => 6,
_ => 12,
};
match last.checked_sub_months(Months::new(months)) {
Some(start) => bars.into_iter().filter(|b| b.date > start).collect(),
None => bars,
}
}
/// 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)]
mod tests {
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 {
std::fs::read_to_string(format!(
"{}/tests/fixtures/stockbit_exodus_{name}",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
}
#[test]
fn order_book_carries_depth_in_shares_and_foreign_flow() {
let book = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
assert_eq!(
(book.price, book.prev_close, book.change),
(5175, Some(6075), Some(-900))
);
assert_eq!(
(book.open, book.high, book.low),
(Some(5825), Some(5825), Some(5175))
);
assert_eq!(book.foreign_net, Some(-2_329_493_087_500));
// BBCA closed at its lower limit: no bids, offers best price first.
assert!(book.bids.is_empty());
assert_eq!(
book.offers[0],
BookLevel {
price: 5175,
shares: 264_462_500,
orders: 3284
}
);
assert!(book.offers.windows(2).all(|w| w[0].price < w[1].price));
assert_eq!(book.session_note, None);
}
#[test]
fn quote_shows_the_best_levels_only_while_the_market_is_open() {
let trading = parse_market_status(&fixture("market_session_trading.json"));
let book = |state: Option<String>| {
parse_orderbook("BBCA.JK", &fixture("orderbook_bbca_in_session.json"), state).unwrap()
};
let open = quote_from_book(&book(trading));
assert_eq!((open.bid, open.offer), (Some(4850), Some(4860)));
assert_eq!(open.price, 4860);
assert_eq!(open.market_status.as_deref(), Some("open"));
for state in ["market_session_break.json", "market_session.json"] {
let shut = quote_from_book(&book(parse_market_status(&fixture(state))));
assert_eq!((shut.bid, shut.offer), (None, None), "{state}");
assert_eq!(shut.market_status.as_deref(), Some("close"), "{state}");
}
let unknown = quote_from_book(&book(None));
assert_eq!((unknown.bid, unknown.market_status), (None, None));
}
#[test]
fn market_status_is_read_verbatim() {
for (name, state) in [
("market_session.json", "STATE_NAME_MARKET_CLOSED"),
("market_session_break.json", "STATE_NAME_BREAK"),
("market_session_trading.json", "STATE_NAME_SESSION_2"),
] {
assert_eq!(parse_market_status(&fixture(name)).as_deref(), Some(state));
}
}
#[test]
fn only_numbered_sessions_are_trading() {
assert!(is_trading("STATE_NAME_SESSION_1"));
assert!(is_trading("STATE_NAME_SESSION_2"));
for state in [
"STATE_NAME_BREAK",
"STATE_NAME_MARKET_CLOSED",
"STATE_NAME_MARKET_OPEN",
"STATE_NAME_SESSION_",
"STATE_NAME_SESSION_PRE_OPENING",
"",
] {
assert!(!is_trading(state), "{state}");
}
}
#[test]
fn foreign_flow_only_when_stockbit_marks_it_current() {
// In session (has_foreign_bs false) the figures were 2026-11-02's.
let live =
parse_orderbook("BBCA.JK", &fixture("orderbook_bbca_in_session.json"), None).unwrap();
assert_eq!(
(live.foreign_buy, live.foreign_sell, live.foreign_net),
(None, None, None)
);
assert_eq!(live.volume, Some(134_744_100));
let done = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
assert_eq!(done.foreign_buy, Some(1_616_461_977_500));
}
#[test]
fn errors_map_to_their_codes() {
let reply = |status, body: &str| Reply {
status,
body: body.to_string(),
};
assert!(matches!(
classify(reply(404, &fixture("orderbook_zzzz.json")), "ZZZZ.JK"),
Err(IdxError::SymbolNotFound(s)) if s == "ZZZZ.JK"
));
assert!(reply(401, &fixture("unauthorized.json")).rejected());
assert!(reply(403, &fixture("unauthorized.json")).rejected());
let blocked = reply(403, "<HTML>Request blocked.</HTML>");
assert!(!blocked.rejected());
assert!(matches!(
classify(blocked, "BBCA.JK"),
Err(IdxError::UpstreamBlocked(_))
));
assert!(matches!(
classify(reply(429, ""), "x"),
Err(IdxError::RateLimited)
));
}
#[test]
fn daily_candles_convert_lots_to_shares_and_trim_to_the_period() {
let year = parse_daily(&fixture("chart_candle_bbca_1y.json")).unwrap();
assert_eq!(year.len(), 240);
assert!(year.windows(2).all(|w| w[0].date < w[1].date));
let month = parse_daily(&fixture("chart_candle_bbca_1m.json")).unwrap();
let first = &month[0];
assert_eq!(first.date.to_string(), "2026-09-16");
assert_eq!(
(first.open, first.high, first.low, first.close),
(6350, 6425, 6325, 6325)
);
assert_eq!(first.volume, 72_157_400);
let six = trim_to_period(year.clone(), &Period::SixMonths);
assert_eq!(six.first().unwrap().date.to_string(), "2026-04-17");
assert_eq!(trim_to_period(month, &Period::FiveDays).len(), 5);
}
#[test]
fn only_daily_windows_map_to_a_chart() {
assert_eq!(history_window(&Period::OneYear, &Interval::Day), Some("1y"));
assert_eq!(history_window(&Period::TwoYears, &Interval::Day), None);
assert_eq!(history_window(&Period::OneYear, &Interval::Week), None);
assert_eq!(history_window(&Period::OneDay, &Interval::Day), None);
}
}

View file

@ -0,0 +1,723 @@
//! The local page `idx stockbit login` opens: a loopback server on
//! 127.0.0.1 that takes the pasted session, checks it, and reports back.
//!
//! Stockbit has no OAuth redirect that could hand idx a session, and the
//! browser will not give one site another site's cookie, so the page takes
//! the value two ways, both one click:
//! - **Read my clipboard**: the user copies `credentialStorage` in DevTools
//! once and clicks the button (`navigator.clipboard`, allowed on localhost).
//! - **Bookmarklet**: a link rendered with this run's port and secret that
//! reads `document.cookie` on stockbit.com and opens the page with the value
//! in the URL *fragment*, which browsers never send to the server.
//!
//! Safeguards: bound to 127.0.0.1, a random port, a per-run secret path (no
//! secret, no page), a short life, `no-store`, no referrer, and a strict CSP.
//! The session value is held in memory only on the way to `stockbit::login`;
//! it is never written to a log or the response.
use std::io::{Read, Write};
use std::net::{Ipv4Addr, TcpListener, TcpStream};
use std::time::{Duration, Instant};
use crate::error::IdxError;
/// Largest paste accepted (the cookie is a few KB).
const MAX_BODY: usize = 16 * 1024;
/// Requests time out instead of pinning a thread; short, because a browser
/// preconnect sends nothing at all.
const IO_TIMEOUT: Duration = Duration::from_secs(3);
/// Connections answered at once; beyond this, new ones are dropped.
const MAX_CONNECTIONS: usize = 16;
/// A running login page.
pub struct LoginPage {
listener: TcpListener,
port: u16,
secret: String,
}
impl LoginPage {
/// Binds 127.0.0.1 on `port` (0 picks a free one).
pub fn bind(port: u16) -> Result<Self, IdxError> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).map_err(|e| {
IdxError::Io(format!(
"cannot open the Stockbit login page on 127.0.0.1:{port}: {e}"
))
})?;
let port = listener
.local_addr()
.map_err(|e| IdxError::Io(e.to_string()))?
.port();
listener
.set_nonblocking(true)
.map_err(|e| IdxError::Io(e.to_string()))?;
Ok(Self {
listener,
port,
secret: random_secret(),
})
}
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}/login/{}", self.port, self.secret)
}
/// The port the page listens on.
pub fn port(&self) -> u16 {
self.port
}
/// Serves until `verify` accepts a paste or `timeout` passes. The error
/// `verify` returns for a refused paste is shown on the page.
///
/// Each connection gets its own thread: browsers open idle preconnects,
/// and answering them one at a time used to hold the page back for the
/// read timeout on every one. Pastes are checked one at a time, and none
/// after one is accepted: two at once (a double click, the bookmarklet
/// and the clipboard button) would each store a session, and the later
/// could overwrite the one Stockbit had just rotated.
pub fn wait<F>(&self, timeout: Duration, verify: F) -> Result<(), IdxError>
where
F: Fn(&str) -> Result<(), IdxError> + Send + Sync + 'static,
{
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let accepted = std::sync::Mutex::new(false);
let verify = std::sync::Arc::new(move |paste: &str| {
let mut accepted = accepted.lock().unwrap_or_else(|e| e.into_inner());
if !*accepted {
verify(paste)?;
*accepted = true;
}
Ok(())
});
let page = std::sync::Arc::new(self.page());
let path = std::sync::Arc::new(format!("/login/{}", self.secret));
let busy = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let deadline = Instant::now() + timeout;
loop {
if done_rx.try_recv().is_ok() {
return Ok(());
}
if Instant::now() >= deadline {
break;
}
match self.listener.accept() {
Ok((stream, _)) => {
// Browsers open a handful; anything beyond this is junk.
if busy.load(std::sync::atomic::Ordering::Relaxed) >= MAX_CONNECTIONS {
continue;
}
busy.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let (verify, page, path) = (verify.clone(), page.clone(), path.clone());
let busy = busy.clone();
let done = done_tx.clone();
std::thread::spawn(move || {
if serve_once(stream, &path, &page, verify.as_ref()) {
let _ = done.send(());
}
busy.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
});
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(20));
}
Err(e) => return Err(IdxError::Io(format!("stockbit login page: {e}"))),
}
}
Err(IdxError::Io(format!(
"the Stockbit login page timed out after {}s; its URL was {} (re-run it, or pin the \
port with --port)",
timeout.as_secs(),
self.url()
)))
}
/// The page, with this run's port and secret baked into the bookmarklet.
fn page(&self) -> String {
let target = self.url();
// Reads stockbit.com's session cookie and opens the page with it in
// the fragment (never sent to a server, never in a referrer).
let bookmarklet = format!(
"javascript:(()=>{{const m=document.cookie.match(/(?:^|;\\s*)credentialStorage=([^;]*)/);\
if(!m){{alert('No Stockbit session in this browser: log in to stockbit.com first.');return}}\
window.open('{target}#t='+encodeURIComponent(m[1]))}})()"
);
PAGE.replace("__TARGET__", &target)
.replace("__BOOKMARKLET__", &html_escape(&bookmarklet))
}
}
/// The forward to run where the browser is, for the page on `port`:
/// `ssh -L <port>:127.0.0.1:<port> <user>@<host>`.
///
/// The target comes from `SSH_CONNECTION` (`client_ip client_port server_ip
/// server_port`) when idx runs inside an SSH session, else from `USER` and
/// `HOSTNAME`, else it is left for the reader to fill in.
pub fn ssh_forward(port: u16) -> String {
let target = ssh_target().unwrap_or_else(|| "<this host>".to_string());
format!("ssh -L {port}:127.0.0.1:{port} {target}")
}
fn ssh_target() -> Option<String> {
if let Ok(connection) = std::env::var("SSH_CONNECTION") {
let fields: Vec<&str> = connection.split_whitespace().collect();
if let Some(server) = fields.get(2) {
return Some(match std::env::var("USER") {
Ok(user) => format!("{user}@{server}"),
Err(_) => (*server).to_string(),
});
}
}
let user = std::env::var("USER").ok()?;
let host = std::env::var("HOSTNAME").ok()?;
Some(format!("{user}@{host}"))
}
/// Answers one request; true when a paste was accepted.
fn serve_once(
mut stream: TcpStream,
page_path: &str,
page: &str,
verify: &dyn Fn(&str) -> Result<(), IdxError>,
) -> bool {
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
let Some(request) = read_request(&mut stream) else {
// An idle preconnect or a malformed request: drop it quietly.
return false;
};
match (request.method.as_str(), request.path.as_str()) {
("GET", p) if p == page_path => {
respond(
&mut stream,
200,
"text/html; charset=utf-8",
page.as_bytes(),
);
}
("POST", p) if p == page_path => match verify(&request.body) {
Ok(()) => {
respond(
&mut stream,
200,
"application/json",
br#"{"ok":true,"message":"Connected. You can close this tab."}"#,
);
return true;
}
Err(err) => {
let body = serde_json::json!({ "ok": false, "message": err.to_string() });
respond(
&mut stream,
200,
"application/json",
body.to_string().as_bytes(),
);
}
},
("GET", "/favicon.ico") => respond(&mut stream, 204, "text/plain", b""),
_ => respond(&mut stream, 404, "text/plain", b"not found"),
}
false
}
fn random_secret() -> String {
(0..4)
.map(|_| format!("{:08x}", fastrand::u32(..)))
.collect::<Vec<_>>()
.join("")
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
struct Request {
method: String,
path: String,
body: String,
}
/// A minimal HTTP/1.1 request: request line, headers, and a bounded body.
fn read_request(stream: &mut TcpStream) -> Option<Request> {
let mut raw = Vec::new();
let mut chunk = [0u8; 2048];
let header_end = loop {
let n = stream.read(&mut chunk).ok()?;
if n == 0 {
return None;
}
raw.extend_from_slice(&chunk[..n]);
if let Some(at) = find(&raw, b"\r\n\r\n") {
break at + 4;
}
if raw.len() > MAX_BODY {
return None;
}
};
let head = String::from_utf8_lossy(&raw[..header_end]).into_owned();
let mut lines = head.lines();
let mut request_line = lines.next()?.split_whitespace();
let method = request_line.next()?.to_string();
let path = request_line.next()?.split('?').next()?.to_string();
let length = lines
.filter_map(|line| line.split_once(':'))
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
.unwrap_or(0)
.min(MAX_BODY);
let mut body = raw[header_end..].to_vec();
while body.len() < length {
let n = stream.read(&mut chunk).ok()?;
if n == 0 {
break;
}
body.extend_from_slice(&chunk[..n]);
}
body.truncate(length);
Some(Request {
method,
path,
body: String::from_utf8_lossy(&body).into_owned(),
})
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) {
let reason = match status {
200 => "OK",
204 => "No Content",
400 => "Bad Request",
_ => "Not Found",
};
let head = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\
Content-Length: {}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\n\
X-Content-Type-Options: nosniff\r\n\
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; \
script-src 'unsafe-inline'; connect-src 'self'\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
}
const PAGE: &str = r#"<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Connect Stockbit to idx</title>
<style>
:root{
color-scheme:light dark;
--bg:#f4f5f8;--glow:rgba(47,92,240,.10);--panel:#fff;--panel-2:#f7f8fb;
--ink:#12141a;--muted:#6a7180;--line:#e5e8ef;--line-strong:#d2d7e2;
--accent:#2f5cf0;--accent-ink:#fff;
--ok:#0e7a48;--ok-bg:#eaf7f0;--ok-line:#bfe6d1;
--err:#b42318;--err-bg:#fdf2f1;--err-line:#f0cecb;
--shadow:0 1px 2px rgba(16,24,40,.05),0 18px 40px -20px rgba(16,24,40,.25)
}
@media (prefers-color-scheme:dark){:root{
--bg:#0b0c0f;--glow:rgba(122,162,255,.09);--panel:#14161b;--panel-2:#191c22;
--ink:#edeef2;--muted:#99a0b0;--line:#252932;--line-strong:#333a46;
--accent:#7aa2ff;--accent-ink:#0b0c0f;
--ok:#5fd39a;--ok-bg:#0f1f19;--ok-line:#1e4135;
--err:#ff8f86;--err-bg:#231413;--err-line:#4a2523;
--shadow:0 1px 2px rgba(0,0,0,.5),0 18px 44px -20px rgba(0,0,0,.9)
}}
*{box-sizing:border-box}
body{margin:0;padding:clamp(20px,5vw,56px) 20px;background:var(--bg);
background-image:radial-gradient(900px 420px at 50% -180px,var(--glow),transparent 70%);
background-repeat:no-repeat;color:var(--ink);
font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
-webkit-font-smoothing:antialiased}
.card{max-width:640px;margin:0 auto;background:var(--panel);border:1px solid var(--line);
border-radius:20px;padding:clamp(24px,4.4vw,40px);box-shadow:var(--shadow)}
.brand{display:flex;align-items:center;gap:9px;font-size:13px;font-weight:600;color:var(--muted);margin-bottom:20px}
.mark{display:grid;place-items:center;width:28px;height:28px;border-radius:9px;background:var(--accent);color:var(--accent-ink)}
.mark svg{width:16px;height:16px}
.sep{opacity:.45}
h1{font-size:clamp(22px,3.2vw,26px);line-height:1.25;letter-spacing:-.02em;margin:0 0 8px;text-wrap:balance}
.lede{color:var(--muted);margin:0 0 26px}
.steps{list-style:none;margin:0;padding:0;display:grid;gap:12px}
.step{display:flex;gap:14px;border:1px solid var(--line);background:var(--panel-2);border-radius:14px;padding:16px 18px}
.num{flex:0 0 auto;display:grid;place-items:center;width:24px;height:24px;margin-top:2px;border-radius:50%;
border:1px solid var(--line-strong);background:var(--panel);color:var(--muted);font-size:12px;font-weight:700}
.step h2{font-size:15px;letter-spacing:-.01em;margin:0 0 4px}
.step p{margin:0 0 13px;color:var(--muted);font-size:14px}
.step p:last-child{margin-bottom:0}
.btn{display:inline-flex;align-items:center;gap:8px;border:1px solid transparent;border-radius:10px;
padding:10px 16px;font:inherit;font-size:14px;font-weight:600;line-height:1.2;cursor:pointer;
text-decoration:none;background:var(--accent);color:var(--accent-ink);
box-shadow:0 1px 2px rgba(16,24,40,.10);transition:filter .12s ease,transform .12s ease,background-color .12s ease}
.btn:hover{filter:brightness(1.07)}
.btn:active{transform:translateY(1px)}
.btn.ghost{background:var(--panel);color:var(--ink);border-color:var(--line-strong);box-shadow:none}
.btn.ghost:hover{background:var(--panel-2);filter:none}
.btn[disabled]{opacity:.55;cursor:progress;transform:none}
.hint{margin:13px 0 0;color:var(--muted);font-size:13px}
.manual{margin-top:14px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}
summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;
cursor:pointer;font-size:14px;font-weight:600;list-style:none}
summary::-webkit-details-marker{display:none}
summary svg{width:16px;height:16px;flex:0 0 auto;color:var(--muted);transition:transform .16s ease}
.manual[open] summary svg{transform:rotate(180deg)}
.manual-body{padding:0 18px 18px;border-top:1px solid var(--line)}
.mini{margin:15px 0;padding-left:19px;color:var(--muted);font-size:13.5px;line-height:1.75}
.mini li::marker{color:var(--line-strong)}
textarea{width:100%;min-height:84px;border:1px solid var(--line-strong);border-radius:10px;
background:var(--panel);color:var(--ink);padding:11px 12px;resize:vertical;
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
textarea::placeholder{color:var(--muted);opacity:.75}
.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
code{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--panel-2);
border:1px solid var(--line);border-radius:6px;padding:1px 5px;white-space:nowrap}
kbd{font:12px ui-monospace,Menlo,monospace;background:var(--panel-2);border:1px solid var(--line-strong);
border-bottom-width:2px;border-radius:5px;padding:0 5px}
em{font-style:normal;color:var(--ink)}
.status{display:flex;align-items:center;gap:10px;margin:18px 0 0;padding:12px 14px;
border:1px solid transparent;border-radius:11px;font-size:14px;font-weight:600}
.status[hidden]{display:none}
.status.pending{color:var(--muted);background:var(--panel-2);border-color:var(--line)}
.status.pending::before{content:"";flex:0 0 auto;width:14px;height:14px;border-radius:50%;
border:2px solid var(--line-strong);border-top-color:var(--accent);animation:spin .7s linear infinite}
.status.err{color:var(--err);background:var(--err-bg);border-color:var(--err-line)}
.status.err::before{content:"!";flex:0 0 auto;display:grid;place-items:center;width:16px;height:16px;
border-radius:50%;background:var(--err);color:var(--err-bg);font-size:11px;font-weight:800}
@keyframes spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){
.status.pending::before{animation-duration:2.4s}
.btn{transition:none}
}
.done{padding:6px 0 10px;text-align:center}
.done[hidden]{display:none}
.done .check{display:grid;place-items:center;width:52px;height:52px;margin:0 auto 16px;
border-radius:50%;background:var(--ok-bg);border:1px solid var(--ok-line);color:var(--ok)}
.done .check svg{width:26px;height:26px}
.done h2{font-size:18px;letter-spacing:-.01em;margin:0 0 6px}
.done p{margin:0;color:var(--muted);font-size:14px}
.foot{margin-top:26px;padding-top:18px;border-top:1px solid var(--line);
color:var(--muted);font-size:13px;line-height:1.65}
.foot p{margin:0 0 8px}
.foot p:last-child{margin-bottom:0}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
</style></head>
<body><main class="card">
<div class="brand">
<span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.7 14.3 14.3 9.7"/><path d="M7.4 12.6l-1.3 1.3a3.6 3.6 0 0 0 5 5l1.3-1.3"/><path d="M16.6 11.4l1.3-1.3a3.6 3.6 0 0 0-5-5L11.6 6.4"/></svg></span>
<span>idx</span><span class="sep" aria-hidden="true">/</span><span>Stockbit</span>
</div>
<header class="head">
<h1>Connect your Stockbit account</h1>
<p class="lede">idx then reads live quotes and the order book with your own session. The session stays on this machine.</p>
</header>
<section id="app">
<ol class="steps">
<li class="step">
<span class="num" aria-hidden="true">1</span>
<div>
<h2>Sign in to Stockbit</h2>
<p>Open stockbit.com in this browser and sign in. If the tab is already signed in, go to the next step.</p>
<a class="btn ghost" href="https://stockbit.com/symbol/BBCA" target="_blank" rel="noreferrer">Open stockbit.com</a>
</div>
</li>
<li class="step">
<span class="num" aria-hidden="true">2</span>
<div>
<h2>Send the session to idx</h2>
<p>While the Stockbit tab is open, click this button. It reads the session cookie in your own browser.</p>
<a class="btn" id="bookmarklet" href="__BOOKMARKLET__">Send my Stockbit session</a>
<p class="hint">If the browser blocks it, drag the button to your bookmarks bar, open stockbit.com, then click it there.</p>
</div>
</li>
</ol>
<details class="manual" id="manual">
<summary><span>Or paste the cookie by hand</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="manual-body">
<ol class="mini">
<li>On stockbit.com, open DevTools with <kbd>F12</kbd>.</li>
<li>Go to <em>Application</em>, then <em>Cookies</em>, then <code>https://stockbit.com</code>.</li>
<li>Copy the value of <code>credentialStorage</code> and paste it below.</li>
</ol>
<textarea id="paste" placeholder="credentialStorage value" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>
<div class="row">
<button class="btn" id="send">Connect</button>
<button class="btn ghost" id="clip">Paste from clipboard</button>
</div>
</div>
</details>
<p id="status" class="status" role="status" aria-live="polite" hidden></p>
</section>
<section id="done" class="done" hidden aria-live="polite">
<span class="check" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m20 6.5-10.5 11L4 12"/></svg></span>
<h2>Connected</h2>
<p id="done-msg">You can close this tab and go back to idx.</p>
</section>
<footer class="foot">
<p>idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser that shares this session signs out once idx refreshes it.</p>
<p>This page stops listening after a few minutes.</p>
</footer>
</main>
<script>
const target = "__TARGET__";
const app = document.getElementById('app');
const done = document.getElementById('done');
const statusEl = document.getElementById('status');
const manual = document.getElementById('manual');
const paste = document.getElementById('paste');
const head = document.querySelector('.head');
const buttons = [document.getElementById('send'), document.getElementById('clip')];
let busy = false;
function setStatus(text, kind) {
statusEl.hidden = !text;
statusEl.className = 'status' + (kind ? ' ' + kind : '');
statusEl.textContent = text || '';
}
function setBusy(value) {
busy = value;
for (const button of buttons) { button.disabled = value; }
}
function askForPaste(message) {
if (message) { setStatus(message, 'err'); }
manual.open = true;
paste.focus();
}
async function send(value) {
const text = (value || '').trim();
if (busy) { return false; }
if (!text) { askForPaste('Paste the credentialStorage value first.'); return false; }
setBusy(true);
setStatus('Checking the session with Stockbit\u2026', 'pending');
try {
const response = await fetch(target, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: text
});
const result = await response.json();
if (result.ok) {
setStatus('');
head.hidden = true;
app.hidden = true;
done.hidden = false;
return true;
}
setStatus(result.message, 'err');
} catch (error) {
setStatus('Could not reach idx: ' + error, 'err');
} finally {
setBusy(false);
}
return false;
}
document.getElementById('send').addEventListener('click', () => send(paste.value));
document.getElementById('clip').addEventListener('click', async () => {
try {
send(await navigator.clipboard.readText());
} catch (error) {
askForPaste('The browser blocked the clipboard. Paste the value into the box instead.');
}
});
paste.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
send(paste.value);
}
});
if (location.hash.startsWith('#t=')) {
const value = decodeURIComponent(location.hash.slice(3));
history.replaceState(null, '', location.pathname);
send(value).then((ok) => {
if (!ok) { paste.value = value; manual.open = true; }
});
}
</script>
</body></html>
"#;
#[cfg(test)]
mod tests {
use std::io::{Read as _, Write as _};
use std::net::TcpStream;
use super::*;
fn request(url: &str, method: &str, body: &str) -> (u16, String) {
let rest = url.trim_start_matches("http://");
let (authority, path) = rest.split_once('/').expect("path");
let mut stream = TcpStream::connect(authority).expect("connect");
// A server that never answers must fail the test, not hang it.
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
write!(
stream,
"{method} /{path} HTTP/1.1\r\nHost: {authority}\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
.unwrap();
let mut raw = String::new();
stream.read_to_string(&mut raw).unwrap();
let status = raw
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.expect("status");
let body = raw.split("\r\n\r\n").nth(1).unwrap_or_default().to_string();
(status, body)
}
/// Two pastes at once are checked one at a time, and the second is not
/// checked at all once the first is accepted.
#[test]
fn concurrent_pastes_are_checked_once() {
let page = LoginPage::bind(0).unwrap();
let url = page.url();
let checks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = checks.clone();
let handle = std::thread::spawn(move || {
page.wait(Duration::from_secs(10), move |_| {
counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(300));
Ok(())
})
});
std::thread::sleep(Duration::from_millis(100));
let clients: Vec<_> = (0..2)
.map(|_| {
let url = url.clone();
std::thread::spawn(move || request(&url, "POST", "good"))
})
.collect();
for client in clients {
let (status, body) = client.join().unwrap();
assert_eq!(status, 200);
assert!(body.contains("\"ok\":true"), "{body}");
}
handle.join().unwrap().unwrap();
assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn serves_the_page_only_with_the_secret_and_accepts_a_good_paste() {
let page = LoginPage::bind(0).unwrap();
let url = page.url();
let bad = url.replace(&page.secret, "not-the-secret");
let handle = std::thread::spawn(move || {
let verify = |paste: &str| match paste.trim() {
"good" => Ok(()),
_ => Err(IdxError::InvalidInput("not a Stockbit session".to_string())),
};
page.wait(Duration::from_secs(10), verify)
});
std::thread::sleep(Duration::from_millis(100));
assert_eq!(request(&bad, "GET", "").0, 404);
let (status, html) = request(&url, "GET", "");
assert_eq!(status, 200);
assert!(html.contains("Connect Stockbit to idx"));
assert!(html.contains("javascript:"), "bookmarklet rendered");
// The bookmarklet carries this run's page and a fragment, not the
// secret in a query string.
assert!(html.contains(&format!("{url}#t=")), "bookmarklet target");
let (status, body) = request(&url, "POST", "nonsense");
assert_eq!(status, 200);
assert!(body.contains("\"ok\":false"), "{body}");
let (status, body) = request(&url, "POST", "good");
assert_eq!(status, 200);
assert!(body.contains("\"ok\":true"), "{body}");
handle.join().unwrap().expect("accepted");
}
/// The CSP allows no fetch of its own, so a stray asset would fail
/// silently; the page also has to keep the two entry points the docs name.
#[test]
fn the_page_is_self_contained_and_keeps_both_entry_points() {
let page = LoginPage::bind(0).unwrap().page();
for forbidden in ["<script src", "<link", "@import", "url(http"] {
assert!(!page.contains(forbidden), "page needs {forbidden}");
}
assert_eq!(
page.matches("https://").count(),
2,
"one link out, and the cookie path spelled out"
);
for url in page.split("https://").skip(1) {
assert!(
url.starts_with("stockbit.com"),
"page links somewhere else: {}",
&url[..url.len().min(40)]
);
}
assert!(page.contains("https://stockbit.com/symbol/BBCA"));
assert!(page.contains("Send my Stockbit session"));
assert!(page.contains("Paste from clipboard"));
}
#[test]
fn times_out_without_a_paste() {
let page = LoginPage::bind(0).unwrap();
let started = Instant::now();
let err = page
.wait(Duration::from_millis(150), |_| {
Err(IdxError::InvalidInput("no".to_string()))
})
.unwrap_err();
assert!(err.to_string().contains("timed out"), "{err}");
assert!(started.elapsed() < Duration::from_secs(5));
}
/// Browsers open idle preconnects; one must not delay the page.
#[test]
fn an_idle_connection_does_not_delay_the_page() {
let page = LoginPage::bind(0).unwrap();
let url = page.url();
let handle = std::thread::spawn(move || {
page.wait(Duration::from_secs(10), |_| {
Err(IdxError::InvalidInput("not a Stockbit session".to_string()))
})
});
std::thread::sleep(Duration::from_millis(100));
// Two sockets that say nothing, as Chrome's preconnects do.
let rest = url.trim_start_matches("http://");
let (authority, _) = rest.split_once('/').unwrap();
let idle: Vec<TcpStream> = (0..2)
.map(|_| TcpStream::connect(authority).unwrap())
.collect();
std::thread::sleep(Duration::from_millis(100));
let started = Instant::now();
let (status, _) = request(&url, "GET", "");
assert_eq!(status, 200);
assert!(
started.elapsed() < Duration::from_secs(2),
"page waited {:?} behind idle connections",
started.elapsed()
);
drop(idle);
drop(handle);
}
}

View file

@ -15,7 +15,14 @@
//! - Stockbit's terms describe its market data as delayed at least 10 //! - Stockbit's terms describe its market data as delayed at least 10
//! minutes and for personal use; frontends say so. //! minutes and for personal use; frontends say so.
pub mod auth;
pub mod browser;
pub mod corpaction;
pub mod exodus;
pub mod loopback;
pub mod movement;
mod parse; mod parse;
pub mod signin;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write}; use std::io::{Read, Seek, SeekFrom, Write};
@ -24,9 +31,16 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub use parse::{parse_symbol_page, status_error, stockbit_code}; pub use parse::{parse_symbol_page, status_error, stockbit_code};
use super::types::{Fundamentals, Intraday, Quote}; use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
use super::{FundamentalsProvider, IntradayProvider, QuoteProvider}; use super::{
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 exodus::Exodus;
pub const SOURCE: &str = "stockbit"; pub const SOURCE: &str = "stockbit";
/// Identifies idx-cli and where to reach its maintainers. /// Identifies idx-cli and where to reach its maintainers.
@ -45,8 +59,13 @@ pub(crate) const CACHE_TTL_SECS: u64 = 5;
const MIN_INTERVAL: Duration = Duration::from_secs(1); const MIN_INTERVAL: Duration = Duration::from_secs(1);
const BASE_URL: &str = "https://stockbit.com/symbol/"; const BASE_URL: &str = "https://stockbit.com/symbol/";
/// Quotes come from the user's session when one is stored (live, from
/// `exodus`), else from the public page. The order book and history need the
/// session; intraday always reads the public page.
pub struct StockbitProvider { pub struct StockbitProvider {
agent: ureq::Agent, agent: ureq::Agent,
/// `None` when the config directory cannot be resolved.
exodus: Option<Exodus>,
} }
impl StockbitProvider { impl StockbitProvider {
@ -59,7 +78,20 @@ impl StockbitProvider {
.timeout_global(Some(Duration::from_secs(20))) .timeout_global(Some(Duration::from_secs(20)))
.build() .build()
.into(); .into();
Self { agent } Self {
agent,
exodus: SessionStore::open().ok().map(Exodus::new),
}
}
/// The session API when the user has a session.
fn session(&self) -> Option<&Exodus> {
self.exodus.as_ref().filter(|e| e.store().has_session())
}
fn require_session(&self) -> Result<&Exodus, IdxError> {
self.session()
.ok_or_else(|| IdxError::AuthError(auth::NO_SESSION.to_string()))
} }
/// The quote and today's series for a resolved symbol (`BBCA.JK`). /// The quote and today's series for a resolved symbol (`BBCA.JK`).
@ -99,7 +131,7 @@ impl Default for StockbitProvider {
/// `idx` calls, a CLI beside idx-mcp) share the time of the last request in /// `idx` calls, a CLI beside idx-mcp) share the time of the last request in
/// `<cache>/stockbit.pace` under an exclusive file lock. When that file is /// `<cache>/stockbit.pace` under an exclusive file lock. When that file is
/// unusable, pacing is per process. /// unusable, pacing is per process.
fn pace() { pub(crate) fn pace() {
static LAST: Mutex<Option<Instant>> = Mutex::new(None); static LAST: Mutex<Option<Instant>> = Mutex::new(None);
let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(prev) = *last { if let Some(prev) = *last {
@ -151,7 +183,10 @@ fn now_ms() -> u128 {
impl QuoteProvider for StockbitProvider { impl QuoteProvider for StockbitProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> { fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
self.snapshot(symbol).map(|day| day.quote) match self.session() {
Some(exodus) => exodus.quote(symbol),
None => self.snapshot(symbol).map(|day| day.quote),
}
} }
} }
@ -167,15 +202,177 @@ impl IntradayProvider for StockbitProvider {
} }
} }
impl OrderBookProvider for StockbitProvider {
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
self.require_session()?.orderbook(symbol)
}
}
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 {
fn history(
&self,
symbol: &str,
period: &Period,
interval: &Interval,
) -> Result<Vec<Bar>, IdxError> {
self.require_session()?.daily(symbol, period, interval)
}
}
/// Whether the user has a Stockbit session stored or seeded.
pub fn has_session() -> bool {
SessionStore::open().is_ok_and(|store| store.has_session())
}
/// Whether the stored session still works, checked with one request.
///
/// The check refreshes the access token when it is close to expiry, which
/// spends the refresh token and rotates the pair; a session Stockbit refuses
/// is reported as `AUTHERROR` and left in place. Frontends use this to skip a
/// fresh sign-in when the session in hand is still good.
pub fn verify_session() -> Result<(), IdxError> {
let store = SessionStore::open()?;
if !store.has_session() {
return Err(IdxError::AuthError(
"no Stockbit session stored".to_string(),
));
}
// As in `login`: the mock provider stands in for the network.
#[cfg(feature = "testing")]
let check = !crate::api::mock::enabled();
#[cfg(not(feature = "testing"))]
let check = true;
if check {
Exodus::new(store).verify()?;
}
Ok(())
}
pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError { pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError {
IdxError::Unsupported(format!( IdxError::Unsupported(format!(
"{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo" "{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo"
)) ))
} }
/// Stores a pasted session (stockbit.com's `credentialStorage` cookie or a
/// refresh token) and checks it with one request, which refreshes it when the
/// access token is missing or stale. A session Stockbit refuses is removed.
///
/// A check that fails another way (network, rate limit) puts the previous
/// session back too, unless Stockbit already rotated the pasted one: the
/// refresh spent the pasted token, so the stored pair is then the only
/// working copy, and a refresh Stockbit answered is proof enough.
pub fn login(pasted: &str) -> Result<auth::SessionStatus, IdxError> {
let session = auth::session_from_paste(pasted).ok_or_else(|| {
IdxError::InvalidInput(
"not a Stockbit session: paste the value of stockbit.com's `credentialStorage` \
cookie, or a refresh token"
.to_string(),
)
})?;
let store = SessionStore::open()?;
// A refused paste must not lose a session that already worked.
let previous = std::fs::read(store.path()).ok();
store.save(&session)?;
let saved = std::fs::read(store.path()).ok();
#[cfg(feature = "testing")]
let check = !crate::api::mock::enabled();
#[cfg(not(feature = "testing"))]
let check = true;
if check && let Err(err) = Exodus::new(store.clone()).verify() {
let rotated = std::fs::read(store.path()).ok() != saved;
if !keep_after_failed_check(&err, rotated) {
restore(&store, previous);
return Err(err);
}
crate::runtime::warn(format!(
"Stockbit accepted the session and rotated it, but the check after that failed \
({err}); the new session is stored"
));
}
Ok(store.status(chrono::Utc::now().timestamp()))
}
/// Whether `login` keeps the stored session after its check failed with
/// `err`: only when the check rotated it and Stockbit did not then refuse it.
fn keep_after_failed_check(err: &IdxError, rotated: bool) -> bool {
rotated && !matches!(err, IdxError::AuthError(_))
}
/// Whether a session check failed for a reason that says nothing about the
/// session itself, so the same session may be checked again later. A CDN
/// block (`UpstreamBlocked`) is not: idx never retries a block.
pub(crate) fn check_is_transient(err: &IdxError) -> bool {
matches!(err, IdxError::Http(_) | IdxError::RateLimited)
}
fn restore(store: &SessionStore, previous: Option<Vec<u8>>) {
match previous {
Some(bytes) => {
let _ = std::fs::write(store.path(), bytes);
}
None => {
let _ = store.clear();
}
}
}
/// The stored session's state, without a request and without any token.
pub fn session_status() -> Result<auth::SessionStatus, IdxError> {
Ok(SessionStore::open()?.status(chrono::Utc::now().timestamp()))
}
/// Deletes the stored session: whether there was one, and the state after
/// (still logged in when `IDX_STOCKBIT_*` seeds one).
pub fn logout() -> Result<(bool, auth::SessionStatus), IdxError> {
let store = SessionStore::open()?;
let removed = store.clear()?;
Ok((removed, store.status(chrono::Utc::now().timestamp())))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::USER_AGENT; use super::{USER_AGENT, check_is_transient, keep_after_failed_check};
use crate::error::IdxError;
#[test]
fn login_keeps_a_rotated_session_only_when_stockbit_did_not_refuse_it() {
let network = IdxError::Http("timeout".to_string());
let refused = IdxError::AuthError("expired".to_string());
assert!(keep_after_failed_check(&network, true));
assert!(!keep_after_failed_check(&network, false));
assert!(!keep_after_failed_check(&refused, true));
assert!(!keep_after_failed_check(&refused, false));
assert!(check_is_transient(&IdxError::RateLimited));
assert!(check_is_transient(&network));
assert!(!check_is_transient(&refused));
assert!(!check_is_transient(&IdxError::UpstreamBlocked(
"403".to_string()
)));
assert!(!check_is_transient(&IdxError::InvalidInput(
"x".to_string()
)));
}
/// CloudFront answers HTTP 403 to agents containing these (probed /// CloudFront answers HTTP 403 to agents containing these (probed
/// 2026-10-22); the honest agent must never match one. /// 2026-10-22); the honest agent must never match one.

View 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"
);
}
}

View file

@ -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()),
@ -271,7 +271,7 @@ fn wib(local: NaiveDateTime) -> Option<DateTime<FixedOffset>> {
/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`, /// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`,
/// `"NA"` and empty strings are absent. /// `"NA"` and empty strings are absent.
fn number(value: &Value) -> Option<f64> { pub(super) fn number(value: &Value) -> Option<f64> {
let n = match value { let n = match value {
Value::Number(n) => n.as_f64()?, Value::Number(n) => n.as_f64()?,
Value::String(s) => s.trim().parse::<f64>().ok()?, Value::String(s) => s.trim().parse::<f64>().ok()?,

View file

@ -0,0 +1,452 @@
//! One Stockbit sign-in for every frontend. `idx stockbit login` and the
//! `stockbit_login` MCP tool both start one here, and differ only in how
//! they wait: the CLI in the foreground, idx-mcp on a thread it polls.
//!
//! Two ways a session reaches idx, both ending in `accept`:
//! - **Browser** (`browser.rs`): idx opens a Chromium-family browser on
//! Stockbit's own sign-in page, in a profile of its own, and reads the
//! session cookie over DevTools. Nothing to copy; the default on a desktop.
//! - **Page** (`loopback.rs`): a page on 127.0.0.1 that takes the cookie from
//! the user's own browser (bookmarklet, clipboard or paste). For a shell
//! over SSH, a machine with no display, or no supported browser.
//!
//! `Method::Auto` picks the browser unless it would not reach the user, and
//! falls back to the page only when the browser cannot start. A sign-in that
//! started and then timed out or was closed is an error, not a fallback.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use serde::Serialize;
use super::auth::SessionStatus;
use super::{browser, loopback};
use crate::error::IdxError;
/// How long the browser window waits: the user signs in there, one-time code
/// and all.
pub const BROWSER_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// How long the page waits for a session from the user's browser.
pub const PAGE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// Port the page prefers, so an `ssh -L` forward stays the same across runs.
pub const PAGE_PORT: u16 = 7878;
/// How long `shutdown` waits for a browser to close.
const SHUTDOWN_GRACE: Duration = Duration::from_secs(6);
/// Which way to sign in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Method {
/// The browser when it would reach the user, else the page.
#[default]
Auto,
Browser,
Page,
}
#[derive(Debug, Clone, Default)]
pub struct Options {
pub method: Method,
/// The page's port; `None` prefers `PAGE_PORT` and takes any free one
/// when that is busy.
pub port: Option<u16>,
/// Open the page in the desktop's default browser, when this machine has
/// a desktop the user is at.
pub open_page: bool,
}
/// How a sign-in reaches the user.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "method", rename_all = "snake_case")]
pub enum Started {
/// A stored session already works; nothing was opened.
Existing,
/// A browser window on Stockbit's sign-in page: sign in there.
Browser { browser: String },
/// The page on 127.0.0.1.
Page {
url: String,
port: u16,
/// The `ssh -L` command that brings the page to another machine.
forward: String,
/// The shell is over SSH, so the page is on another machine than the
/// user's browser.
remote: bool,
/// Why the browser was not used, when `Auto` picked the page.
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
}
/// A sign-in that has started and not yet been waited on.
pub struct SignIn {
started: Started,
timeout: Duration,
waiting: Waiting,
}
enum Waiting {
Browser(browser::Window),
Page(loopback::LoginPage),
}
impl SignIn {
/// Opens the browser window or the page, and returns once it is up.
pub fn start(options: &Options) -> Result<Self, IdxError> {
match options.method {
Method::Browser => Self::browser(),
Method::Page => Self::page(options, None),
Method::Auto => match no_local_browser() {
Some(reason) => Self::page(options, Some(reason.to_string())),
None => Self::browser().or_else(|err| {
Self::page(
options,
Some(format!("the browser sign-in could not start: {err}")),
)
}),
},
}
}
fn browser() -> Result<Self, IdxError> {
let profile = browser::profile_dir()?;
let browser = browser::Browser::find(&profile)?;
let window = browser.open(&profile)?;
Ok(Self {
started: Started::Browser {
browser: browser.name,
},
timeout: BROWSER_TIMEOUT,
waiting: Waiting::Browser(window),
})
}
fn page(options: &Options, reason: Option<String>) -> Result<Self, IdxError> {
let page = match options.port {
Some(port) => loopback::LoginPage::bind(port)?,
None => {
loopback::LoginPage::bind(PAGE_PORT).or_else(|_| loopback::LoginPage::bind(0))?
}
};
if options.open_page && no_local_browser().is_none() {
open_in_browser(&page.url());
}
Ok(Self {
started: Started::Page {
url: page.url(),
port: page.port(),
forward: loopback::ssh_forward(page.port()),
remote: over_ssh(),
reason,
},
timeout: PAGE_TIMEOUT,
waiting: Waiting::Page(page),
})
}
pub fn started(&self) -> &Started {
&self.started
}
/// How long `wait` waits.
pub fn timeout(&self) -> Duration {
self.timeout
}
/// Waits for the user to finish, and reports the session stored.
pub fn wait(self) -> Result<SessionStatus, IdxError> {
self.wait_until(&AtomicBool::new(false))
}
/// `wait`, ending early with the browser closed when `cancel` is set.
/// The page has nothing to close and stops with the process.
fn wait_until(self, cancel: &AtomicBool) -> Result<SessionStatus, IdxError> {
match self.waiting {
Waiting::Browser(window) => window.wait(self.timeout, cancel, &accept)?,
Waiting::Page(page) => page.wait(self.timeout, accept)?,
}
super::session_status()
}
}
/// Checks a session value (the `credentialStorage` cookie or a refresh
/// token) and stores it: the step every way in ends with.
pub fn accept(value: &str) -> Result<(), IdxError> {
super::login(value).map(|_| ())
}
/// Why a browser opened here would not reach the user, if it would not.
fn no_local_browser() -> Option<&'static str> {
#[cfg(all(unix, not(target_os = "macos")))]
let display =
std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some();
#[cfg(not(all(unix, not(target_os = "macos"))))]
let display = true;
blocker(over_ssh(), display)
}
fn blocker(ssh: bool, display: bool) -> Option<&'static str> {
if ssh {
Some("this shell is over SSH, so a browser started here opens on the remote machine")
} else if !display {
Some("this machine has no display (DISPLAY and WAYLAND_DISPLAY are unset)")
} else {
None
}
}
fn over_ssh() -> bool {
std::env::var_os("SSH_CONNECTION").is_some()
}
/// Opens `url` with the desktop's default browser, best effort.
fn open_in_browser(url: &str) {
#[cfg(target_os = "macos")]
let (program, args) = ("open", vec![url]);
#[cfg(target_os = "windows")]
let (program, args) = ("cmd", vec!["/C", "start", "", url]);
#[cfg(all(unix, not(target_os = "macos")))]
let (program, args) = ("xdg-open", vec![url]);
let opened = std::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok();
if !opened {
crate::runtime::warn(format!("could not open a browser; open {url} yourself"));
}
}
/// What a background sign-in is doing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum State {
Waiting,
SignedIn,
Failed,
}
/// A background sign-in as `start_background` and `progress` report it.
#[derive(Debug, Clone, Serialize)]
pub struct Progress {
pub state: State,
#[serde(flatten)]
pub started: Started,
/// Seconds before a waiting sign-in gives up; 0 once it has ended.
pub expires_in_secs: u64,
/// True when `start_background` found this sign-in already waiting.
pub already_open: bool,
/// Why it failed: the error's code and message.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ProgressError>,
/// The latest warning while it waits, such as Stockbit refusing the
/// session signed in so far; gone once it signs in.
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProgressError {
pub code: String,
pub message: String,
}
struct Slot {
id: u64,
started: Started,
opened: Instant,
timeout: Duration,
state: State,
error: Option<ProgressError>,
warning: Option<String>,
cancel: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl Slot {
fn progress(&self, already_open: bool) -> Progress {
let expires_in_secs = match self.state {
State::Waiting => self.timeout.saturating_sub(self.opened.elapsed()).as_secs(),
_ => 0,
};
Progress {
state: self.state,
started: self.started.clone(),
expires_in_secs,
already_open,
error: self.error.clone(),
warning: self.warning.clone(),
}
}
fn is_waiting(&self) -> bool {
self.state == State::Waiting && self.opened.elapsed() < self.timeout
}
}
/// This process's background sign-in, if one has run: one at a time.
static SLOT: Mutex<Option<Slot>> = Mutex::new(None);
/// Starts a sign-in on a thread and returns at once, for a frontend that
/// polls (`progress`) instead of blocking.
///
/// A stored session that still works means nothing to sign in to; a sign-in
/// already waiting is returned again rather than opening a second one.
pub fn start_background(options: &Options) -> Result<Progress, IdxError> {
static NEXT: AtomicU64 = AtomicU64::new(1);
// Held while the sign-in starts, so two calls never open two windows.
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(open) = slot.as_ref().filter(|open| open.is_waiting()) {
return Ok(open.progress(true));
}
if super::verify_session().is_ok() {
return Ok(Progress {
state: State::SignedIn,
started: Started::Existing,
expires_in_secs: 0,
already_open: false,
error: None,
warning: None,
});
}
let sign_in = SignIn::start(options)?;
let id = NEXT.fetch_add(1, Ordering::Relaxed);
let cancel = Arc::new(AtomicBool::new(false));
let mut open = Slot {
id,
started: sign_in.started().clone(),
opened: Instant::now(),
timeout: sign_in.timeout(),
state: State::Waiting,
error: None,
warning: None,
cancel: cancel.clone(),
thread: None,
};
let progress = open.progress(false);
open.thread = Some(std::thread::spawn(move || {
// Nobody reads a polling frontend's stderr, so what the wait warns
// about (a refused session) goes into the progress it polls.
let sink = crate::runtime::on_warning(move |message| {
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(open) = slot.as_mut().filter(|open| open.id == id) {
open.warning = Some(message.to_string());
}
});
let result = sign_in.wait_until(&cancel);
drop(sink);
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(open) = slot.as_mut().filter(|open| open.id == id) {
match result {
Ok(_) => {
open.state = State::SignedIn;
open.warning = None;
}
Err(err) => {
open.state = State::Failed;
open.error = Some(ProgressError {
code: err.code().to_string(),
message: err.to_string(),
});
}
}
}
}));
*slot = Some(open);
Ok(progress)
}
/// Ends a background sign-in, for a process about to exit: a thread does not
/// outlive `main`, so without this its browser window would stay open with
/// nothing reading it.
pub fn shutdown() {
let (thread, browser) = {
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
let Some(open) = slot.as_mut() else {
return;
};
open.cancel.store(true, Ordering::Relaxed);
(
open.thread.take(),
matches!(open.started, Started::Browser { .. }),
)
};
let Some(thread) = thread.filter(|_| browser) else {
return;
};
let deadline = Instant::now() + SHUTDOWN_GRACE;
while !thread.is_finished() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
}
/// The background sign-in this process ran last, if any.
pub fn progress() -> Option<Progress> {
SLOT.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.map(|open| open.progress(false))
}
#[cfg(test)]
mod tests {
use super::*;
/// The browser is skipped exactly when it would not reach the user.
#[test]
fn the_browser_is_skipped_over_ssh_and_without_a_display() {
assert_eq!(blocker(false, true), None);
assert!(blocker(true, true).unwrap().contains("SSH"));
assert!(blocker(false, false).unwrap().contains("no display"));
}
/// Frontends read the method from one field, whichever way was taken.
#[test]
fn progress_names_its_method_and_hides_what_it_does_not_have() {
let page = Progress {
state: State::Waiting,
started: Started::Page {
url: "http://127.0.0.1:7878/login/x".to_string(),
port: 7878,
forward: "ssh -L 7878:127.0.0.1:7878 me@host".to_string(),
remote: false,
reason: None,
},
expires_in_secs: 300,
already_open: false,
error: None,
warning: None,
};
let value = serde_json::to_value(&page).unwrap();
assert_eq!(value["state"], "waiting");
assert_eq!(value["method"], "page");
assert_eq!(value["port"], 7878);
assert!(
["reason", "error", "warning"]
.iter()
.all(|key| value.get(key).is_none())
);
let failed = Progress {
state: State::Failed,
started: Started::Browser {
browser: "Helium".to_string(),
},
expires_in_secs: 0,
already_open: false,
error: Some(ProgressError {
code: "IO".to_string(),
message: "the window was closed".to_string(),
}),
warning: Some("Stockbit refused the session".to_string()),
};
let value = serde_json::to_value(&failed).unwrap();
assert_eq!(value["method"], "browser");
assert_eq!(value["browser"], "Helium");
assert_eq!(value["error"]["code"], "IO");
assert_eq!(value["warning"], "Stockbit refused the session");
}
}

View file

@ -65,8 +65,10 @@ pub struct Quote {
/// Best offer in IDR (whole Rupiah), gated like `bid`. /// Best offer in IDR (whole Rupiah), gated like `bid`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub offer: Option<i64>, pub offer: Option<i64>,
/// The provider's own session state, verbatim (Stockbit /// Stockbit's market state as its public page words it: `open` while
/// `market_hour.status`, e.g. `close`). Absent for providers without one. /// the regular market trades, `close` otherwise (also the lunch break
/// and auctions). A logged-in quote maps its session state to the same
/// two words. Absent for other providers, or when the state is unknown.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub market_status: Option<String>, pub market_status: Option<String>,
/// What the provider says about this symbol's session: suspended, /// What the provider says about this symbol's session: suspended,
@ -94,6 +96,54 @@ pub struct Intraday {
pub points: Vec<IntradayPoint>, pub points: Vec<IntradayPoint>,
} }
/// One price level of an order book.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BookLevel {
/// Limit price in IDR (whole Rupiah).
pub price: i64,
/// Size resting at this price, in shares (1 lot = 100 shares).
pub shares: u64,
/// Orders queued at this price.
pub orders: u64,
}
/// The order book of one IDX stock from the user's Stockbit session, with
/// the session's figures and foreign flow. Outside trading it is the book as
/// the market left it; `market_status` says which.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderBook {
pub symbol: String,
pub name: Option<String>,
/// Last price in IDR (whole Rupiah).
pub price: i64,
pub change: Option<i64>,
pub change_pct: Option<f64>,
pub prev_close: Option<i64>,
pub open: Option<i64>,
pub high: Option<i64>,
pub low: Option<i64>,
/// Session volume in shares.
pub volume: Option<u64>,
/// Session value traded in IDR.
pub value: Option<u64>,
/// Trades in the session.
pub frequency: Option<u64>,
/// Foreign buy, sell and net value over the session, IDR; `None` while
/// Stockbit marks them not current (in session they lag a day).
pub foreign_buy: Option<i64>,
pub foreign_sell: Option<i64>,
pub foreign_net: Option<i64>,
/// Best price first.
pub bids: Vec<BookLevel>,
/// Best price first.
pub offers: Vec<BookLevel>,
/// Stockbit's session state, verbatim (e.g. `STATE_NAME_SESSION_2`,
/// `STATE_NAME_BREAK`, `STATE_NAME_MARKET_CLOSED`).
pub market_status: Option<String>,
/// Stockbit's listing status when it is not active (e.g. suspended).
pub session_note: Option<String>,
}
/// OHLC candle data normalized from Yahoo Finance chart indicators. /// OHLC candle data normalized from Yahoo Finance chart indicators.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ohlc { pub struct Ohlc {

View file

@ -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));

View file

@ -31,6 +31,8 @@ pub enum HistoryProviderKind {
Auto, Auto,
Yahoo, Yahoo,
Msn, Msn,
// Needs the user's Stockbit session (`idx stockbit login`).
Stockbit,
} }
impl ProviderKind { impl ProviderKind {
@ -63,6 +65,7 @@ impl HistoryProviderKind {
Self::Auto => "auto", Self::Auto => "auto",
Self::Yahoo => "yahoo", Self::Yahoo => "yahoo",
Self::Msn => "msn", Self::Msn => "msn",
Self::Stockbit => "stockbit",
} }
} }
@ -73,9 +76,11 @@ impl HistoryProviderKind {
Ok(Self::Yahoo) Ok(Self::Yahoo)
} else if value.eq_ignore_ascii_case("msn") { } else if value.eq_ignore_ascii_case("msn") {
Ok(Self::Msn) Ok(Self::Msn)
} else if value.eq_ignore_ascii_case("stockbit") {
Ok(Self::Stockbit)
} else { } else {
Err(IdxError::ConfigError(format!( Err(IdxError::ConfigError(format!(
"invalid history provider '{value}' (expected auto, yahoo, or msn)" "invalid history provider '{value}' (expected auto, yahoo, msn, or stockbit)"
))) )))
} }
} }

View 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)
}
}

View 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
);
}
}

View file

@ -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;
@ -22,12 +24,40 @@ pub mod text;
pub use client::{CachePolicy, Fetched, Idx}; pub use client::{CachePolicy, Fetched, Idx};
pub mod runtime { pub mod runtime {
use std::cell::RefCell;
use std::fmt::Display; use std::fmt::Display;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
static QUIET: AtomicBool = AtomicBool::new(false); static QUIET: AtomicBool = AtomicBool::new(false);
static JSON: AtomicBool = AtomicBool::new(false); static JSON: AtomicBool = AtomicBool::new(false);
type Sink = Box<dyn Fn(&str)>;
thread_local! {
static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
}
/// Sends this thread's warnings to `sink` too, until the guard drops: a
/// frontend whose stderr nobody reads (idx-mcp) hands them on this way.
/// Warnings still go to stderr, and `set_quiet` does not silence `sink`.
pub fn on_warning(sink: impl Fn(&str) + 'static) -> WarningSink {
let previous = SINK.with(|slot| slot.replace(Some(Box::new(sink))));
WarningSink { previous }
}
/// Restores the thread's previous sink when dropped.
#[must_use = "warnings reach the sink only while this guard lives"]
pub struct WarningSink {
previous: Option<Sink>,
}
impl Drop for WarningSink {
fn drop(&mut self) {
let previous = self.previous.take();
SINK.with(|slot| *slot.borrow_mut() = previous);
}
}
pub fn set_quiet(quiet: bool) { pub fn set_quiet(quiet: bool) {
QUIET.store(quiet, Ordering::Relaxed); QUIET.store(quiet, Ordering::Relaxed);
} }
@ -62,6 +92,40 @@ pub mod runtime {
} }
pub fn warn(message: impl Display) { pub fn warn(message: impl Display) {
let message = message.to_string();
SINK.with(|slot| {
if let Some(sink) = slot.borrow().as_ref() {
sink(&message);
}
});
emit("warning", message); emit("warning", message);
} }
#[cfg(test)]
mod tests {
use super::*;
use std::rc::Rc;
/// A sink sees this thread's warnings while its guard lives, and an
/// inner one hands back to the outer when it drops.
#[test]
fn warnings_reach_the_sink_while_its_guard_lives() {
let seen = Rc::new(RefCell::new(Vec::new()));
let outer = {
let seen = seen.clone();
on_warning(move |m| seen.borrow_mut().push(format!("outer {m}")))
};
warn("one");
{
let seen = seen.clone();
let _inner = on_warning(move |m| seen.borrow_mut().push(format!("inner {m}")));
warn("two");
}
warn("three");
drop(outer);
warn("four");
info("not a warning");
assert_eq!(*seen.borrow(), ["outer one", "inner two", "outer three"]);
}
}
} }

View file

@ -6,8 +6,10 @@ use std::str::FromStr;
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday}; use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
use crate::api::types::{Interval, Intraday, Ohlc, Period, Quote}; use crate::api::types::{Interval, Intraday, Ohlc, OrderBook, Period, Quote};
use crate::api::{history_provider, history_source, idx_offset, intraday_provider, stockbit}; use crate::api::{
history_provider, history_source, idx_offset, intraday_provider, orderbook_provider, stockbit,
};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::config::{HistoryProviderKind, ProviderKind}; use crate::config::{HistoryProviderKind, ProviderKind};
use crate::error::IdxError; use crate::error::IdxError;
@ -95,11 +97,20 @@ impl Market<'_> {
} }
/// Resolves the history source for an optional `--history-provider` /// Resolves the history source for an optional `--history-provider`
/// override, without fetching. /// override and the window asked for, without fetching.
pub fn history_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource { pub fn history_source(
&self,
mode: Option<HistoryProviderKind>,
period: &Period,
interval: &Interval,
) -> HistorySource {
let mode = mode.unwrap_or(self.idx.config.history_provider); let mode = mode.unwrap_or(self.idx.config.history_provider);
let selected = self.idx.provider.kind(); let selected = self.idx.provider.kind();
let provider = history_source(selected, mode); // Only `auto` under the stockbit provider looks for a session.
let session = mode == HistoryProviderKind::Auto
&& selected == ProviderKind::Stockbit
&& stockbit::has_session();
let provider = history_source(selected, mode, period, interval, session);
HistorySource { HistorySource {
provider, provider,
mode, mode,
@ -116,22 +127,72 @@ impl Market<'_> {
policy: CachePolicy, policy: CachePolicy,
) -> Result<Fetched<Vec<Ohlc>>, IdxError> { ) -> Result<Fetched<Vec<Ohlc>>, IdxError> {
let idx = self.idx; let idx = self.idx;
let (provider_kind, provider) =
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
let bucket = format!("{}-history", provider_kind.as_str());
let resolved = idx.resolve(symbol)?; let resolved = idx.resolve(symbol)?;
let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str()); let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str());
self.with_history_fallback(source, |source| {
let provider = history_provider(source.provider, idx.config.verbose);
let bucket = format!("{}-history", source.provider.as_str());
cached_fetch(
&idx.cache,
CacheSpec {
bucket: &bucket,
key: &key,
ttl_secs: idx.config.quote_ttl,
subject: &resolved,
source: source.provider.as_str(),
},
policy,
|| provider.history(&resolved, period, interval),
Ok,
)
})
}
/// Runs `fetch` on `source`; when `auto` picked Stockbit because a
/// session file exists and Stockbit then refuses that session, runs it
/// again on Yahoo instead of failing (`has_session` sees only the file).
pub(crate) fn with_history_fallback<T>(
&self,
source: HistorySource,
fetch: impl Fn(HistorySource) -> Result<Fetched<T>, IdxError>,
) -> Result<Fetched<T>, IdxError> {
match fetch(source) {
Err(IdxError::AuthError(reason))
if source.mode == HistoryProviderKind::Auto
&& source.provider == ProviderKind::Stockbit =>
{
crate::runtime::warn(format!("{reason}; history falls back to yahoo meanwhile"));
fetch(HistorySource {
provider: ProviderKind::Yahoo,
is_fallback: self.idx.provider.kind() != ProviderKind::Yahoo,
..source
})
}
other => other,
}
}
/// The order book of one IDX stock from the user's Stockbit session,
/// whichever market provider is selected.
pub fn orderbook(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<OrderBook>, IdxError> {
let idx = self.idx;
let resolved = idx.resolve(symbol)?;
let provider = orderbook_provider();
cached_fetch( cached_fetch(
&idx.cache, &idx.cache,
CacheSpec { CacheSpec {
bucket: &bucket, bucket: "stockbit-orderbook",
key: &key, key: &resolved,
ttl_secs: idx.config.quote_ttl, ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
subject: &resolved, subject: &resolved,
source: provider_kind.as_str(), source: stockbit::SOURCE,
}, },
policy, policy,
|| provider.history(&resolved, period, interval), || provider.orderbook(&resolved),
Ok, Ok,
) )
} }

View 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:?}");
}
}

View 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));
}
}

View file

@ -0,0 +1 @@
{"data":{"cagr":"", "change":-1225, "drawdown":"", "markingpoint":"", "percentage":"-19.14", "prices":[{"date":"1789491600000", "formatted_date":"2026-09-16", "xlabel":"", "value":"6325", "percentage":"-1.17", "change":-75, "open":"6350", "high":"6425", "low":"6325", "volume":"721574"}, {"date":"1789578000000", "formatted_date":"2026-09-17", "xlabel":"", "value":"6350", "percentage":"-0.78", "change":-50, "open":"6400", "high":"6400", "low":"6325", "volume":"618120"}, {"date":"1789664400000", "formatted_date":"2026-09-18", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6325", "high":"6350", "low":"6225", "volume":"1767537"}, {"date":"1789923600000", "formatted_date":"2026-09-21", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6300", "high":"6300", "low":"6225", "volume":"699141"}, {"date":"1790010000000", "formatted_date":"2026-09-22", "xlabel":"", "value":"6200", "percentage":"-3.12", "change":-200, "open":"6225", "high":"6250", "low":"6175", "volume":"932112"}, {"date":"1790096400000", "formatted_date":"2026-09-23", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6200", "high":"6300", "low":"6200", "volume":"968261"}, {"date":"1790182800000", "formatted_date":"2026-09-24", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6250", "high":"6275", "low":"6225", "volume":"756486"}, {"date":"1790269200000", "formatted_date":"2026-09-25", "xlabel":"", "value":"6250", "percentage":"-2.34", "change":-150, "open":"6225", "high":"6275", "low":"6200", "volume":"894474"}, {"date":"1790528400000", "formatted_date":"2026-09-28", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6225", "high":"6250", "low":"6175", "volume":"1121076"}, {"date":"1790614800000", "formatted_date":"2026-09-29", "xlabel":"", "value":"6150", "percentage":"-3.91", "change":-250, "open":"6150", "high":"6225", "low":"6125", "volume":"1497834"}, {"date":"1790701200000", "formatted_date":"2026-09-30", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6100", "high":"6150", "low":"6050", "volume":"2213357"}, {"date":"1790787600000", "formatted_date":"2026-10-01", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6050", "high":"6150", "low":"6000", "volume":"1180449"}, {"date":"1790874000000", "formatted_date":"2026-10-02", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6000", "high":"6150", "low":"5975", "volume":"1319203"}, {"date":"1791133200000", "formatted_date":"2026-10-05", "xlabel":"", "value":"6175", "percentage":"-3.52", "change":-225, "open":"6100", "high":"6175", "low":"6075", "volume":"899057"}, {"date":"1791219600000", "formatted_date":"2026-10-06", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6175", "high":"6200", "low":"6100", "volume":"1521734"}, {"date":"1791306000000", "formatted_date":"2026-10-07", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6075", "high":"6100", "low":"5975", "volume":"1626908"}, {"date":"1791392400000", "formatted_date":"2026-10-08", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6100", "high":"6150", "low":"6000", "volume":"1131403"}, {"date":"1791478800000", "formatted_date":"2026-10-09", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6025", "high":"6075", "low":"5975", "volume":"870986"}, {"date":"1791738000000", "formatted_date":"2026-10-12", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"6050", "high":"6100", "low":"5950", "volume":"979651"}, {"date":"1791824400000", "formatted_date":"2026-10-13", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"5925", "high":"5950", "low":"5850", "volume":"1227169"}, {"date":"1791910800000", "formatted_date":"2026-10-14", "xlabel":"", "value":"6125", "percentage":"-4.30", "change":-275, "open":"6000", "high":"6125", "low":"5950", "volume":"1363031"}, {"date":"1791997200000", "formatted_date":"2026-10-15", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6075", "high":"6125", "low":"6025", "volume":"1347632"}, {"date":"1792083600000", "formatted_date":"2026-10-16", "xlabel":"", "value":"5175", "percentage":"-19.14", "change":-1225, "open":"5825", "high":"5825", "low":"5175", "volume":"12410982"}], "timeframe":"1m", "xaxisopt":"", "previous":6400, "line_weight":2, "previous_timeframe_price":{"date":"", "formatted_date":"", "xlabel":"", "value":"", "percentage":"", "change":0, "open":"", "high":"", "low":"", "volume":""}, "chart_type":"PRICE_CHART_TYPE_CANDLE", "interval_in_minutes":0, "allowed_chart_type":["PRICE_CHART_TYPE_LINE", "PRICE_CHART_TYPE_CANDLE"], "max_candles":24}, "message":"Successfully retrieved company daily chart"}

File diff suppressed because one or more lines are too long

View 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"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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"}

View file

@ -0,0 +1 @@
{"data":{"stock_reverse":[]},"message":"Successfully retrieved reverse split data"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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"}

View file

@ -0,0 +1 @@
{"data":{"stock_dividend":[]},"message":"Successfully retrieved stock dividend data"}

View 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"}

View 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"}

View 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"}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
%7B%22state%22%3A%7B%22access%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-19T04%3A43%3A28Z%22%7D%2C%22refresh%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-25T04%3A43%3A28Z%22%7D%2C%22user%22%3A%7B%22username%22%3A%22demo%22%7D%7D%2C%22version%22%3A0%7D

View file

@ -0,0 +1,13 @@
{
"message": "Successfully refresh token",
"data": {
"access": {
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU",
"expired_at": "2026-10-19T04:43:28Z"
},
"refresh": {
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU",
"expired_at": "2026-10-25T04:43:28Z"
}
}
}

View 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"
}
]
}
}

View 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"
}
]
}
}

View 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"
}
]
}
}

View 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"
}
]
}
}

Some files were not shown because too many files have changed in this diff Show more