Merge pull request #73 from 0xrsydn/feat/stockbit

feat: login-free Stockbit intraday and quote provider
This commit is contained in:
dogfiles 2026-11-05 18:03:58 +07:00 • committed by GitHub
commit be9d17bb80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
157 changed files with 14699 additions and 481 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.5.0" version = "0.5.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

@ -71,6 +71,7 @@ idx stocks history BBCA --period 3mo
idx stocks technical BBCA idx stocks technical BBCA
idx stocks fundamental BBCA idx stocks fundamental BBCA
idx stocks compare BBCA BBRI BMRI idx stocks compare BBCA BBRI BMRI
idx stocks intraday BBCA # today by the minute (Stockbit, delayed)
idx -o json stocks quote BBCA idx -o json stocks quote BBCA
# Ownership: download the maintained snapshot once, then query locally # Ownership: download the maintained snapshot once, then query locally
@ -90,8 +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 `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: without a login, `stocks intraday` (today's price by the
minute) and `--provider stockbit` quotes with the best bid/offer while the
market is open, delayed at least 10 minutes. With your own account
(`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%)
@ -158,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 14 tools: `idx-mcp` serves the same data to MCP clients over stdio, as 21 tools:
`get_quotes`, `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

@ -4,16 +4,40 @@ Open work only. Shipped behavior is described in `README.md` and `docs/`;
history lives in git. history lives in git.
## Stocks ## Stocks
- `market summary`: IHSG index and market breadth - `market summary`: market breadth and an index overview (index levels alone
are already `stocks intraday ^JKSE` and `--provider stockbit stocks quote IHSG`)
- `market movers`: top gainers, losers, and volume - `market movers`: top gainers, losers, and volume
- `market sectors`: sector performance overview - `market sectors`: sector performance overview
- `screen query "<EXPR>"`: filter stocks by expression - `screen query "<EXPR>"`: filter stocks by expression
- `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 (bid/offer depth): no free, login-free source exists. IDX sells - Order book depth without a login: no free source exists (IDX sells delayed
delayed depth to vendors and Stockbit's needs a user login. Revisit if a depth to vendors); `stocks orderbook` needs the user's Stockbit login.
permitted source appears; do not ship the closing best bid/offer as one. - Shareholder movement follow-ups (`docs/STOCKBIT.md`): `ownership movements
--summary`, a per-symbol score (net value, distinct buyers, share-plan
flag), then an event study before calling any of it a signal; joining
holders to ownership entities by name. Corporate-action follow-ups:
ex-dates of splits, bonuses and rights noted in `stocks history`, and the
`stock_dividend` / `reversesplit` row shapes once Stockbit lists one.
- Stockbit login follow-ups: hourly OHLCV from the session's `1w` chart (the
`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
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
for anything but `open`.
- Bandarmology follow-ups: a daily series (per-session readings and the - Bandarmology follow-ups: a daily series (per-session readings and the
cumulative top-N net) and a price-move phase (quiet accumulation, markup), cumulative top-N net) and a price-move phase (quiet accumulation, markup),
calibration of the `top_concentration_v1` thresholds across more sessions, calibration of the `top_concentration_v1` thresholds across more sessions,
@ -32,7 +56,8 @@ history lives in git.
- `watchlist`: named symbol sets usable by other commands - `watchlist`: named symbol sets usable by other commands
- `alerts`: price alerts with a daemon - `alerts`: price alerts with a daemon
- CSV/TSV output - CSV/TSV output
- Additional providers (Alpha Vantage, Twelve Data, IDX official) - Additional providers (Alpha Vantage, Twelve Data); IDX's own site serves
only `stocks flow --source idx` today
## Agent integration ## Agent integration
- JSON Schema export for command output - JSON Schema export for command output
@ -45,11 +70,16 @@ history lives in git.
- `stocks flow --source idx` depends on IDX's Cloudflare passing a - `stocks flow --source idx` depends on IDX's Cloudflare passing a
curl-impersonate profile (only `curl_safari184` in October 2026) and on the curl-impersonate profile (only `curl_safari184` in October 2026) and on the
network; the default IPOT source rounds to one decimal. network; the default IPOT source rounds to one decimal.
- 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
minute of the clock); its CDN refuses some user agents with a 403
(`UPSTREAMBLOCKED`). The public page's best bid/offer sizes are not shown
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
200 daily candles are returned. 200 daily candles are returned.
- MSN's `low-pe` screen returned no priced stocks on 2026-10-14 (upstream; - MSN's `low-pe` screen returns no priced stocks (`PARSEERROR`; still so on
other filters work). 2026-10-23, upstream; other filters work).
- MSN history for IDX is price-only (`1mo`, `3mo`, `1y`); Yahoo stays the - MSN history for IDX is price-only (`1mo`, `3mo`, `1y`); Yahoo stays the
default OHLCV source. default OHLCV source.

View file

@ -2,9 +2,11 @@ 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};
use idx_core::config::ProviderKind;
use crate::output::OutputSetting; use crate::output::OutputSetting;
@ -39,6 +41,13 @@ pub struct Cli {
pub quiet: bool, pub quiet: bool,
#[arg(short, long, global = true, action = clap::ArgAction::Count, help = "Increase verbosity (-v, -vv)")] #[arg(short, long, global = true, action = clap::ArgAction::Count, help = "Increase verbosity (-v, -vv)")]
pub verbose: u8, pub verbose: u8,
#[arg(
long,
value_enum,
global = true,
help = "Market data provider [default: general.provider, else msn]"
)]
pub provider: Option<ProviderKind>,
#[arg(long, global = true, help = "Run without network requests")] #[arg(long, global = true, help = "Run without network requests")]
pub offline: bool, pub offline: bool,
#[arg(long, global = true, help = "Bypass local cache")] #[arg(long, global = true, help = "Bypass local cache")]
@ -61,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_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment, render_fundamental, render_growth, render_history, render_insights, render_intraday,
render_technical, render_valuation, render_news, render_orderbook, render_profile, render_quotes, render_risk, render_screener,
render_sentiment, render_technical, render_valuation,
}; };
use idx_core::analysis::BandarQuery; use idx_core::analysis::BandarQuery;
use idx_core::analysis::bandar::DEFAULT_TOP; use idx_core::analysis::bandar::DEFAULT_TOP;
use idx_core::api::types::{Interval, Period}; use idx_core::api::types::{Interval, Period};
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
use idx_core::corporate::types::{ActionKind, CalendarRange};
use idx_core::error::IdxError; use idx_core::error::IdxError;
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind}; use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
use idx_core::flow::types::{FlowRange, FlowSource}; use idx_core::flow::types::{FlowRange, FlowSource};
@ -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();
@ -126,13 +130,78 @@ pub struct StocksCmd {
pub enum StocksSubcommand { pub enum StocksSubcommand {
#[command( #[command(
about = "Get real-time stock quotes", about = "Get real-time stock quotes",
after_help = "Examples:\n idx stocks quote BBCA\n idx stocks quote BBCA,BBRI,BMRI\n idx -o json stocks quote BBCA\n echo BBCA BBRI | idx stocks quote -" after_help = "Examples:\n idx stocks quote BBCA\n idx stocks quote BBCA,BBRI,BMRI\n idx -o json stocks quote BBCA\n echo BBCA BBRI | idx stocks quote -\n idx --provider stockbit stocks quote BBCA # with bid/offer while the market is open"
)] )]
Quote { Quote {
/// One or more symbols, comma-separated or space-separated; `-` reads them from stdin. /// One or more symbols, comma-separated or space-separated; `-` reads them from stdin.
#[arg(required = true)] #[arg(required = true)]
symbols: Vec<String>, symbols: Vec<String>,
}, },
#[command(
about = "Today's session price by the minute (Stockbit)",
long_about = "Today's session of one IDX stock or index, by the minute, from Stockbit's \
public page (no login): the quote, the best bid/offer while the market is open, a sparkline, \
and the last minutes. Delayed at least 10 minutes per Stockbit's terms; prices only, no \
per-minute volume. A stock that did not trade has no minutes, which is not an error. \
Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45).",
after_help = "Examples:\n idx stocks intraday BBCA\n idx stocks intraday ^JKSE\n idx -o json stocks intraday BBCA | jq '.points[-1]'"
)]
Intraday {
/// Single ticker symbol (e.g. BBCA).
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"
@ -313,7 +382,43 @@ pub fn handle(
StocksSubcommand::Quote { symbols } => { StocksSubcommand::Quote { symbols } => {
let quotes = idx.market().quotes(&expand_symbols(symbols)?, policy)?; let quotes = idx.market().quotes(&expand_symbols(symbols)?, policy)?;
let quotes: Vec<_> = quotes.into_iter().map(|q| q.data).collect(); let quotes: Vec<_> = quotes.into_iter().map(|q| q.data).collect();
render_quotes(&quotes, format, no_color) render_quotes(&quotes, config.core.provider, format, no_color)
}
StocksSubcommand::Intraday { symbol } => {
let day = idx.market().intraday(symbol, policy)?;
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,
@ -321,7 +426,9 @@ pub fn handle(
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()
@ -333,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

@ -34,6 +34,9 @@ impl IdxConfig {
pub fn load_with_cli(cli: &Cli) -> Result<Self, IdxError> { pub fn load_with_cli(cli: &Cli) -> Result<Self, IdxError> {
let mut core = CoreConfig::load()?; let mut core = CoreConfig::load()?;
core.verbose = cli.verbose > 0; core.verbose = cli.verbose > 0;
if let Some(provider) = cli.provider {
core.provider = provider;
}
let mut setting = OutputSetting::Auto; let mut setting = OutputSetting::Auto;
let mut no_color = false; let mut no_color = false;

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

@ -14,12 +14,15 @@ 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, NewsItem, Ohlc, Quote, CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc,
SentimentData, OrderBook, Quote, SentimentData,
}; };
use idx_core::broker::types::BrokerSummary; use idx_core::broker::types::BrokerSummary;
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)]
@ -67,15 +70,88 @@ impl OutputFormat {
pub fn render_quotes( pub fn render_quotes(
quotes: &[Quote], quotes: &[Quote],
provider: ProviderKind,
format: &OutputFormat, format: &OutputFormat,
no_color: bool, no_color: bool,
) -> Result<(), IdxError> { ) -> Result<(), IdxError> {
match format { match format {
OutputFormat::Table if provider == ProviderKind::Stockbit => {
table::print_stockbit_quotes(quotes, no_color)
}
OutputFormat::Table => table::print_quotes(quotes, no_color), OutputFormat::Table => table::print_quotes(quotes, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(quotes), OutputFormat::Json | OutputFormat::Jsonl => json::print_json(quotes),
} }
} }
pub fn render_intraday(
day: &Intraday,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_intraday(day, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(day),
}
}
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, NewsItem, Ohlc, BookLevel, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData,
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 { "" };
@ -61,12 +65,7 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
]); ]);
for q in quotes { for q in quotes {
let pct_cell = match q.change_pct { let pct_cell = pct_cell(q.change_pct, no_color);
None => cell("-"),
Some(pct) if no_color => cell(format!("{pct:+.2}%")),
Some(pct) if pct >= 0.0 => cell(format!("{pct:+.2}%")).fg(Color::Green),
Some(pct) => cell(format!("{pct:+.2}%")).fg(Color::Red),
};
table.add_row(vec![ table.add_row(vec![
cell(&q.symbol), cell(&q.symbol),
cell(format_idr(q.price)), cell(format_idr(q.price)),
@ -121,6 +120,299 @@ fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime<chrono::Utc>) -> Ve
.collect() .collect()
} }
fn pct_cell(change_pct: Option<f64>, no_color: bool) -> Cell {
match change_pct {
None => cell("-"),
Some(pct) if no_color => cell(format!("{pct:+.2}%")),
Some(pct) if pct >= 0.0 => cell(format!("{pct:+.2}%")).fg(Color::Green),
Some(pct) => cell(format!("{pct:+.2}%")).fg(Color::Red),
}
}
/// 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 \
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 {
value.map(format_idr).unwrap_or_else(|| "-".to_string())
}
fn wib_minute(time: chrono::DateTime<chrono::FixedOffset>) -> String {
time.format("%Y-%m-%d %H:%M").to_string()
}
/// Quotes from `--provider stockbit`: Stockbit has no market cap or 52-week
/// range, but a best bid/offer and the market's state.
pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
"SYMBOL", "PRICE", "CHG", "CHG%", "VOLUME", "BID", "OFFER", "MARKET", "AS OF",
]);
for q in quotes {
table.add_row(vec![
cell(&q.symbol),
cell(format_idr(q.price)),
cell(
q.change
.map(|c| format!("{c:+}"))
.unwrap_or_else(|| "-".to_string()),
),
pct_cell(q.change_pct, no_color),
cell(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())),
cell(opt_idr(q.bid)),
cell(opt_idr(q.offer)),
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())),
]);
}
println!("{table}");
for q in quotes {
if let Some(note) = &q.session_note {
println!(
"Note: {}: {}",
terminal_safe(&q.symbol),
terminal_safe(note)
);
}
}
for note in stale_quote_notes(quotes, chrono::Utc::now()) {
println!("{}", terminal_safe(&note));
}
println!("{STOCKBIT_QUOTE_NOTE}");
Ok(())
}
/// Minutes listed under the intraday chart.
const INTRADAY_TAIL: usize = 5;
/// Widest the intraday sparkline gets, in characters.
const SPARKLINE_WIDTH: usize = 60;
pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> {
let q = &day.quote;
let change = match (q.change, q.change_pct) {
(Some(c), Some(p)) => format!(" {c:+} ({p:+.2}%)"),
(Some(c), None) => format!(" {c:+}"),
_ => String::new(),
};
let change = match q.change {
_ if no_color || change.is_empty() => change,
Some(c) if c >= 0 => change.green(),
_ => change.red(),
};
println!(
"{}{change}",
format!("{} {}", terminal_safe(&q.symbol), format_idr(q.price)).bold()
);
let mut facts = vec![
format!("prev close {}", opt_idr(q.prev_close)),
format!(
"volume {}",
q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())
),
format!(
"market {}",
terminal_safe(q.market_status.as_deref().unwrap_or("unknown"))
),
];
if let Some(as_of) = q.as_of {
facts.push(format!("last trade {} WIB", wib_minute(as_of)));
}
println!("{}", facts.join(" · "));
if q.bid.is_some() || q.offer.is_some() {
println!("bid {} · offer {}", opt_idr(q.bid), opt_idr(q.offer));
}
if let Some(note) = &q.session_note {
println!("Note: {}", terminal_safe(note));
}
for note in stale_quote_notes(std::slice::from_ref(q), chrono::Utc::now()) {
println!("{}", terminal_safe(&note));
}
let prices: Vec<f64> = day.points.iter().map(|p| p.price).collect();
if let (Some(first), Some(last)) = (day.points.first(), day.points.last()) {
let (low, high) = prices
.iter()
.fold((f64::MAX, f64::MIN), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
println!();
println!("{}", sparkline(&prices, SPARKLINE_WIDTH));
println!(
"{} to {} WIB · low {} · high {} · {} minutes",
first.time.format("%H:%M"),
last.time.format("%H:%M"),
format_price(low),
format_price(high),
day.points.len()
);
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["TIME", "PRICE"]);
let start = day.points.len().saturating_sub(INTRADAY_TAIL);
for point in &day.points[start..] {
table.add_row(vec![
cell(point.time.format("%H:%M")),
cell(format_price(point.price)),
]);
}
println!("{table}");
} else if q.session_note.is_none() {
println!("No trades this session.");
}
println!("{STOCKBIT_SOURCE_NOTE}");
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.
fn format_price(price: f64) -> String {
let cents = (price * 100.0).round() as i64;
if cents % 100 == 0 {
format_idr(cents / 100)
} else {
format!("{}.{:02}", format_idr(cents / 100), (cents % 100).abs())
}
}
/// One block per bucket of points (the bucket's last price), scaled from the
/// lowest to the highest price.
fn sparkline(prices: &[f64], width: usize) -> String {
const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let width = width.min(prices.len());
if width == 0 {
return String::new();
}
let (low, high) = prices
.iter()
.fold((f64::MAX, f64::MIN), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
(1..=width)
.map(|i| {
let price = prices[i * prices.len() / width - 1];
if high > low {
BARS[((price - low) / (high - low) * 7.0).round() as usize]
} else {
BARS[3]
}
})
.collect()
}
pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> { pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> {
println!( println!(
"{}", "{}",
@ -1385,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(),
@ -1410,6 +2069,10 @@ mod tests {
avg_volume: None, avg_volume: None,
as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()), as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()),
behind_session: false, behind_session: false,
bid: None,
offer: None,
market_status: None,
session_note: None,
} }
} }

View file

@ -2165,6 +2165,177 @@ fn invalid_provider_env_honors_json_output() {
); );
} }
#[test]
fn provider_flag_overrides_env() {
let quotes = run_success_json(
test_bin("provider-flag")
.env("IDX_PROVIDER", "msn")
.env("IDX_USE_MOCK_PROVIDER", "1")
.args([
"-o",
"json",
"--provider",
"stockbit",
"stocks",
"quote",
"BBCA",
]),
);
// Only Stockbit reports the market's state; MSN has a market cap.
assert_eq!(quotes[0]["market_status"], "close");
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")
@ -3171,3 +3342,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

@ -244,6 +244,71 @@ name = "yahoo-fundamental"
args = ["stocks", "fundamental", "BBCA"] args = ["stocks", "fundamental", "BBCA"]
env = { IDX_PROVIDER = "yahoo" } env = { IDX_PROVIDER = "yahoo" }
# --- stocks: Stockbit (intraday, and the opt-in quote provider) -----------------
[[case]]
name = "stocks-intraday"
args = ["stocks", "intraday", "BBCA"]
[[case]]
name = "stocks-intraday-index"
args = ["stocks", "intraday", "^JKSE"]
formats = ["table"]
[[case]]
name = "stocks-intraday-suspended"
args = ["stocks", "intraday", "MABA"]
[[case]]
name = "stockbit-quote"
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]]
@ -334,8 +399,63 @@ env = { IDX_MOCK_ERROR = "1" }
name = "error-fetch-bing-unsupported" name = "error-fetch-bing-unsupported"
args = ["ownership", "import", "--fetch-bing", "BBCA"] args = ["ownership", "import", "--fetch-bing", "BBCA"]
[[case]]
name = "error-intraday-blocked"
args = ["stocks", "intraday", "BBCA"]
env = { IDX_MOCK_STOCKBIT_STATUS = "403" }
[[case]]
name = "error-intraday-unknown-symbol"
args = ["stocks", "intraday", "ZZZZ"]
[[case]]
name = "error-stockbit-fundamental-unsupported"
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"]

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- 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; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"}

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- 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; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit

View file

@ -0,0 +1,4 @@
exit: 3
--- stdout
--- stderr
{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: ZZZZ.JK"}

View file

@ -0,0 +1,4 @@
exit: 3
--- stdout
--- stderr
Error: symbol not found: ZZZZ.JK

View file

@ -1,4 +1,4 @@
exit: 2 exit: 2
--- stdout --- stdout
--- stderr --- stderr
{"code":"CONFIGERROR","error":true,"message":"config error: invalid provider 'bogus' (expected yahoo or msn)"} {"code":"CONFIGERROR","error":true,"message":"config error: invalid provider 'bogus' (expected yahoo, msn, or stockbit)"}

View file

@ -1,4 +1,4 @@
exit: 2 exit: 2
--- stdout --- stdout
--- stderr --- stderr
Error: config error: invalid provider 'bogus' (expected yahoo or msn) Error: config error: invalid provider 'bogus' (expected yahoo, msn, or 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: BBCA.JK: Stockbit has no fundamentals; command requires --provider msn or yahoo"}

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
Error: unsupported: BBCA.JK: Stockbit has no fundamentals; command requires --provider msn or yahoo

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

@ -14,6 +14,7 @@ Options:
--no-color Disable colored output --no-color Disable colored output
-q, --quiet Suppress non-essential output -q, --quiet Suppress non-essential output
-v, --verbose... Increase verbosity (-v, -vv) -v, --verbose... Increase verbosity (-v, -vv)
--provider <PROVIDER> Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit]
--offline Run without network requests --offline Run without network requests
--no-cache Bypass local cache --no-cache Bypass local cache
-h, --help Print help -h, --help Print help

View file

@ -28,6 +28,11 @@ Options:
-v, --verbose... -v, --verbose...
Increase verbosity (-v, -vv) Increase verbosity (-v, -vv)
--provider <PROVIDER>
Market data provider [default: general.provider, else msn]
[possible values: yahoo, msn, stockbit]
--offline --offline
Run without network requests Run without network requests

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
@ -40,6 +41,11 @@ Options:
-v, --verbose... -v, --verbose...
Increase verbosity (-v, -vv) Increase verbosity (-v, -vv)
--provider <PROVIDER>
Market data provider [default: general.provider, else msn]
[possible values: yahoo, msn, stockbit]
--offline --offline
Run without network requests Run without network requests

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)
@ -29,6 +30,11 @@ Options:
-v, --verbose... -v, --verbose...
Increase verbosity (-v, -vv) Increase verbosity (-v, -vv)
--provider <PROVIDER>
Market data provider [default: general.provider, else msn]
[possible values: yahoo, msn, stockbit]
--offline --offline
Run without network requests Run without network requests

View file

@ -6,6 +6,10 @@ 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)
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
@ -30,6 +34,7 @@ Options:
--no-color Disable colored output --no-color Disable colored output
-q, --quiet Suppress non-essential output -q, --quiet Suppress non-essential output
-v, --verbose... Increase verbosity (-v, -vv) -v, --verbose... Increase verbosity (-v, -vv)
--provider <PROVIDER> Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit]
--offline Run without network requests --offline Run without network requests
--no-cache Bypass local cache --no-cache Bypass local cache
-h, --help Print help -h, --help Print help

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

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"symbol":"BBCA.JK","price":5175,"change":-100,"change_pct":-1.9,"volume":286928500,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":5275,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close"},{"symbol":"MABA.JK","price":50,"change":0,"change_pct":0.0,"volume":0,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":50,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close","session_note":"Stockbit lists the stock as suspended"}]
--- stderr

View file

@ -0,0 +1,12 @@
exit: 0
--- stdout
┌─────────┬───────┬──────┬────────┬─────────────┬─────┬───────┬────────┬───────┐
│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ BID ┆ OFFER ┆ MARKET ┆ AS OF │
╞═════════╪═══════╪══════╪════════╪═════════════╪═════╪═══════╪════════╪═══════╡
│ BBCA.JK ┆ 5,175 ┆ -100 ┆ -1.90% ┆ 286,928,500 ┆ - ┆ - ┆ close ┆ - │
├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │
└─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘
Note: MABA.JK: Stockbit lists the stock as suspended
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
[{"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

@ -0,0 +1,23 @@
exit: 0
--- stdout
^JKSE.JK 5,388 +60 (+1.12%)
prev close 5,328 · volume 36,446,806,700 · market close · last trade 2026-10-22 16:14 WIB
Note: ^JKSE.JK last traded on 2026-10-22; this price is not current.
▃▄▄▆▆▆▆▆▆▇▇▇▇▇████▇▇▇▇███▇▇▇▇███▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
08:58 to 16:14 WIB · low 5,192.41 · high 5,419.34 · 335 minutes
┌───────┬──────────┐
│ TIME ┆ PRICE │
╞═══════╪══════════╡
│ 16:10 ┆ 5,387.90 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤
│ 16:11 ┆ 5,387.90 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤
│ 16:12 ┆ 5,387.90 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤
│ 16:13 ┆ 5,387.90 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤
│ 16:14 ┆ 5,387.90 │
└───────┴──────────┘
Source: Stockbit's 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
{"quote":{"symbol":"MABA.JK","price":50,"change":0,"change_pct":0.0,"volume":0,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":50,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close","session_note":"Stockbit lists the stock as suspended"},"points":[]}
--- stderr

View file

@ -0,0 +1,7 @@
exit: 0
--- stdout
MABA.JK 50 +0 (+0.00%)
prev close 50 · volume 0 · market close
Note: 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.
--- stderr

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,23 @@
exit: 0
--- stdout
BBCA.JK 5,175 -100 (-1.90%)
prev close 5,275 · volume 286,928,500 · market close · last trade 2026-10-22 16:14 WIB
Note: BBCA.JK last traded on 2026-10-22; this price is not current.
▄▅▇█▅▇▇▇▅▇▇▇▇▇▇██▇▅▇▅▅███▇▅▇▅▇▇▇▅▇▅▅▄▅▄▅▄▄▅▅▅▅▅▇▅▄▄▄▄▄▄▂▄▄▄▄
08:58 to 16:14 WIB · low 5,125 · high 5,250 · 335 minutes
┌───────┬───────┐
│ TIME ┆ PRICE │
╞═══════╪═══════╡
│ 16:10 ┆ 5,175 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 16:11 ┆ 5,175 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 16:12 ┆ 5,175 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 16:13 ┆ 5,175 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 16:14 ┆ 5,175 │
└───────┴───────┘
Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
--- 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,10 +128,10 @@ 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)?;
idx.market().with_history_fallback(source, |source| {
let provider = history_provider(source.provider, idx.config.verbose);
let bucket = format!("{}-technical", source.provider.as_str());
cached_fetch( cached_fetch(
&idx.cache, &idx.cache,
CacheSpec { CacheSpec {
@ -130,12 +139,13 @@ impl Analysis<'_> {
key: &resolved, key: &resolved,
ttl_secs: idx.config.quote_ttl, ttl_secs: idx.config.quote_ttl,
subject: &resolved, subject: &resolved,
source: provider_kind.as_str(), source: source.provider.as_str(),
}, },
policy, policy,
|| provider.history(&resolved, &Period::OneYear, &Interval::Day), || provider.history(&resolved, &Period::OneYear, &Interval::Day),
|history| build_technical_report(&resolved, &history), |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, 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, QuoteProvider, BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider,
TradingSummaryProvider, idx, ipot, msn, yahoo, IntradayProvider, OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
}; };
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
use crate::config::ProviderKind; use crate::config::ProviderKind;
use crate::corporate::types::{ActionKind, CorporateAction};
use crate::error::IdxError; use crate::error::IdxError;
use crate::movements::types::{MovementPage, MovementQuery};
pub(crate) fn enabled() -> bool { pub(crate) fn enabled() -> bool {
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
@ -45,6 +49,10 @@ impl MockProvider {
match provider { match provider {
ProviderKind::Yahoo => Self::from_yahoo_fixtures(history_verbose), ProviderKind::Yahoo => Self::from_yahoo_fixtures(history_verbose),
ProviderKind::Msn => Self::from_msn_fixtures(), ProviderKind::Msn => Self::from_msn_fixtures(),
// `MockStockbitProvider` serves Stockbit; it has no history.
ProviderKind::Stockbit => Self::with_error(IdxError::Unsupported(
"Stockbit serves no price history".to_string(),
)),
} }
} }
@ -131,6 +139,181 @@ impl HistoryProvider for MockProvider {
} }
} }
/// Stockbit from fixtures. Without a session (none stored or seeded through
/// `IDX_STOCKBIT_REFRESH_TOKEN`): the public page,
/// `tests/fixtures/stockbit_symbol_<code>.html`. With one: the session API,
/// `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;
impl MockStockbitProvider {
fn failure(&self, symbol: &str) -> Option<IdxError> {
if std::env::var("IDX_MOCK_ERROR").is_ok() {
return Some(IdxError::ProviderUnavailable);
}
let status = std::env::var("IDX_MOCK_STOCKBIT_STATUS").ok()?;
Some(stockbit::status_error(
status.parse().unwrap_or(500),
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 path = format!(
"tests/fixtures/stockbit_symbol_{}.html",
code.to_ascii_lowercase()
);
match std::fs::read_to_string(path) {
Ok(html) => stockbit::parse_symbol_page(symbol, &html),
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 {
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;
// Quotes are judged against the clock (behind the session, a week
// old); a fixed trade time would make their output age.
quote.as_of = None;
Ok(quote)
}
}
impl FundamentalsProvider for MockStockbitProvider {
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
Err(stockbit::fundamentals_unsupported(symbol))
}
}
impl IntradayProvider for MockStockbitProvider {
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError> {
self.snapshot(symbol)
}
}
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

@ -3,6 +3,7 @@ pub mod ipot;
#[cfg(feature = "testing")] #[cfg(feature = "testing")]
pub mod mock; pub mod mock;
pub mod msn; pub mod msn;
pub mod stockbit;
pub mod types; pub mod types;
pub mod yahoo; pub mod yahoo;
@ -13,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,
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
@ -64,6 +65,41 @@ pub trait TradingSummaryProvider {
) -> Result<Vec<crate::flow::types::StockDay>, IdxError>; ) -> Result<Vec<crate::flow::types::StockDay>, IdxError>;
} }
/// Today's session of one IDX symbol: quote and price per minute. Served by
/// Stockbit's public page whichever market provider is selected.
pub trait IntradayProvider {
/// `symbol` is resolved, e.g. `BBCA.JK`.
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>;
} }
@ -221,7 +257,10 @@ fn mock_enabled() -> bool {
#[cfg(feature = "testing")] #[cfg(feature = "testing")]
fn mock_market(provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> { fn mock_market(provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
Some(Box::new(mock::MockProvider::from_fixtures(provider))) match provider {
ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)),
_ => Some(Box::new(mock::MockProvider::from_fixtures(provider))),
}
} }
#[cfg(not(feature = "testing"))] #[cfg(not(feature = "testing"))]
@ -231,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 {
ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)),
_ => Some(Box::new(
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose), mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
)) )),
}
} }
#[cfg(not(feature = "testing"))] #[cfg(not(feature = "testing"))]
@ -274,6 +316,18 @@ 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 needs a session (see `history_source`).
ProviderKind::Stockbit => SelectedProvider {
kind: ProviderKind::Stockbit,
market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())),
profile: None,
financials: None,
earnings: None,
sentiment: None,
insights: None,
news: None,
screener: None,
},
} }
} }
@ -297,6 +351,38 @@ pub fn trading_summary_provider() -> Box<dyn TradingSummaryProvider> {
Box::new(idx::IdxSiteProvider) Box::new(idx::IdxSiteProvider)
} }
pub fn intraday_provider() -> Box<dyn IntradayProvider> {
#[cfg(feature = "testing")]
if mock::enabled() {
return Box::new(mock::MockStockbitProvider);
}
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")]
@ -307,38 +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::Yahoo, HistoryProviderKind::Auto => {
ProviderKind::Msn => ProviderKind::Yahoo, if provider == ProviderKind::Stockbit
}, && stockbit_session
} && stockbit::exodus::history_window(period, interval).is_some()
}
/// Resolves a history provider based on the selected market data provider and
/// history provider strategy.
///
/// `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()
&& let Some(mock) = mock_history(resolved, verbose)
{ {
return Ok((resolved, mock)); ProviderKind::Stockbit
} else {
ProviderKind::Yahoo
}
}
}
} }
match resolved { /// The history provider for a source from `history_source`.
ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))), pub fn history_provider(source: ProviderKind, verbose: bool) -> Box<dyn HistoryProvider> {
ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))), if mock_enabled()
&& let Some(mock) = mock_history(source, verbose)
{
return mock;
}
match source {
ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)),
ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)),
ProviderKind::Stockbit => Box::new(stockbit::StockbitProvider::new()),
} }
} }
@ -439,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

