diff --git a/.gitignore b/.gitignore index 2cc5a6b..b877eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ out.txt # Cloudflare CLI account cache .cloudflare/ + +# macOS Finder metadata +.DS_Store diff --git a/AGENTS.md b/AGENTS.md index 36d9690..360d15c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ Docs: - `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/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 - `ROADMAP.md`: open work diff --git a/Cargo.lock b/Cargo.lock index ad2b9bc..653be63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -979,6 +979,7 @@ dependencies = [ name = "idx-core" version = "0.4.0" dependencies = [ + "base64", "chrono", "clap", "directories", @@ -1134,6 +1135,17 @@ dependencies = [ "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]] name = "new_debug_unreachable" version = "1.0.6" @@ -1764,6 +1776,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "simd-adler32" version = "0.3.8" @@ -1966,8 +1988,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] diff --git a/README.md b/README.md index bd6b5b4..695c8d3 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,16 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31 - Analysis: `technical`, `growth`, `valuation`, `risk`, and a combined `fundamental` report; multi-symbol `compare`; MSN stock `screen` - Providers: MSN is the default for quotes, fundamentals, and screening; - Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo` and - `IDX_HISTORY_PROVIDER=auto|yahoo|msn` -- Stockbit, opt-in and 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, for personal use under - Stockbit's terms; see the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/) + Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo|stockbit` + 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 ### Ownership (KSEI shareholders above 1%) @@ -163,8 +167,9 @@ change it to `auto` with `idx config set general.output auto`. ## MCP server -`idx-mcp` serves the same data to MCP clients over stdio, as 15 tools: -`get_quotes`, `get_intraday`, `get_price_history`, `screen_stocks`, `get_company`, +`idx-mcp` serves the same data to MCP clients over stdio, as 21 tools: +`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`, `get_foreign_flow`, `get_shareholders`, `get_investor_holdings`, `search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as diff --git a/ROADMAP.md b/ROADMAP.md index d7c453c..a2929cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,11 +12,28 @@ history lives in git. - `screen presets` / `screen run `: built-in screener presets (graduate `screen` out of `stocks` only if these grow into a richer workflow) - Multi-period financial statements (today `financials` is single-period) -- Order book depth: no free, login-free source exists (IDX sells delayed depth - to vendors). Planned as the Stockbit login follow-on: opt-in with the - user's own account, the depth ladder, intraday OHLCV and token refresh from - `exodus.stockbit.com`; fzn0x/idx (MIT) maps the endpoints. The public page - already gives the best bid/offer (L1) while the market is open. +- Order book depth without a login: no free source exists (IDX sells delayed + depth to vendors); `stocks orderbook` needs the user's Stockbit login. +- 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 @@ -50,8 +67,8 @@ history lives in git. - 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`). Its order-book sizes are not shown: the page does not - say whether they are lots or shares. + (`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 can still fail. - `stocks technical` reports the SMA200 trend as unavailable when fewer than diff --git a/crates/idx-cli/src/cli/mod.rs b/crates/idx-cli/src/cli/mod.rs index 4b4ac70..59643e6 100644 --- a/crates/idx-cli/src/cli/mod.rs +++ b/crates/idx-cli/src/cli/mod.rs @@ -2,6 +2,7 @@ pub mod cache; pub mod config; #[cfg(feature = "ownership")] pub mod ownership; +pub mod stockbit; pub mod stocks; use clap::{Parser, Subcommand, ValueEnum}; @@ -69,6 +70,8 @@ pub enum Commands { #[cfg(feature = "ownership")] #[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")] Ownership(ownership::OwnershipCmd), + #[command(about = "Your Stockbit session (opt-in: order book, live quotes)")] + Stockbit(stockbit::StockbitCmd), #[command(about = "Generate shell completions")] Completions { shell: Shell }, #[command(about = "Show idx-cli version")] diff --git a/crates/idx-cli/src/cli/ownership.rs b/crates/idx-cli/src/cli/ownership.rs index 125952f..63cef02 100644 --- a/crates/idx-cli/src/cli/ownership.rs +++ b/crates/idx-cli/src/cli/ownership.rs @@ -13,9 +13,11 @@ use idx_core::error::IdxError; use idx_core::investor::{ ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family, }; +use idx_core::movements::types::{ActionFilter, MovementQuery, MovementSource}; use idx_core::ownership::graph; use idx_core::ownership::snapshot; use idx_core::ownership::types::{ChangeType, FlowSignal, HolderRow, OwnershipSource}; +use idx_core::{CachePolicy, Idx}; #[derive(Debug, Args)] #[command( @@ -72,6 +74,17 @@ pub enum OwnershipCommand { after_help = "Examples:\n idx ownership releases\n idx ownership changes --from 2026-08-31 --to 2026-09-30" )] Changes(ChangesArgs), + #[command( + about = "Insider and major holder buys and sells, as reported (Stockbit login)", + long_about = "Reported changes in holders' stakes from your own Stockbit session (`idx \ +stockbit login`), newest first: IDX insider disclosures (directors, commissioners and other \ +insiders, any size, with the trade price) and KSEI's changes of holders at 5% and above (Stockbit \ +estimates their price). One stock's, or the whole market's without SYMBOL. Each page is 50 rows \ +and one request; answers are cached for an hour.\n\nThese are filings, not signals: a share \ +plan shows as many insiders buying at one price, and a row appears days after the change.", + after_help = "Examples:\n idx ownership movements BBCA\n idx ownership movements --action sell --source ksei\n idx ownership movements --from 2026-10-01 --to 2026-10-31 --pages 5\n idx ownership movements --holder 1000004481 # one holder's history (HOLDER ID column)" + )] + Movements(MovementsArgs), /// Ownership network graph. Graph(GraphArgs), /// Manual entity resolution workflow. @@ -168,6 +181,30 @@ pub struct ChangesArgs { pub to: String, } +#[derive(Debug, Args)] +pub struct MovementsArgs { + /// Single ticker symbol (e.g. BBCA); omit for the whole market. + pub symbol: Option, + /// First day, YYYY-MM-DD. + #[arg(long)] + pub from: Option, + /// Last day, YYYY-MM-DD. + #[arg(long)] + pub to: Option, + /// One holder's movements, by the id in the HOLDER ID column. + #[arg(long = "holder", value_name = "ID")] + pub holder_id: Option, + /// Only this kind of movement. + #[arg(long, value_enum)] + pub action: Option, + /// Only IDX insider disclosures or KSEI 5% holder changes. + #[arg(long, value_enum)] + pub source: Option, + /// Pages of 50 rows to read (1-10). + #[arg(long, default_value_t = 1)] + pub pages: u32, +} + #[derive(Debug, Args)] pub struct GraphArgs { /// Ticker code or entity name to start from. @@ -209,7 +246,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 inv = &investor; match cmd { @@ -223,6 +265,9 @@ pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError OwnershipCommand::Concentration(args) => handle_concentration(inv, args, config), OwnershipCommand::Flow(args) => handle_flow(inv, args, config), OwnershipCommand::Changes(args) => handle_changes(inv, args, config), + OwnershipCommand::Movements(args) => { + handle_movements(args, config, CachePolicy::from_flags(offline, no_cache)?) + } OwnershipCommand::Graph(args) => handle_graph(inv, args, config), OwnershipCommand::Resolve(args) => handle_resolve(inv, args, config), OwnershipCommand::Releases => handle_releases(inv, config), @@ -678,6 +723,38 @@ fn handle_changes( Ok(()) } +fn handle_movements( + args: &MovementsArgs, + config: &IdxConfig, + policy: CachePolicy, +) -> Result<(), IdxError> { + let mut query = MovementQuery::from_args( + args.symbol.as_deref(), + args.from.as_deref(), + args.to.as_deref(), + args.holder_id.as_deref(), + args.pages, + )?; + query.action = args.action; + query.source = args.source; + let idx = Idx::new(config.core.clone())?; + let list = idx.movements().list(&query, policy)?; + let symbol = match &query.symbol { + Some(symbol) => Some(idx_core::api::resolve_symbol( + symbol, + &config.core.exchange, + )?), + None => None, + }; + crate::output::render_shareholder_movements( + symbol.as_deref(), + &list.data, + query.pages, + &config.output, + config.no_color, + ) +} + fn handle_resolve( investor: &Investor, args: &ResolveArgs, diff --git a/crates/idx-cli/src/cli/stockbit.rs b/crates/idx-cli/src/cli/stockbit.rs new file mode 100644 index 0000000..991d1b6 --- /dev/null +++ b/crates/idx-cli/src/cli/stockbit.rs @@ -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, + }, + #[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, + format: &OutputFormat, +) -> Result { + 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 { + 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 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(), + } +} diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index e538a34..0982248 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -4,15 +4,17 @@ use clap::{Args, Subcommand}; use crate::config::{HistoryProviderKind, IdxConfig}; use crate::output::{ - render_bandar, render_broker_summary, render_compare, render_earnings, render_financials, - render_foreign_flow, render_fundamental, render_growth, render_history, render_insights, - render_intraday, render_news, render_profile, render_quotes, render_risk, render_screener, + render_bandar, render_broker_summary, render_compare, render_corporate_actions, + render_corporate_calendar, render_earnings, render_financials, render_foreign_flow, + render_fundamental, render_growth, render_history, render_insights, render_intraday, + render_news, render_orderbook, render_profile, render_quotes, render_risk, render_screener, render_sentiment, render_technical, render_valuation, }; use idx_core::analysis::BandarQuery; use idx_core::analysis::bandar::DEFAULT_TOP; use idx_core::api::types::{Interval, Period}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; +use idx_core::corporate::types::{ActionKind, CalendarRange}; use idx_core::error::IdxError; use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind}; use idx_core::flow::types::{FlowRange, FlowSource}; @@ -23,6 +25,8 @@ use idx_core::{CachePolicy, Idx, runtime}; /// separated by whitespace or commas. /// Most symbols one `bandar` screen fetches, one request each. 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, IdxError> { let mut symbols = Vec::new(); @@ -146,6 +150,58 @@ Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45).", /// Single ticker symbol (e.g. BBCA). 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, + }, + #[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, + /// Last day, YYYY-MM-DD. Default: 30 days after --from (at most 366). + #[arg(long)] + to: Option, + /// Kinds to list (default: all). + #[arg(long = "kind", value_enum, value_delimiter = ',', num_args = 1..)] + kinds: Vec, + }, #[command( about = "Get historical OHLC data", after_help = "Examples:\n idx stocks history BBCA --period 3mo\n idx stocks history BBCA --period 1y --interval 1wk" @@ -332,13 +388,47 @@ pub fn handle( let day = idx.market().intraday(symbol, policy)?; 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 { symbol, period, interval, 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); let history = idx .market() @@ -350,7 +440,7 @@ pub fn handle( symbol, history_provider, } => { - let source = idx.market().history_source(*history_provider); + let source = idx.analysis().technical_source(*history_provider); announce_history_fallback(&idx, source, config); let report = idx.analysis().technical(symbol, source, policy)?; render_technical(&report.data, format, no_color) diff --git a/crates/idx-cli/src/main.rs b/crates/idx-cli/src/main.rs index f5a7dec..e7da72d 100644 --- a/crates/idx-cli/src/main.rs +++ b/crates/idx-cli/src/main.rs @@ -73,9 +73,17 @@ fn run() -> Result<(), IdxError> { 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")] Commands::Ownership(cmd) => { - if let Err(err) = cli::ownership::handle(&cmd.command, &config) { + if let Err(err) = + cli::ownership::handle(&cmd.command, &config, cli.offline, cli.no_cache) + { emit_error(&err, &config.output); return Err(err); } diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 62d35b2..4b7aad2 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -15,12 +15,14 @@ use idx_core::analysis::fundamental::{ use idx_core::analysis::report::TechnicalReport; use idx_core::api::types::{ CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc, - Quote, SentimentData, + OrderBook, Quote, SentimentData, }; 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::flow::types::ForeignFlow; +use idx_core::movements::types::MovementList; /// Output format requested by the user (flag, `IDX_OUTPUT`, or config file). #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)] @@ -92,6 +94,64 @@ pub fn render_intraday( } } +pub fn render_orderbook( + book: &OrderBook, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_orderbook(book, no_color), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(book), + } +} + +pub fn render_corporate_actions( + symbol: &str, + actions: &[CorporateAction], + format: &OutputFormat, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_corporate_actions(symbol, actions), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(actions), + } +} + +pub fn render_corporate_calendar( + range: &CalendarRange, + actions: &[CorporateAction], + format: &OutputFormat, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_corporate_calendar(range, actions), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(actions), + } +} + +/// JSON is `{movements, more}` (`more`: rows remain past the pages read), plus +/// `incomplete` when a later page failed and only the rows before it came back; +/// JSONL is one movement per line. +/// `pages` is how many pages the query asked for, for the hint when more +/// rows exist. JSONL streams the rows only, so the hint goes to stderr. +pub fn render_shareholder_movements( + symbol: Option<&str>, + list: &MovementList, + pages: u32, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_shareholder_movements(symbol, list, pages, no_color), + OutputFormat::Json => json::print_json(list), + OutputFormat::Jsonl => { + json::print_json(&list.movements)?; + if list.more { + idx_core::runtime::info(table::more_movements_hint(pages)); + } + Ok(()) + } + } +} + pub fn render_broker_summary( summary: &BrokerSummary, format: &OutputFormat, diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 4994a93..0943690 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -11,12 +11,16 @@ use idx_core::analysis::fundamental::{ use idx_core::analysis::report::TechnicalReport; use idx_core::analysis::signals::Signal; use idx_core::api::types::{ - CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, Intraday, - NewsItem, Ohlc, Quote, SentimentData, + BookLevel, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, + Intraday, NewsItem, Ohlc, OrderBook, Quote, SentimentData, }; 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::flow::types::{FlowSource, ForeignFlow}; +use idx_core::movements::types::{ + MovementAction, MovementList, MovementSource, ShareholderMovement, +}; pub fn format_idr(value: i64) -> String { let sign = if value.is_negative() { "-" } else { "" }; @@ -128,6 +132,13 @@ fn pct_cell(change_pct: Option, no_color: bool) -> Cell { /// 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) -> String { value.map(format_idr).unwrap_or_else(|| "-".to_string()) @@ -160,7 +171,12 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx cell(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())), cell(opt_idr(q.bid)), cell(opt_idr(q.offer)), - cell(q.market_status.as_deref().unwrap_or("-")), + cell( + q.market_status + .as_deref() + .map(market_words) + .unwrap_or_else(|| "-".to_string()), + ), cell(q.as_of.map(wib_minute).unwrap_or_else(|| "-".to_string())), ]); } @@ -177,7 +193,7 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx for note in stale_quote_notes(quotes, chrono::Utc::now()) { println!("{}", terminal_safe(¬e)); } - println!("{STOCKBIT_SOURCE_NOTE}"); + println!("{STOCKBIT_QUOTE_NOTE}"); Ok(()) } @@ -262,6 +278,108 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> { 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| 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; @@ -1559,15 +1677,382 @@ outside the regular board (negotiated and cash)." ); } +fn action_kind_label(kind: ActionKind) -> &'static str { + match kind { + ActionKind::Dividend => "dividend", + ActionKind::StockDividend => "stock dividend", + ActionKind::Bonus => "bonus", + ActionKind::Rights => "rights", + ActionKind::Warrant => "warrant", + ActionKind::Split => "split", + ActionKind::ReverseSplit => "reverse split", + ActionKind::Meeting => "meeting", + ActionKind::PublicExpose => "public expose", + ActionKind::Ipo => "IPO", + ActionKind::TenderOffer => "tender offer", + } +} + +/// IDR with thousands separators and up to two decimals: `1,191.09`, `3.8`. +fn format_rupiah(value: f64) -> String { + let cents = (value * 100.0).round() as i64; + let (whole, frac) = (cents / 100, (cents % 100).abs()); + match frac { + 0 => format_idr(whole), + f if f % 10 == 0 => format!("{}.{}", format_idr(whole), f / 10), + f => format!("{}.{f:02}", format_idr(whole)), + } +} + +/// Another currency's amount with up to six decimals, since a dollar +/// dividend can be a fraction of a cent: `0.0012`, `1,250.5`. +fn format_foreign_amount(value: f64) -> String { + let micros = (value.abs() * 1_000_000.0).round() as u64; + let (whole, frac) = (micros / 1_000_000, micros % 1_000_000); + let sign = if value < 0.0 && micros > 0 { "-" } else { "" }; + let whole = format_u64(whole); + if frac == 0 { + return format!("{sign}{whole}"); + } + let frac = format!("{frac:06}"); + format!("{sign}{whole}.{}", frac.trim_end_matches('0')) +} + +/// What an action gives or asks, in one line. +fn action_detail(action: &CorporateAction) -> String { + let span = |range: Option| range.map(|r| format!("{} to {}", r.start, r.end)); + let price = action.price.map(|p| format!("Rp {}", format_idr(p))); + let mut parts: Vec = 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) -> 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)] mod tests { use super::{ - format_earnings_period, format_idr, format_signal, format_table_date, format_u64, - humanize_metric_key, stale_quote_notes, + format_earnings_period, format_foreign_amount, format_idr, format_rupiah, format_signal, + format_table_date, format_u64, humanize_metric_key, stale_quote_notes, }; use idx_core::analysis::signals::Signal; 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 { Quote { symbol: symbol.into(), diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 82bf3fc..ee02057 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2137,6 +2137,156 @@ fn provider_flag_overrides_env() { 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::(&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] fn invalid_quote_ttl_env_returns_non_zero() { test_bin("invalid-quote-ttl") @@ -3143,3 +3293,24 @@ fn bandar_never_reads_a_broker_summary_cached_in_the_old_shape() { assert!(!table.contains("No trades."), "{table}"); assert!(table.contains("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); +} diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 00e1ff3..af7eced 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -263,6 +263,52 @@ args = ["stocks", "intraday", "MABA"] 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) -------------------- [[case]] @@ -366,8 +412,50 @@ args = ["stocks", "intraday", "ZZZZ"] 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 -------------------------------------------------------------------- +[[case]] +name = "ownership-movements" +args = ["ownership", "movements", "--pages", "2"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "ownership-movements-bbca" +args = ["ownership", "movements", "BBCA"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "ownership-movements-ksei-sells" +args = ["ownership", "movements", "--action", "sell", "--source", "ksei", "--pages", "3"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + [[case]] name = "ownership-releases-empty" args = ["ownership", "releases"] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index 998698f..b7506c3 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -33,6 +33,9 @@ _idx() { idx,ownership) cmd="idx__ownership" ;; + idx,stockbit) + cmd="idx__stockbit" + ;; idx,stocks) cmd="idx__stocks" ;; @@ -102,6 +105,9 @@ _idx() { idx__help,ownership) cmd="idx__help__ownership" ;; + idx__help,stockbit) + cmd="idx__help__stockbit" + ;; idx__help,stocks) cmd="idx__help__stocks" ;; @@ -150,6 +156,9 @@ _idx() { idx__help__ownership,import) cmd="idx__help__ownership__import" ;; + idx__help__ownership,movements) + cmd="idx__help__ownership__movements" + ;; idx__help__ownership,releases) cmd="idx__help__ownership__releases" ;; @@ -174,12 +183,27 @@ _idx() { idx__help__ownership__resolve,unresolved) cmd="idx__help__ownership__resolve__unresolved" ;; + idx__help__stockbit,login) + cmd="idx__help__stockbit__login" + ;; + idx__help__stockbit,logout) + cmd="idx__help__stockbit__logout" + ;; + idx__help__stockbit,status) + cmd="idx__help__stockbit__status" + ;; + idx__help__stocks,actions) + cmd="idx__help__stocks__actions" + ;; idx__help__stocks,bandar) cmd="idx__help__stocks__bandar" ;; idx__help__stocks,broker) cmd="idx__help__stocks__broker" ;; + idx__help__stocks,calendar) + cmd="idx__help__stocks__calendar" + ;; idx__help__stocks,compare) cmd="idx__help__stocks__compare" ;; @@ -210,6 +234,9 @@ _idx() { idx__help__stocks,news) cmd="idx__help__stocks__news" ;; + idx__help__stocks,orderbook) + cmd="idx__help__stocks__orderbook" + ;; idx__help__stocks,profile) cmd="idx__help__stocks__profile" ;; @@ -258,6 +285,9 @@ _idx() { idx__ownership,import) cmd="idx__ownership__import" ;; + idx__ownership,movements) + cmd="idx__ownership__movements" + ;; idx__ownership,releases) cmd="idx__ownership__releases" ;; @@ -300,6 +330,9 @@ _idx() { idx__ownership__help,import) cmd="idx__ownership__help__import" ;; + idx__ownership__help,movements) + cmd="idx__ownership__help__movements" + ;; idx__ownership__help,releases) cmd="idx__ownership__help__releases" ;; @@ -348,12 +381,42 @@ _idx() { idx__ownership__resolve__help,unresolved) cmd="idx__ownership__resolve__help__unresolved" ;; + idx__stockbit,help) + cmd="idx__stockbit__help" + ;; + idx__stockbit,login) + cmd="idx__stockbit__login" + ;; + idx__stockbit,logout) + cmd="idx__stockbit__logout" + ;; + idx__stockbit,status) + cmd="idx__stockbit__status" + ;; + idx__stockbit__help,help) + cmd="idx__stockbit__help__help" + ;; + idx__stockbit__help,login) + cmd="idx__stockbit__help__login" + ;; + idx__stockbit__help,logout) + cmd="idx__stockbit__help__logout" + ;; + idx__stockbit__help,status) + cmd="idx__stockbit__help__status" + ;; + idx__stocks,actions) + cmd="idx__stocks__actions" + ;; idx__stocks,bandar) cmd="idx__stocks__bandar" ;; idx__stocks,broker) cmd="idx__stocks__broker" ;; + idx__stocks,calendar) + cmd="idx__stocks__calendar" + ;; idx__stocks,compare) cmd="idx__stocks__compare" ;; @@ -387,6 +450,9 @@ _idx() { idx__stocks,news) cmd="idx__stocks__news" ;; + idx__stocks,orderbook) + cmd="idx__stocks__orderbook" + ;; idx__stocks,profile) cmd="idx__stocks__profile" ;; @@ -408,12 +474,18 @@ _idx() { idx__stocks,valuation) cmd="idx__stocks__valuation" ;; + idx__stocks__help,actions) + cmd="idx__stocks__help__actions" + ;; idx__stocks__help,bandar) cmd="idx__stocks__help__bandar" ;; idx__stocks__help,broker) cmd="idx__stocks__help__broker" ;; + idx__stocks__help,calendar) + cmd="idx__stocks__help__calendar" + ;; idx__stocks__help,compare) cmd="idx__stocks__help__compare" ;; @@ -447,6 +519,9 @@ _idx() { idx__stocks__help,news) cmd="idx__stocks__help__news" ;; + idx__stocks__help,orderbook) + cmd="idx__stocks__help__orderbook" + ;; idx__stocks__help,profile) cmd="idx__stocks__help__profile" ;; @@ -475,7 +550,7 @@ _idx() { case "${cmd}" in idx) - opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership completions version help" + opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership stockbit completions version help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -875,7 +950,7 @@ _idx() { return 0 ;; idx__help) - opts="stocks config cache ownership completions version help" + opts="stocks config cache ownership stockbit completions version help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1029,7 +1104,7 @@ _idx() { return 0 ;; idx__help__ownership) - opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases" + opts="discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1154,6 +1229,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__ownership__movements) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__ownership__releases) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -1266,8 +1355,8 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - idx__help__stocks) - opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare" + idx__help__stockbit) + opts="login status logout" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1280,6 +1369,76 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stockbit__login) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stockbit__logout) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stockbit__status) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks) + opts="quote intraday orderbook actions calendar history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stocks__actions) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks__bandar) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -1308,6 +1467,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stocks__calendar) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks__compare) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -1448,6 +1621,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stocks__orderbook) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks__profile) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -1561,7 +1748,7 @@ _idx() { return 0 ;; idx__ownership) - opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1809,7 +1996,7 @@ _idx() { return 0 ;; idx__ownership__help) - opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help" + opts="discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1948,6 +2135,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__ownership__help__movements) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__ownership__help__releases) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2098,6 +2299,56 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__ownership__movements) + opts="-o -q -v -h --from --to --holder --action --source --pages --output --no-color --quiet --verbose --provider --offline --no-cache --help [SYMBOL]" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --holder) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --action) + COMPREPLY=($(compgen -W "buy sell transfer cross corporate-action" -- "${cur}")) + return 0 + ;; + --source) + COMPREPLY=($(compgen -W "idx ksei" -- "${cur}")) + return 0 + ;; + --pages) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__ownership__releases) opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2392,8 +2643,8 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - idx__stocks) - opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" + idx__stockbit) + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help login status logout help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2418,6 +2669,214 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stockbit__help) + opts="login status logout help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__help__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__help__login) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__help__logout) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__help__status) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__login) + opts="-o -q -v -h --no-browser --port --output --no-color --quiet --verbose --provider --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --port) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__logout) + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__status) + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks) + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday orderbook actions calendar history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stocks__actions) + opts="-o -q -v -h --kind --output --no-color --quiet --verbose --provider --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --kind) + COMPREPLY=($(compgen -W "dividend stock-dividend bonus rights warrant split reverse-split meeting expose ipo tender" -- "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__bandar) opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --provider --offline --no-cache --help ..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2514,6 +2973,44 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__calendar) + opts="-o -q -v -h --from --to --kind --output --no-color --quiet --verbose --provider --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --kind) + COMPREPLY=($(compgen -W "dividend stock-dividend bonus rights warrant split reverse-split meeting expose ipo tender" -- "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__compare) opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help ..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2691,7 +3188,7 @@ _idx() { return 0 ;; idx__stocks__help) - opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" + opts="quote intraday orderbook actions calendar history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2704,6 +3201,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__actions) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__help__bandar) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2732,6 +3243,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__calendar) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__help__compare) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2886,6 +3411,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__orderbook) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__help__profile) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -3000,7 +3539,7 @@ _idx() { return 0 ;; --history-provider) - COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}")) + COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}")) return 0 ;; --output) @@ -3104,6 +3643,36 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__orderbook) + opts="-o -q -v -h --depth --output --no-color --quiet --verbose --provider --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --depth) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__profile) opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -3254,7 +3823,7 @@ _idx() { fi case "${prev}" in --history-provider) - COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}")) + COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}")) return 0 ;; --output) diff --git a/crates/idx-cli/tests/golden/error-actions-no-session.json.golden b/crates/idx-cli/tests/golden/error-actions-no-session.json.golden new file mode 100644 index 0000000..d38ac26 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-actions-no-session.json.golden @@ -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)"} diff --git a/crates/idx-cli/tests/golden/error-actions-no-session.table.golden b/crates/idx-cli/tests/golden/error-actions-no-session.table.golden new file mode 100644 index 0000000..e1fcda0 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-actions-no-session.table.golden @@ -0,0 +1,4 @@ +exit: 4 +--- stdout +--- stderr +Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN) diff --git a/crates/idx-cli/tests/golden/error-calendar-range.json.golden b/crates/idx-cli/tests/golden/error-calendar-range.json.golden new file mode 100644 index 0000000..e6c9ad9 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-calendar-range.json.golden @@ -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)"} diff --git a/crates/idx-cli/tests/golden/error-calendar-range.table.golden b/crates/idx-cli/tests/golden/error-calendar-range.table.golden new file mode 100644 index 0000000..3fc65af --- /dev/null +++ b/crates/idx-cli/tests/golden/error-calendar-range.table.golden @@ -0,0 +1,4 @@ +exit: 2 +--- stdout +--- stderr +Error: invalid input: to (2026-11-01) is before from (2026-11-10) diff --git a/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden b/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden index 402cc2b..7554cf0 100644 --- a/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden +++ b/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden @@ -1,4 +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; `--provider msn` serves quotes without Stockbit"} +{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"} diff --git a/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden b/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden index fd793c7..d7b4759 100644 --- a/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden +++ b/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden @@ -1,4 +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; `--provider msn` serves quotes without Stockbit +Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit diff --git a/crates/idx-cli/tests/golden/error-movements-holder.json.golden b/crates/idx-cli/tests/golden/error-movements-holder.json.golden new file mode 100644 index 0000000..782a248 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-movements-holder.json.golden @@ -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"} diff --git a/crates/idx-cli/tests/golden/error-movements-holder.table.golden b/crates/idx-cli/tests/golden/error-movements-holder.table.golden new file mode 100644 index 0000000..2f44611 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-movements-holder.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/error-movements-no-session.json.golden b/crates/idx-cli/tests/golden/error-movements-no-session.json.golden new file mode 100644 index 0000000..d38ac26 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-movements-no-session.json.golden @@ -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)"} diff --git a/crates/idx-cli/tests/golden/error-movements-no-session.table.golden b/crates/idx-cli/tests/golden/error-movements-no-session.table.golden new file mode 100644 index 0000000..e1fcda0 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-movements-no-session.table.golden @@ -0,0 +1,4 @@ +exit: 4 +--- stdout +--- stderr +Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN) diff --git a/crates/idx-cli/tests/golden/error-orderbook-no-session.json.golden b/crates/idx-cli/tests/golden/error-orderbook-no-session.json.golden new file mode 100644 index 0000000..d38ac26 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-orderbook-no-session.json.golden @@ -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)"} diff --git a/crates/idx-cli/tests/golden/error-orderbook-no-session.table.golden b/crates/idx-cli/tests/golden/error-orderbook-no-session.table.golden new file mode 100644 index 0000000..e1fcda0 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-orderbook-no-session.table.golden @@ -0,0 +1,4 @@ +exit: 4 +--- stdout +--- stderr +Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN) diff --git a/crates/idx-cli/tests/golden/error-stockbit-history-window.json.golden b/crates/idx-cli/tests/golden/error-stockbit-history-window.json.golden new file mode 100644 index 0000000..5051558 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-stockbit-history-window.json.golden @@ -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"} diff --git a/crates/idx-cli/tests/golden/error-stockbit-history-window.table.golden b/crates/idx-cli/tests/golden/error-stockbit-history-window.table.golden new file mode 100644 index 0000000..5a39fac --- /dev/null +++ b/crates/idx-cli/tests/golden/error-stockbit-history-window.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/help-ownership.golden b/crates/idx-cli/tests/golden/help-ownership.golden index 202f0da..406d191 100644 --- a/crates/idx-cli/tests/golden/help-ownership.golden +++ b/crates/idx-cli/tests/golden/help-ownership.golden @@ -20,6 +20,7 @@ Commands: concentration Rank tickers by ownership concentration flow Show Bing institutional flow for a ticker changes Diff two KSEI releases + movements Insider and major holder buys and sells, as reported (Stockbit login) graph Ownership network graph resolve Manual entity resolution workflow releases List imported KSEI releases diff --git a/crates/idx-cli/tests/golden/help-root.golden b/crates/idx-cli/tests/golden/help-root.golden index 5593677..588684d 100644 --- a/crates/idx-cli/tests/golden/help-root.golden +++ b/crates/idx-cli/tests/golden/help-root.golden @@ -10,6 +10,7 @@ Commands: config Manage configuration cache Manage local cache ownership Ownership intelligence (bootstrap with `ownership sync`) + stockbit Your Stockbit session (opt-in: order book, live quotes) completions Generate shell completions version Show idx-cli version help Print this message or the help of the given subcommand(s) diff --git a/crates/idx-cli/tests/golden/help-stocks.golden b/crates/idx-cli/tests/golden/help-stocks.golden index af937f3..e004151 100644 --- a/crates/idx-cli/tests/golden/help-stocks.golden +++ b/crates/idx-cli/tests/golden/help-stocks.golden @@ -7,6 +7,9 @@ Usage: idx stocks [OPTIONS] Commands: 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 technical Run technical analysis on a stock growth Run growth analysis on a stock diff --git a/crates/idx-cli/tests/golden/ownership-movements-bbca.json.golden b/crates/idx-cli/tests/golden/ownership-movements-bbca.json.golden new file mode 100644 index 0000000..fa4664c --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements-bbca.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/ownership-movements-bbca.table.golden b/crates/idx-cli/tests/golden/ownership-movements-bbca.table.golden new file mode 100644 index 0000000..537acfd --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements-bbca.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.json.golden b/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.json.golden new file mode 100644 index 0000000..8f0f8c7 --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.table.golden b/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.table.golden new file mode 100644 index 0000000..b67a877 --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements-ksei-sells.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/ownership-movements.json.golden b/crates/idx-cli/tests/golden/ownership-movements.json.golden new file mode 100644 index 0000000..3e5b499 --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"movements":[{"symbol":"PEGE","date":"2026-11-02","holder":"OPTIMUS VISION GLOBAL","holder_id":"1000006115","action":"buy","source":"idx","locality":"Foreign","shares_before":249112774,"shares_after":251612774,"shares_change":2500000,"percentage_before_bps":659,"percentage_after_bps":666,"percentage_change_bps":7,"price":120,"price_estimated":false,"value":300000000,"purpose":"Pembelian"},{"symbol":"PEGE","date":"2026-11-02","holder":"OPTIMUS VISION GLOBAL","holder_id":"1000006115","action":"buy","source":"idx","locality":"Foreign","shares_before":246212774,"shares_after":249112774,"shares_change":2900000,"percentage_before_bps":652,"percentage_after_bps":659,"percentage_change_bps":8,"price":122,"price_estimated":false,"value":353800000,"purpose":"Pembelian"},{"symbol":"PEGE","date":"2026-11-02","holder":"OPTIMUS VISION GLOBAL","holder_id":"1000006115","action":"buy","source":"idx","locality":"Foreign","shares_before":242512774,"shares_after":243712774,"shares_change":1200000,"percentage_before_bps":642,"percentage_after_bps":645,"percentage_change_bps":3,"price":123,"price_estimated":false,"value":147600000,"purpose":"Pembelian"},{"symbol":"PEGE","date":"2026-11-02","holder":"OPTIMUS VISION GLOBAL","holder_id":"1000006115","action":"buy","source":"idx","locality":"Foreign","shares_before":241674474,"shares_after":242512774,"shares_change":838300,"percentage_before_bps":640,"percentage_after_bps":642,"percentage_change_bps":2,"price":119,"price_estimated":false,"value":99757700,"purpose":"Pembelian"},{"symbol":"PEGE","date":"2026-11-02","holder":"OPTIMUS VISION GLOBAL","holder_id":"1000006115","action":"buy","source":"idx","locality":"Foreign","shares_before":241650974,"shares_after":241674474,"shares_change":23500,"percentage_before_bps":640,"percentage_after_bps":640,"percentage_change_bps":0,"price":118,"price_estimated":false,"value":2773000,"purpose":"Pembelian"},{"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":"BUVA","date":"2026-10-30","holder":"NUSANTARA UTAMA INVESTAMA","holder_id":"12606","action":"rights_issue","source":"ksei","locality":"Local","shares_before":18508539715,"shares_after":18508539715,"shares_change":0,"percentage_before_bps":6251,"percentage_after_bps":6154,"percentage_change_bps":-97,"price":353,"price_estimated":true},{"symbol":"SGER","date":"2026-10-30","holder":"WELLY THOMAS","holder_id":"14074","action":"transfer","source":"ksei","locality":"Local","shares_before":1285644762,"shares_after":1341269756,"shares_change":55624994,"percentage_before_bps":825,"percentage_after_bps":861,"percentage_change_bps":36,"price":794,"price_estimated":true,"value":44166245236},{"symbol":"ENRG","date":"2026-10-29","holder":"CGS INTERNATIONAL SEKURITAS INDONESIA","holder_id":"13898","action":"cross","source":"idx","locality":"Local","shares_before":3600545611,"shares_after":3100545614,"shares_change":-499999997,"percentage_before_bps":904,"percentage_after_bps":778,"percentage_change_bps":-125,"price":715,"price_estimated":false,"value":357499997855,"purpose":"Reverse REPO"},{"symbol":"ENRG","date":"2026-10-28","holder":"MAYBANK SEKURITAS INDONESIA","holder_id":"12202","action":"cross","source":"idx","locality":"Local","shares_before":2380252500,"shares_after":2442752500,"shares_change":62500000,"percentage_before_bps":597,"percentage_after_bps":613,"percentage_change_bps":16,"price":724,"price_estimated":true,"value":45250000000,"purpose":"Penambahan jaminan saham untuk transaksi repo"},{"symbol":"VISI","date":"2026-10-28","holder":"TRINUGRAHA THOHIR HARMONI","holder_id":"1000000448","action":"private_placement","source":"idx","locality":"Local","shares_before":246000000,"shares_after":270144900,"shares_change":24144900,"percentage_before_bps":770,"percentage_after_bps":845,"percentage_change_bps":76,"price":994,"price_estimated":false,"value":24000030600,"purpose":"PMTHMETD"},{"symbol":"MBSS","date":"2026-10-15","holder":"WIBOWO GROUP CAPITAL","holder_id":"1000005952","action":"tender_offer","source":"idx","locality":"Local","shares_before":1443766800,"shares_after":1576605000,"shares_change":132838200,"percentage_before_bps":8250,"percentage_after_bps":9009,"percentage_change_bps":759,"price":2915,"price_estimated":false,"value":387223353000},{"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 diff --git a/crates/idx-cli/tests/golden/ownership-movements.table.golden b/crates/idx-cli/tests/golden/ownership-movements.table.golden new file mode 100644 index 0000000..2500f25 --- /dev/null +++ b/crates/idx-cli/tests/golden/ownership-movements.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-actions-dividends.json.golden b/crates/idx-cli/tests/golden/stockbit-actions-dividends.json.golden new file mode 100644 index 0000000..3b6bd49 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-actions-dividends.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"symbol":"BBCA.JK","kind":"dividend","date":"2026-08-28","cum_date":"2026-08-28","ex_date":"2026-08-31","record_date":"2026-09-01","pay_date":"2026-09-16","cash_per_share":25.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2026-06-15","cum_date":"2026-06-15","ex_date":"2026-06-17","record_date":"2026-06-18","pay_date":"2026-06-26","cash_per_share":20.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2026-03-27","cum_date":"2026-03-27","ex_date":"2026-03-30","record_date":"2026-03-31","pay_date":"2026-04-08","cash_per_share":281.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2025-12-02","cum_date":"2025-12-02","ex_date":"2025-12-03","record_date":"2025-12-04","pay_date":"2025-12-22","cash_per_share":55.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2025-03-20","cum_date":"2025-03-20","ex_date":"2025-03-21","record_date":"2025-03-24","pay_date":"2025-04-11","cash_per_share":250.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2024-11-20","cum_date":"2024-11-20","ex_date":"2024-11-21","record_date":"2024-11-22","pay_date":"2024-12-11","cash_per_share":50.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2024-03-22","cum_date":"2024-03-22","ex_date":"2024-03-25","record_date":"2024-03-26","pay_date":"2024-04-04","cash_per_share":227.5},{"symbol":"BBCA.JK","kind":"dividend","date":"2023-12-01","cum_date":"2023-12-01","ex_date":"2023-12-04","record_date":"2023-12-05","pay_date":"2023-12-20","cash_per_share":42.5},{"symbol":"BBCA.JK","kind":"dividend","date":"2023-03-28","cum_date":"2023-03-28","ex_date":"2023-03-29","record_date":"2023-03-30","pay_date":"2023-04-14","cash_per_share":170.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2022-12-01","cum_date":"2022-12-01","ex_date":"2022-12-02","record_date":"2022-12-05","pay_date":"2022-12-20","cash_per_share":35.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2022-03-25","cum_date":"2022-03-25","ex_date":"2022-03-28","record_date":"2022-03-29","pay_date":"2022-04-19","cash_per_share":120.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2021-11-16","cum_date":"2021-11-16","ex_date":"2021-11-17","record_date":"2021-11-18","pay_date":"2021-12-07","cash_per_share":25.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2021-04-07","cum_date":"2021-04-07","ex_date":"2021-04-08","record_date":"2021-04-09","pay_date":"2021-04-28","cash_per_share":432.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2020-12-07","cum_date":"2020-12-07","ex_date":"2020-12-08","record_date":"2020-12-10","pay_date":"2020-12-22","cash_per_share":98.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2020-04-20","cum_date":"2020-04-20","ex_date":"2020-04-21","record_date":"2020-04-22","pay_date":"2020-05-11","cash_per_share":455.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2019-12-05","cum_date":"2019-12-05","ex_date":"2019-12-06","record_date":"2019-12-09","pay_date":"2019-12-20","cash_per_share":100.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2019-04-23","cum_date":"2019-04-23","ex_date":"2019-04-24","record_date":"2019-04-25","pay_date":"2019-04-30","cash_per_share":255.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2018-11-30","cum_date":"2018-11-30","ex_date":"2018-12-03","record_date":"2018-12-04","pay_date":"2018-12-21","cash_per_share":85.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2018-04-12","cum_date":"2018-04-12","ex_date":"2018-04-13","record_date":"2018-04-17","pay_date":"2018-04-30","cash_per_share":175.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2017-11-28","cum_date":"2017-11-28","ex_date":"2017-11-29","record_date":"2017-12-04","pay_date":"2017-12-20","cash_per_share":80.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2017-04-13","cum_date":"2017-04-13","ex_date":"2017-04-17","record_date":"2017-04-20","pay_date":"2017-04-28","cash_per_share":130.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2016-11-30","cum_date":"2016-11-30","ex_date":"2016-12-01","record_date":"2016-12-05","pay_date":"2016-12-22","cash_per_share":70.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2016-04-14","cum_date":"2016-04-14","ex_date":"2016-04-15","record_date":"2016-04-19","pay_date":"2016-04-29","cash_per_share":105.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2015-11-12","cum_date":"2015-11-12","ex_date":"2015-11-13","record_date":"2015-11-17","pay_date":"2015-12-09","cash_per_share":55.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2015-04-16","cum_date":"2015-04-16","ex_date":"2015-04-17","record_date":"2015-04-21","pay_date":"2015-05-13","cash_per_share":98.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2014-12-04","cum_date":"2014-12-04","ex_date":"2014-12-05","record_date":"2014-12-09","pay_date":"2014-12-23","cash_per_share":50.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2014-04-29","cum_date":"2014-04-29","ex_date":"2014-04-30","record_date":"2014-05-05","pay_date":"2014-05-20","cash_per_share":75.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2013-11-28","cum_date":"2013-11-28","ex_date":"2013-11-29","record_date":"2013-12-03","pay_date":"2013-12-17","cash_per_share":45.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2013-05-28","cum_date":"2013-05-28","ex_date":"2013-05-29","record_date":"2013-05-31","pay_date":"2013-06-17","cash_per_share":71.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2012-12-03","cum_date":"2012-12-03","ex_date":"2012-12-04","record_date":"2012-12-06","pay_date":"2012-12-20","cash_per_share":43.5},{"symbol":"BBCA.JK","kind":"dividend","date":"2012-06-08","cum_date":"2012-06-08","ex_date":"2012-06-11","record_date":"2012-06-13","pay_date":"2012-06-27","cash_per_share":70.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2011-12-06","cum_date":"2011-12-06","ex_date":"2011-12-07","record_date":"2011-12-09","pay_date":"2011-12-23","cash_per_share":43.5},{"symbol":"BBCA.JK","kind":"dividend","date":"2011-06-06","cum_date":"2011-06-06","ex_date":"2011-06-07","record_date":"2011-06-09","pay_date":"2011-06-23","cash_per_share":70.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2010-11-19","cum_date":"2010-11-19","ex_date":"2010-11-22","record_date":"2010-11-24","pay_date":"2010-12-09","cash_per_share":42.5},{"symbol":"BBCA.JK","kind":"dividend","date":"2010-05-31","cum_date":"2010-05-31","ex_date":"2010-06-01","record_date":"2010-06-03","pay_date":"2010-06-17","cash_per_share":70.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2009-11-12","cum_date":"2009-11-12","ex_date":"2009-11-13","record_date":"2009-11-17","pay_date":"2009-12-02","cash_per_share":40.0},{"symbol":"BBCA.JK","kind":"dividend","date":"2009-06-09","cum_date":"2009-06-09","ex_date":"2009-06-10","record_date":"2009-06-12","pay_date":"2009-06-26","cash_per_share":65.0}] +--- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-actions-dividends.table.golden b/crates/idx-cli/tests/golden/stockbit-actions-dividends.table.golden new file mode 100644 index 0000000..85b4d46 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-actions-dividends.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-actions.json.golden b/crates/idx-cli/tests/golden/stockbit-actions.json.golden new file mode 100644 index 0000000..a49932d --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-actions.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-actions.table.golden b/crates/idx-cli/tests/golden/stockbit-actions.table.golden new file mode 100644 index 0000000..c7ac018 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-actions.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden b/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden new file mode 100644 index 0000000..0f7f01b --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-calendar-meetings.table.golden b/crates/idx-cli/tests/golden/stockbit-calendar-meetings.table.golden new file mode 100644 index 0000000..e48c3b5 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-calendar-meetings.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-calendar.json.golden b/crates/idx-cli/tests/golden/stockbit-calendar.json.golden new file mode 100644 index 0000000..505d3ad --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-calendar.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-calendar.table.golden b/crates/idx-cli/tests/golden/stockbit-calendar.table.golden new file mode 100644 index 0000000..7376ced --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-calendar.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-quote.table.golden b/crates/idx-cli/tests/golden/stockbit-quote.table.golden index e8ee346..2dff9e7 100644 --- a/crates/idx-cli/tests/golden/stockbit-quote.table.golden +++ b/crates/idx-cli/tests/golden/stockbit-quote.table.golden @@ -8,5 +8,5 @@ exit: 0 │ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │ └─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘ Note: MABA.JK: Stockbit lists the stock as suspended -Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. +Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. --- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-session-history.json.golden b/crates/idx-cli/tests/golden/stockbit-session-history.json.golden new file mode 100644 index 0000000..4eeecbb --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-history.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-session-history.table.golden b/crates/idx-cli/tests/golden/stockbit-session-history.table.golden new file mode 100644 index 0000000..7bc35c6 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-history.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-session-orderbook.json.golden b/crates/idx-cli/tests/golden/stockbit-session-orderbook.json.golden new file mode 100644 index 0000000..34939b7 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-orderbook.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-session-orderbook.table.golden b/crates/idx-cli/tests/golden/stockbit-session-orderbook.table.golden new file mode 100644 index 0000000..fec3a84 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-orderbook.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-session-quote.json.golden b/crates/idx-cli/tests/golden/stockbit-session-quote.json.golden new file mode 100644 index 0000000..7d27f04 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-quote.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-session-quote.table.golden b/crates/idx-cli/tests/golden/stockbit-session-quote.table.golden new file mode 100644 index 0000000..70fae5d --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-quote.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-status-logged-out.json.golden b/crates/idx-cli/tests/golden/stockbit-status-logged-out.json.golden new file mode 100644 index 0000000..af3e2e4 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-status-logged-out.json.golden @@ -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":"/config/idx/stockbit-session.json"} +--- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-status-logged-out.table.golden b/crates/idx-cli/tests/golden/stockbit-status-logged-out.table.golden new file mode 100644 index 0000000..cf15909 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-status-logged-out.table.golden @@ -0,0 +1,8 @@ +exit: 0 +--- stdout +logged in: no +access token: none +refresh token: none +session file: /config/idx/stockbit-session.json +Run `idx stockbit login` to add one. +--- stderr diff --git a/crates/idx-core/Cargo.toml b/crates/idx-core/Cargo.toml index 187ff54..f84e780 100644 --- a/crates/idx-core/Cargo.toml +++ b/crates/idx-core/Cargo.toml @@ -30,6 +30,8 @@ directories = "5" chrono.workspace = true thiserror = "2" fastrand = "2" +# Decodes a Stockbit session JWT's expiry (`exp`); never verifies or signs. +base64 = "0.22" rusqlite = { workspace = true, optional = true } quick-xml = { version = "0.37", optional = true } sha2 = { workspace = true, optional = true } diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index b0741f2..6b985c0 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -11,6 +11,7 @@ use crate::api::history_provider; use crate::api::types::{Fundamentals, Interval, Period}; use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::config::HistoryProviderKind; use crate::error::IdxError; use crate::market::HistorySource; use bandar::{BandarReport, analyze_bandar, check_top}; @@ -110,8 +111,16 @@ impl Analysis<'_> { 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) -> HistorySource { + self.idx + .market() + .history_source(mode, &Period::OneYear, &Interval::Day) + } + /// Technical analysis over one year of daily history from `source` - /// (see `Market::history_source`). + /// (see `technical_source`). pub fn technical( &self, symbol: &str, @@ -119,23 +128,24 @@ impl Analysis<'_> { policy: CachePolicy, ) -> Result, IdxError> { 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)?; - cached_fetch( - &idx.cache, - CacheSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: idx.config.quote_ttl, - subject: &resolved, - source: provider_kind.as_str(), - }, - policy, - || provider.history(&resolved, &Period::OneYear, &Interval::Day), - |history| build_technical_report(&resolved, &history), - ) + 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( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &resolved, + ttl_secs: idx.config.quote_ttl, + subject: &resolved, + source: source.provider.as_str(), + }, + policy, + || provider.history(&resolved, &Period::OneYear, &Interval::Day), + |history| build_technical_report(&resolved, &history), + ) + }) } pub fn growth( diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index 1a90c92..8477e7e 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -1,14 +1,18 @@ //! Fixture-backed provider for tests, enabled by the `testing` feature and //! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it. -use super::types::{Bar, Fundamentals, Interval, Intraday, Period, Quote}; +use super::stockbit::{corpaction, exodus, movement}; +use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote}; use super::{ - BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, QuoteProvider, + BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider, + IntradayProvider, OrderBookProvider, QuoteProvider, ShareholderMovementProvider, TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo, }; use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; use crate::config::ProviderKind; +use crate::corporate::types::{ActionKind, CorporateAction}; use crate::error::IdxError; +use crate::movements::types::{MovementPage, MovementQuery}; pub(crate) fn enabled() -> bool { std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() @@ -135,22 +139,32 @@ impl HistoryProvider for MockProvider { } } -/// Stockbit symbol pages from `tests/fixtures/stockbit_symbol_.html`; -/// a code without a fixture is Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` -/// (e.g. `403`) answers that HTTP status instead, and `IDX_MOCK_ERROR` fails -/// as for the other providers. +/// Stockbit from fixtures. Without a session (none stored or seeded through +/// `IDX_STOCKBIT_REFRESH_TOKEN`): the public page, +/// `tests/fixtures/stockbit_symbol_.html`. With one: the session API, +/// `tests/fixtures/stockbit_exodus_orderbook_.json` and +/// `stockbit_exodus_chart_candle__.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 snapshot(&self, symbol: &str) -> Result { + fn failure(&self, symbol: &str) -> Option { if std::env::var("IDX_MOCK_ERROR").is_ok() { - return Err(IdxError::ProviderUnavailable); + return Some(IdxError::ProviderUnavailable); } - if let Ok(status) = std::env::var("IDX_MOCK_STOCKBIT_STATUS") { - return Err(stockbit::status_error( - status.parse().unwrap_or(500), - symbol, - )); + 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 { + if let Some(err) = self.failure(symbol) { + return Err(err); } let code = stockbit::stockbit_code(symbol)?; let path = format!( @@ -162,10 +176,41 @@ impl MockStockbitProvider { 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 { + 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 { + 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 { + 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. @@ -186,6 +231,89 @@ impl IntradayProvider for MockStockbitProvider { } } +impl OrderBookProvider for MockStockbitProvider { + fn orderbook(&self, symbol: &str) -> Result { + self.book(symbol) + } +} + +/// `stockbit_exodus_corpaction_.json` for the market, +/// `...__.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, 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[_][_p].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 { + 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, 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__ /// [_][__].html`; a query without a fixture reads as an /// unknown code. diff --git a/crates/idx-core/src/api/mod.rs b/crates/idx-core/src/api/mod.rs index 8ff9fdd..f64cb8e 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -14,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind}; use crate::error::IdxError; use types::{ Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, - Intraday, NewsItem, Period, Quote, SentimentData, + Intraday, NewsItem, OrderBook, Period, Quote, SentimentData, }; /// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp @@ -72,6 +72,34 @@ pub trait IntradayProvider { fn intraday(&self, symbol: &str) -> Result; } +/// 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; +} + +/// 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, 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; +} + pub trait FundamentalsProvider { fn fundamentals(&self, symbol: &str) -> Result; } @@ -242,9 +270,12 @@ fn mock_market(_provider: ProviderKind) -> Option> { #[cfg(feature = "testing")] fn mock_history(provider: ProviderKind, verbose: bool) -> Option> { - Some(Box::new( - mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose), - )) + match provider { + ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)), + _ => Some(Box::new( + mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose), + )), + } } #[cfg(not(feature = "testing"))] @@ -285,7 +316,7 @@ fn build_selected_provider( news: Some(Box::new(msn::MsnProvider::new(verbose))), screener: Some(Box::new(msn::MsnProvider::new(verbose))), }, - // Quotes only; history falls back to Yahoo (see `history_source`). + // Quotes only; history needs a session (see `history_source`). ProviderKind::Stockbit => SelectedProvider { kind: ProviderKind::Stockbit, market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())), @@ -328,6 +359,30 @@ pub fn intraday_provider() -> Box { Box::new(stockbit::StockbitProvider::new()) } +pub fn orderbook_provider() -> Box { + #[cfg(feature = "testing")] + if mock::enabled() { + return Box::new(mock::MockStockbitProvider); + } + Box::new(stockbit::StockbitProvider::new()) +} + +pub fn corporate_action_provider() -> Box { + #[cfg(feature = "testing")] + if mock::enabled() { + return Box::new(mock::MockStockbitProvider); + } + Box::new(stockbit::StockbitProvider::new()) +} + +pub fn shareholder_movement_provider() -> Box { + #[cfg(feature = "testing")] + if mock::enabled() { + return Box::new(mock::MockStockbitProvider); + } + Box::new(stockbit::StockbitProvider::new()) +} + /// Today in WIB, the date IDX trades on. pub(crate) fn today_wib() -> chrono::NaiveDate { #[cfg(feature = "testing")] @@ -338,40 +393,44 @@ pub(crate) fn today_wib() -> chrono::NaiveDate { } /// 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 { HistoryProviderKind::Yahoo => ProviderKind::Yahoo, HistoryProviderKind::Msn => ProviderKind::Msn, - HistoryProviderKind::Auto => match provider { - ProviderKind::Yahoo | ProviderKind::Msn | ProviderKind::Stockbit => ProviderKind::Yahoo, - }, + HistoryProviderKind::Stockbit => ProviderKind::Stockbit, + HistoryProviderKind::Auto => { + if provider == ProviderKind::Stockbit + && stockbit_session + && stockbit::exodus::history_window(period, interval).is_some() + { + ProviderKind::Stockbit + } else { + ProviderKind::Yahoo + } + } } } -/// Resolves a history provider based on the selected market data provider and -/// 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), IdxError> { - let resolved = history_source(provider, history_mode); - +/// The history provider for a source from `history_source`. +pub fn history_provider(source: ProviderKind, verbose: bool) -> Box { if mock_enabled() - && let Some(mock) = mock_history(resolved, verbose) + && let Some(mock) = mock_history(source, verbose) { - return Ok((resolved, mock)); + return mock; } - - match resolved { - ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))), - ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))), - ProviderKind::Stockbit => Err(IdxError::Unsupported( - "Stockbit serves no price history; use --history-provider yahoo or msn".to_string(), - )), + 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()), } } @@ -472,4 +531,72 @@ mod tests { "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 + ); + } } diff --git a/crates/idx-core/src/api/stockbit/auth.rs b/crates/idx-core/src/api/stockbit/auth.rs new file mode 100644 index 0000000..a20f13e --- /dev/null +++ b/crates/idx-core/src/api/stockbit/auth.rs @@ -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 `/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("")) + .field( + "refresh", + &(!self.refresh.is_empty()).then_some(""), + ) + .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>, + /// Seconds until expiry, negative once expired. + pub expires_in_secs: Option, +} + +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 { + 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 { + 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 { + 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 { + 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::(&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 { + 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, + now: i64, + ) -> Result { + let current = self.load(); + if current.is_empty() { + return Err(IdxError::AuthError(NO_SESSION.to_string())); + } + if fresh(¤t.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, + ) -> Result { + 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, + ) -> Result { + 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(&self, f: impl FnOnce() -> Result) -> Result { + 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 { + 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 { + 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::(&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 { + 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 { + 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 { + let raw = raw.trim(); + let decoded = percent_decode(raw); + for candidate in [decoded.as_str(), raw] { + if let Ok(value) = serde_json::from_str::(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 { 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 { 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 { 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}"); + } +} diff --git a/crates/idx-core/src/api/stockbit/browser.rs b/crates/idx-core/src/api/stockbit/browser.rs new file mode 100644 index 0000000..edf4f36 --- /dev/null +++ b/crates/idx-core/src/api/stockbit/browser.rs @@ -0,0 +1,1478 @@ +//! Signing in to Stockbit inside a real browser, with no DevTools and no +//! copying. +//! +//! Stockbit publishes no OAuth client a third party can register, and its own +//! sign-in is a first-party flow guarded by reCAPTCHA, so a CLI cannot ask for +//! a token directly. What it can do is hand the sign-in to the browser that +//! already knows how to do it, then read the cookie that browser ends up with: +//! +//! 1. Launch a Chromium-family browser on Stockbit's sign-in page with a +//! profile directory only idx uses and `--remote-debugging-port=0`. Chrome +//! writes the port it picked and the WebSocket path to +//! `/DevToolsActivePort`. +//! 2. Connect to that endpoint over WebSocket and ask for the cookies. +//! 3. Keep asking until the `credentialStorage` cookie holds tokens `verify` +//! accepts, take it out of the profile, then close the browser. +//! +//! What Stockbit's web app does with that cookie (read from its own bundle, +//! 2026-10-29): it is the app's persisted credential store, written by +//! script, host-only on `stockbit.com`, for 30 days, as percent-encoded +//! `{"state":{"access","refresh","user"},"version":0}`. Every way in (password, +//! Google or Apple, a new device's one-time code, account recovery) and the +//! app's own `POST /login/refresh` end in that one store, so the cookie is +//! the single place a finished sign-in shows up. A signed-out page writes the +//! store too, without tokens, which is why a cookie counts only once +//! `auth::session_from_paste` finds tokens in it. +//! +//! The password, the one-time code and any reCAPTCHA stay in the browser and +//! on Stockbit's page: idx never sees them. The cookie travels in memory only, +//! and only as far as `verify`, which stores the two tokens. +//! +//! A dedicated profile is required by the browser (Chrome 136 and later refuse +//! remote debugging on the default profile) and wanted anyway: idx's session +//! never mixes with the user's everyday browsing. + +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use base64::Engine; +use serde_json::{Value, json}; + +use crate::error::IdxError; + +/// The cookie Stockbit keeps its session in. +const COOKIE: &str = "credentialStorage"; +/// The page the browser opens, which is Stockbit's own sign-in. +const SIGN_IN_URL: &str = "https://stockbit.com/login"; +/// Profile directory idx keeps its own browser session in, beside +/// `config.toml`. +pub const PROFILE_DIR: &str = "stockbit-browser"; +/// How long the browser may take to publish its DevTools endpoint. +const START_TIMEOUT: Duration = Duration::from_secs(20); +/// Pause between cookie reads. +const POLL: Duration = Duration::from_millis(500); +/// How long a session check that failed for a passing reason (network, rate +/// limit) waits before the same cookie is checked again. +#[cfg(not(test))] +const VERIFY_RETRY: Duration = Duration::from_secs(5); +#[cfg(test)] +const VERIFY_RETRY: Duration = Duration::from_millis(50); +/// Socket timeout for one DevTools message. +const IO_TIMEOUT: Duration = Duration::from_secs(10); +/// How long the browser gets to quit after `Browser.close`. +const EXIT_GRACE: Duration = Duration::from_secs(3); +/// Largest DevTools message accepted. +const MAX_MESSAGE: u64 = 8 * 1024 * 1024; + +/// Names the browser idx starts, ahead of any it finds. +pub const ENV_BROWSER: &str = "IDX_STOCKBIT_BROWSER"; +/// File in the profile naming the executable that last opened it. A profile +/// belongs to one browser: its cookies are encrypted with that browser's own +/// key, and an older Chromium may refuse a profile a newer one wrote. +const MADE_BY: &str = "idx-browser"; + +/// A Chromium-family browser idx can drive over the DevTools protocol. +pub struct Browser { + /// What to call the browser in messages (`Google Chrome`). + pub name: String, + /// Its executable. + pub path: PathBuf, +} + +impl Browser { + /// The browser to open `profile` with: the one `IDX_STOCKBIT_BROWSER` + /// names, else the one that opened that profile before, else the first + /// supported browser installed here. + /// + /// Under the mock provider only the variable counts, so a test never + /// opens a browser it did not ask for. + pub fn find(profile: &Path) -> Result { + if let Some(path) = std::env::var_os(ENV_BROWSER) { + let path = PathBuf::from(path); + if !path.is_file() { + return Err(IdxError::ConfigError(format!( + "{ENV_BROWSER} is {}, which is not a file", + path.display() + ))); + } + return Ok(Self::at(path)); + } + #[cfg(feature = "testing")] + if crate::api::mock::enabled() { + return Err(IdxError::Unsupported(format!( + "no browser under the mock provider; set {ENV_BROWSER}" + ))); + } + if let Some(browser) = Self::recorded(profile) { + return Ok(browser); + } + candidates() + .into_iter() + .find(|(_, path)| path.is_file()) + .map(|(name, path)| Self { + name: name.to_string(), + path, + }) + .ok_or_else(|| { + IdxError::Unsupported(format!( + "no Chrome, Chromium, Edge, Brave or Helium found; set {ENV_BROWSER} to a \ + Chromium-family browser" + )) + }) + } + + /// A browser known only by its executable. + fn at(path: PathBuf) -> Self { + let name = path + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "the browser".to_string()); + Self { name, path } + } + + /// The browser that last opened `profile`, if it is still installed. + fn recorded(profile: &Path) -> Option { + let raw = std::fs::read_to_string(profile.join(MADE_BY)).ok()?; + let path = PathBuf::from(raw.trim()); + path.is_file().then(|| Self::at(path)) + } + + /// Opens the browser on Stockbit's sign-in page and connects to it. + /// + /// Returns once the browser answers over DevTools, so a browser that will + /// not start is reported here, before anyone waits on it. + pub fn open(&self, profile: &Path) -> Result { + let launched = self.launch(profile)?; + let devtools = connect(profile, &|| launched.is_running())?; + Ok(Window { launched, devtools }) + } + + /// Starts the browser on its sign-in page. + fn launch(&self, profile: &Path) -> Result { + std::fs::create_dir_all(profile) + .map_err(|e| IdxError::Io(format!("cannot create idx's browser profile: {e}")))?; + owner_only(profile); + // A port file left by an earlier run would be read as this run's. + let _ = std::fs::remove_file(profile.join("DevToolsActivePort")); + let child = Command::new(&self.path) + .args(self.args(profile)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| IdxError::Io(format!("cannot open {}: {e}", self.name)))?; + // Best effort: without it, the next run searches again. + if let Some(path) = self.path.to_str() { + let _ = std::fs::write(profile.join(MADE_BY), path); + } + Ok(Launched { + child: std::cell::RefCell::new(child), + }) + } + + /// Arguments that keep idx's sign-in in its own profile and publish a + /// DevTools port instead of a fixed one. + fn args(&self, profile: &Path) -> Vec { + vec![ + format!("--user-data-dir={}", profile.display()), + "--remote-debugging-port=0".to_string(), + "--no-first-run".to_string(), + "--no-default-browser-check".to_string(), + "--disable-search-engine-choice-screen".to_string(), + SIGN_IN_URL.to_string(), + ] + } +} + +/// A browser window idx opened on Stockbit's sign-in page. +pub struct Window { + launched: Launched, + devtools: DevTools, +} + +impl Window { + /// Waits for a session `verify` accepts, then closes the browser. + /// + /// A session already in idx's profile that still verifies is taken at + /// once. One `verify` refuses is reported once and then left alone: + /// signing in again in the open window replaces it. Setting `cancel` + /// ends the wait and closes the browser too. + pub fn wait( + mut self, + timeout: Duration, + cancel: &AtomicBool, + verify: &dyn Fn(&str) -> Result<(), IdxError>, + ) -> Result<(), IdxError> { + let launched = &self.launched; + let result = wait_for_session(&mut self.devtools, timeout, verify, &|| { + launched.is_running() && !cancel.load(Ordering::Relaxed) + }); + self.launched.exit(EXIT_GRACE); + result + } +} + +/// Connects to the browser that is starting on `profile`. +fn connect(profile: &Path, alive: &dyn Fn() -> bool) -> Result { + let (port, path) = read_endpoint(profile, START_TIMEOUT, alive)?; + DevTools::connect(port, &path) +} + +/// Waits on a running browser for a session `verify` accepts. +/// +/// Apart from `Window` so tests can drive the wait against a stand-in +/// DevTools endpoint without launching a browser. +fn wait_for_session( + devtools: &mut DevTools, + timeout: Duration, + verify: &dyn Fn(&str) -> Result<(), IdxError>, + alive: &dyn Fn() -> bool, +) -> Result<(), IdxError> { + let deadline = Instant::now() + timeout; + let mut rejected: Option = None; + // Why Stockbit refused that cookie, so the error that ends the wait says + // so: a polling frontend never saw the warning. + let mut refusal: Option = None; + // After a check fails for a passing reason, the next is held back. + let mut retry_at: Option = None; + let mut last_error: Option = None; + // The window is watched so closing it ends the wait instead of running + // out the clock; the first polls may not see a page yet. + let mut saw_window = false; + let mut misses = 0; + loop { + match devtools.session_cookie() { + Ok(Some(cookie)) + if rejected.as_deref() != Some(cookie.value.as_str()) + && retry_at.is_none_or(|at| Instant::now() >= at) => + { + match verify(&cookie.value) { + Ok(()) => { + // The session now lives in idx's own file. Take the + // copy out of the browser, but leave Stockbit's + // device cookies in place. + if let Err(err) = devtools.forget_session(&cookie) { + crate::runtime::warn(format!( + "could not remove the session cookie from idx's browser \ + profile ({err}); deleting that profile folder removes it" + )); + } + devtools.close(); + return Ok(()); + } + Err(err) if super::check_is_transient(&err) => { + crate::runtime::warn(format!( + "could not check the session in idx's browser yet ({err}); \ + retrying" + )); + retry_at = Some(Instant::now() + VERIFY_RETRY); + } + // A block holds for every session from this network, so + // the wait ends with it rather than at the timeout. + Err(err @ IdxError::UpstreamBlocked(_)) => { + devtools.close(); + return Err(err); + } + Err(err) => { + crate::runtime::warn(format!( + "Stockbit refused the session in idx's browser ({err}); sign in \ + again in that window" + )); + refusal = Some(err.to_string()); + rejected = Some(cookie.value); + retry_at = None; + } + } + } + // A later read that works clears an earlier failure, so the + // timeout message names only what is still wrong. + Ok(_) => last_error = None, + Err(err) => last_error = Some(err.to_string()), + } + let refused = || { + refusal + .as_ref() + .map(|err| format!("; Stockbit refused the session signed in there ({err})")) + .unwrap_or_default() + }; + if !alive() { + devtools.close(); + return Err(IdxError::Io(format!( + "the browser exited before idx got a session{}; run `idx stockbit login` again", + refused() + ))); + } + match devtools.has_page() { + Ok(true) => { + saw_window = true; + misses = 0; + } + Ok(false) => { + misses += 1; + if saw_window && misses >= 2 { + devtools.close(); + return Err(IdxError::Io(format!( + "the Stockbit sign-in window was closed before the session arrived{}; \ + run `idx stockbit login` again", + refused() + ))); + } + } + Err(err) => last_error = Some(err.to_string()), + } + if Instant::now() >= deadline { + devtools.close(); + let mut message = format!( + "the Stockbit sign-in timed out after {}s and idx closed its browser window{}; \ + re-run `idx stockbit login`, or add --no-browser for the local page", + timeout.as_secs(), + refused() + ); + if let Some(err) = last_error { + message.push_str(&format!(" (last DevTools error: {err})")); + } + return Err(IdxError::Io(message)); + } + std::thread::sleep(POLL); + } +} + +/// Where idx keeps its own browser profile. +pub fn profile_dir() -> Result { + Ok(super::auth::session_dir()?.join(PROFILE_DIR)) +} + +/// Browsers to try, in order, for this platform. +fn candidates() -> Vec<(&'static str, PathBuf)> { + let mut found = Vec::new(); + #[cfg(target_os = "macos")] + { + let homes: Vec = ["/Applications", "/System/Applications"] + .iter() + .map(PathBuf::from) + .chain(std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Applications"))) + .collect(); + let apps = [ + ( + "Google Chrome", + "Google Chrome.app/Contents/MacOS/Google Chrome", + ), + ("Chromium", "Chromium.app/Contents/MacOS/Chromium"), + ( + "Microsoft Edge", + "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ), + ("Brave", "Brave Browser.app/Contents/MacOS/Brave Browser"), + ("Helium", "Helium.app/Contents/MacOS/Helium"), + ( + "Google Chrome Canary", + "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + ), + ]; + for home in homes { + for (name, rel) in apps { + found.push((name, home.join(rel))); + } + } + } + #[cfg(target_os = "windows")] + { + let roots: Vec = ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] + .iter() + .filter_map(std::env::var_os) + .map(PathBuf::from) + .collect(); + for root in roots { + for (name, rel) in [ + ("Google Chrome", r"Google\Chrome\Application\chrome.exe"), + ("Chromium", r"Chromium\Application\chrome.exe"), + ("Microsoft Edge", r"Microsoft\Edge\Application\msedge.exe"), + ( + "Brave", + r"BraveSoftware\Brave-Browser\Application\brave.exe", + ), + ] { + found.push((name, root.join(rel))); + } + } + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let names = [ + ("Google Chrome", "google-chrome"), + ("Google Chrome", "google-chrome-stable"), + ("Chromium", "chromium"), + ("Chromium", "chromium-browser"), + ("Microsoft Edge", "microsoft-edge"), + ("Brave", "brave-browser"), + ]; + let dirs: Vec = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default(); + for (name, exe) in names { + for dir in &dirs { + found.push((name, dir.join(exe))); + } + } + } + found +} + +/// A browser process idx started and must not leave behind. +struct Launched { + // A `RefCell` because the wait needs liveness through an immutable + // borrow; the process is only touched from one thread. + child: std::cell::RefCell, +} + +impl Launched { + /// Whether the browser is still running. + fn is_running(&self) -> bool { + matches!(self.child.borrow_mut().try_wait(), Ok(None)) + } + + /// Gives the browser `grace` to quit after `Browser.close`, then kills it. + fn exit(&self, grace: Duration) { + let deadline = Instant::now() + grace; + while Instant::now() < deadline { + if !self.is_running() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + } +} + +impl Drop for Launched { + fn drop(&mut self) { + let child = self.child.get_mut(); + let _ = child.kill(); + let _ = child.wait(); + } +} + +/// Reads `/DevToolsActivePort`: the port Chrome picked, then the +/// WebSocket path to the browser endpoint. +/// +/// A browser that exits before writing it has usually handed the window to +/// one already running on the same profile, which idx cannot drive. +fn read_endpoint( + profile: &Path, + timeout: Duration, + alive: &dyn Fn() -> bool, +) -> Result<(u16, String), IdxError> { + let file = profile.join("DevToolsActivePort"); + let deadline = Instant::now() + timeout; + loop { + if let Ok(raw) = std::fs::read_to_string(&file) + && let Some((port, path)) = parse_endpoint(&raw) + { + return Ok((port, path)); + } + if !alive() { + return Err(IdxError::Io( + "the browser exited as it started; if a window for idx is already open, close \ + it and try again" + .to_string(), + )); + } + if Instant::now() >= deadline { + return Err(IdxError::Io(format!( + "the browser did not publish a DevTools port within {}s; if a window for idx is \ + already open, close it and try again", + timeout.as_secs() + ))); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// The two lines of a `DevToolsActivePort` file. +fn parse_endpoint(raw: &str) -> Option<(u16, String)> { + let mut lines = raw.lines(); + let port = lines.next()?.trim().parse::().ok()?; + let path = lines.next()?.trim(); + (port != 0 && path.starts_with('/')).then(|| (port, path.to_string())) +} + +/// Stockbit's session cookie as the browser holds it: the value, and the +/// domain and path that name it for deletion. +struct SessionCookie { + value: String, + domain: String, + path: String, +} + +/// The session cookie among DevTools' cookies, if the browser has one with +/// tokens in it; the store a signed-out page writes does not count. +fn session_cookie(cookies: &Value) -> Option { + cookies + .as_array()? + .iter() + .filter(|cookie| cookie["name"] == COOKIE) + .filter_map(|cookie| { + let domain = cookie["domain"].as_str()?; + is_stockbit(domain).then(|| SessionCookie { + value: cookie["value"].as_str().unwrap_or_default().to_string(), + domain: domain.to_string(), + path: cookie["path"].as_str().unwrap_or("/").to_string(), + }) + }) + .find(|cookie| super::auth::session_from_paste(&cookie.value).is_some()) +} + +/// `stockbit.com` or a subdomain, as a cookie's domain names it (host-only +/// cookies have no leading dot). +fn is_stockbit(domain: &str) -> bool { + let host = domain.strip_prefix('.').unwrap_or(domain); + host == "stockbit.com" || host.ends_with(".stockbit.com") +} + +/// A DevTools protocol connection: one WebSocket, one request at a time. +struct DevTools { + stream: TcpStream, + next_id: u64, +} + +impl DevTools { + fn connect(port: u16, path: &str) -> Result { + let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}")); + let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).map_err(io)?; + stream.set_read_timeout(Some(IO_TIMEOUT)).map_err(io)?; + stream.set_write_timeout(Some(IO_TIMEOUT)).map_err(io)?; + let key = base64::engine::general_purpose::STANDARD.encode(random_bytes()); + let request = format!( + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\n\ + Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + stream.write_all(request.as_bytes()).map_err(io)?; + let head = read_head(&mut stream)?; + if !head.starts_with("HTTP/1.1 101") { + return Err(IdxError::Io(format!( + "browser DevTools refused the connection: {}", + head.lines().next().unwrap_or_default() + ))); + } + Ok(Self { stream, next_id: 0 }) + } + + /// The `credentialStorage` cookie the browser holds, if it has tokens. + fn session_cookie(&mut self) -> Result, IdxError> { + let result = self.call("Storage.getCookies", json!({}))?; + Ok(session_cookie(&result["cookies"])) + } + + /// Asks the browser to quit; it usually closes the socket instead of + /// answering, which is not an error here. + fn close(&mut self) { + let _ = self + .stream + .set_read_timeout(Some(Duration::from_millis(500))); + let _ = self.call("Browser.close", json!({})); + } + + /// Whether the browser still has a window open. + fn has_page(&mut self) -> Result { + Ok(!self.pages()?.is_empty()) + } + + /// Removes the session cookie from the profile, keeping the rest, and + /// checks it is gone. + /// + /// Stockbit's script holds the session in memory, writes it back to the + /// cookie, and syncs it across its tabs, so no Stockbit page may be open + /// when the cookie goes: idx opens a blank tab of its own, closes every + /// other one, and deletes from there. The cookie is named by the domain + /// and path the browser reported, since Stockbit sets it host-only. + fn forget_session(&mut self, cookie: &SessionCookie) -> Result<(), IdxError> { + let created = self.call("Target.createTarget", json!({ "url": "about:blank" }))?; + let blank = created["targetId"] + .as_str() + .ok_or_else(|| IdxError::Io("browser DevTools opened no tab".to_string()))? + .to_string(); + for target in self.pages()? { + if target != blank { + self.call("Target.closeTarget", json!({ "targetId": target }))?; + } + } + let attached = self.call( + "Target.attachToTarget", + json!({ "targetId": blank, "flatten": true }), + )?; + let session = attached["sessionId"] + .as_str() + .ok_or_else(|| IdxError::Io("browser DevTools attached no session".to_string()))? + .to_string(); + self.call_in( + Some(&session), + "Network.deleteCookies", + json!({ "name": COOKIE, "domain": cookie.domain, "path": cookie.path }), + )?; + if self.session_cookie()?.is_some() { + return Err(IdxError::Io( + "the session cookie was still there after deleting it".to_string(), + )); + } + Ok(()) + } + + /// The ids of the browser's open tabs. + fn pages(&mut self) -> Result, IdxError> { + let targets = self.call("Target.getTargets", json!({}))?; + Ok(targets["targetInfos"] + .as_array() + .map(|infos| { + infos + .iter() + .filter(|target| target["type"] == "page") + .filter_map(|target| target["targetId"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default()) + } + + /// One request and its reply, skipping events. + fn call(&mut self, method: &str, params: Value) -> Result { + self.call_in(None, method, params) + } + + /// A request in a session (a page attached with `flatten`), or in the + /// browser itself when `session` is `None`. + fn call_in( + &mut self, + session: Option<&str>, + method: &str, + params: Value, + ) -> Result { + self.next_id += 1; + let id = self.next_id; + let mut message = json!({ "id": id, "method": method, "params": params }); + if let Some(session) = session { + message["sessionId"] = json!(session); + } + let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}")); + self.stream + .write_all(&frame(1, message.to_string().as_bytes())) + .map_err(io)?; + loop { + let payload = self.read_message()?; + let reply: Value = serde_json::from_slice(&payload) + .map_err(|e| IdxError::Io(format!("browser DevTools: {e}")))?; + if reply["id"].as_u64() != Some(id) { + continue; + } + if let Some(error) = reply.get("error") { + return Err(IdxError::Io(format!( + "browser DevTools {method}: {}", + error["message"].as_str().unwrap_or("failed") + ))); + } + return Ok(reply.get("result").cloned().unwrap_or(Value::Null)); + } + } + + /// One whole message, joining fragments and answering pings. + fn read_message(&mut self) -> Result, IdxError> { + let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}")); + let mut buffer = Vec::new(); + loop { + let (fin, opcode, payload) = read_frame(&mut self.stream)?; + match opcode { + // ping: answer so the browser keeps the socket open. + 0x9 => self.stream.write_all(&frame(0xA, &payload)).map_err(io)?, + // pong: nothing to do. + 0xA => continue, + 0x8 => { + return Err(IdxError::Io( + "browser DevTools closed the connection".to_string(), + )); + } + // text, binary, or a continuation of the message in hand. + 0x0 => buffer.extend_from_slice(&payload), + 0x1 | 0x2 => { + buffer.clear(); + buffer.extend_from_slice(&payload); + } + _ => continue, + } + if fin { + return Ok(buffer); + } + } + } +} + +/// Reads the HTTP response head, up to the empty line. +fn read_head(stream: &mut TcpStream) -> Result { + let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}")); + let mut raw = Vec::new(); + let mut byte = [0u8; 1]; + while !raw.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte).map_err(io)? == 0 { + return Err(IdxError::Io( + "browser DevTools closed the connection".to_string(), + )); + } + raw.push(byte[0]); + if raw.len() > 8192 { + return Err(IdxError::Io( + "browser DevTools sent an oversized handshake".to_string(), + )); + } + } + Ok(String::from_utf8_lossy(&raw).into_owned()) +} + +/// A masked client frame: clients must mask, servers must not. +fn frame(opcode: u8, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(payload.len() + 14); + out.push(0x80 | opcode); + let mask = 0x80; + match payload.len() { + len if len < 126 => out.push(mask | len as u8), + len if len <= u16::MAX as usize => { + out.push(mask | 126); + out.extend_from_slice(&(len as u16).to_be_bytes()); + } + len => { + out.push(mask | 127); + out.extend_from_slice(&(len as u64).to_be_bytes()); + } + } + let key = fastrand::u32(..).to_be_bytes(); + out.extend_from_slice(&key); + out.extend( + payload + .iter() + .enumerate() + .map(|(index, byte)| byte ^ key[index % 4]), + ); + out +} + +/// One frame: `(fin, opcode, payload)`. +fn read_frame(reader: &mut impl Read) -> Result<(bool, u8, Vec), IdxError> { + let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}")); + let mut head = [0u8; 2]; + reader.read_exact(&mut head).map_err(io)?; + let fin = head[0] & 0x80 != 0; + let opcode = head[0] & 0x0F; + let masked = head[1] & 0x80 != 0; + let length = match head[1] & 0x7F { + 126 => { + let mut bytes = [0u8; 2]; + reader.read_exact(&mut bytes).map_err(io)?; + u16::from_be_bytes(bytes) as u64 + } + 127 => { + let mut bytes = [0u8; 8]; + reader.read_exact(&mut bytes).map_err(io)?; + u64::from_be_bytes(bytes) + } + short => u64::from(short), + }; + if length > MAX_MESSAGE { + return Err(IdxError::Io(format!( + "browser DevTools sent an oversized message ({length} bytes)" + ))); + } + let key = if masked { + let mut key = [0u8; 4]; + reader.read_exact(&mut key).map_err(io)?; + Some(key) + } else { + None + }; + let mut payload = vec![0u8; length as usize]; + reader.read_exact(&mut payload).map_err(io)?; + if let Some(key) = key { + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= key[index % 4]; + } + } + Ok((fin, opcode, payload)) +} + +fn random_bytes() -> [u8; 16] { + let mut bytes = [0u8; 16]; + for chunk in bytes.chunks_mut(4) { + chunk.copy_from_slice(&fastrand::u32(..).to_be_bytes()); + } + bytes +} + +/// Owner-only permissions on the profile, which holds Stockbit's cookie. +fn owner_only(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)); + } + #[cfg(not(unix))] + let _ = path; +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::net::TcpListener; + + use super::*; + + /// A masked client frame round-trips through the reader. + #[test] + fn frames_round_trip() { + for payload in [b"".as_slice(), b"hi", &[b'x'; 300], &[b'y'; 70_000]] { + let raw = frame(1, payload); + assert_eq!(raw[1] & 0x80, 0x80, "client frames are masked"); + let (fin, opcode, decoded) = read_frame(&mut Cursor::new(raw)).unwrap(); + assert!(fin); + assert_eq!(opcode, 1); + assert_eq!(decoded, payload); + } + } + + /// The port file is two lines, and anything else is not an endpoint. + #[test] + fn endpoint_needs_a_port_and_a_path() { + assert_eq!( + parse_endpoint("9222\n/devtools/browser/abc\n"), + Some((9222, "/devtools/browser/abc".to_string())) + ); + assert_eq!(parse_endpoint("0\n/devtools/browser/abc"), None); + assert_eq!(parse_endpoint("9222\n"), None); + assert_eq!(parse_endpoint(""), None); + } + + /// What a signed-out Stockbit page stores: the credential store with no + /// tokens (constructed from the app's `partialize`, which drops unset + /// fields). + const SIGNED_OUT: &str = "%7B%22state%22%3A%7B%7D%2C%22version%22%3A0%7D"; + + /// A `credentialStorage` value with tokens in it. + fn session_value() -> String { + std::fs::read_to_string(format!( + "{}/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() + .trim() + .to_string() + } + + /// Only Stockbit's own cookie counts, and only once it holds tokens. + #[test] + fn only_a_stockbit_cookie_with_tokens_counts() { + let session = session_value(); + let cookies = json!([ + {"name": COOKIE, "domain": ".stockbit.example", "path": "/", "value": session}, + {"name": COOKIE, "domain": "notstockbit.com", "path": "/", "value": session}, + {"name": "other", "domain": "stockbit.com", "path": "/", "value": session}, + {"name": COOKIE, "domain": "stockbit.com", "path": "/", "value": SIGNED_OUT}, + {"name": COOKIE, "domain": "stockbit.com", "path": "/", "value": session}, + ]); + let found = session_cookie(&cookies).expect("the session cookie"); + assert_eq!(found.value, session); + assert_eq!( + found.domain, "stockbit.com", + "host-only, as Stockbit sets it" + ); + assert_eq!(found.path, "/"); + + for value in [SIGNED_OUT, ""] { + let lone = json!([{"name": COOKIE, "domain": "stockbit.com", "value": value}]); + assert!( + session_cookie(&lone).is_none(), + "{value:?} is not a session" + ); + } + assert!(session_cookie(&json!([])).is_none()); + assert!(is_stockbit(".stockbit.com") && is_stockbit("exodus.stockbit.com")); + } + + /// The browser must not share the user's profile, and must pick its port. + #[test] + fn launch_keeps_its_own_profile_and_port() { + let browser = Browser { + name: "test".to_string(), + path: PathBuf::from("/bin/true"), + }; + let args = browser.args(Path::new("/tmp/idx-profile")); + assert!(args.contains(&"--user-data-dir=/tmp/idx-profile".to_string())); + assert!(args.contains(&"--remote-debugging-port=0".to_string())); + assert_eq!(args.last().map(String::as_str), Some(SIGN_IN_URL)); + } + + /// A whole sign-in against a stand-in browser: the session is handed to + /// `verify`, every Stockbit tab is closed before the cookie is deleted by + /// its own (host-only) domain, the cookie is gone afterwards, and the + /// browser is asked to close. + #[test] + fn a_sign_in_takes_the_cookie_and_removes_it_from_the_profile() { + let profile = temp_dir("sign-in"); + let browser = FakeBrowser::start(Fake::default()); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let seen = std::cell::RefCell::new(Vec::new()); + wait_for_session( + &mut devtools, + Duration::from_secs(5), + &|pasted| { + seen.borrow_mut().push(pasted.to_string()); + Ok(()) + }, + &|| true, + ) + .unwrap(); + assert_eq!(seen.borrow().as_slice(), [session_value()]); + + let log = browser.finish(); + let at = |name: &str| log.methods.iter().position(|method| method == name); + assert!( + at("Target.closeTarget") < at("Network.deleteCookies"), + "Stockbit's tab closes before the cookie goes: {:?}", + log.methods + ); + assert_eq!(log.closed, ["T1"], "the sign-in tab, not idx's blank one"); + assert!(!log.cookie_left, "the session cookie is out of the profile"); + assert_eq!( + log.methods.last().map(String::as_str), + Some("Browser.close") + ); + let _ = std::fs::remove_dir_all(&profile); + } + + /// A cookie that survives the delete is an error, not a silent success. + #[test] + fn a_cookie_that_survives_the_delete_is_reported() { + let profile = temp_dir("sticky"); + let browser = FakeBrowser::start(Fake { + sticky: true, + ..Fake::default() + }); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let cookie = devtools.session_cookie().unwrap().expect("a session"); + let err = devtools.forget_session(&cookie).unwrap_err(); + assert!(err.to_string().contains("still there"), "{err}"); + devtools.close(); + assert!(browser.finish().cookie_left); + let _ = std::fs::remove_dir_all(&profile); + } + + /// The store a signed-out page writes is not offered to `verify`. + #[test] + fn a_signed_out_page_is_not_a_session() { + let profile = temp_dir("signed-out"); + let browser = FakeBrowser::start(Fake { + cookie: Some(SIGNED_OUT.to_string()), + ..Fake::default() + }); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let checks = std::cell::Cell::new(0); + let err = wait_for_session( + &mut devtools, + Duration::from_millis(300), + &|_| { + checks.set(checks.get() + 1); + Ok(()) + }, + &|| true, + ) + .unwrap_err(); + assert!(err.to_string().contains("timed out"), "{err}"); + assert_eq!(checks.get(), 0); + browser.finish(); + let _ = std::fs::remove_dir_all(&profile); + } + + /// A session idx refuses is reported once, and the wait goes on. + #[test] + fn a_refused_cookie_is_reported_once_and_the_wait_continues() { + let profile = temp_dir("refused"); + let browser = FakeBrowser::start(Fake::default()); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let checks = std::cell::Cell::new(0); + let err = wait_for_session( + &mut devtools, + Duration::from_millis(300), + &|_| { + checks.set(checks.get() + 1); + Err(IdxError::AuthError("expired".to_string())) + }, + &|| true, + ) + .unwrap_err(); + assert!(err.to_string().contains("timed out"), "{err}"); + assert!( + err.to_string() + .contains("Stockbit refused the session signed in there (auth error: expired)"), + "{err}" + ); + assert_eq!(checks.get(), 1, "one refusal per distinct cookie"); + browser.finish(); + let _ = std::fs::remove_dir_all(&profile); + } + + /// A check that fails for a passing reason (a timeout, a 429) is not a + /// refusal: the same cookie is checked again and can still sign in. + #[test] + fn a_check_that_fails_in_passing_is_retried() { + let profile = temp_dir("transient"); + let browser = FakeBrowser::start(Fake::default()); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let checks = std::cell::Cell::new(0); + wait_for_session( + &mut devtools, + Duration::from_secs(5), + &|_| { + checks.set(checks.get() + 1); + match checks.get() { + 1 => Err(IdxError::RateLimited), + _ => Ok(()), + } + }, + &|| true, + ) + .unwrap(); + assert_eq!(checks.get(), 2); + browser.finish(); + let _ = std::fs::remove_dir_all(&profile); + } + + /// A CDN block is never retried: the wait ends with it at once, not at + /// the timeout. + #[test] + fn a_blocked_check_ends_the_wait() { + let profile = temp_dir("blocked"); + let browser = FakeBrowser::start(Fake::default()); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let checks = std::cell::Cell::new(0); + let started = Instant::now(); + let err = wait_for_session( + &mut devtools, + Duration::from_secs(30), + &|_| { + checks.set(checks.get() + 1); + Err(IdxError::UpstreamBlocked( + "HTTP 403 from its CDN".to_string(), + )) + }, + &|| true, + ) + .unwrap_err(); + assert!(matches!(err, IdxError::UpstreamBlocked(_)), "{err}"); + assert_eq!(checks.get(), 1); + assert!(started.elapsed() < Duration::from_secs(5)); + browser.finish(); + let _ = std::fs::remove_dir_all(&profile); + } + + /// Closing the window ends the wait at once, not at the timeout. + #[test] + fn closing_the_window_ends_the_wait() { + let profile = temp_dir("closed"); + let browser = FakeBrowser::start(Fake { + cookie: None, + pages_for: 1, + ..Fake::default() + }); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let started = Instant::now(); + let err = wait_for_session(&mut devtools, Duration::from_secs(30), &|_| Ok(()), &|| { + true + }) + .unwrap_err(); + assert!(err.to_string().contains("window was closed"), "{err}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "waited {:?}", + started.elapsed() + ); + browser.finish(); + let _ = std::fs::remove_dir_all(&profile); + } + + /// A browser that quits ends the wait too. + #[test] + fn a_browser_that_quits_ends_the_wait() { + let profile = temp_dir("quit"); + let browser = FakeBrowser::start(Fake { + cookie: None, + ..Fake::default() + }); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let err = wait_for_session(&mut devtools, Duration::from_secs(30), &|_| Ok(()), &|| { + false + }) + .unwrap_err(); + assert!(err.to_string().contains("browser exited"), "{err}"); + browser.finish(); + let _ = std::fs::remove_dir_all(&profile); + } + + /// Writes a stand-in browser executable into `dir`. + #[cfg(unix)] + fn fake_executable(dir: &Path, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("fake-browser.sh"); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + script + } + + /// Launch, endpoint, WebSocket, verify, close: one sign-in end to end, + /// with a stand-in executable in place of the browser. + #[cfg(unix)] + #[test] + fn a_sign_in_starts_the_browser_and_closes_it() { + let profile = temp_dir("launch"); + let server = FakeBrowser::start(Fake::default()); + + // Writes the port file where the browser would, then waits to be + // closed. + let path = fake_executable( + &profile, + &format!( + "for arg in \"$@\"; do\n\ + case \"$arg\" in --user-data-dir=*) dir=\"${{arg#--user-data-dir=}}\" ;; esac\n\ + done\n\ + printf '%s\\n%s\\n' {port} /devtools/browser/test > \"$dir/DevToolsActivePort\"\n\ + sleep 30\n", + port = server.port + ), + ); + let browser = Browser { + name: "fake".to_string(), + path, + }; + let started = Instant::now(); + browser + .open(&profile) + .unwrap() + .wait(Duration::from_secs(5), &AtomicBool::new(false), &|_| Ok(())) + .unwrap(); + assert_eq!( + server.finish().methods.last().map(String::as_str), + Some("Browser.close") + ); + // The stand-in sleeps for 30 s; the sign-in must not wait for it. + assert!( + started.elapsed() < Duration::from_secs(10), + "the sign-in waited {:?}", + started.elapsed() + ); + let _ = std::fs::remove_dir_all(&profile); + } + + /// Cancelling ends the wait and closes the browser, so a server that + /// is exiting leaves no window behind. + #[cfg(unix)] + #[test] + fn cancelling_closes_the_browser() { + let profile = temp_dir("cancel"); + let server = FakeBrowser::start(Fake { + cookie: None, + ..Fake::default() + }); + let path = fake_executable( + &profile, + &format!( + "for arg in \"$@\"; do\n\ + case \"$arg\" in --user-data-dir=*) dir=\"${{arg#--user-data-dir=}}\" ;; esac\n\ + done\n\ + printf '%s\\n%s\\n' {port} /devtools/browser/test > \"$dir/DevToolsActivePort\"\n\ + sleep 30\n", + port = server.port + ), + ); + let browser = Browser { + name: "fake".to_string(), + path, + }; + let window = browser.open(&profile).unwrap(); + let cancel = std::sync::Arc::new(AtomicBool::new(false)); + let waiter = { + let cancel = cancel.clone(); + std::thread::spawn(move || window.wait(Duration::from_secs(30), &cancel, &|_| Ok(()))) + }; + std::thread::sleep(Duration::from_millis(200)); + let started = Instant::now(); + cancel.store(true, Ordering::Relaxed); + assert!(waiter.join().unwrap().is_err()); + assert!( + started.elapsed() < Duration::from_secs(6), + "cancelling took {:?}", + started.elapsed() + ); + assert_eq!( + server.finish().methods.last().map(String::as_str), + Some("Browser.close") + ); + let _ = std::fs::remove_dir_all(&profile); + } + + /// The browser that opened a profile is named in it, and is the one used + /// again; one no longer installed is not. + #[cfg(unix)] + #[test] + fn a_profile_remembers_its_browser() { + let profile = temp_dir("made-by"); + let path = fake_executable(&profile, "exit 0\n"); + assert!(Browser::recorded(&profile).is_none()); + let browser = Browser { + name: "Named".to_string(), + path: path.clone(), + }; + // The launch records it even when the browser then goes away. + assert!(browser.open(&profile).is_err()); + let again = Browser::recorded(&profile).expect("the recorded browser"); + assert_eq!(again.path, path); + assert_eq!(again.name, "fake-browser"); + + std::fs::remove_file(&path).unwrap(); + assert!(Browser::recorded(&profile).is_none()); + let _ = std::fs::remove_dir_all(&profile); + } + + /// A browser that hands its window to one already running exits at + /// once; that is reported then, not after the start timeout. + #[cfg(unix)] + #[test] + fn a_browser_that_exits_at_launch_fails_fast() { + let profile = temp_dir("handoff"); + let browser = Browser { + name: "fake".to_string(), + path: fake_executable(&profile, "exit 0\n"), + }; + let started = Instant::now(); + let err = browser.open(&profile).err().expect("no window"); + assert!(err.to_string().contains("exited as it started"), "{err}"); + assert!(started.elapsed() < Duration::from_secs(5)); + let _ = std::fs::remove_dir_all(&profile); + } + + /// The real thing: launches the browser named by `IDX_STOCKBIT_BROWSER` + /// and checks the wait ends on the timeout, not on a broken connection, + /// which is what proves the browser answered `Storage.getCookies`. + /// + /// Ignored by default: it opens a browser window and loads Stockbit's + /// page, so it needs a machine with a browser and a network. + #[test] + #[ignore = "opens a real browser; set IDX_STOCKBIT_BROWSER"] + fn a_real_browser_answers_the_devtools_requests() { + let profile = temp_dir("real"); + let Ok(browser) = Browser::find(&profile) else { + eprintln!("IDX_STOCKBIT_BROWSER is not set; nothing to launch"); + return; + }; + let err = browser + .open(&profile) + .unwrap() + .wait(Duration::from_secs(25), &AtomicBool::new(false), &|_| { + panic!("a fresh profile holds no session to check") + }) + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("timed out"), "{message}"); + assert!( + !message.contains("last DevTools error"), + "DevTools never answered: {message}" + ); + let _ = std::fs::remove_dir_all(&profile); + } + + /// How a stand-in browser behaves. + struct Fake { + /// The session cookie's value, if it holds one. + cookie: Option, + /// `Target.getTargets` reports pages for this many calls, then none. + pages_for: usize, + /// The cookie survives `Network.deleteCookies`. + sticky: bool, + } + + impl Default for Fake { + fn default() -> Self { + Self { + cookie: Some(session_value()), + pages_for: usize::MAX, + sticky: false, + } + } + } + + /// What a stand-in browser was asked, and what it was left holding. + struct Log { + methods: Vec, + /// Tabs closed with `Target.closeTarget`. + closed: Vec, + cookie_left: bool, + } + + /// A stand-in for a browser's DevTools endpoint: one WebSocket, scripted + /// answers, and the methods it was asked for. + struct FakeBrowser { + port: u16, + handle: Option>, + } + + impl FakeBrowser { + fn start(fake: Fake) -> Self { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || serve(listener, fake)); + Self { + port, + handle: Some(handle), + } + } + + fn finish(mut self) -> Log { + self.handle.take().unwrap().join().unwrap() + } + } + + /// Stockbit's cookie lives host-only on `stockbit.com`, and, as in + /// Chromium, a delete must name that exact domain and path. + fn serve(listener: TcpListener, fake: Fake) -> Log { + const DOMAIN: &str = "stockbit.com"; + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let head = read_head(&mut stream).unwrap(); + assert!(head.contains("Upgrade: websocket"), "{head}"); + stream + .write_all(b"HTTP/1.1 101 Switching Protocols\r\n\r\n") + .unwrap(); + let mut cookie = fake.cookie.clone(); + let mut pages = vec!["T1".to_string()]; + let mut log = Log { + methods: Vec::new(), + closed: Vec::new(), + cookie_left: false, + }; + let mut target_calls = 0; + while let Ok(payload) = read_message(&mut stream) { + let request: Value = serde_json::from_slice(&payload).unwrap(); + let method = request["method"].as_str().unwrap_or_default().to_string(); + let params = &request["params"]; + log.methods.push(method.clone()); + if method == "Browser.close" { + let reply = json!({ "id": request["id"], "result": {} }); + let _ = stream.write_all(&server_frame(reply.to_string().as_bytes())); + break; + } + let result = match method.as_str() { + "Storage.getCookies" => { + let cookies: Vec = cookie + .iter() + .map(|value| { + json!({ "name": COOKIE, "domain": DOMAIN, "path": "/", "value": value }) + }) + .collect(); + json!({ "cookies": cookies }) + } + "Target.getTargets" => { + target_calls += 1; + let infos: Vec = if target_calls <= fake.pages_for { + pages + .iter() + .map(|id| json!({ "targetId": id, "type": "page", "url": SIGN_IN_URL })) + .collect() + } else { + Vec::new() + }; + json!({ "targetInfos": infos }) + } + "Target.createTarget" => { + pages.push("BLANK".to_string()); + json!({ "targetId": "BLANK" }) + } + "Target.closeTarget" => { + let id = params["targetId"].as_str().unwrap_or_default().to_string(); + pages.retain(|page| *page != id); + log.closed.push(id); + json!({ "success": true }) + } + "Target.attachToTarget" => json!({ "sessionId": "S1" }), + "Network.deleteCookies" => { + assert_eq!(request["sessionId"], "S1", "deleted from a tab"); + if !fake.sticky + && params["name"] == COOKIE + && params["domain"] == DOMAIN + && params["path"] == "/" + { + cookie = None; + } + json!({}) + } + _ => json!({}), + }; + let reply = json!({ "id": request["id"], "result": result }); + if stream + .write_all(&server_frame(reply.to_string().as_bytes())) + .is_err() + { + break; + } + } + log.cookie_left = cookie.is_some(); + log + } + + /// One unmasked server frame. + fn server_frame(payload: &[u8]) -> Vec { + let mut out = vec![0x81]; + match payload.len() { + len if len < 126 => out.push(len as u8), + len => { + out.push(126); + out.extend_from_slice(&(len as u16).to_be_bytes()); + } + } + out.extend_from_slice(payload); + out + } + + fn read_message(stream: &mut TcpStream) -> Result, IdxError> { + let mut buffer = Vec::new(); + loop { + let (fin, _, payload) = read_frame(stream)?; + buffer.extend_from_slice(&payload); + if fin { + return Ok(buffer); + } + } + } + + fn write_endpoint(profile: &Path, port: u16) { + std::fs::write( + profile.join("DevToolsActivePort"), + format!("{port}\n/devtools/browser/test\n"), + ) + .unwrap(); + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "idx-browser-{name}-{}-{}", + std::process::id(), + fastrand::u32(..) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } +} diff --git a/crates/idx-core/src/api/stockbit/corpaction.rs b/crates/idx-core/src/api/stockbit/corpaction.rs new file mode 100644 index 0000000..6679c8d --- /dev/null +++ b/crates/idx-core/src/api/stockbit/corpaction.rs @@ -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, 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) -> Option { + 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, +) -> Option { + 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) -> Option { + 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 `_`; the shortest name +/// wins should several end so. +fn suffixed<'a>(row: &'a Map, 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) -> Option { + let pair = |old: Option, new: Option| { + 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 { + 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 { + 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 { + 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 { + number(value).map(|n| n.round() as i64) +} + +fn count(value: Option<&Value>) -> Option { + 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::>().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::>().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"); + } +} diff --git a/crates/idx-core/src/api/stockbit/exodus.rs b/crates/idx-core/src/api/stockbit/exodus.rs new file mode 100644 index 0000000..a872162 --- /dev/null +++ b/crates/idx-core/src/api/stockbit/exodus.rs @@ -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::(&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 { + 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 { + 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, 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, 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 { + 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 { + static CACHE: Mutex)>> = 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 { + 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 { + 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 { + 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) -> Result { + 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 { + 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 { + serde_json::from_str::(body) + .ok()? + .get("error_type")? + .as_str() + .map(str::to_string) +} + +fn message(body: &str) -> Option { + serde_json::from_str::(body) + .ok()? + .get("message")? + .as_str() + .map(str::to_string) +} + +fn data(body: &str, what: &str) -> Result { + 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 { + value.and_then(number).map(|n| n.round() as i64) +} + +fn count(value: Option<&Value>) -> Option { + 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, +) -> Result { + 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 { + 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 { + 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, 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, 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, period: &Period) -> Vec { + 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| { + 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, "Request blocked."); + 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); + } +} diff --git a/crates/idx-core/src/api/stockbit/loopback.rs b/crates/idx-core/src/api/stockbit/loopback.rs new file mode 100644 index 0000000..f18e2f8 --- /dev/null +++ b/crates/idx-core/src/api/stockbit/loopback.rs @@ -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 { + 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(&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 :127.0.0.1: @`. +/// +/// 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(|| "".to_string()); + format!("ssh -L {port}:127.0.0.1:{port} {target}") +} + +fn ssh_target() -> Option { + 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::>() + .join("") +} + +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +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 { + 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::().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 { + 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#" + + + +Connect Stockbit to idx + +
+
+ +idxStockbit +
+ +
+

Connect your Stockbit account

+

idx then reads live quotes and the order book with your own session. The session stays on this machine.

+
+ +
+
    +
  1. + +
    +

    Sign in to Stockbit

    +

    Open stockbit.com in this browser and sign in. If the tab is already signed in, go to the next step.

    +Open stockbit.com +
    +
  2. +
  3. + +
    +

    Send the session to idx

    +

    While the Stockbit tab is open, click this button. It reads the session cookie in your own browser.

    +Send my Stockbit session +

    If the browser blocks it, drag the button to your bookmarks bar, open stockbit.com, then click it there.

    +
    +
  4. +
+ +
+Or paste the cookie by hand +
+
    +
  1. On stockbit.com, open DevTools with F12.
  2. +
  3. Go to Application, then Cookies, then https://stockbit.com.
  4. +
  5. Copy the value of credentialStorage and paste it below.
  6. +
+ +
+ + +
+
+
+ + +
+ + + +
+

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.

+

This page stops listening after a few minutes.

+
+
+ + +"#; + +#[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 ["