@ -71,6 +71,10 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
avg_volume: round_u64(quote.average_volume), avg_volume: round_u64(quote.average_volume),
as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()), as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()),
behind_session: false, behind_session: false,
bid: None,
offer: None,
market_status: None,
session_note: None,
}) })
} }
@ -720,6 +724,10 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
avg_volume: round_u64(q.average_volume), avg_volume: round_u64(q.average_volume),
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()), as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
behind_session: false, behind_session: false,
bid: None,
offer: None,
market_status: None,
session_note: None,
}) })
}) })
.collect(); .collect();

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

@ -0,0 +1,390 @@
//! Stockbit's public symbol page: a quote, the best bid/offer and today's
//! price per minute for one IDX symbol, without a login or key.
//!
//! `GET https://stockbit.com/symbol/{CODE}` server-renders everything into
//! the page's `__NEXT_DATA__` JSON (see `parse`). Stockbit's own JSON API
//! (`exodus.stockbit.com`) needs a logged-in bearer token and is not used.
//!
//! Access rules this module keeps:
//! - An honest user agent. CloudFront in front of the page answers a static
//! HTTP 403 to `curl/`, `axios/`, `Go-http-client/` and empty agents; it
//! does not fingerprint TLS, so no browser impersonation is involved.
//! - One request per symbol, at least `MIN_INTERVAL` apart across every idx
//! process of the user (see `pace`), no retries, and a 403 surfaces as
//! `UPSTREAMBLOCKED` at once.
//! - Stockbit's terms describe its market data as delayed at least 10
//! 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;
pub mod signin;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub use parse::{parse_symbol_page, status_error, stockbit_code};
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
use super::{
CorporateActionProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
};
use crate::corporate::types::{ActionKind, CorporateAction};
use crate::error::IdxError;
use crate::movements::types::{MovementPage, MovementQuery};
use auth::SessionStore;
use exodus::Exodus;
pub const SOURCE: &str = "stockbit";
/// Identifies idx-cli and where to reach its maintainers.
pub const USER_AGENT: &str = concat!(
"idx-cli/",
env!("CARGO_PKG_VERSION"),
" (+",
env!("CARGO_PKG_REPOSITORY"),
")"
);
/// Longest a snapshot is served from cache: enough to coalesce a burst (an
/// agent asking for a quote and the intraday series), short enough that a
/// session view stays current.
pub(crate) const CACHE_TTL_SECS: u64 = 5;
/// Least time between two requests to stockbit.com from one process.
const MIN_INTERVAL: Duration = Duration::from_secs(1);
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 {
agent: ureq::Agent,
/// `None` when the config directory cannot be resolved.
exodus: Option<Exodus>,
}
impl StockbitProvider {
pub fn new() -> Self {
let agent = ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_recv_body(Some(Duration::from_secs(10)))
// A server that stalls before sending headers must not hold a
// call (and an MCP call slot) forever.
.timeout_global(Some(Duration::from_secs(20)))
.build()
.into();
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`).
pub fn snapshot(&self, resolved: &str) -> Result<Intraday, IdxError> {
let code = stockbit_code(resolved)?;
let html = self.page(&code, resolved)?;
parse_symbol_page(resolved, &html)
}
fn page(&self, code: &str, resolved: &str) -> Result<String, IdxError> {
pace();
let response = self
.agent
.get(format!("{BASE_URL}{code}"))
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html")
.call();
match response {
Ok(ok) => ok
.into_body()
.read_to_string()
.map_err(|e| IdxError::Http(format!("stockbit: {e}"))),
Err(ureq::Error::StatusCode(status)) => Err(status_error(status, resolved)),
Err(err) => Err(IdxError::Http(format!("stockbit: {err}"))),
}
}
}
impl Default for StockbitProvider {
fn default() -> Self {
Self::new()
}
}
/// Waits until `MIN_INTERVAL` has passed since the previous request. Threads
/// of one process queue on a mutex; separate processes (a shell loop of
/// `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
/// unusable, pacing is per process.
pub(crate) fn pace() {
static LAST: Mutex<Option<Instant>> = Mutex::new(None);
let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(prev) = *last {
let wait = MIN_INTERVAL.saturating_sub(prev.elapsed());
if !wait.is_zero() {
std::thread::sleep(wait);
}
}
// Best effort: the in-process pacing above already holds.
let _ = pace_across_processes();
*last = Some(Instant::now());
}
fn pace_across_processes() -> std::io::Result<()> {
let dir = crate::cache::cache_dir().map_err(|e| std::io::Error::other(e.to_string()))?;
std::fs::create_dir_all(&dir)?;
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(dir.join("stockbit.pace"))?;
// Released when `file` is dropped, after the new time is written.
file.lock()?;
let mut raw = String::new();
file.read_to_string(&mut raw)?;
if let Ok(prev_ms) = raw.trim().parse::<u128>() {
// A clock that went backwards waits at most one interval.
let elapsed = now_ms().saturating_sub(prev_ms);
let wait = MIN_INTERVAL.as_millis().saturating_sub(elapsed);
if wait > 0 {
std::thread::sleep(Duration::from_millis(wait as u64));
}
}
stamp(&mut file)
}
fn stamp(file: &mut File) -> std::io::Result<()> {
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
write!(file, "{}", now_ms())
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis())
}
impl QuoteProvider for StockbitProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
match self.session() {
Some(exodus) => exodus.quote(symbol),
None => self.snapshot(symbol).map(|day| day.quote),
}
}
}
impl FundamentalsProvider for StockbitProvider {
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
Err(fundamentals_unsupported(symbol))
}
}
impl IntradayProvider for StockbitProvider {
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError> {
self.snapshot(symbol)
}
}
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 {
IdxError::Unsupported(format!(
"{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)]
mod tests {
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
/// 2026-10-22); the honest agent must never match one.
#[test]
fn user_agent_is_honest_and_not_on_the_cdn_blocklist() {
assert!(USER_AGENT.starts_with("idx-cli/"));
assert!(USER_AGENT.contains("github.com/0xrsydn/idx-cli"));
for blocked in ["curl/", "axios/", "Go-http-client/"] {
assert!(
!USER_AGENT.contains(blocked),
"{USER_AGENT} contains {blocked}"
);
}
}
}

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

@ -0,0 +1,440 @@
//! Pure parsing of a Stockbit symbol page: the `__NEXT_DATA__` JSON that
//! Next.js renders into `https://stockbit.com/symbol/{CODE}`. No I/O.
//!
//! Shapes (recorded 2026-10-22 after the close, see
//! `tests/fixtures/stockbit_symbol_*.html`):
//! - `props.pageProps.company`: the quote. Numbers are mostly strings
//! (`price: "5175"`, an index `"5387.90478515625"`, `change: "+59.54"`),
//! `percentage` is a JSON number, `orderbook.{bid,offer}.price` is
//! `"5175.000000"` or `"N/A"`, `market_hour.status` is e.g. `close`, and
//! `status` is `STATUS_ACTIVE`, `STATUS_SUSPENDED` or `STATUS_DELISTED`.
//! `date`/`time` (`"22 Oct 2026"`, `"Thu 16:14"`) are the last trade only
//! when the stock traded: a suspended one carries today's date.
//! - `props.pageProps.chart.prices[]`: one row per minute,
//! `formatted_date: "2026-10-22 16:14:00"` (WIB, no offset) and
//! `value: "5175"`. The first row is a synthetic anchor at the previous
//! close with an empty `formatted_date`. Per-minute OHLC and volume are
//! empty strings, so only the price is kept.
//! - An unknown code renders the 404 page: `page: "/404"`, empty pageProps.
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
use serde::Deserialize;
use serde_json::Value;
use crate::api::idx_offset;
use crate::api::types::{Intraday, IntradayPoint, Quote};
use crate::error::IdxError;
/// Stockbit's market status while continuous trading runs. Bid and offer are
/// reported only then: outside it they are the closing book.
const MARKET_OPEN: &str = "open";
#[derive(Debug, Deserialize)]
struct NextData {
page: String,
props: Props,
}
#[derive(Debug, Deserialize)]
struct Props {
#[serde(rename = "pageProps", default)]
page_props: PageProps,
}
#[derive(Debug, Default, Deserialize)]
struct PageProps {
company: Option<RawCompany>,
chart: Option<RawChart>,
}
#[derive(Debug, Deserialize)]
struct RawCompany {
price: Value,
#[serde(default)]
previous: Value,
#[serde(default)]
change: Value,
#[serde(default)]
percentage: Value,
#[serde(default)]
volume: Value,
date: Option<String>,
time: Option<String>,
status: Option<String>,
orderbook: Option<RawOrderbook>,
market_hour: Option<RawMarketHour>,
}
#[derive(Debug, Deserialize)]
struct RawOrderbook {
bid: Option<RawLevel>,
offer: Option<RawLevel>,
}
#[derive(Debug, Deserialize)]
struct RawLevel {
#[serde(default)]
price: Value,
}
#[derive(Debug, Deserialize)]
struct RawMarketHour {
status: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawChart {
#[serde(default)]
prices: Vec<RawPoint>,
}
#[derive(Debug, Deserialize)]
struct RawPoint {
#[serde(default)]
formatted_date: String,
#[serde(default)]
value: Value,
}
/// Stockbit's code for a resolved symbol: `BBCA.JK` -> `BBCA`, and Yahoo's
/// index tickers to Stockbit's (`^JKSE` -> `IHSG`, `^JKLQ45` -> `LQ45`).
pub fn stockbit_code(resolved: &str) -> Result<String, IdxError> {
let unsupported = || {
IdxError::Unsupported(format!(
"{resolved}: Stockbit covers IDX (.JK) symbols only"
))
};
let code = resolved.strip_suffix(".JK").ok_or_else(unsupported)?;
let code = match code.strip_prefix('^') {
Some("JKSE") => "IHSG",
Some("JKLQ45") => "LQ45",
Some(_) => return Err(unsupported()),
None => code,
};
if code.is_empty() || !code.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(unsupported());
}
Ok(code.to_string())
}
/// The error for a non-success HTTP status from stockbit.com. A 403 is
/// CloudFront's static block page (it refuses some user agents outright),
/// not a challenge, so nothing is retried.
pub fn status_error(status: u16, resolved: &str) -> IdxError {
match status {
403 => IdxError::UpstreamBlocked(
"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"
.to_string(),
),
404 => IdxError::SymbolNotFound(resolved.to_string()),
429 => IdxError::RateLimited,
_ => IdxError::Http(format!("stockbit: HTTP {status}")),
}
}
/// The quote and today's per-minute prices from a symbol page, stamped with
/// `resolved` (e.g. `BBCA.JK`).
pub fn parse_symbol_page(resolved: &str, html: &str) -> Result<Intraday, IdxError> {
let data: NextData = serde_json::from_str(next_data(html)?)
.map_err(|e| layout_error(format!("__NEXT_DATA__ is not the expected JSON: {e}")))?;
if data.page == "/404" {
return Err(IdxError::SymbolNotFound(resolved.to_string()));
}
let company = data
.props
.page_props
.company
.ok_or_else(|| layout_error("no company in the page".to_string()))?;
let points = parse_points(&data.props.page_props.chart.unwrap_or_default())?;
let quote = parse_quote(resolved, &company)?;
Ok(Intraday { quote, points })
}
fn layout_error(detail: String) -> IdxError {
IdxError::ParseError(format!(
"stockbit symbol page: {detail} (the page layout changed?)"
))
}
/// The JSON inside `<script id="__NEXT_DATA__" ...>...</script>`.
fn next_data(html: &str) -> Result<&str, IdxError> {
let missing = || layout_error("no __NEXT_DATA__ script".to_string());
let tag = html.find("id=\"__NEXT_DATA__\"").ok_or_else(missing)?;
let start = tag + html[tag..].find('>').ok_or_else(missing)? + 1;
let len = html[start..].find("</script>").ok_or_else(missing)?;
Ok(&html[start..start + len])
}
fn parse_quote(resolved: &str, company: &RawCompany) -> Result<Quote, IdxError> {
let price = number(&company.price)
.ok_or_else(|| layout_error(format!("price {} is not a number", company.price)))?;
let price = price.round() as i64;
let prev_close = number(&company.previous).map(|p| p.round() as i64);
let change = number(&company.change)
.map(|c| c.round() as i64)
.or_else(|| prev_close.map(|prev| price - prev));
let volume = count(&company.volume);
let listing = company.status.as_deref().unwrap_or_default();
let market_status = company
.market_hour
.as_ref()
.and_then(|m| m.status.clone())
.filter(|s| !s.is_empty());
let open = market_status.as_deref() == Some(MARKET_OPEN);
let level = |side: Option<&RawLevel>| {
side.and_then(|l| number(&l.price))
.filter(|p| *p > 0.0)
.map(|p| p.round() as i64)
};
let book = company.orderbook.as_ref();
let (bid, offer) = if open {
(
level(book.and_then(|b| b.bid.as_ref())),
level(book.and_then(|b| b.offer.as_ref())),
)
} else {
(None, None)
};
let traded = volume.is_some_and(|v| v > 0);
let as_of = if listing == "STATUS_ACTIVE" && traded {
last_trade(company.date.as_deref(), company.time.as_deref())
} else {
None
};
// Facts about this symbol only; the market's state is `market_status`.
// An untraded stock can still have a flat chart at the previous close.
let session_note = match listing {
"STATUS_SUSPENDED" => Some("Stockbit lists the stock as suspended".to_string()),
"STATUS_DELISTED" => Some("Stockbit lists the stock as delisted".to_string()),
_ if volume == Some(0) => Some("no trades this session".to_string()),
_ => None,
};
Ok(Quote {
symbol: resolved.to_string(),
price,
change,
change_pct: number(&company.percentage),
volume,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close,
avg_volume: None,
as_of,
behind_session: false,
bid,
offer,
market_status,
session_note,
})
}
/// Today's prices, oldest first, one per minute. The anchor row (no
/// timestamp) is dropped; any other row that does not parse means the
/// layout changed.
fn parse_points(chart: &RawChart) -> Result<Vec<IntradayPoint>, IdxError> {
let mut points = Vec::with_capacity(chart.prices.len());
for row in &chart.prices {
if row.formatted_date.is_empty() {
continue;
}
let bad = || layout_error(format!("chart row {:?} {}", row.formatted_date, row.value));
let local = NaiveDateTime::parse_from_str(&row.formatted_date, "%Y-%m-%d %H:%M:%S")
.map_err(|_| bad())?;
let time = wib(local).ok_or_else(bad)?;
let price = number(&row.value).ok_or_else(bad)?;
points.push(IntradayPoint { time, price });
}
points.sort_by_key(|p| p.time);
// A repeated minute keeps its last row.
points.reverse();
points.dedup_by_key(|p| p.time);
points.reverse();
Ok(points)
}
/// `"22 Oct 2026"` and `"Thu 16:14"` as a WIB time.
fn last_trade(date: Option<&str>, time: Option<&str>) -> Option<DateTime<FixedOffset>> {
let date = NaiveDate::parse_from_str(date?.trim(), "%d %b %Y").ok()?;
let clock = time?.trim().rsplit(' ').next()?;
let clock = NaiveTime::parse_from_str(clock, "%H:%M").ok()?;
wib(date.and_time(clock))
}
fn wib(local: NaiveDateTime) -> Option<DateTime<FixedOffset>> {
idx_offset().from_local_datetime(&local).single()
}
/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`,
/// `"NA"` and empty strings are absent.
pub(super) fn number(value: &Value) -> Option<f64> {
let n = match value {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.trim().parse::<f64>().ok()?,
_ => return None,
};
n.is_finite().then_some(n)
}
fn count(value: &Value) -> Option<u64> {
match value {
Value::Number(n) => n.as_u64(),
Value::String(s) => s.trim().parse::<u64>().ok(),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
}
/// The recorded BBCA page with Stockbit's market status set to `status`.
fn bbca_with_status(status: &str) -> String {
let page = fixture("stockbit_symbol_bbca.html");
assert!(page.contains(r#""status":"close""#));
page.replace(r#""status":"close""#, &format!(r#""status":"{status}""#))
}
#[test]
fn closed_session_keeps_the_series_and_hides_the_closing_book() {
let day = parse_symbol_page("BBCA.JK", &fixture("stockbit_symbol_bbca.html")).unwrap();
let q = &day.quote;
assert_eq!(
(q.price, q.prev_close, q.change),
(5175, Some(5275), Some(-100))
);
assert_eq!(q.change_pct, Some(-1.9));
assert_eq!(q.volume, Some(286_928_500));
assert_eq!(q.market_status.as_deref(), Some("close"));
assert_eq!((q.bid, q.offer), (None, None));
assert_eq!(q.session_note, None);
assert_eq!(q.as_of.unwrap().to_rfc3339(), "2026-10-22T16:14:00+07:00");
// 336 rows less the anchor at the previous close.
assert_eq!(day.points.len(), 335);
let first = &day.points[0];
assert_eq!(first.time.to_rfc3339(), "2026-10-22T08:58:00+07:00");
assert_eq!(first.price, 5125.0);
let last = day.points.last().unwrap();
assert_eq!(last.time.to_rfc3339(), "2026-10-22T16:14:00+07:00");
assert_eq!(last.price, 5175.0);
assert!(day.points.windows(2).all(|w| w[0].time < w[1].time));
}
#[test]
fn open_market_reports_best_bid_and_offer() {
let day = parse_symbol_page("BBCA.JK", &bbca_with_status(MARKET_OPEN)).unwrap();
assert_eq!((day.quote.bid, day.quote.offer), (Some(5175), Some(5200)));
assert_eq!(day.quote.market_status.as_deref(), Some(MARKET_OPEN));
}
#[test]
fn any_other_market_status_hides_bid_and_offer() {
for status in ["pre_open", "break", "OPEN", ""] {
let day = parse_symbol_page("BBCA.JK", &bbca_with_status(status)).unwrap();
assert_eq!((day.quote.bid, day.quote.offer), (None, None), "{status:?}");
}
}
#[test]
fn index_keeps_decimals_in_the_series_and_rounds_the_quote() {
let day = parse_symbol_page("^JKSE.JK", &fixture("stockbit_symbol_ihsg.html")).unwrap();
let q = &day.quote;
assert_eq!(
(q.price, q.prev_close, q.change),
(5388, Some(5328), Some(60))
);
// An index has no book even while open.
assert_eq!((q.bid, q.offer), (None, None));
assert_eq!(day.points.last().unwrap().price, 5387.90);
}
#[test]
fn suspended_and_delisted_stocks_are_empty_sessions_not_errors() {
for (file, note) in [
(
"stockbit_symbol_maba.html",
"Stockbit lists the stock as suspended",
),
(
"stockbit_symbol_myrx.html",
"Stockbit lists the stock as delisted",
),
] {
let day = parse_symbol_page("X.JK", &fixture(file)).unwrap();
assert!(day.points.is_empty(), "{file}");
assert_eq!(day.quote.session_note.as_deref(), Some(note), "{file}");
// The page's date/time is not a trade time when nothing traded.
assert_eq!(day.quote.as_of, None, "{file}");
}
}
/// HOME and BIMA on 2026-10-22: active, a flat chart at the previous
/// close, volume 0. Without a note they read as a traded flat session.
#[test]
fn an_untraded_active_stock_says_so_even_with_a_flat_chart() {
let page = fixture("stockbit_symbol_bbca.html");
assert!(page.contains(r#""volume":"286928500""#));
let page = page.replace(r#""volume":"286928500""#, r#""volume":"0""#);
let day = parse_symbol_page("BBCA.JK", &page).unwrap();
assert!(!day.points.is_empty());
assert_eq!(
day.quote.session_note.as_deref(),
Some("no trades this session")
);
assert_eq!(day.quote.as_of, None);
}
#[test]
fn unknown_code_is_symbol_not_found() {
let err = parse_symbol_page("ZZZZ.JK", &fixture("stockbit_symbol_zzzz_404.html"));
assert!(matches!(err, Err(IdxError::SymbolNotFound(s)) if s == "ZZZZ.JK"));
}
#[test]
fn a_page_without_next_data_is_a_layout_change() {
let err = parse_symbol_page("BBCA.JK", &fixture("stockbit_blocked_403.html"));
assert!(matches!(err, Err(IdxError::ParseError(_))), "{err:?}");
}
#[test]
fn http_403_is_upstream_blocked_and_404_is_not_found() {
assert!(matches!(
status_error(403, "BBCA.JK"),
IdxError::UpstreamBlocked(_)
));
assert!(
matches!(status_error(404, "BBCA.JK"), IdxError::SymbolNotFound(s) if s == "BBCA.JK")
);
assert!(matches!(
status_error(429, "BBCA.JK"),
IdxError::RateLimited
));
assert!(matches!(status_error(502, "BBCA.JK"), IdxError::Http(_)));
}
#[test]
fn codes_map_idx_symbols_and_index_tickers() {
assert_eq!(stockbit_code("BBCA.JK").unwrap(), "BBCA");
assert_eq!(stockbit_code("^JKSE.JK").unwrap(), "IHSG");
assert_eq!(stockbit_code("^JKLQ45.JK").unwrap(), "LQ45");
for other in ["AAPL.US", "^GSPC.JK", "BRK-B.JK", ".JK"] {
assert!(
matches!(stockbit_code(other), Err(IdxError::Unsupported(_))),
"{other}"
);
}
}
}

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

@ -57,6 +57,91 @@ pub struct Quote {
/// quote is returned, not when it is cached. /// quote is returned, not when it is cached.
#[serde(default)] #[serde(default)]
pub behind_session: bool, pub behind_session: bool,
/// Best bid in IDR (whole Rupiah). Stockbit only, and only while its
/// market status is `open`: outside trading it is the closing book, not
/// a live one. Absent from JSON when unknown.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bid: Option<i64>,
/// Best offer in IDR (whole Rupiah), gated like `bid`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offer: Option<i64>,
/// Stockbit's market state as its public page words it: `open` while
/// 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")]
pub market_status: Option<String>,
/// What the provider says about this symbol's session: suspended,
/// delisted, or no trades. Never inferred.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_note: Option<String>,
}
/// One price of today's session, minute by minute (Stockbit's chart).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntradayPoint {
/// The minute, in WIB.
pub time: DateTime<FixedOffset>,
/// Last price at that minute in IDR; indices carry two decimals.
pub price: f64,
}
/// The latest session of one IDX symbol: its quote and the price per minute.
/// Stockbit's public page carries no per-minute OHLC or volume.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Intraday {
pub quote: Quote,
/// Oldest first; empty when the symbol did not trade (suspended,
/// delisted, or no session yet), never an error.
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.

View file

@ -65,6 +65,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)) .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
.map(|t| t.with_timezone(&crate::api::idx_offset())), .map(|t| t.with_timezone(&crate::api::idx_offset())),
behind_session: false, behind_session: false,
bid: None,
offer: None,
market_status: None,
session_note: None,
}) })
} }

View file

@ -41,7 +41,7 @@ impl CachePolicy {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Fetched<T> { pub struct Fetched<T> {
pub data: T, pub data: T,
/// Provider that produced the data (`msn` or `yahoo`). /// Provider that produced the data (e.g. `msn`, `yahoo`, `ipot`, `stockbit`).
pub source: &'static str, pub source: &'static str,
pub fetched_at: DateTime<Utc>, pub fetched_at: DateTime<Utc>,
/// True when an expired cache entry was served (offline, or after a /// True when an expired cache entry was served (offline, or after a
@ -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

@ -13,10 +13,14 @@ use crate::error::IdxError;
pub const OUTPUT_VALUES: &[&str] = &["auto", "table", "json", "jsonl"]; pub const OUTPUT_VALUES: &[&str] = &["auto", "table", "json", "jsonl"];
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "clap", value(rename_all = "lower"))]
pub enum ProviderKind { pub enum ProviderKind {
Yahoo, Yahoo,
Msn, Msn,
// Opt-in: quotes from Stockbit's public page (delayed, no login).
Stockbit,
} }
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
@ -27,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 {
@ -34,6 +40,7 @@ impl ProviderKind {
match self { match self {
Self::Yahoo => "yahoo", Self::Yahoo => "yahoo",
Self::Msn => "msn", Self::Msn => "msn",
Self::Stockbit => "stockbit",
} }
} }
@ -42,9 +49,11 @@ impl ProviderKind {
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 provider '{value}' (expected yahoo or msn)" "invalid provider '{value}' (expected yahoo, msn, or stockbit)"
))) )))
} }
} }
@ -56,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",
} }
} }
@ -66,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

@ -34,6 +34,10 @@ pub enum IdxError {
InvalidInput(String), InvalidInput(String),
#[error("no market data: {0}")] #[error("no market data: {0}")]
NoMarketData(String), NoMarketData(String),
/// The upstream refused this client outright (e.g. a CDN's HTTP 403
/// block page). Retrying does not help; the message says what might.
#[error("upstream blocked: {0}")]
UpstreamBlocked(String),
} }
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
@ -53,6 +57,7 @@ pub enum ErrorCode {
PdfParseError, PdfParseError,
InvalidInput, InvalidInput,
NoMarketData, NoMarketData,
UpstreamBlocked,
} }
/// The stable wire form frontends emit, e.g. `SYMBOLNOTFOUND`. /// The stable wire form frontends emit, e.g. `SYMBOLNOTFOUND`.
@ -80,6 +85,7 @@ impl IdxError {
Self::PdfParseError(_) => ErrorCode::PdfParseError, Self::PdfParseError(_) => ErrorCode::PdfParseError,
Self::InvalidInput(_) => ErrorCode::InvalidInput, Self::InvalidInput(_) => ErrorCode::InvalidInput,
Self::NoMarketData(_) => ErrorCode::NoMarketData, Self::NoMarketData(_) => ErrorCode::NoMarketData,
Self::UpstreamBlocked(_) => ErrorCode::UpstreamBlocked,
} }
} }
@ -93,7 +99,8 @@ impl IdxError {
| ErrorCode::RateLimited | ErrorCode::RateLimited
| ErrorCode::ProviderUnavailable | ErrorCode::ProviderUnavailable
| ErrorCode::Offline | ErrorCode::Offline
| ErrorCode::AuthError => 4, | ErrorCode::AuthError
| ErrorCode::UpstreamBlocked => 4,
ErrorCode::Unsupported => 5, ErrorCode::Unsupported => 5,
ErrorCode::Io ErrorCode::Io
| ErrorCode::ParseError | ErrorCode::ParseError
@ -119,4 +126,11 @@ mod tests {
let unsupported = IdxError::Unsupported("history unavailable".to_string()); let unsupported = IdxError::Unsupported("history unavailable".to_string());
assert_eq!(unsupported.code(), ErrorCode::Unsupported); assert_eq!(unsupported.code(), ErrorCode::Unsupported);
} }
#[test]
fn upstream_blocked_has_its_own_wire_code_and_network_exit() {
let blocked = IdxError::UpstreamBlocked("stockbit.com: HTTP 403".to_string());
assert_eq!(blocked.code().to_string(), "UPSTREAMBLOCKED");
assert_eq!(blocked.exit_code(), 4);
}
} }

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, Ohlc, Period, Quote}; use crate::api::types::{Interval, Intraday, Ohlc, OrderBook, Period, Quote};
use crate::api::{history_provider, history_source, idx_offset}; 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;
@ -34,6 +36,10 @@ impl Market<'_> {
) -> Result<Vec<Fetched<Quote>>, IdxError> { ) -> Result<Vec<Fetched<Quote>>, IdxError> {
let idx = self.idx; let idx = self.idx;
let bucket = idx.bucket("quote"); let bucket = idx.bucket("quote");
let ttl_secs = match idx.provider.kind() {
ProviderKind::Stockbit => idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
_ => idx.config.quote_ttl,
};
let session = latest_session(Utc::now()); let session = latest_session(Utc::now());
let mut quotes = Vec::with_capacity(symbols.len()); let mut quotes = Vec::with_capacity(symbols.len());
for symbol in symbols { for symbol in symbols {
@ -43,7 +49,7 @@ impl Market<'_> {
CacheSpec { CacheSpec {
bucket: &bucket, bucket: &bucket,
key: &resolved, key: &resolved,
ttl_secs: idx.config.quote_ttl, ttl_secs,
subject: &resolved, subject: &resolved,
source: idx.provider.kind().as_str(), source: idx.provider.kind().as_str(),
}, },
@ -60,12 +66,51 @@ impl Market<'_> {
Ok(quotes) Ok(quotes)
} }
/// Today's session of one IDX symbol from Stockbit's public page,
/// whichever market provider is selected: the quote (bid/offer only while
/// the market is open) and the price per minute.
pub fn intraday(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<Intraday>, IdxError> {
let idx = self.idx;
let resolved = idx.resolve(symbol)?;
let provider = intraday_provider();
let mut day = cached_fetch(
&idx.cache,
CacheSpec {
bucket: "stockbit-intraday",
key: &resolved,
ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
subject: &resolved,
source: stockbit::SOURCE,
},
policy,
|| provider.intraday(&resolved),
Ok,
)?;
// Stockbit's page keeps the last session's chart up after the close,
// so a cached or lagging day reads as current unless it is marked.
mark_behind_session(&mut day.data.quote, latest_session(Utc::now()));
Ok(day)
}
/// 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,
@ -82,11 +127,11 @@ 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( cached_fetch(
&idx.cache, &idx.cache,
CacheSpec { CacheSpec {
@ -94,12 +139,62 @@ impl Market<'_> {
key: &key, key: &key,
ttl_secs: idx.config.quote_ttl, ttl_secs: idx.config.quote_ttl,
subject: &resolved, subject: &resolved,
source: provider_kind.as_str(), source: source.provider.as_str(),
}, },
policy, policy,
|| provider.history(&resolved, period, interval), || provider.history(&resolved, period, interval),
Ok, 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(
&idx.cache,
CacheSpec {
bucket: "stockbit-orderbook",
key: &resolved,
ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
subject: &resolved,
source: stockbit::SOURCE,
},
policy,
|| provider.orderbook(&resolved),
Ok,
)
} }
/// Screener results, ranked locally and truncated to `query.limit`. /// Screener results, ranked locally and truncated to `query.limit`.
@ -380,6 +475,10 @@ mod tests {
avg_volume: None, avg_volume: None,
as_of: None, as_of: None,
behind_session: false, behind_session: false,
bid: None,
offer: None,
market_status: None,
session_note: None,
} }
} }

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,20 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
<TITLE>ERROR: The request could not be satisfied</TITLE>
</HEAD><BODY>
<H1>403 ERROR</H1>
<H2>The request could not be satisfied.</H2>
<HR noshade size="1px">
Request blocked.
We can't connect to the server for this app or website at this time. There might be too much traffic or a configuration error. Try again later, or contact the app or website owner.
<BR clear="all">
If you provide content to customers through CloudFront, you can find steps to troubleshoot and help prevent this error by reviewing the CloudFront documentation.
<BR clear="all">
<HR noshade size="1px">
<PRE>
Generated by cloudfront (CloudFront)
Request ID: VajZoZu7lzhasdjnxU37dHaztROLXHQ6DMpJzTPZoDhkd4I2dVc2mw==
</PRE>
<ADDRESS>
</ADDRESS>
</BODY></HTML>

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

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