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 0219b1d..59e4281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -979,6 +979,7 @@ dependencies = [ name = "idx-core" version = "0.5.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 2613422..abd376d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ idx stocks history BBCA --period 3mo idx stocks technical BBCA idx stocks fundamental BBCA idx stocks compare BBCA BBRI BMRI +idx stocks intraday BBCA # today by the minute (Stockbit, delayed) idx -o json stocks quote BBCA # Ownership: download the maintained snapshot once, then query locally @@ -90,8 +91,16 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31 - Analysis: `technical`, `growth`, `valuation`, `risk`, and a combined `fundamental` report; multi-symbol `compare`; MSN stock `screen` - Providers: MSN is the default for quotes, fundamentals, and screening; - Yahoo serves history. Switch with `IDX_PROVIDER=msn|yahoo` and - `IDX_HISTORY_PROVIDER=auto|yahoo|msn` + 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%) @@ -158,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 14 tools: -`get_quotes`, `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 b064e35..00a31cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,16 +4,40 @@ Open work only. Shipped behavior is described in `README.md` and `docs/`; history lives in git. ## Stocks -- `market summary`: IHSG index and market breadth +- `market summary`: market breadth and an index overview (index levels alone + are already `stocks intraday ^JKSE` and `--provider stockbit stocks quote IHSG`) - `market movers`: top gainers, losers, and volume - `market sectors`: sector performance overview - `screen query ""`: filter stocks by expression - `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 (bid/offer depth): no free, login-free source exists. IDX sells - delayed depth to vendors and Stockbit's needs a user login. Revisit if a - permitted source appears; do not ship the closing best bid/offer as one. +- 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 + for anything but `open`. - Bandarmology follow-ups: a daily series (per-session readings and the cumulative top-N net) and a price-move phase (quiet accumulation, markup), calibration of the `top_concentration_v1` thresholds across more sessions, @@ -32,7 +56,8 @@ history lives in git. - `watchlist`: named symbol sets usable by other commands - `alerts`: price alerts with a daemon - CSV/TSV output -- Additional providers (Alpha Vantage, Twelve Data, IDX official) +- Additional providers (Alpha Vantage, Twelve Data); IDX's own site serves + only `stocks flow --source idx` today ## Agent integration - JSON Schema export for command output @@ -45,11 +70,16 @@ history lives in git. - `stocks flow --source idx` depends on IDX's Cloudflare passing a curl-impersonate profile (only `curl_safari184` in October 2026) and on the network; the default IPOT source rounds to one decimal. +- Stockbit's terms call its data delayed at least 10 minutes and for + personal, non-commercial use (on 2026-10-23 the last trade was within a + minute of the clock); its CDN refuses some user agents with a 403 + (`UPSTREAMBLOCKED`). The public page's best bid/offer sizes are not shown + yet; they are shares (equal to the session API's book on 2026-10-23). - Yahoo can return `429` from datacenter IPs; requests retry with backoff but can still fail. - `stocks technical` reports the SMA200 trend as unavailable when fewer than 200 daily candles are returned. -- MSN's `low-pe` screen returned no priced stocks on 2026-10-14 (upstream; - other filters work). +- MSN's `low-pe` screen returns no priced stocks (`PARSEERROR`; still so on + 2026-10-23, upstream; other filters work). - MSN history for IDX is price-only (`1mo`, `3mo`, `1y`); Yahoo stays the default OHLCV source. diff --git a/crates/idx-cli/src/cli/mod.rs b/crates/idx-cli/src/cli/mod.rs index 1ced634..59643e6 100644 --- a/crates/idx-cli/src/cli/mod.rs +++ b/crates/idx-cli/src/cli/mod.rs @@ -2,9 +2,11 @@ pub mod cache; pub mod config; #[cfg(feature = "ownership")] pub mod ownership; +pub mod stockbit; pub mod stocks; use clap::{Parser, Subcommand, ValueEnum}; +use idx_core::config::ProviderKind; use crate::output::OutputSetting; @@ -39,6 +41,13 @@ pub struct Cli { pub quiet: bool, #[arg(short, long, global = true, action = clap::ArgAction::Count, help = "Increase verbosity (-v, -vv)")] pub verbose: u8, + #[arg( + long, + value_enum, + global = true, + help = "Market data provider [default: general.provider, else msn]" + )] + pub provider: Option, #[arg(long, global = true, help = "Run without network requests")] pub offline: bool, #[arg(long, global = true, help = "Bypass local cache")] @@ -61,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 d27e576..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_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment, - render_technical, render_valuation, + 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(); @@ -126,13 +130,78 @@ pub struct StocksCmd { pub enum StocksSubcommand { #[command( about = "Get real-time stock quotes", - after_help = "Examples:\n idx stocks quote BBCA\n idx stocks quote BBCA,BBRI,BMRI\n idx -o json stocks quote BBCA\n echo BBCA BBRI | idx stocks quote -" + after_help = "Examples:\n idx stocks quote BBCA\n idx stocks quote BBCA,BBRI,BMRI\n idx -o json stocks quote BBCA\n echo BBCA BBRI | idx stocks quote -\n idx --provider stockbit stocks quote BBCA # with bid/offer while the market is open" )] Quote { /// One or more symbols, comma-separated or space-separated; `-` reads them from stdin. #[arg(required = true)] symbols: Vec, }, + #[command( + about = "Today's session price by the minute (Stockbit)", + long_about = "Today's session of one IDX stock or index, by the minute, from Stockbit's \ +public page (no login): the quote, the best bid/offer while the market is open, a sparkline, \ +and the last minutes. Delayed at least 10 minutes per Stockbit's terms; prices only, no \ +per-minute volume. A stock that did not trade has no minutes, which is not an error. \ +Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45).", + after_help = "Examples:\n idx stocks intraday BBCA\n idx stocks intraday ^JKSE\n idx -o json stocks intraday BBCA | jq '.points[-1]'" + )] + Intraday { + /// Single ticker symbol (e.g. BBCA). + symbol: String, + }, + #[command( + about = "Order book depth and foreign flow (Stockbit login)", + long_about = "The order book of one IDX stock from your own Stockbit session (`idx stockbit \ +login`): bid and offer levels with lots and queued orders, the session's OHLC, volume, value \ +and foreign buy, sell and net. Outside trading it is the book as the market left it, and \ +the output says so.", + after_help = "Examples:\n idx stocks orderbook BBCA\n idx stocks orderbook BBRI --depth 5\n idx -o json stocks orderbook TLKM | jq '.offers[0]'" + )] + Orderbook { + /// Single ticker symbol (e.g. BBCA). + symbol: String, + /// Price levels per side (1-50). + #[arg(long, default_value_t = 10)] + depth: usize, + }, + #[command( + about = "Dividends, rights, splits and other corporate actions of a stock (Stockbit login)", + long_about = "One IDX stock's corporate actions from your own Stockbit session (`idx \ +stockbit login`), newest first: cash and stock dividends, bonus shares, rights issues, \ +warrants, splits and reverse splits, shareholders' meetings, public exposes and tender offers \ +(`--kind ipo` adds its IPO). Stockbit serves one kind per request, 1 s apart, so the first \ +call takes about a second per kind; answers are cached for 6 hours.", + after_help = "Examples:\n idx stocks actions BBCA\n idx stocks actions TLKM --kind dividend\n idx -o json stocks actions BBRI --kind dividend,split | jq '.[0]'" + )] + Actions { + /// Single ticker symbol (e.g. BBCA). + symbol: String, + /// Kinds to list (default: all but ipo). + #[arg(long = "kind", value_enum, value_delimiter = ',', num_args = 1..)] + kinds: Vec, + }, + #[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" @@ -313,7 +382,43 @@ pub fn handle( StocksSubcommand::Quote { symbols } => { let quotes = idx.market().quotes(&expand_symbols(symbols)?, policy)?; let quotes: Vec<_> = quotes.into_iter().map(|q| q.data).collect(); - render_quotes("es, format, no_color) + render_quotes("es, config.core.provider, format, no_color) + } + StocksSubcommand::Intraday { symbol } => { + let day = idx.market().intraday(symbol, policy)?; + render_intraday(&day.data, format, no_color) + } + StocksSubcommand::Orderbook { symbol, depth } => { + if !(1..=MAX_BOOK_DEPTH).contains(depth) { + return Err(IdxError::InvalidInput(format!( + "--depth must be 1 to {MAX_BOOK_DEPTH} (got {depth})" + ))); + } + let mut book = idx.market().orderbook(symbol, policy)?.data; + book.bids.truncate(*depth); + book.offers.truncate(*depth); + render_orderbook(&book, format, no_color) + } + StocksSubcommand::Actions { symbol, kinds } => { + let kinds = if kinds.is_empty() { + ActionKind::STOCK_DEFAULT.to_vec() + } else { + kinds.clone() + }; + let actions = idx.corporate().actions(symbol, &kinds, policy)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.core.exchange)?; + render_corporate_actions(&resolved, &actions.data, format) + } + StocksSubcommand::Calendar { from, to, kinds } => { + let range = + CalendarRange::from_args(from.as_deref(), to.as_deref(), idx.corporate().today())?; + let kinds = if kinds.is_empty() { + ActionKind::ALL.to_vec() + } else { + kinds.clone() + }; + let actions = idx.corporate().calendar(range, &kinds, policy)?; + render_corporate_calendar(&range, &actions.data, format) } StocksSubcommand::History { symbol, @@ -321,7 +426,9 @@ pub fn handle( 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() @@ -333,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/config.rs b/crates/idx-cli/src/config.rs index a0ad8f3..69eac1f 100644 --- a/crates/idx-cli/src/config.rs +++ b/crates/idx-cli/src/config.rs @@ -34,6 +34,9 @@ impl IdxConfig { pub fn load_with_cli(cli: &Cli) -> Result { let mut core = CoreConfig::load()?; core.verbose = cli.verbose > 0; + if let Some(provider) = cli.provider { + core.provider = provider; + } let mut setting = OutputSetting::Auto; let mut no_color = false; 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 3429e04..4b7aad2 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -14,12 +14,15 @@ use idx_core::analysis::fundamental::{ }; use idx_core::analysis::report::TechnicalReport; use idx_core::api::types::{ - CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, - SentimentData, + CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc, + 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)] @@ -67,15 +70,88 @@ impl OutputFormat { pub fn render_quotes( quotes: &[Quote], + provider: ProviderKind, format: &OutputFormat, no_color: bool, ) -> Result<(), IdxError> { match format { + OutputFormat::Table if provider == ProviderKind::Stockbit => { + table::print_stockbit_quotes(quotes, no_color) + } OutputFormat::Table => table::print_quotes(quotes, no_color), OutputFormat::Json | OutputFormat::Jsonl => json::print_json(quotes), } } +pub fn render_intraday( + day: &Intraday, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_intraday(day, no_color), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(day), + } +} + +pub fn render_orderbook( + book: &OrderBook, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_orderbook(book, no_color), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(book), + } +} + +pub fn render_corporate_actions( + symbol: &str, + actions: &[CorporateAction], + format: &OutputFormat, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_corporate_actions(symbol, actions), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(actions), + } +} + +pub fn render_corporate_calendar( + range: &CalendarRange, + actions: &[CorporateAction], + format: &OutputFormat, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_corporate_calendar(range, actions), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(actions), + } +} + +/// JSON is `{movements, more}` (`more`: rows remain past the pages read), plus +/// `incomplete` when a later page failed and only the rows before it came back; +/// JSONL is one movement per line. +/// `pages` is how many pages the query asked for, for the hint when more +/// rows exist. JSONL streams the rows only, so the hint goes to stderr. +pub fn render_shareholder_movements( + symbol: Option<&str>, + list: &MovementList, + pages: u32, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_shareholder_movements(symbol, list, pages, no_color), + OutputFormat::Json => json::print_json(list), + OutputFormat::Jsonl => { + json::print_json(&list.movements)?; + if list.more { + idx_core::runtime::info(table::more_movements_hint(pages)); + } + Ok(()) + } + } +} + pub fn render_broker_summary( summary: &BrokerSummary, format: &OutputFormat, diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 4481b62..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, 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 { "" }; @@ -61,12 +65,7 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> { ]); for q in quotes { - let pct_cell = match q.change_pct { - None => cell("-"), - Some(pct) if no_color => cell(format!("{pct:+.2}%")), - Some(pct) if pct >= 0.0 => cell(format!("{pct:+.2}%")).fg(Color::Green), - Some(pct) => cell(format!("{pct:+.2}%")).fg(Color::Red), - }; + let pct_cell = pct_cell(q.change_pct, no_color); table.add_row(vec![ cell(&q.symbol), cell(format_idr(q.price)), @@ -121,6 +120,299 @@ fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime) -> Ve .collect() } +fn pct_cell(change_pct: Option, no_color: bool) -> Cell { + match change_pct { + None => cell("-"), + Some(pct) if no_color => cell(format!("{pct:+.2}%")), + Some(pct) if pct >= 0.0 => cell(format!("{pct:+.2}%")).fg(Color::Green), + Some(pct) => cell(format!("{pct:+.2}%")).fg(Color::Red), + } +} + +/// Printed under every Stockbit table: what the data is and is not. +const STOCKBIT_SOURCE_NOTE: &str = "Source: Stockbit's public page, delayed at least 10 \ +minutes (Stockbit's terms); bid/offer only while the market is open."; +/// Under `--provider stockbit` quotes, which may come from either source. +const STOCKBIT_QUOTE_NOTE: &str = "Source: Stockbit, your session when logged in (`idx \ +stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); \ +bid/offer only while the market is open."; +/// Under the order book, which comes from the user's own session. +const STOCKBIT_SESSION_NOTE: &str = + "Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms)."; + +fn opt_idr(value: Option) -> String { + value.map(format_idr).unwrap_or_else(|| "-".to_string()) +} + +fn wib_minute(time: chrono::DateTime) -> String { + time.format("%Y-%m-%d %H:%M").to_string() +} + +/// Quotes from `--provider stockbit`: Stockbit has no market cap or 52-week +/// range, but a best bid/offer and the market's state. +pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + "SYMBOL", "PRICE", "CHG", "CHG%", "VOLUME", "BID", "OFFER", "MARKET", "AS OF", + ]); + for q in quotes { + table.add_row(vec![ + cell(&q.symbol), + cell(format_idr(q.price)), + cell( + q.change + .map(|c| format!("{c:+}")) + .unwrap_or_else(|| "-".to_string()), + ), + pct_cell(q.change_pct, no_color), + cell(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())), + cell(opt_idr(q.bid)), + cell(opt_idr(q.offer)), + cell( + q.market_status + .as_deref() + .map(market_words) + .unwrap_or_else(|| "-".to_string()), + ), + cell(q.as_of.map(wib_minute).unwrap_or_else(|| "-".to_string())), + ]); + } + println!("{table}"); + for q in quotes { + if let Some(note) = &q.session_note { + println!( + "Note: {}: {}", + terminal_safe(&q.symbol), + terminal_safe(note) + ); + } + } + for note in stale_quote_notes(quotes, chrono::Utc::now()) { + println!("{}", terminal_safe(¬e)); + } + println!("{STOCKBIT_QUOTE_NOTE}"); + Ok(()) +} + +/// Minutes listed under the intraday chart. +const INTRADAY_TAIL: usize = 5; +/// Widest the intraday sparkline gets, in characters. +const SPARKLINE_WIDTH: usize = 60; + +pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> { + let q = &day.quote; + let change = match (q.change, q.change_pct) { + (Some(c), Some(p)) => format!(" {c:+} ({p:+.2}%)"), + (Some(c), None) => format!(" {c:+}"), + _ => String::new(), + }; + let change = match q.change { + _ if no_color || change.is_empty() => change, + Some(c) if c >= 0 => change.green(), + _ => change.red(), + }; + println!( + "{}{change}", + format!("{} {}", terminal_safe(&q.symbol), format_idr(q.price)).bold() + ); + let mut facts = vec![ + format!("prev close {}", opt_idr(q.prev_close)), + format!( + "volume {}", + q.volume.map(format_u64).unwrap_or_else(|| "-".to_string()) + ), + format!( + "market {}", + terminal_safe(q.market_status.as_deref().unwrap_or("unknown")) + ), + ]; + if let Some(as_of) = q.as_of { + facts.push(format!("last trade {} WIB", wib_minute(as_of))); + } + println!("{}", facts.join(" · ")); + if q.bid.is_some() || q.offer.is_some() { + println!("bid {} · offer {}", opt_idr(q.bid), opt_idr(q.offer)); + } + if let Some(note) = &q.session_note { + println!("Note: {}", terminal_safe(note)); + } + for note in stale_quote_notes(std::slice::from_ref(q), chrono::Utc::now()) { + println!("{}", terminal_safe(¬e)); + } + + let prices: Vec = day.points.iter().map(|p| p.price).collect(); + if let (Some(first), Some(last)) = (day.points.first(), day.points.last()) { + let (low, high) = prices + .iter() + .fold((f64::MAX, f64::MIN), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + println!(); + println!("{}", sparkline(&prices, SPARKLINE_WIDTH)); + println!( + "{} to {} WIB · low {} · high {} · {} minutes", + first.time.format("%H:%M"), + last.time.format("%H:%M"), + format_price(low), + format_price(high), + day.points.len() + ); + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec!["TIME", "PRICE"]); + let start = day.points.len().saturating_sub(INTRADAY_TAIL); + for point in &day.points[start..] { + table.add_row(vec![ + cell(point.time.format("%H:%M")), + cell(format_price(point.price)), + ]); + } + println!("{table}"); + } else if q.session_note.is_none() { + println!("No trades this session."); + } + println!("{STOCKBIT_SOURCE_NOTE}"); + Ok(()) +} + +/// Stockbit's market state for people: `STATE_NAME_MARKET_CLOSED` reads +/// `closed`, `STATE_NAME_BREAK` `break`, `STATE_NAME_SESSION_2` `session 2`. +fn market_words(state: &str) -> String { + state + .strip_prefix("STATE_NAME_MARKET_") + .or_else(|| state.strip_prefix("STATE_NAME_")) + .unwrap_or(state) + .replace('_', " ") + .to_lowercase() +} + +pub fn print_orderbook(book: &OrderBook, no_color: bool) -> Result<(), IdxError> { + let change = match (book.change, book.change_pct) { + (Some(c), Some(p)) => format!(" {c:+} ({p:+.2}%)"), + (Some(c), None) => format!(" {c:+}"), + _ => String::new(), + }; + let change = match book.change { + _ if no_color || change.is_empty() => change, + Some(c) if c >= 0 => change.green(), + _ => change.red(), + }; + let name = book + .name + .as_deref() + .map(|n| format!(" {}", terminal_safe(n))) + .unwrap_or_default(); + println!( + "{}{change}", + format!( + "{}{name} {}", + terminal_safe(&book.symbol), + format_idr(book.price) + ) + .bold() + ); + let count = |v: Option| v.map(format_u64).unwrap_or_else(|| "-".to_string()); + println!( + "prev close {} · open {} · high {} · low {} · volume {} · value {} · {} trades", + opt_idr(book.prev_close), + opt_idr(book.open), + opt_idr(book.high), + opt_idr(book.low), + count(book.volume), + count(book.value), + count(book.frequency) + ); + if book.foreign_buy.is_some() || book.foreign_sell.is_some() { + println!( + "foreign buy {} · sell {} · net {}", + opt_idr(book.foreign_buy), + opt_idr(book.foreign_sell), + opt_idr(book.foreign_net) + ); + } + match book.market_status.as_deref() { + Some(state) if idx_core::api::stockbit::exodus::is_trading(state) => { + println!("market open ({})", terminal_safe(&market_words(state))) + } + Some(state) => println!( + "market {}: the book as the market left it", + terminal_safe(&market_words(state)) + ), + None => println!("market state unknown: the book may not be live"), + } + if let Some(note) = &book.session_note { + println!("Note: {}", terminal_safe(note)); + } + if book.bids.is_empty() && book.offers.is_empty() { + println!("No bids or offers."); + } else { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec!["ORDERS", "LOTS", "BID", "OFFER", "LOTS", "ORDERS"]); + let side = |level: Option<&BookLevel>| match level { + Some(l) => [ + format_u64(l.orders), + format_u64(l.shares / 100), + format_idr(l.price), + ], + None => ["".to_string(), "".to_string(), "".to_string()], + }; + for i in 0..book.bids.len().max(book.offers.len()) { + let [bid_orders, bid_lots, bid] = side(book.bids.get(i)); + let [offer_orders, offer_lots, offer] = side(book.offers.get(i)); + table.add_row(vec![ + cell(bid_orders), + cell(bid_lots), + cell(bid), + cell(offer), + cell(offer_lots), + cell(offer_orders), + ]); + } + println!("{table}"); + } + println!("{STOCKBIT_SESSION_NOTE}"); + Ok(()) +} + +/// Whole Rupiah for stocks, two decimals for an index level. +fn format_price(price: f64) -> String { + let cents = (price * 100.0).round() as i64; + if cents % 100 == 0 { + format_idr(cents / 100) + } else { + format!("{}.{:02}", format_idr(cents / 100), (cents % 100).abs()) + } +} + +/// One block per bucket of points (the bucket's last price), scaled from the +/// lowest to the highest price. +fn sparkline(prices: &[f64], width: usize) -> String { + const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let width = width.min(prices.len()); + if width == 0 { + return String::new(); + } + let (low, high) = prices + .iter() + .fold((f64::MAX, f64::MIN), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + (1..=width) + .map(|i| { + let price = prices[i * prices.len() / width - 1]; + if high > low { + BARS[((price - low) / (high - low) * 7.0).round() as usize] + } else { + BARS[3] + } + }) + .collect() +} + pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> { println!( "{}", @@ -1385,15 +1677,382 @@ outside the regular board (negotiated and cash)." ); } +fn action_kind_label(kind: ActionKind) -> &'static str { + match kind { + ActionKind::Dividend => "dividend", + ActionKind::StockDividend => "stock dividend", + ActionKind::Bonus => "bonus", + ActionKind::Rights => "rights", + ActionKind::Warrant => "warrant", + ActionKind::Split => "split", + ActionKind::ReverseSplit => "reverse split", + ActionKind::Meeting => "meeting", + ActionKind::PublicExpose => "public expose", + ActionKind::Ipo => "IPO", + ActionKind::TenderOffer => "tender offer", + } +} + +/// IDR with thousands separators and up to two decimals: `1,191.09`, `3.8`. +fn format_rupiah(value: f64) -> String { + let cents = (value * 100.0).round() as i64; + let (whole, frac) = (cents / 100, (cents % 100).abs()); + match frac { + 0 => format_idr(whole), + f if f % 10 == 0 => format!("{}.{}", format_idr(whole), f / 10), + f => format!("{}.{f:02}", format_idr(whole)), + } +} + +/// Another currency's amount with up to six decimals, since a dollar +/// dividend can be a fraction of a cent: `0.0012`, `1,250.5`. +fn format_foreign_amount(value: f64) -> String { + let micros = (value.abs() * 1_000_000.0).round() as u64; + let (whole, frac) = (micros / 1_000_000, micros % 1_000_000); + let sign = if value < 0.0 && micros > 0 { "-" } else { "" }; + let whole = format_u64(whole); + if frac == 0 { + return format!("{sign}{whole}"); + } + let frac = format!("{frac:06}"); + format!("{sign}{whole}.{}", frac.trim_end_matches('0')) +} + +/// What an action gives or asks, in one line. +fn action_detail(action: &CorporateAction) -> String { + let span = |range: Option| 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(), @@ -1410,6 +2069,10 @@ mod tests { avg_volume: None, as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()), behind_session: false, + bid: None, + offer: None, + market_status: None, + session_note: None, } } diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index a74683e..b335075 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2165,6 +2165,177 @@ fn invalid_provider_env_honors_json_output() { ); } +#[test] +fn provider_flag_overrides_env() { + let quotes = run_success_json( + test_bin("provider-flag") + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args([ + "-o", + "json", + "--provider", + "stockbit", + "stocks", + "quote", + "BBCA", + ]), + ); + // Only Stockbit reports the market's state; MSN has a market cap. + assert_eq!(quotes[0]["market_status"], "close"); + assert_eq!(quotes[0]["market_cap"], Value::Null); +} + +#[test] +fn stockbit_login_stores_a_private_session_and_logout_removes_it() { + let root = test_env_dir("stockbit-login"); + let cookie = fs::read_to_string(fixture_path( + "stockbit_exodus_credential_storage_cookie_constructed.txt", + )) + .expect("cookie fixture"); + let session = root.join("config/idx/stockbit-session.json"); + + let status = run_success_json( + bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stockbit", "login"]) + .write_stdin(cookie.clone()), + ); + assert_eq!(status["logged_in"], true); + assert_eq!(status["refresh"]["present"], true); + let stored = fs::read_to_string(&session).expect("session file"); + assert!(stored.contains("\"refresh\"")); + // Status shows expiries, never a token. + assert!(!serde_json::to_string(&status).unwrap().contains("eyJ")); + #[cfg(unix)] + assert_eq!( + fs::metadata(&session).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + let book = run_success_json( + bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stocks", "orderbook", "BBCA"]), + ); + assert_eq!(book["offers"][0]["shares"], 264_462_500); + + let out = run_success_json( + bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stockbit", "logout"]), + ); + assert_eq!(out["removed"], true); + assert!(!session.exists()); + run_error_json( + bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + // The book fetched above is still cached for a few seconds. + .args(["-o", "json", "--no-cache", "stocks", "orderbook", "BBCA"]), + "AUTHERROR", + ); + + // Anything that is not a session is refused and stores nothing. + run_error_json( + bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stockbit", "login"]) + .write_stdin("not a token"), + "INVALIDINPUT", + ); + assert!(!session.exists()); +} + +/// A session file Stockbit no longer accepts does not break `auto` history: +/// it falls back to Yahoo with a warning. An explicit Stockbit choice fails. +#[test] +fn auto_history_falls_back_to_yahoo_when_the_session_is_refused() { + for args in [ + &["stocks", "history", "BBCA", "--period", "1mo"][..], + &["stocks", "technical", "BBCA"][..], + ] { + let root = test_env_dir(&format!("stockbit-refused-{}", args[1])); + let output = mock_bin_with_root(&root) + .env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN) + .env("IDX_MOCK_STOCKBIT_STATUS", "401") + .args(["-o", "json", "--provider", "stockbit"]) + .args(args) + .output() + .expect("run history"); + assert!(output.status.success(), "{args:?}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("falls back to yahoo"), "{stderr}"); + serde_json::from_slice::(&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") @@ -3171,3 +3342,24 @@ fn bandar_never_reads_a_broker_summary_cached_in_the_old_shape() { assert!(!table.contains("No trades."), "{table}"); assert!(table.contains("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 2cb5311..af7eced 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -244,6 +244,71 @@ name = "yahoo-fundamental" args = ["stocks", "fundamental", "BBCA"] env = { IDX_PROVIDER = "yahoo" } +# --- stocks: Stockbit (intraday, and the opt-in quote provider) ----------------- + +[[case]] +name = "stocks-intraday" +args = ["stocks", "intraday", "BBCA"] + +[[case]] +name = "stocks-intraday-index" +args = ["stocks", "intraday", "^JKSE"] +formats = ["table"] + +[[case]] +name = "stocks-intraday-suspended" +args = ["stocks", "intraday", "MABA"] + +[[case]] +name = "stockbit-quote" +args = ["--provider", "stockbit", "stocks", "quote", "BBCA,MABA"] + +# With a session: IDX_STOCKBIT_REFRESH_TOKEN seeds an unsigned test token that +# expires in 2100; the mock serves the recorded exodus responses. + +[[case]] +name = "stockbit-session-orderbook" +args = ["stocks", "orderbook", "BBCA", "--depth", "3"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "stockbit-session-quote" +args = ["--provider", "stockbit", "stocks", "quote", "BBCA"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "stockbit-session-history" +args = ["--provider", "stockbit", "stocks", "history", "BBCA", "--period", "1mo"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "stockbit-status-logged-out" +args = ["stockbit", "status"] + +# Corporate actions: BBCA's recorded dividends, splits, meetings and exposes +# (no fixture for a kind means none, as Stockbit answers); the calendar reads +# the market-wide recordings of 2026-11-03. + +[[case]] +name = "stockbit-actions" +args = ["stocks", "actions", "BBCA", "--kind", "split,meeting,expose"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "stockbit-actions-dividends" +args = ["stocks", "actions", "BBCA", "--kind", "dividend"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "stockbit-calendar" +args = ["stocks", "calendar", "--to", "2026-11-12", "--kind", "dividend,bonus,rights,warrant,split,ipo,tender"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", IDX_MOCK_TODAY = "2026-11-03" } + +[[case]] +name = "stockbit-calendar-meetings" +args = ["stocks", "calendar", "--from", "2026-11-05", "--to", "2026-11-06", "--kind", "meeting,expose"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", IDX_MOCK_TODAY = "2026-11-03" } + # --- stocks: broker summaries (IPOT) and foreign flow (IDX) -------------------- [[case]] @@ -334,8 +399,63 @@ env = { IDX_MOCK_ERROR = "1" } name = "error-fetch-bing-unsupported" args = ["ownership", "import", "--fetch-bing", "BBCA"] +[[case]] +name = "error-intraday-blocked" +args = ["stocks", "intraday", "BBCA"] +env = { IDX_MOCK_STOCKBIT_STATUS = "403" } + +[[case]] +name = "error-intraday-unknown-symbol" +args = ["stocks", "intraday", "ZZZZ"] + +[[case]] +name = "error-stockbit-fundamental-unsupported" +args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"] + +[[case]] +name = "error-orderbook-no-session" +args = ["stocks", "orderbook", "BBCA"] + +[[case]] +name = "error-actions-no-session" +args = ["stocks", "actions", "BBCA"] + +[[case]] +name = "error-movements-no-session" +args = ["ownership", "movements", "BBCA"] + +[[case]] +name = "error-movements-holder" +args = ["ownership", "movements", "--holder", "RUDI"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "error-calendar-range" +args = ["stocks", "calendar", "--from", "2026-11-10", "--to", "2026-11-01"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", IDX_MOCK_TODAY = "2026-11-03" } + +[[case]] +name = "error-stockbit-history-window" +args = ["stocks", "history", "BBCA", "--period", "5y", "--history-provider", "stockbit"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + # --- ownership -------------------------------------------------------------------- +[[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 aff5968..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" ;; @@ -204,9 +228,15 @@ _idx() { idx__help__stocks,insights) cmd="idx__help__stocks__insights" ;; + idx__help__stocks,intraday) + cmd="idx__help__stocks__intraday" + ;; 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" ;; @@ -255,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" ;; @@ -297,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" ;; @@ -345,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" ;; @@ -378,9 +444,15 @@ _idx() { idx__stocks,insights) cmd="idx__stocks__insights" ;; + idx__stocks,intraday) + cmd="idx__stocks__intraday" + ;; idx__stocks,news) cmd="idx__stocks__news" ;; + idx__stocks,orderbook) + cmd="idx__stocks__orderbook" + ;; idx__stocks,profile) cmd="idx__stocks__profile" ;; @@ -402,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" ;; @@ -435,9 +513,15 @@ _idx() { idx__stocks__help,insights) cmd="idx__stocks__help__insights" ;; + idx__stocks__help,intraday) + cmd="idx__stocks__help__intraday" + ;; 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" ;; @@ -466,7 +550,7 @@ _idx() { case "${cmd}" in idx) - opts="-o -q -v -V -h --output --no-color --quiet --verbose --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 @@ -480,6 +564,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -488,7 +576,7 @@ _idx() { return 0 ;; idx__cache) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help info clear help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help info clear help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -502,6 +590,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -510,7 +602,7 @@ _idx() { return 0 ;; idx__cache__clear) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -524,6 +616,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -588,7 +684,7 @@ _idx() { return 0 ;; idx__cache__info) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -602,6 +698,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -610,7 +710,7 @@ _idx() { return 0 ;; idx__completions) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help bash zsh fish" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help bash zsh fish" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -624,6 +724,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -632,7 +736,7 @@ _idx() { return 0 ;; idx__config) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help init get set path help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help init get set path help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -646,6 +750,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -654,7 +762,7 @@ _idx() { return 0 ;; idx__config__get) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -668,6 +776,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -760,7 +872,7 @@ _idx() { return 0 ;; idx__config__init) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -774,6 +886,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -782,7 +898,7 @@ _idx() { return 0 ;; idx__config__path) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -796,6 +912,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -804,7 +924,7 @@ _idx() { return 0 ;; idx__config__set) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -818,6 +938,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -826,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 @@ -980,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 @@ -1105,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 @@ -1217,8 +1355,8 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - idx__help__stocks) - opts="quote 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 @@ -1231,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 @@ -1259,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 @@ -1371,6 +1593,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stocks__intraday) + 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__news) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -1385,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 @@ -1498,7 +1748,7 @@ _idx() { return 0 ;; idx__ownership) - opts="-o -q -v -h --output --no-color --quiet --verbose --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 @@ -1512,6 +1762,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -1520,7 +1774,7 @@ _idx() { return 0 ;; idx__ownership__changes) - opts="-o -q -v -h --from --to --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --from --to --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1542,6 +1796,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -1550,7 +1808,7 @@ _idx() { return 0 ;; idx__ownership__concentration) - opts="-o -q -v -h --by --top --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --by --top --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1572,6 +1830,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -1580,7 +1842,7 @@ _idx() { return 0 ;; idx__ownership__cross__holders) - opts="-o -q -v -h --top --min-tickers --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --top --min-tickers --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1602,6 +1864,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -1610,7 +1876,7 @@ _idx() { return 0 ;; idx__ownership__discover) - opts="-o -q -v -h --family --limit --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --family --limit --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1632,6 +1898,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -1640,7 +1910,7 @@ _idx() { return 0 ;; idx__ownership__entity) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1654,6 +1924,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -1662,7 +1936,7 @@ _idx() { return 0 ;; idx__ownership__flow) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1676,6 +1950,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -1684,7 +1962,7 @@ _idx() { return 0 ;; idx__ownership__graph) - opts="-o -q -v -h --depth --format --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --depth --format --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1706,6 +1984,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -1714,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 @@ -1853,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 @@ -1966,7 +2262,7 @@ _idx() { return 0 ;; idx__ownership__import) - opts="-o -q -v -h --url --file --fetch-bing --force --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --url --file --fetch-bing --force --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1992,6 +2288,60 @@ _idx() { 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__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=() ;; @@ -2000,7 +2350,7 @@ _idx() { return 0 ;; idx__ownership__releases) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2014,6 +2364,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2022,7 +2376,7 @@ _idx() { return 0 ;; idx__ownership__resolve) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help unresolved map merge help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help unresolved map merge help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2036,6 +2390,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2114,7 +2472,7 @@ _idx() { return 0 ;; idx__ownership__resolve__map) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2128,6 +2486,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2136,7 +2498,7 @@ _idx() { return 0 ;; idx__ownership__resolve__merge) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2150,6 +2512,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2158,7 +2524,7 @@ _idx() { return 0 ;; idx__ownership__resolve__unresolved) - opts="-o -q -v -h --limit --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --limit --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2176,6 +2542,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2184,7 +2554,7 @@ _idx() { return 0 ;; idx__ownership__search) - opts="-o -q -v -h --limit --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --limit --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2202,6 +2572,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2210,7 +2584,7 @@ _idx() { return 0 ;; idx__ownership__sync) - opts="-o -q -v -h --manifest --force --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --manifest --force --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2228,6 +2602,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2236,7 +2614,7 @@ _idx() { return 0 ;; idx__ownership__ticker) - opts="-o -q -v -h --source --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --source --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2254,6 +2632,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2261,8 +2643,8 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - idx__stocks) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote 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 @@ -2276,6 +2658,218 @@ _idx() { 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__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=() ;; @@ -2284,7 +2878,7 @@ _idx() { return 0 ;; idx__stocks__bandar) - opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --offline --no-cache --help ..." + 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 COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2322,6 +2916,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2330,7 +2928,7 @@ _idx() { return 0 ;; idx__stocks__broker) - opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2364,6 +2962,48 @@ _idx() { 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__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=() ;; @@ -2372,7 +3012,7 @@ _idx() { return 0 ;; idx__stocks__compare) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help ..." + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help ..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2386,6 +3026,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2394,7 +3038,7 @@ _idx() { return 0 ;; idx__stocks__earnings) - opts="-o -q -v -h --forecast --history --annual --quarterly --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --forecast --history --annual --quarterly --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2408,6 +3052,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2416,7 +3064,7 @@ _idx() { return 0 ;; idx__stocks__financials) - opts="-o -q -v -h --statement --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --statement --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2434,6 +3082,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2442,7 +3094,7 @@ _idx() { return 0 ;; idx__stocks__flow) - opts="-o -q -v -h --days --from --to --source --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --days --from --to --source --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2472,6 +3124,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2480,7 +3136,7 @@ _idx() { return 0 ;; idx__stocks__fundamental) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2494,6 +3150,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2502,7 +3162,7 @@ _idx() { return 0 ;; idx__stocks__growth) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2516,6 +3176,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2524,7 +3188,7 @@ _idx() { return 0 ;; idx__stocks__help) - opts="quote 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 @@ -2537,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 @@ -2565,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 @@ -2691,6 +3383,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__intraday) + 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__news) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2705,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 @@ -2804,7 +3524,7 @@ _idx() { return 0 ;; idx__stocks__history) - opts="-o -q -v -h --period --interval --history-provider --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --period --interval --history-provider --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2819,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) @@ -2830,6 +3550,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2838,7 +3562,7 @@ _idx() { return 0 ;; idx__stocks__insights) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2852,6 +3576,36 @@ _idx() { 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__intraday) + 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=() ;; @@ -2860,7 +3614,7 @@ _idx() { return 0 ;; idx__stocks__news) - opts="-o -q -v -h --limit --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --limit --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2878,6 +3632,40 @@ _idx() { 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__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=() ;; @@ -2886,7 +3674,7 @@ _idx() { return 0 ;; idx__stocks__profile) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2900,6 +3688,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2908,7 +3700,7 @@ _idx() { return 0 ;; idx__stocks__quote) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help ..." + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help ..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2922,6 +3714,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2930,7 +3726,7 @@ _idx() { return 0 ;; idx__stocks__risk) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2944,6 +3740,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2952,7 +3752,7 @@ _idx() { return 0 ;; idx__stocks__screen) - opts="-o -q -v -h --filter --region --limit --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --filter --region --limit --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2978,6 +3778,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -2986,7 +3790,7 @@ _idx() { return 0 ;; idx__stocks__sentiment) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3000,6 +3804,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -3008,14 +3816,14 @@ _idx() { return 0 ;; idx__stocks__technical) - opts="-o -q -v -h --history-provider --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --history-provider --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 --history-provider) - COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}")) + COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}")) return 0 ;; --output) @@ -3026,6 +3834,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -3034,7 +3846,7 @@ _idx() { return 0 ;; idx__stocks__valuation) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3048,6 +3860,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -3056,7 +3872,7 @@ _idx() { return 0 ;; idx__version) - opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3070,6 +3886,10 @@ _idx() { COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; 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 new file mode 100644 index 0000000..7554cf0 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden @@ -0,0 +1,4 @@ +exit: 4 +--- stdout +--- stderr +{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"} diff --git a/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden b/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden new file mode 100644 index 0000000..d7b4759 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden @@ -0,0 +1,4 @@ +exit: 4 +--- stdout +--- stderr +Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit diff --git a/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.json.golden b/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.json.golden new file mode 100644 index 0000000..33601b3 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.json.golden @@ -0,0 +1,4 @@ +exit: 3 +--- stdout +--- stderr +{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: ZZZZ.JK"} diff --git a/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.table.golden b/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.table.golden new file mode 100644 index 0000000..0673de7 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.table.golden @@ -0,0 +1,4 @@ +exit: 3 +--- stdout +--- stderr +Error: symbol not found: ZZZZ.JK diff --git a/crates/idx-cli/tests/golden/error-invalid-provider.json.golden b/crates/idx-cli/tests/golden/error-invalid-provider.json.golden index 7e9cef0..02963d8 100644 --- a/crates/idx-cli/tests/golden/error-invalid-provider.json.golden +++ b/crates/idx-cli/tests/golden/error-invalid-provider.json.golden @@ -1,4 +1,4 @@ exit: 2 --- stdout --- stderr -{"code":"CONFIGERROR","error":true,"message":"config error: invalid provider 'bogus' (expected yahoo or msn)"} +{"code":"CONFIGERROR","error":true,"message":"config error: invalid provider 'bogus' (expected yahoo, msn, or stockbit)"} diff --git a/crates/idx-cli/tests/golden/error-invalid-provider.table.golden b/crates/idx-cli/tests/golden/error-invalid-provider.table.golden index 37464ed..3be5d0a 100644 --- a/crates/idx-cli/tests/golden/error-invalid-provider.table.golden +++ b/crates/idx-cli/tests/golden/error-invalid-provider.table.golden @@ -1,4 +1,4 @@ exit: 2 --- stdout --- stderr -Error: config error: invalid provider 'bogus' (expected yahoo or msn) +Error: config error: invalid provider 'bogus' (expected yahoo, msn, or 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-fundamental-unsupported.json.golden b/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.json.golden new file mode 100644 index 0000000..85f90cb --- /dev/null +++ b/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.json.golden @@ -0,0 +1,4 @@ +exit: 5 +--- stdout +--- stderr +{"code":"UNSUPPORTED","error":true,"message":"unsupported: BBCA.JK: Stockbit has no fundamentals; command requires --provider msn or yahoo"} diff --git a/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.table.golden b/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.table.golden new file mode 100644 index 0000000..905987e --- /dev/null +++ b/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.table.golden @@ -0,0 +1,4 @@ +exit: 5 +--- stdout +--- stderr +Error: unsupported: BBCA.JK: Stockbit has no fundamentals; command requires --provider msn or yahoo 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-cache.golden b/crates/idx-cli/tests/golden/help-cache.golden index 27f05b0..95c3001 100644 --- a/crates/idx-cli/tests/golden/help-cache.golden +++ b/crates/idx-cli/tests/golden/help-cache.golden @@ -10,11 +10,12 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help --- stderr diff --git a/crates/idx-cli/tests/golden/help-config.golden b/crates/idx-cli/tests/golden/help-config.golden index 845a21d..305e163 100644 --- a/crates/idx-cli/tests/golden/help-config.golden +++ b/crates/idx-cli/tests/golden/help-config.golden @@ -28,6 +28,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests diff --git a/crates/idx-cli/tests/golden/help-ownership.golden b/crates/idx-cli/tests/golden/help-ownership.golden index a7e4741..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 @@ -40,6 +41,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests diff --git a/crates/idx-cli/tests/golden/help-root.golden b/crates/idx-cli/tests/golden/help-root.golden index 2814cde..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) @@ -29,6 +30,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests diff --git a/crates/idx-cli/tests/golden/help-stocks.golden b/crates/idx-cli/tests/golden/help-stocks.golden index 368e4a9..e004151 100644 --- a/crates/idx-cli/tests/golden/help-stocks.golden +++ b/crates/idx-cli/tests/golden/help-stocks.golden @@ -6,6 +6,10 @@ 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 @@ -26,11 +30,12 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help --- stderr 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.json.golden b/crates/idx-cli/tests/golden/stockbit-quote.json.golden new file mode 100644 index 0000000..5f33145 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-quote.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"symbol":"BBCA.JK","price":5175,"change":-100,"change_pct":-1.9,"volume":286928500,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":5275,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close"},{"symbol":"MABA.JK","price":50,"change":0,"change_pct":0.0,"volume":0,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":50,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close","session_note":"Stockbit lists the stock as suspended"}] +--- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-quote.table.golden b/crates/idx-cli/tests/golden/stockbit-quote.table.golden new file mode 100644 index 0000000..2dff9e7 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-quote.table.golden @@ -0,0 +1,12 @@ +exit: 0 +--- stdout +┌─────────┬───────┬──────┬────────┬─────────────┬─────┬───────┬────────┬───────┐ +│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ BID ┆ OFFER ┆ MARKET ┆ AS OF │ +╞═════════╪═══════╪══════╪════════╪═════════════╪═════╪═══════╪════════╪═══════╡ +│ BBCA.JK ┆ 5,175 ┆ -100 ┆ -1.90% ┆ 286,928,500 ┆ - ┆ - ┆ close ┆ - │ +├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │ +└─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘ +Note: MABA.JK: Stockbit lists the stock as suspended +Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. +--- stderr 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-cli/tests/golden/stocks-intraday-index.table.golden b/crates/idx-cli/tests/golden/stocks-intraday-index.table.golden new file mode 100644 index 0000000..173b7c5 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-intraday-index.table.golden @@ -0,0 +1,23 @@ +exit: 0 +--- stdout +^JKSE.JK 5,388 +60 (+1.12%) +prev close 5,328 · volume 36,446,806,700 · market close · last trade 2026-10-22 16:14 WIB +Note: ^JKSE.JK last traded on 2026-10-22; this price is not current. + +▃▄▄▆▆▆▆▆▆▇▇▇▇▇████▇▇▇▇███▇▇▇▇███▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ +08:58 to 16:14 WIB · low 5,192.41 · high 5,419.34 · 335 minutes +┌───────┬──────────┐ +│ TIME ┆ PRICE │ +╞═══════╪══════════╡ +│ 16:10 ┆ 5,387.90 │ +├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤ +│ 16:11 ┆ 5,387.90 │ +├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤ +│ 16:12 ┆ 5,387.90 │ +├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤ +│ 16:13 ┆ 5,387.90 │ +├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤ +│ 16:14 ┆ 5,387.90 │ +└───────┴──────────┘ +Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-intraday-suspended.json.golden b/crates/idx-cli/tests/golden/stocks-intraday-suspended.json.golden new file mode 100644 index 0000000..f29ffce --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-intraday-suspended.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"quote":{"symbol":"MABA.JK","price":50,"change":0,"change_pct":0.0,"volume":0,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":50,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close","session_note":"Stockbit lists the stock as suspended"},"points":[]} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-intraday-suspended.table.golden b/crates/idx-cli/tests/golden/stocks-intraday-suspended.table.golden new file mode 100644 index 0000000..bfb97a3 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-intraday-suspended.table.golden @@ -0,0 +1,7 @@ +exit: 0 +--- stdout +MABA.JK 50 +0 (+0.00%) +prev close 50 · volume 0 · market close +Note: Stockbit lists the stock as suspended +Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-intraday.json.golden b/crates/idx-cli/tests/golden/stocks-intraday.json.golden new file mode 100644 index 0000000..40d7b72 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-intraday.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"quote":{"symbol":"BBCA.JK","price":5175,"change":-100,"change_pct":-1.9,"volume":286928500,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":5275,"avg_volume":null,"as_of":"2026-10-22T16:14:00+07:00","behind_session":true,"market_status":"close"},"points":[{"time":"2026-10-22T08:58:00+07:00","price":5125.0},{"time":"2026-10-22T09:00:00+07:00","price":5125.0},{"time":"2026-10-22T09:01:00+07:00","price":5175.0},{"time":"2026-10-22T09:02:00+07:00","price":5150.0},{"time":"2026-10-22T09:03:00+07:00","price":5175.0},{"time":"2026-10-22T09:04:00+07:00","price":5175.0},{"time":"2026-10-22T09:05:00+07:00","price":5175.0},{"time":"2026-10-22T09:06:00+07:00","price":5175.0},{"time":"2026-10-22T09:07:00+07:00","price":5200.0},{"time":"2026-10-22T09:08:00+07:00","price":5175.0},{"time":"2026-10-22T09:09:00+07:00","price":5200.0},{"time":"2026-10-22T09:10:00+07:00","price":5200.0},{"time":"2026-10-22T09:11:00+07:00","price":5200.0},{"time":"2026-10-22T09:12:00+07:00","price":5200.0},{"time":"2026-10-22T09:13:00+07:00","price":5200.0},{"time":"2026-10-22T09:14:00+07:00","price":5225.0},{"time":"2026-10-22T09:15:00+07:00","price":5200.0},{"time":"2026-10-22T09:16:00+07:00","price":5225.0},{"time":"2026-10-22T09:17:00+07:00","price":5225.0},{"time":"2026-10-22T09:18:00+07:00","price":5200.0},{"time":"2026-10-22T09:19:00+07:00","price":5225.0},{"time":"2026-10-22T09:20:00+07:00","price":5250.0},{"time":"2026-10-22T09:21:00+07:00","price":5225.0},{"time":"2026-10-22T09:22:00+07:00","price":5225.0},{"time":"2026-10-22T09:23:00+07:00","price":5225.0},{"time":"2026-10-22T09:24:00+07:00","price":5225.0},{"time":"2026-10-22T09:25:00+07:00","price":5200.0},{"time":"2026-10-22T09:26:00+07:00","price":5200.0},{"time":"2026-10-22T09:27:00+07:00","price":5225.0},{"time":"2026-10-22T09:28:00+07:00","price":5225.0},{"time":"2026-10-22T09:29:00+07:00","price":5250.0},{"time":"2026-10-22T09:30:00+07:00","price":5225.0},{"time":"2026-10-22T09:31:00+07:00","price":5225.0},{"time":"2026-10-22T09:32:00+07:00","price":5200.0},{"time":"2026-10-22T09:33:00+07:00","price":5225.0},{"time":"2026-10-22T09:34:00+07:00","price":5225.0},{"time":"2026-10-22T09:35:00+07:00","price":5225.0},{"time":"2026-10-22T09:36:00+07:00","price":5250.0},{"time":"2026-10-22T09:37:00+07:00","price":5225.0},{"time":"2026-10-22T09:38:00+07:00","price":5225.0},{"time":"2026-10-22T09:39:00+07:00","price":5225.0},{"time":"2026-10-22T09:40:00+07:00","price":5225.0},{"time":"2026-10-22T09:41:00+07:00","price":5225.0},{"time":"2026-10-22T09:42:00+07:00","price":5225.0},{"time":"2026-10-22T09:43:00+07:00","price":5225.0},{"time":"2026-10-22T09:44:00+07:00","price":5225.0},{"time":"2026-10-22T09:45:00+07:00","price":5200.0},{"time":"2026-10-22T09:46:00+07:00","price":5200.0},{"time":"2026-10-22T09:47:00+07:00","price":5225.0},{"time":"2026-10-22T09:48:00+07:00","price":5200.0},{"time":"2026-10-22T09:49:00+07:00","price":5225.0},{"time":"2026-10-22T09:50:00+07:00","price":5225.0},{"time":"2026-10-22T09:51:00+07:00","price":5225.0},{"time":"2026-10-22T09:52:00+07:00","price":5225.0},{"time":"2026-10-22T09:53:00+07:00","price":5225.0},{"time":"2026-10-22T09:54:00+07:00","price":5200.0},{"time":"2026-10-22T09:55:00+07:00","price":5225.0},{"time":"2026-10-22T09:56:00+07:00","price":5225.0},{"time":"2026-10-22T09:57:00+07:00","price":5225.0},{"time":"2026-10-22T09:58:00+07:00","price":5225.0},{"time":"2026-10-22T09:59:00+07:00","price":5225.0},{"time":"2026-10-22T10:00:00+07:00","price":5250.0},{"time":"2026-10-22T10:01:00+07:00","price":5250.0},{"time":"2026-10-22T10:02:00+07:00","price":5250.0},{"time":"2026-10-22T10:03:00+07:00","price":5250.0},{"time":"2026-10-22T10:04:00+07:00","price":5250.0},{"time":"2026-10-22T10:05:00+07:00","price":5225.0},{"time":"2026-10-22T10:06:00+07:00","price":5225.0},{"time":"2026-10-22T10:07:00+07:00","price":5225.0},{"time":"2026-10-22T10:08:00+07:00","price":5225.0},{"time":"2026-10-22T10:09:00+07:00","price":5225.0},{"time":"2026-10-22T10:10:00+07:00","price":5225.0},{"time":"2026-10-22T10:11:00+07:00","price":5225.0},{"time":"2026-10-22T10:12:00+07:00","price":5225.0},{"time":"2026-10-22T10:13:00+07:00","price":5225.0},{"time":"2026-10-22T10:14:00+07:00","price":5225.0},{"time":"2026-10-22T10:15:00+07:00","price":5225.0},{"time":"2026-10-22T10:16:00+07:00","price":5225.0},{"time":"2026-10-22T10:17:00+07:00","price":5225.0},{"time":"2026-10-22T10:18:00+07:00","price":5225.0},{"time":"2026-10-22T10:19:00+07:00","price":5225.0},{"time":"2026-10-22T10:20:00+07:00","price":5225.0},{"time":"2026-10-22T10:21:00+07:00","price":5225.0},{"time":"2026-10-22T10:22:00+07:00","price":5250.0},{"time":"2026-10-22T10:23:00+07:00","price":5250.0},{"time":"2026-10-22T10:24:00+07:00","price":5250.0},{"time":"2026-10-22T10:25:00+07:00","price":5250.0},{"time":"2026-10-22T10:26:00+07:00","price":5250.0},{"time":"2026-10-22T10:27:00+07:00","price":5250.0},{"time":"2026-10-22T10:28:00+07:00","price":5250.0},{"time":"2026-10-22T10:29:00+07:00","price":5250.0},{"time":"2026-10-22T10:30:00+07:00","price":5225.0},{"time":"2026-10-22T10:31:00+07:00","price":5250.0},{"time":"2026-10-22T10:32:00+07:00","price":5250.0},{"time":"2026-10-22T10:33:00+07:00","price":5250.0},{"time":"2026-10-22T10:34:00+07:00","price":5225.0},{"time":"2026-10-22T10:35:00+07:00","price":5250.0},{"time":"2026-10-22T10:36:00+07:00","price":5250.0},{"time":"2026-10-22T10:37:00+07:00","price":5250.0},{"time":"2026-10-22T10:38:00+07:00","price":5225.0},{"time":"2026-10-22T10:39:00+07:00","price":5250.0},{"time":"2026-10-22T10:40:00+07:00","price":5250.0},{"time":"2026-10-22T10:41:00+07:00","price":5250.0},{"time":"2026-10-22T10:42:00+07:00","price":5225.0},{"time":"2026-10-22T10:43:00+07:00","price":5225.0},{"time":"2026-10-22T10:44:00+07:00","price":5200.0},{"time":"2026-10-22T10:45:00+07:00","price":5200.0},{"time":"2026-10-22T10:46:00+07:00","price":5200.0},{"time":"2026-10-22T10:47:00+07:00","price":5200.0},{"time":"2026-10-22T10:48:00+07:00","price":5225.0},{"time":"2026-10-22T10:49:00+07:00","price":5225.0},{"time":"2026-10-22T10:50:00+07:00","price":5225.0},{"time":"2026-10-22T10:51:00+07:00","price":5200.0},{"time":"2026-10-22T10:52:00+07:00","price":5225.0},{"time":"2026-10-22T10:53:00+07:00","price":5225.0},{"time":"2026-10-22T10:54:00+07:00","price":5200.0},{"time":"2026-10-22T10:55:00+07:00","price":5200.0},{"time":"2026-10-22T10:56:00+07:00","price":5225.0},{"time":"2026-10-22T10:57:00+07:00","price":5200.0},{"time":"2026-10-22T10:58:00+07:00","price":5200.0},{"time":"2026-10-22T10:59:00+07:00","price":5225.0},{"time":"2026-10-22T11:00:00+07:00","price":5200.0},{"time":"2026-10-22T11:01:00+07:00","price":5200.0},{"time":"2026-10-22T11:02:00+07:00","price":5225.0},{"time":"2026-10-22T11:03:00+07:00","price":5225.0},{"time":"2026-10-22T11:04:00+07:00","price":5200.0},{"time":"2026-10-22T11:05:00+07:00","price":5225.0},{"time":"2026-10-22T11:06:00+07:00","price":5250.0},{"time":"2026-10-22T11:07:00+07:00","price":5225.0},{"time":"2026-10-22T11:08:00+07:00","price":5225.0},{"time":"2026-10-22T11:09:00+07:00","price":5250.0},{"time":"2026-10-22T11:10:00+07:00","price":5250.0},{"time":"2026-10-22T11:11:00+07:00","price":5225.0},{"time":"2026-10-22T11:12:00+07:00","price":5250.0},{"time":"2026-10-22T11:13:00+07:00","price":5250.0},{"time":"2026-10-22T11:14:00+07:00","price":5250.0},{"time":"2026-10-22T11:15:00+07:00","price":5250.0},{"time":"2026-10-22T11:16:00+07:00","price":5250.0},{"time":"2026-10-22T11:17:00+07:00","price":5250.0},{"time":"2026-10-22T11:18:00+07:00","price":5225.0},{"time":"2026-10-22T11:19:00+07:00","price":5225.0},{"time":"2026-10-22T11:20:00+07:00","price":5225.0},{"time":"2026-10-22T11:21:00+07:00","price":5225.0},{"time":"2026-10-22T11:22:00+07:00","price":5200.0},{"time":"2026-10-22T11:23:00+07:00","price":5225.0},{"time":"2026-10-22T11:24:00+07:00","price":5200.0},{"time":"2026-10-22T11:25:00+07:00","price":5200.0},{"time":"2026-10-22T11:26:00+07:00","price":5225.0},{"time":"2026-10-22T11:27:00+07:00","price":5225.0},{"time":"2026-10-22T11:28:00+07:00","price":5200.0},{"time":"2026-10-22T11:29:00+07:00","price":5225.0},{"time":"2026-10-22T11:30:00+07:00","price":5225.0},{"time":"2026-10-22T11:31:00+07:00","price":5200.0},{"time":"2026-10-22T11:32:00+07:00","price":5200.0},{"time":"2026-10-22T11:33:00+07:00","price":5225.0},{"time":"2026-10-22T11:34:00+07:00","price":5225.0},{"time":"2026-10-22T11:35:00+07:00","price":5200.0},{"time":"2026-10-22T11:36:00+07:00","price":5225.0},{"time":"2026-10-22T11:37:00+07:00","price":5225.0},{"time":"2026-10-22T11:38:00+07:00","price":5225.0},{"time":"2026-10-22T11:39:00+07:00","price":5200.0},{"time":"2026-10-22T11:40:00+07:00","price":5225.0},{"time":"2026-10-22T11:41:00+07:00","price":5225.0},{"time":"2026-10-22T11:42:00+07:00","price":5200.0},{"time":"2026-10-22T11:43:00+07:00","price":5225.0},{"time":"2026-10-22T11:44:00+07:00","price":5200.0},{"time":"2026-10-22T11:45:00+07:00","price":5225.0},{"time":"2026-10-22T11:46:00+07:00","price":5225.0},{"time":"2026-10-22T11:47:00+07:00","price":5225.0},{"time":"2026-10-22T11:48:00+07:00","price":5250.0},{"time":"2026-10-22T11:49:00+07:00","price":5250.0},{"time":"2026-10-22T11:50:00+07:00","price":5225.0},{"time":"2026-10-22T11:51:00+07:00","price":5225.0},{"time":"2026-10-22T11:52:00+07:00","price":5225.0},{"time":"2026-10-22T11:53:00+07:00","price":5225.0},{"time":"2026-10-22T11:54:00+07:00","price":5225.0},{"time":"2026-10-22T11:55:00+07:00","price":5225.0},{"time":"2026-10-22T11:56:00+07:00","price":5225.0},{"time":"2026-10-22T11:57:00+07:00","price":5225.0},{"time":"2026-10-22T11:58:00+07:00","price":5250.0},{"time":"2026-10-22T11:59:00+07:00","price":5250.0},{"time":"2026-10-22T13:30:00+07:00","price":5225.0},{"time":"2026-10-22T13:31:00+07:00","price":5200.0},{"time":"2026-10-22T13:32:00+07:00","price":5200.0},{"time":"2026-10-22T13:33:00+07:00","price":5200.0},{"time":"2026-10-22T13:34:00+07:00","price":5200.0},{"time":"2026-10-22T13:35:00+07:00","price":5225.0},{"time":"2026-10-22T13:36:00+07:00","price":5225.0},{"time":"2026-10-22T13:37:00+07:00","price":5225.0},{"time":"2026-10-22T13:38:00+07:00","price":5200.0},{"time":"2026-10-22T13:39:00+07:00","price":5225.0},{"time":"2026-10-22T13:40:00+07:00","price":5200.0},{"time":"2026-10-22T13:41:00+07:00","price":5225.0},{"time":"2026-10-22T13:42:00+07:00","price":5225.0},{"time":"2026-10-22T13:43:00+07:00","price":5200.0},{"time":"2026-10-22T13:44:00+07:00","price":5200.0},{"time":"2026-10-22T13:45:00+07:00","price":5175.0},{"time":"2026-10-22T13:46:00+07:00","price":5175.0},{"time":"2026-10-22T13:47:00+07:00","price":5175.0},{"time":"2026-10-22T13:48:00+07:00","price":5175.0},{"time":"2026-10-22T13:49:00+07:00","price":5200.0},{"time":"2026-10-22T13:50:00+07:00","price":5200.0},{"time":"2026-10-22T13:51:00+07:00","price":5175.0},{"time":"2026-10-22T13:52:00+07:00","price":5200.0},{"time":"2026-10-22T13:53:00+07:00","price":5200.0},{"time":"2026-10-22T13:54:00+07:00","price":5175.0},{"time":"2026-10-22T13:55:00+07:00","price":5175.0},{"time":"2026-10-22T13:56:00+07:00","price":5175.0},{"time":"2026-10-22T13:57:00+07:00","price":5200.0},{"time":"2026-10-22T13:58:00+07:00","price":5200.0},{"time":"2026-10-22T13:59:00+07:00","price":5200.0},{"time":"2026-10-22T14:00:00+07:00","price":5200.0},{"time":"2026-10-22T14:01:00+07:00","price":5175.0},{"time":"2026-10-22T14:02:00+07:00","price":5200.0},{"time":"2026-10-22T14:03:00+07:00","price":5200.0},{"time":"2026-10-22T14:04:00+07:00","price":5175.0},{"time":"2026-10-22T14:05:00+07:00","price":5175.0},{"time":"2026-10-22T14:06:00+07:00","price":5175.0},{"time":"2026-10-22T14:07:00+07:00","price":5175.0},{"time":"2026-10-22T14:08:00+07:00","price":5175.0},{"time":"2026-10-22T14:09:00+07:00","price":5175.0},{"time":"2026-10-22T14:10:00+07:00","price":5175.0},{"time":"2026-10-22T14:11:00+07:00","price":5200.0},{"time":"2026-10-22T14:12:00+07:00","price":5200.0},{"time":"2026-10-22T14:13:00+07:00","price":5200.0},{"time":"2026-10-22T14:14:00+07:00","price":5175.0},{"time":"2026-10-22T14:15:00+07:00","price":5200.0},{"time":"2026-10-22T14:16:00+07:00","price":5175.0},{"time":"2026-10-22T14:17:00+07:00","price":5175.0},{"time":"2026-10-22T14:18:00+07:00","price":5175.0},{"time":"2026-10-22T14:19:00+07:00","price":5175.0},{"time":"2026-10-22T14:20:00+07:00","price":5200.0},{"time":"2026-10-22T14:21:00+07:00","price":5200.0},{"time":"2026-10-22T14:22:00+07:00","price":5175.0},{"time":"2026-10-22T14:23:00+07:00","price":5175.0},{"time":"2026-10-22T14:24:00+07:00","price":5200.0},{"time":"2026-10-22T14:25:00+07:00","price":5175.0},{"time":"2026-10-22T14:26:00+07:00","price":5200.0},{"time":"2026-10-22T14:27:00+07:00","price":5200.0},{"time":"2026-10-22T14:28:00+07:00","price":5200.0},{"time":"2026-10-22T14:29:00+07:00","price":5200.0},{"time":"2026-10-22T14:30:00+07:00","price":5175.0},{"time":"2026-10-22T14:31:00+07:00","price":5200.0},{"time":"2026-10-22T14:32:00+07:00","price":5175.0},{"time":"2026-10-22T14:33:00+07:00","price":5200.0},{"time":"2026-10-22T14:34:00+07:00","price":5200.0},{"time":"2026-10-22T14:35:00+07:00","price":5200.0},{"time":"2026-10-22T14:36:00+07:00","price":5225.0},{"time":"2026-10-22T14:37:00+07:00","price":5200.0},{"time":"2026-10-22T14:38:00+07:00","price":5200.0},{"time":"2026-10-22T14:39:00+07:00","price":5200.0},{"time":"2026-10-22T14:40:00+07:00","price":5200.0},{"time":"2026-10-22T14:41:00+07:00","price":5200.0},{"time":"2026-10-22T14:42:00+07:00","price":5200.0},{"time":"2026-10-22T14:43:00+07:00","price":5200.0},{"time":"2026-10-22T14:44:00+07:00","price":5200.0},{"time":"2026-10-22T14:45:00+07:00","price":5200.0},{"time":"2026-10-22T14:46:00+07:00","price":5200.0},{"time":"2026-10-22T14:47:00+07:00","price":5175.0},{"time":"2026-10-22T14:48:00+07:00","price":5175.0},{"time":"2026-10-22T14:49:00+07:00","price":5200.0},{"time":"2026-10-22T14:50:00+07:00","price":5200.0},{"time":"2026-10-22T14:51:00+07:00","price":5200.0},{"time":"2026-10-22T14:52:00+07:00","price":5175.0},{"time":"2026-10-22T14:53:00+07:00","price":5200.0},{"time":"2026-10-22T14:54:00+07:00","price":5200.0},{"time":"2026-10-22T14:55:00+07:00","price":5225.0},{"time":"2026-10-22T14:56:00+07:00","price":5225.0},{"time":"2026-10-22T14:57:00+07:00","price":5225.0},{"time":"2026-10-22T14:58:00+07:00","price":5200.0},{"time":"2026-10-22T14:59:00+07:00","price":5200.0},{"time":"2026-10-22T15:00:00+07:00","price":5175.0},{"time":"2026-10-22T15:01:00+07:00","price":5200.0},{"time":"2026-10-22T15:02:00+07:00","price":5200.0},{"time":"2026-10-22T15:03:00+07:00","price":5200.0},{"time":"2026-10-22T15:04:00+07:00","price":5200.0},{"time":"2026-10-22T15:05:00+07:00","price":5200.0},{"time":"2026-10-22T15:06:00+07:00","price":5200.0},{"time":"2026-10-22T15:07:00+07:00","price":5175.0},{"time":"2026-10-22T15:08:00+07:00","price":5200.0},{"time":"2026-10-22T15:09:00+07:00","price":5200.0},{"time":"2026-10-22T15:10:00+07:00","price":5175.0},{"time":"2026-10-22T15:11:00+07:00","price":5175.0},{"time":"2026-10-22T15:12:00+07:00","price":5175.0},{"time":"2026-10-22T15:13:00+07:00","price":5200.0},{"time":"2026-10-22T15:14:00+07:00","price":5175.0},{"time":"2026-10-22T15:15:00+07:00","price":5175.0},{"time":"2026-10-22T15:16:00+07:00","price":5175.0},{"time":"2026-10-22T15:17:00+07:00","price":5200.0},{"time":"2026-10-22T15:18:00+07:00","price":5175.0},{"time":"2026-10-22T15:19:00+07:00","price":5175.0},{"time":"2026-10-22T15:20:00+07:00","price":5150.0},{"time":"2026-10-22T15:21:00+07:00","price":5175.0},{"time":"2026-10-22T15:22:00+07:00","price":5175.0},{"time":"2026-10-22T15:23:00+07:00","price":5175.0},{"time":"2026-10-22T15:24:00+07:00","price":5175.0},{"time":"2026-10-22T15:25:00+07:00","price":5150.0},{"time":"2026-10-22T15:26:00+07:00","price":5175.0},{"time":"2026-10-22T15:27:00+07:00","price":5175.0},{"time":"2026-10-22T15:28:00+07:00","price":5175.0},{"time":"2026-10-22T15:29:00+07:00","price":5175.0},{"time":"2026-10-22T15:30:00+07:00","price":5200.0},{"time":"2026-10-22T15:31:00+07:00","price":5200.0},{"time":"2026-10-22T15:32:00+07:00","price":5200.0},{"time":"2026-10-22T15:33:00+07:00","price":5200.0},{"time":"2026-10-22T15:34:00+07:00","price":5200.0},{"time":"2026-10-22T15:35:00+07:00","price":5175.0},{"time":"2026-10-22T15:36:00+07:00","price":5200.0},{"time":"2026-10-22T15:37:00+07:00","price":5175.0},{"time":"2026-10-22T15:38:00+07:00","price":5175.0},{"time":"2026-10-22T15:39:00+07:00","price":5175.0},{"time":"2026-10-22T15:40:00+07:00","price":5150.0},{"time":"2026-10-22T15:41:00+07:00","price":5150.0},{"time":"2026-10-22T15:42:00+07:00","price":5175.0},{"time":"2026-10-22T15:43:00+07:00","price":5175.0},{"time":"2026-10-22T15:44:00+07:00","price":5200.0},{"time":"2026-10-22T15:45:00+07:00","price":5200.0},{"time":"2026-10-22T15:46:00+07:00","price":5175.0},{"time":"2026-10-22T15:47:00+07:00","price":5200.0},{"time":"2026-10-22T15:48:00+07:00","price":5175.0},{"time":"2026-10-22T15:49:00+07:00","price":5175.0},{"time":"2026-10-22T16:00:00+07:00","price":5175.0},{"time":"2026-10-22T16:02:00+07:00","price":5175.0},{"time":"2026-10-22T16:03:00+07:00","price":5175.0},{"time":"2026-10-22T16:04:00+07:00","price":5175.0},{"time":"2026-10-22T16:05:00+07:00","price":5175.0},{"time":"2026-10-22T16:06:00+07:00","price":5175.0},{"time":"2026-10-22T16:07:00+07:00","price":5175.0},{"time":"2026-10-22T16:08:00+07:00","price":5175.0},{"time":"2026-10-22T16:09:00+07:00","price":5175.0},{"time":"2026-10-22T16:10:00+07:00","price":5175.0},{"time":"2026-10-22T16:11:00+07:00","price":5175.0},{"time":"2026-10-22T16:12:00+07:00","price":5175.0},{"time":"2026-10-22T16:13:00+07:00","price":5175.0},{"time":"2026-10-22T16:14:00+07:00","price":5175.0}]} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-intraday.table.golden b/crates/idx-cli/tests/golden/stocks-intraday.table.golden new file mode 100644 index 0000000..303612a --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-intraday.table.golden @@ -0,0 +1,23 @@ +exit: 0 +--- stdout +BBCA.JK 5,175 -100 (-1.90%) +prev close 5,275 · volume 286,928,500 · market close · last trade 2026-10-22 16:14 WIB +Note: BBCA.JK last traded on 2026-10-22; this price is not current. + +▄▅▇█▅▇▇▇▅▇▇▇▇▇▇██▇▅▇▅▅███▇▅▇▅▇▇▇▅▇▅▅▄▅▄▅▄▄▅▅▅▅▅▇▅▄▄▄▄▄▄▂▄▄▄▄ +08:58 to 16:14 WIB · low 5,125 · high 5,250 · 335 minutes +┌───────┬───────┐ +│ TIME ┆ PRICE │ +╞═══════╪═══════╡ +│ 16:10 ┆ 5,175 │ +├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 16:11 ┆ 5,175 │ +├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 16:12 ┆ 5,175 │ +├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 16:13 ┆ 5,175 │ +├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤ +│ 16:14 ┆ 5,175 │ +└───────┴───────┘ +Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. +--- stderr 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 64c8694..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, Period, Quote}; +use super::stockbit::{corpaction, exodus, movement}; +use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote}; use super::{ - BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider, - TradingSummaryProvider, idx, ipot, msn, yahoo, + 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() @@ -45,6 +49,10 @@ impl MockProvider { match provider { ProviderKind::Yahoo => Self::from_yahoo_fixtures(history_verbose), ProviderKind::Msn => Self::from_msn_fixtures(), + // `MockStockbitProvider` serves Stockbit; it has no history. + ProviderKind::Stockbit => Self::with_error(IdxError::Unsupported( + "Stockbit serves no price history".to_string(), + )), } } @@ -131,6 +139,181 @@ impl HistoryProvider for MockProvider { } } +/// Stockbit from fixtures. Without a session (none stored or seeded through +/// `IDX_STOCKBIT_REFRESH_TOKEN`): the public page, +/// `tests/fixtures/stockbit_symbol_.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 failure(&self, symbol: &str) -> Option { + if std::env::var("IDX_MOCK_ERROR").is_ok() { + return Some(IdxError::ProviderUnavailable); + } + let status = std::env::var("IDX_MOCK_STOCKBIT_STATUS").ok()?; + Some(stockbit::status_error( + status.parse().unwrap_or(500), + symbol, + )) + } + + fn snapshot(&self, symbol: &str) -> Result { + if let Some(err) = self.failure(symbol) { + return Err(err); + } + let code = stockbit::stockbit_code(symbol)?; + let path = format!( + "tests/fixtures/stockbit_symbol_{}.html", + code.to_ascii_lowercase() + ); + match std::fs::read_to_string(path) { + Ok(html) => stockbit::parse_symbol_page(symbol, &html), + Err(_) => Err(stockbit::status_error(404, symbol)), + } + } + + /// A session API fixture, or the error a request would meet. + fn exodus(&self, symbol: &str, name: &str) -> Result { + 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. + quote.as_of = None; + Ok(quote) + } +} + +impl FundamentalsProvider for MockStockbitProvider { + fn fundamentals(&self, symbol: &str) -> Result { + Err(stockbit::fundamentals_unsupported(symbol)) + } +} + +impl IntradayProvider for MockStockbitProvider { + fn intraday(&self, symbol: &str) -> Result { + self.snapshot(symbol) + } +} + +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 3d3a8fe..f64cb8e 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod ipot; #[cfg(feature = "testing")] pub mod mock; pub mod msn; +pub mod stockbit; pub mod types; pub mod yahoo; @@ -13,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind}; use crate::error::IdxError; use types::{ Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, - NewsItem, Period, Quote, SentimentData, + Intraday, NewsItem, OrderBook, Period, Quote, SentimentData, }; /// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp @@ -64,6 +65,41 @@ pub trait TradingSummaryProvider { ) -> Result, IdxError>; } +/// Today's session of one IDX symbol: quote and price per minute. Served by +/// Stockbit's public page whichever market provider is selected. +pub trait IntradayProvider { + /// `symbol` is resolved, e.g. `BBCA.JK`. + fn intraday(&self, symbol: &str) -> Result; +} + +/// 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; } @@ -221,7 +257,10 @@ fn mock_enabled() -> bool { #[cfg(feature = "testing")] fn mock_market(provider: ProviderKind) -> Option> { - Some(Box::new(mock::MockProvider::from_fixtures(provider))) + match provider { + ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)), + _ => Some(Box::new(mock::MockProvider::from_fixtures(provider))), + } } #[cfg(not(feature = "testing"))] @@ -231,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"))] @@ -274,6 +316,18 @@ fn build_selected_provider( news: Some(Box::new(msn::MsnProvider::new(verbose))), screener: Some(Box::new(msn::MsnProvider::new(verbose))), }, + // Quotes only; history needs a session (see `history_source`). + ProviderKind::Stockbit => SelectedProvider { + kind: ProviderKind::Stockbit, + market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())), + profile: None, + financials: None, + earnings: None, + sentiment: None, + insights: None, + news: None, + screener: None, + }, } } @@ -297,6 +351,38 @@ pub fn trading_summary_provider() -> Box { Box::new(idx::IdxSiteProvider) } +pub fn intraday_provider() -> Box { + #[cfg(feature = "testing")] + if mock::enabled() { + return Box::new(mock::MockStockbitProvider); + } + 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")] @@ -307,38 +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::Yahoo, - ProviderKind::Msn => 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)))), + match source { + ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)), + ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)), + ProviderKind::Stockbit => Box::new(stockbit::StockbitProvider::new()), } } @@ -439,4 +531,72 @@ mod tests { "unsupported: screen: command requires --provider msn" ); } + + #[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/msn/map.rs b/crates/idx-core/src/api/msn/map.rs index a643841..165bd07 100644 --- a/crates/idx-core/src/api/msn/map.rs +++ b/crates/idx-core/src/api/msn/map.rs @@ -71,6 +71,10 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result Result/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 ["`. +fn next_data(html: &str) -> Result<&str, IdxError> { + let missing = || layout_error("no __NEXT_DATA__ script".to_string()); + let tag = html.find("id=\"__NEXT_DATA__\"").ok_or_else(missing)?; + let start = tag + html[tag..].find('>').ok_or_else(missing)? + 1; + let len = html[start..].find("").ok_or_else(missing)?; + Ok(&html[start..start + len]) +} + +fn parse_quote(resolved: &str, company: &RawCompany) -> Result { + let price = number(&company.price) + .ok_or_else(|| layout_error(format!("price {} is not a number", company.price)))?; + let price = price.round() as i64; + let prev_close = number(&company.previous).map(|p| p.round() as i64); + let change = number(&company.change) + .map(|c| c.round() as i64) + .or_else(|| prev_close.map(|prev| price - prev)); + let volume = count(&company.volume); + let listing = company.status.as_deref().unwrap_or_default(); + let market_status = company + .market_hour + .as_ref() + .and_then(|m| m.status.clone()) + .filter(|s| !s.is_empty()); + let open = market_status.as_deref() == Some(MARKET_OPEN); + let level = |side: Option<&RawLevel>| { + side.and_then(|l| number(&l.price)) + .filter(|p| *p > 0.0) + .map(|p| p.round() as i64) + }; + let book = company.orderbook.as_ref(); + let (bid, offer) = if open { + ( + level(book.and_then(|b| b.bid.as_ref())), + level(book.and_then(|b| b.offer.as_ref())), + ) + } else { + (None, None) + }; + let traded = volume.is_some_and(|v| v > 0); + let as_of = if listing == "STATUS_ACTIVE" && traded { + last_trade(company.date.as_deref(), company.time.as_deref()) + } else { + None + }; + // Facts about this symbol only; the market's state is `market_status`. + // An untraded stock can still have a flat chart at the previous close. + let session_note = match listing { + "STATUS_SUSPENDED" => Some("Stockbit lists the stock as suspended".to_string()), + "STATUS_DELISTED" => Some("Stockbit lists the stock as delisted".to_string()), + _ if volume == Some(0) => Some("no trades this session".to_string()), + _ => None, + }; + Ok(Quote { + symbol: resolved.to_string(), + price, + change, + change_pct: number(&company.percentage), + volume, + market_cap: None, + week52_high: None, + week52_low: None, + week52_position: None, + range_signal: None, + prev_close, + avg_volume: None, + as_of, + behind_session: false, + bid, + offer, + market_status, + session_note, + }) +} + +/// Today's prices, oldest first, one per minute. The anchor row (no +/// timestamp) is dropped; any other row that does not parse means the +/// layout changed. +fn parse_points(chart: &RawChart) -> Result, IdxError> { + let mut points = Vec::with_capacity(chart.prices.len()); + for row in &chart.prices { + if row.formatted_date.is_empty() { + continue; + } + let bad = || layout_error(format!("chart row {:?} {}", row.formatted_date, row.value)); + let local = NaiveDateTime::parse_from_str(&row.formatted_date, "%Y-%m-%d %H:%M:%S") + .map_err(|_| bad())?; + let time = wib(local).ok_or_else(bad)?; + let price = number(&row.value).ok_or_else(bad)?; + points.push(IntradayPoint { time, price }); + } + points.sort_by_key(|p| p.time); + // A repeated minute keeps its last row. + points.reverse(); + points.dedup_by_key(|p| p.time); + points.reverse(); + Ok(points) +} + +/// `"22 Oct 2026"` and `"Thu 16:14"` as a WIB time. +fn last_trade(date: Option<&str>, time: Option<&str>) -> Option> { + let date = NaiveDate::parse_from_str(date?.trim(), "%d %b %Y").ok()?; + let clock = time?.trim().rsplit(' ').next()?; + let clock = NaiveTime::parse_from_str(clock, "%H:%M").ok()?; + wib(date.and_time(clock)) +} + +fn wib(local: NaiveDateTime) -> Option> { + idx_offset().from_local_datetime(&local).single() +} + +/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`, +/// `"NA"` and empty strings are absent. +pub(super) fn number(value: &Value) -> Option { + let n = match value { + Value::Number(n) => n.as_f64()?, + Value::String(s) => s.trim().parse::().ok()?, + _ => return None, + }; + n.is_finite().then_some(n) +} + +fn count(value: &Value) -> Option { + match value { + Value::Number(n) => n.as_u64(), + Value::String(s) => s.trim().parse::().ok(), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(name: &str) -> String { + std::fs::read_to_string(format!( + "{}/tests/fixtures/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() + } + + /// The recorded BBCA page with Stockbit's market status set to `status`. + fn bbca_with_status(status: &str) -> String { + let page = fixture("stockbit_symbol_bbca.html"); + assert!(page.contains(r#""status":"close""#)); + page.replace(r#""status":"close""#, &format!(r#""status":"{status}""#)) + } + + #[test] + fn closed_session_keeps_the_series_and_hides_the_closing_book() { + let day = parse_symbol_page("BBCA.JK", &fixture("stockbit_symbol_bbca.html")).unwrap(); + let q = &day.quote; + assert_eq!( + (q.price, q.prev_close, q.change), + (5175, Some(5275), Some(-100)) + ); + assert_eq!(q.change_pct, Some(-1.9)); + assert_eq!(q.volume, Some(286_928_500)); + assert_eq!(q.market_status.as_deref(), Some("close")); + assert_eq!((q.bid, q.offer), (None, None)); + assert_eq!(q.session_note, None); + assert_eq!(q.as_of.unwrap().to_rfc3339(), "2026-10-22T16:14:00+07:00"); + + // 336 rows less the anchor at the previous close. + assert_eq!(day.points.len(), 335); + let first = &day.points[0]; + assert_eq!(first.time.to_rfc3339(), "2026-10-22T08:58:00+07:00"); + assert_eq!(first.price, 5125.0); + let last = day.points.last().unwrap(); + assert_eq!(last.time.to_rfc3339(), "2026-10-22T16:14:00+07:00"); + assert_eq!(last.price, 5175.0); + assert!(day.points.windows(2).all(|w| w[0].time < w[1].time)); + } + + #[test] + fn open_market_reports_best_bid_and_offer() { + let day = parse_symbol_page("BBCA.JK", &bbca_with_status(MARKET_OPEN)).unwrap(); + assert_eq!((day.quote.bid, day.quote.offer), (Some(5175), Some(5200))); + assert_eq!(day.quote.market_status.as_deref(), Some(MARKET_OPEN)); + } + + #[test] + fn any_other_market_status_hides_bid_and_offer() { + for status in ["pre_open", "break", "OPEN", ""] { + let day = parse_symbol_page("BBCA.JK", &bbca_with_status(status)).unwrap(); + assert_eq!((day.quote.bid, day.quote.offer), (None, None), "{status:?}"); + } + } + + #[test] + fn index_keeps_decimals_in_the_series_and_rounds_the_quote() { + let day = parse_symbol_page("^JKSE.JK", &fixture("stockbit_symbol_ihsg.html")).unwrap(); + let q = &day.quote; + assert_eq!( + (q.price, q.prev_close, q.change), + (5388, Some(5328), Some(60)) + ); + // An index has no book even while open. + assert_eq!((q.bid, q.offer), (None, None)); + assert_eq!(day.points.last().unwrap().price, 5387.90); + } + + #[test] + fn suspended_and_delisted_stocks_are_empty_sessions_not_errors() { + for (file, note) in [ + ( + "stockbit_symbol_maba.html", + "Stockbit lists the stock as suspended", + ), + ( + "stockbit_symbol_myrx.html", + "Stockbit lists the stock as delisted", + ), + ] { + let day = parse_symbol_page("X.JK", &fixture(file)).unwrap(); + assert!(day.points.is_empty(), "{file}"); + assert_eq!(day.quote.session_note.as_deref(), Some(note), "{file}"); + // The page's date/time is not a trade time when nothing traded. + assert_eq!(day.quote.as_of, None, "{file}"); + } + } + + /// HOME and BIMA on 2026-10-22: active, a flat chart at the previous + /// close, volume 0. Without a note they read as a traded flat session. + #[test] + fn an_untraded_active_stock_says_so_even_with_a_flat_chart() { + let page = fixture("stockbit_symbol_bbca.html"); + assert!(page.contains(r#""volume":"286928500""#)); + let page = page.replace(r#""volume":"286928500""#, r#""volume":"0""#); + let day = parse_symbol_page("BBCA.JK", &page).unwrap(); + assert!(!day.points.is_empty()); + assert_eq!( + day.quote.session_note.as_deref(), + Some("no trades this session") + ); + assert_eq!(day.quote.as_of, None); + } + + #[test] + fn unknown_code_is_symbol_not_found() { + let err = parse_symbol_page("ZZZZ.JK", &fixture("stockbit_symbol_zzzz_404.html")); + assert!(matches!(err, Err(IdxError::SymbolNotFound(s)) if s == "ZZZZ.JK")); + } + + #[test] + fn a_page_without_next_data_is_a_layout_change() { + let err = parse_symbol_page("BBCA.JK", &fixture("stockbit_blocked_403.html")); + assert!(matches!(err, Err(IdxError::ParseError(_))), "{err:?}"); + } + + #[test] + fn http_403_is_upstream_blocked_and_404_is_not_found() { + assert!(matches!( + status_error(403, "BBCA.JK"), + IdxError::UpstreamBlocked(_) + )); + assert!( + matches!(status_error(404, "BBCA.JK"), IdxError::SymbolNotFound(s) if s == "BBCA.JK") + ); + assert!(matches!( + status_error(429, "BBCA.JK"), + IdxError::RateLimited + )); + assert!(matches!(status_error(502, "BBCA.JK"), IdxError::Http(_))); + } + + #[test] + fn codes_map_idx_symbols_and_index_tickers() { + assert_eq!(stockbit_code("BBCA.JK").unwrap(), "BBCA"); + assert_eq!(stockbit_code("^JKSE.JK").unwrap(), "IHSG"); + assert_eq!(stockbit_code("^JKLQ45.JK").unwrap(), "LQ45"); + for other in ["AAPL.US", "^GSPC.JK", "BRK-B.JK", ".JK"] { + assert!( + matches!(stockbit_code(other), Err(IdxError::Unsupported(_))), + "{other}" + ); + } + } +} diff --git a/crates/idx-core/src/api/stockbit/signin.rs b/crates/idx-core/src/api/stockbit/signin.rs new file mode 100644 index 0000000..fa77057 --- /dev/null +++ b/crates/idx-core/src/api/stockbit/signin.rs @@ -0,0 +1,452 @@ +//! One Stockbit sign-in for every frontend. `idx stockbit login` and the +//! `stockbit_login` MCP tool both start one here, and differ only in how +//! they wait: the CLI in the foreground, idx-mcp on a thread it polls. +//! +//! Two ways a session reaches idx, both ending in `accept`: +//! - **Browser** (`browser.rs`): idx opens a Chromium-family browser on +//! Stockbit's own sign-in page, in a profile of its own, and reads the +//! session cookie over DevTools. Nothing to copy; the default on a desktop. +//! - **Page** (`loopback.rs`): a page on 127.0.0.1 that takes the cookie from +//! the user's own browser (bookmarklet, clipboard or paste). For a shell +//! over SSH, a machine with no display, or no supported browser. +//! +//! `Method::Auto` picks the browser unless it would not reach the user, and +//! falls back to the page only when the browser cannot start. A sign-in that +//! started and then timed out or was closed is an error, not a fallback. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +use super::auth::SessionStatus; +use super::{browser, loopback}; +use crate::error::IdxError; + +/// How long the browser window waits: the user signs in there, one-time code +/// and all. +pub const BROWSER_TIMEOUT: Duration = Duration::from_secs(15 * 60); +/// How long the page waits for a session from the user's browser. +pub const PAGE_TIMEOUT: Duration = Duration::from_secs(5 * 60); +/// Port the page prefers, so an `ssh -L` forward stays the same across runs. +pub const PAGE_PORT: u16 = 7878; +/// How long `shutdown` waits for a browser to close. +const SHUTDOWN_GRACE: Duration = Duration::from_secs(6); + +/// Which way to sign in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Method { + /// The browser when it would reach the user, else the page. + #[default] + Auto, + Browser, + Page, +} + +#[derive(Debug, Clone, Default)] +pub struct Options { + pub method: Method, + /// The page's port; `None` prefers `PAGE_PORT` and takes any free one + /// when that is busy. + pub port: Option, + /// Open the page in the desktop's default browser, when this machine has + /// a desktop the user is at. + pub open_page: bool, +} + +/// How a sign-in reaches the user. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "method", rename_all = "snake_case")] +pub enum Started { + /// A stored session already works; nothing was opened. + Existing, + /// A browser window on Stockbit's sign-in page: sign in there. + Browser { browser: String }, + /// The page on 127.0.0.1. + Page { + url: String, + port: u16, + /// The `ssh -L` command that brings the page to another machine. + forward: String, + /// The shell is over SSH, so the page is on another machine than the + /// user's browser. + remote: bool, + /// Why the browser was not used, when `Auto` picked the page. + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + }, +} + +/// A sign-in that has started and not yet been waited on. +pub struct SignIn { + started: Started, + timeout: Duration, + waiting: Waiting, +} + +enum Waiting { + Browser(browser::Window), + Page(loopback::LoginPage), +} + +impl SignIn { + /// Opens the browser window or the page, and returns once it is up. + pub fn start(options: &Options) -> Result { + match options.method { + Method::Browser => Self::browser(), + Method::Page => Self::page(options, None), + Method::Auto => match no_local_browser() { + Some(reason) => Self::page(options, Some(reason.to_string())), + None => Self::browser().or_else(|err| { + Self::page( + options, + Some(format!("the browser sign-in could not start: {err}")), + ) + }), + }, + } + } + + fn browser() -> Result { + let profile = browser::profile_dir()?; + let browser = browser::Browser::find(&profile)?; + let window = browser.open(&profile)?; + Ok(Self { + started: Started::Browser { + browser: browser.name, + }, + timeout: BROWSER_TIMEOUT, + waiting: Waiting::Browser(window), + }) + } + + fn page(options: &Options, reason: Option) -> Result { + let page = match options.port { + Some(port) => loopback::LoginPage::bind(port)?, + None => { + loopback::LoginPage::bind(PAGE_PORT).or_else(|_| loopback::LoginPage::bind(0))? + } + }; + if options.open_page && no_local_browser().is_none() { + open_in_browser(&page.url()); + } + Ok(Self { + started: Started::Page { + url: page.url(), + port: page.port(), + forward: loopback::ssh_forward(page.port()), + remote: over_ssh(), + reason, + }, + timeout: PAGE_TIMEOUT, + waiting: Waiting::Page(page), + }) + } + + pub fn started(&self) -> &Started { + &self.started + } + + /// How long `wait` waits. + pub fn timeout(&self) -> Duration { + self.timeout + } + + /// Waits for the user to finish, and reports the session stored. + pub fn wait(self) -> Result { + self.wait_until(&AtomicBool::new(false)) + } + + /// `wait`, ending early with the browser closed when `cancel` is set. + /// The page has nothing to close and stops with the process. + fn wait_until(self, cancel: &AtomicBool) -> Result { + match self.waiting { + Waiting::Browser(window) => window.wait(self.timeout, cancel, &accept)?, + Waiting::Page(page) => page.wait(self.timeout, accept)?, + } + super::session_status() + } +} + +/// Checks a session value (the `credentialStorage` cookie or a refresh +/// token) and stores it: the step every way in ends with. +pub fn accept(value: &str) -> Result<(), IdxError> { + super::login(value).map(|_| ()) +} + +/// Why a browser opened here would not reach the user, if it would not. +fn no_local_browser() -> Option<&'static str> { + #[cfg(all(unix, not(target_os = "macos")))] + let display = + std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some(); + #[cfg(not(all(unix, not(target_os = "macos"))))] + let display = true; + blocker(over_ssh(), display) +} + +fn blocker(ssh: bool, display: bool) -> Option<&'static str> { + if ssh { + Some("this shell is over SSH, so a browser started here opens on the remote machine") + } else if !display { + Some("this machine has no display (DISPLAY and WAYLAND_DISPLAY are unset)") + } else { + None + } +} + +fn over_ssh() -> bool { + std::env::var_os("SSH_CONNECTION").is_some() +} + +/// Opens `url` with the desktop's default browser, best effort. +fn open_in_browser(url: &str) { + #[cfg(target_os = "macos")] + let (program, args) = ("open", vec![url]); + #[cfg(target_os = "windows")] + let (program, args) = ("cmd", vec!["/C", "start", "", url]); + #[cfg(all(unix, not(target_os = "macos")))] + let (program, args) = ("xdg-open", vec![url]); + let opened = std::process::Command::new(program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .is_ok(); + if !opened { + crate::runtime::warn(format!("could not open a browser; open {url} yourself")); + } +} + +/// What a background sign-in is doing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum State { + Waiting, + SignedIn, + Failed, +} + +/// A background sign-in as `start_background` and `progress` report it. +#[derive(Debug, Clone, Serialize)] +pub struct Progress { + pub state: State, + #[serde(flatten)] + pub started: Started, + /// Seconds before a waiting sign-in gives up; 0 once it has ended. + pub expires_in_secs: u64, + /// True when `start_background` found this sign-in already waiting. + pub already_open: bool, + /// Why it failed: the error's code and message. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// The latest warning while it waits, such as Stockbit refusing the + /// session signed in so far; gone once it signs in. + #[serde(skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProgressError { + pub code: String, + pub message: String, +} + +struct Slot { + id: u64, + started: Started, + opened: Instant, + timeout: Duration, + state: State, + error: Option, + warning: Option, + cancel: Arc, + thread: Option>, +} + +impl Slot { + fn progress(&self, already_open: bool) -> Progress { + let expires_in_secs = match self.state { + State::Waiting => self.timeout.saturating_sub(self.opened.elapsed()).as_secs(), + _ => 0, + }; + Progress { + state: self.state, + started: self.started.clone(), + expires_in_secs, + already_open, + error: self.error.clone(), + warning: self.warning.clone(), + } + } + + fn is_waiting(&self) -> bool { + self.state == State::Waiting && self.opened.elapsed() < self.timeout + } +} + +/// This process's background sign-in, if one has run: one at a time. +static SLOT: Mutex> = Mutex::new(None); + +/// Starts a sign-in on a thread and returns at once, for a frontend that +/// polls (`progress`) instead of blocking. +/// +/// A stored session that still works means nothing to sign in to; a sign-in +/// already waiting is returned again rather than opening a second one. +pub fn start_background(options: &Options) -> Result { + static NEXT: AtomicU64 = AtomicU64::new(1); + // Held while the sign-in starts, so two calls never open two windows. + let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(open) = slot.as_ref().filter(|open| open.is_waiting()) { + return Ok(open.progress(true)); + } + if super::verify_session().is_ok() { + return Ok(Progress { + state: State::SignedIn, + started: Started::Existing, + expires_in_secs: 0, + already_open: false, + error: None, + warning: None, + }); + } + let sign_in = SignIn::start(options)?; + let id = NEXT.fetch_add(1, Ordering::Relaxed); + let cancel = Arc::new(AtomicBool::new(false)); + let mut open = Slot { + id, + started: sign_in.started().clone(), + opened: Instant::now(), + timeout: sign_in.timeout(), + state: State::Waiting, + error: None, + warning: None, + cancel: cancel.clone(), + thread: None, + }; + let progress = open.progress(false); + open.thread = Some(std::thread::spawn(move || { + // Nobody reads a polling frontend's stderr, so what the wait warns + // about (a refused session) goes into the progress it polls. + let sink = crate::runtime::on_warning(move |message| { + let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(open) = slot.as_mut().filter(|open| open.id == id) { + open.warning = Some(message.to_string()); + } + }); + let result = sign_in.wait_until(&cancel); + drop(sink); + let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(open) = slot.as_mut().filter(|open| open.id == id) { + match result { + Ok(_) => { + open.state = State::SignedIn; + open.warning = None; + } + Err(err) => { + open.state = State::Failed; + open.error = Some(ProgressError { + code: err.code().to_string(), + message: err.to_string(), + }); + } + } + } + })); + *slot = Some(open); + Ok(progress) +} + +/// Ends a background sign-in, for a process about to exit: a thread does not +/// outlive `main`, so without this its browser window would stay open with +/// nothing reading it. +pub fn shutdown() { + let (thread, browser) = { + let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner); + let Some(open) = slot.as_mut() else { + return; + }; + open.cancel.store(true, Ordering::Relaxed); + ( + open.thread.take(), + matches!(open.started, Started::Browser { .. }), + ) + }; + let Some(thread) = thread.filter(|_| browser) else { + return; + }; + let deadline = Instant::now() + SHUTDOWN_GRACE; + while !thread.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// The background sign-in this process ran last, if any. +pub fn progress() -> Option { + SLOT.lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(|open| open.progress(false)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The browser is skipped exactly when it would not reach the user. + #[test] + fn the_browser_is_skipped_over_ssh_and_without_a_display() { + assert_eq!(blocker(false, true), None); + assert!(blocker(true, true).unwrap().contains("SSH")); + assert!(blocker(false, false).unwrap().contains("no display")); + } + + /// Frontends read the method from one field, whichever way was taken. + #[test] + fn progress_names_its_method_and_hides_what_it_does_not_have() { + let page = Progress { + state: State::Waiting, + started: Started::Page { + url: "http://127.0.0.1:7878/login/x".to_string(), + port: 7878, + forward: "ssh -L 7878:127.0.0.1:7878 me@host".to_string(), + remote: false, + reason: None, + }, + expires_in_secs: 300, + already_open: false, + error: None, + warning: None, + }; + let value = serde_json::to_value(&page).unwrap(); + assert_eq!(value["state"], "waiting"); + assert_eq!(value["method"], "page"); + assert_eq!(value["port"], 7878); + assert!( + ["reason", "error", "warning"] + .iter() + .all(|key| value.get(key).is_none()) + ); + + let failed = Progress { + state: State::Failed, + started: Started::Browser { + browser: "Helium".to_string(), + }, + expires_in_secs: 0, + already_open: false, + error: Some(ProgressError { + code: "IO".to_string(), + message: "the window was closed".to_string(), + }), + warning: Some("Stockbit refused the session".to_string()), + }; + let value = serde_json::to_value(&failed).unwrap(); + assert_eq!(value["method"], "browser"); + assert_eq!(value["browser"], "Helium"); + assert_eq!(value["error"]["code"], "IO"); + assert_eq!(value["warning"], "Stockbit refused the session"); + } +} diff --git a/crates/idx-core/src/api/types.rs b/crates/idx-core/src/api/types.rs index deaaafa..6f4e854 100644 --- a/crates/idx-core/src/api/types.rs +++ b/crates/idx-core/src/api/types.rs @@ -57,6 +57,91 @@ pub struct Quote { /// quote is returned, not when it is cached. #[serde(default)] pub behind_session: bool, + /// Best bid in IDR (whole Rupiah). Stockbit only, and only while its + /// market status is `open`: outside trading it is the closing book, not + /// a live one. Absent from JSON when unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bid: Option, + /// Best offer in IDR (whole Rupiah), gated like `bid`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offer: Option, + /// Stockbit's market state as its public page words it: `open` while + /// the regular market trades, `close` otherwise (also the lunch break + /// and auctions). A logged-in quote maps its session state to the same + /// two words. Absent for other providers, or when the state is unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub market_status: Option, + /// What the provider says about this symbol's session: suspended, + /// delisted, or no trades. Never inferred. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_note: Option, +} + +/// One price of today's session, minute by minute (Stockbit's chart). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct IntradayPoint { + /// The minute, in WIB. + pub time: DateTime, + /// Last price at that minute in IDR; indices carry two decimals. + pub price: f64, +} + +/// The latest session of one IDX symbol: its quote and the price per minute. +/// Stockbit's public page carries no per-minute OHLC or volume. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Intraday { + pub quote: Quote, + /// Oldest first; empty when the symbol did not trade (suspended, + /// delisted, or no session yet), never an error. + pub points: Vec, +} + +/// One price level of an order book. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookLevel { + /// Limit price in IDR (whole Rupiah). + pub price: i64, + /// Size resting at this price, in shares (1 lot = 100 shares). + pub shares: u64, + /// Orders queued at this price. + pub orders: u64, +} + +/// The order book of one IDX stock from the user's Stockbit session, with +/// the session's figures and foreign flow. Outside trading it is the book as +/// the market left it; `market_status` says which. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderBook { + pub symbol: String, + pub name: Option, + /// Last price in IDR (whole Rupiah). + pub price: i64, + pub change: Option, + pub change_pct: Option, + pub prev_close: Option, + pub open: Option, + pub high: Option, + pub low: Option, + /// Session volume in shares. + pub volume: Option, + /// Session value traded in IDR. + pub value: Option, + /// Trades in the session. + pub frequency: Option, + /// Foreign buy, sell and net value over the session, IDR; `None` while + /// Stockbit marks them not current (in session they lag a day). + pub foreign_buy: Option, + pub foreign_sell: Option, + pub foreign_net: Option, + /// Best price first. + pub bids: Vec, + /// Best price first. + pub offers: Vec, + /// Stockbit's session state, verbatim (e.g. `STATE_NAME_SESSION_2`, + /// `STATE_NAME_BREAK`, `STATE_NAME_MARKET_CLOSED`). + pub market_status: Option, + /// Stockbit's listing status when it is not active (e.g. suspended). + pub session_note: Option, } /// OHLC candle data normalized from Yahoo Finance chart indicators. diff --git a/crates/idx-core/src/api/yahoo/map.rs b/crates/idx-core/src/api/yahoo/map.rs index f349b85..7bbfc67 100644 --- a/crates/idx-core/src/api/yahoo/map.rs +++ b/crates/idx-core/src/api/yahoo/map.rs @@ -65,6 +65,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result { pub data: T, - /// Provider that produced the data (`msn` or `yahoo`). + /// Provider that produced the data (e.g. `msn`, `yahoo`, `ipot`, `stockbit`). pub source: &'static str, pub fetched_at: DateTime, /// True when an expired cache entry was served (offline, or after a @@ -94,6 +94,14 @@ impl Idx { crate::flow::Flow { idx: self } } + pub fn corporate(&self) -> crate::corporate::Corporate<'_> { + crate::corporate::Corporate { idx: self } + } + + pub fn movements(&self) -> crate::movements::Movements<'_> { + crate::movements::Movements { idx: self } + } + pub fn financial(&self) -> crate::financial::Financial<'_> { crate::financial::Financial { idx: self } } @@ -191,7 +199,7 @@ where Err(err) => { if use_cache && let Some(stale) = cache.any::(spec.bucket, spec.key)? { runtime::warn(format!( - "network failed, serving stale cache for {}", + "network failed ({err}), serving stale cache for {}", spec.subject )); return Ok(hit(stale, true)); diff --git a/crates/idx-core/src/config.rs b/crates/idx-core/src/config.rs index a76c104..a14d505 100644 --- a/crates/idx-core/src/config.rs +++ b/crates/idx-core/src/config.rs @@ -13,10 +13,14 @@ use crate::error::IdxError; pub const OUTPUT_VALUES: &[&str] = &["auto", "table", "json", "jsonl"]; #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "clap", value(rename_all = "lower"))] pub enum ProviderKind { Yahoo, Msn, + // Opt-in: quotes from Stockbit's public page (delayed, no login). + Stockbit, } #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] @@ -27,6 +31,8 @@ pub enum HistoryProviderKind { Auto, Yahoo, Msn, + // Needs the user's Stockbit session (`idx stockbit login`). + Stockbit, } impl ProviderKind { @@ -34,6 +40,7 @@ impl ProviderKind { match self { Self::Yahoo => "yahoo", Self::Msn => "msn", + Self::Stockbit => "stockbit", } } @@ -42,9 +49,11 @@ impl ProviderKind { Ok(Self::Yahoo) } else if value.eq_ignore_ascii_case("msn") { Ok(Self::Msn) + } else if value.eq_ignore_ascii_case("stockbit") { + Ok(Self::Stockbit) } else { Err(IdxError::ConfigError(format!( - "invalid provider '{value}' (expected yahoo or msn)" + "invalid provider '{value}' (expected yahoo, msn, or stockbit)" ))) } } @@ -56,6 +65,7 @@ impl HistoryProviderKind { Self::Auto => "auto", Self::Yahoo => "yahoo", Self::Msn => "msn", + Self::Stockbit => "stockbit", } } @@ -66,9 +76,11 @@ impl HistoryProviderKind { Ok(Self::Yahoo) } else if value.eq_ignore_ascii_case("msn") { Ok(Self::Msn) + } else if value.eq_ignore_ascii_case("stockbit") { + Ok(Self::Stockbit) } else { Err(IdxError::ConfigError(format!( - "invalid history provider '{value}' (expected auto, yahoo, or msn)" + "invalid history provider '{value}' (expected auto, yahoo, msn, or stockbit)" ))) } } diff --git a/crates/idx-core/src/corporate/mod.rs b/crates/idx-core/src/corporate/mod.rs new file mode 100644 index 0000000..58ec161 --- /dev/null +++ b/crates/idx-core/src/corporate/mod.rs @@ -0,0 +1,109 @@ +//! Corporate actions (cash and stock dividends, bonus shares, rights issues, +//! warrants, splits, shareholders' meetings, public exposes, IPOs, tender +//! offers) from the user's Stockbit session: one stock's history, or the +//! market's calendar. Stockbit serves one kind per request, so a query costs +//! one request per kind (paced 1 s apart), each cached for 6 hours. + +pub mod types; + +use chrono::{NaiveDate, Utc}; + +use crate::api::{corporate_action_provider, stockbit, today_wib}; +use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::error::IdxError; +use types::{ActionKind, CalendarRange, CorporateAction}; + +/// Announcements change a few times a day at most. +const TTL_SECS: u64 = 6 * 3600; +const BUCKET: &str = "stockbit-corpaction"; + +pub struct Corporate<'a> { + pub(crate) idx: &'a Idx, +} + +impl Corporate<'_> { + /// Today in WIB. + pub fn today(&self) -> NaiveDate { + today_wib() + } + + /// One IDX stock's corporate actions of `kinds`, newest first. + pub fn actions( + &self, + symbol: &str, + kinds: &[ActionKind], + policy: CachePolicy, + ) -> Result>, IdxError> { + let (resolved, _) = self.idx.idx_code(symbol, "corporate actions")?; + let mut fetched = self.fetch(kinds, Some(&resolved), policy)?; + fetched + .data + .sort_by(|a, b| b.date.cmp(&a.date).then(a.kind.cmp(&b.kind))); + Ok(fetched) + } + + /// The market's corporate actions of `kinds` dated within `range` (see + /// `CorporateAction::date`), plus offers still open in it (see + /// `CalendarRange::includes`), oldest first. + pub fn calendar( + &self, + range: CalendarRange, + kinds: &[ActionKind], + policy: CachePolicy, + ) -> Result>, IdxError> { + let fetched = self.fetch(kinds, None, policy)?; + Ok(fetched.map(|mut actions| { + actions.retain(|a| range.includes(a)); + actions.sort_by(|a, b| (a.date, &a.symbol, a.kind).cmp(&(b.date, &b.symbol, b.kind))); + actions + })) + } + + /// Every kind in turn, through the cache; the result is as old as its + /// oldest part and stale if any part is. + fn fetch( + &self, + kinds: &[ActionKind], + resolved: Option<&str>, + policy: CachePolicy, + ) -> Result>, IdxError> { + if kinds.is_empty() { + return Err(IdxError::InvalidInput( + "name at least one kind of corporate action".to_string(), + )); + } + let provider = corporate_action_provider(); + let subject = resolved.unwrap_or("corporate actions"); + let mut merged = Fetched { + data: Vec::new(), + source: stockbit::SOURCE, + fetched_at: Utc::now(), + stale: false, + }; + let mut seen = Vec::with_capacity(kinds.len()); + for &kind in kinds { + if seen.contains(&kind) { + continue; + } + seen.push(kind); + let key = format!("{}:{}", kind.as_str(), resolved.unwrap_or("market")); + let part = cached_fetch( + &self.idx.cache, + CacheSpec { + bucket: BUCKET, + key: &key, + ttl_secs: TTL_SECS, + subject, + source: stockbit::SOURCE, + }, + policy, + || provider.corporate_actions(kind, resolved), + Ok, + )?; + merged.fetched_at = merged.fetched_at.min(part.fetched_at); + merged.stale |= part.stale; + merged.data.extend(part.data); + } + Ok(merged) + } +} diff --git a/crates/idx-core/src/corporate/types.rs b/crates/idx-core/src/corporate/types.rs new file mode 100644 index 0000000..c03fa90 --- /dev/null +++ b/crates/idx-core/src/corporate/types.rs @@ -0,0 +1,419 @@ +use chrono::{Duration, NaiveDate}; +use serde::{Deserialize, Serialize}; + +use crate::error::IdxError; + +/// The kinds of corporate action Stockbit lists, one request each. JSON +/// names them as `--kind` does (`as_str`), so an output kind can be passed +/// back in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "kebab-case")] +pub enum ActionKind { + /// Cash dividend. + Dividend, + #[cfg_attr( + feature = "clap", + value(name = "stock-dividend", alias = "stock_dividend") + )] + #[serde(alias = "stock_dividend")] + StockDividend, + /// Bonus shares. + Bonus, + /// Rights issue (HMETD). + Rights, + Warrant, + /// Stock split. + Split, + #[cfg_attr( + feature = "clap", + value(name = "reverse-split", alias = "reverse_split") + )] + #[serde(alias = "reverse_split")] + ReverseSplit, + /// Shareholders' meeting (RUPS). + Meeting, + /// Public expose. + #[cfg_attr( + feature = "clap", + value(name = "expose", alias = "public-expose", alias = "public_expose") + )] + #[serde(rename = "expose", alias = "public_expose")] + PublicExpose, + Ipo, + #[cfg_attr( + feature = "clap", + value(name = "tender", alias = "tender-offer", alias = "tender_offer") + )] + #[serde(rename = "tender", alias = "tender_offer")] + TenderOffer, +} + +impl ActionKind { + pub const ALL: [Self; 11] = [ + Self::Dividend, + Self::StockDividend, + Self::Bonus, + Self::Rights, + Self::Warrant, + Self::Split, + Self::ReverseSplit, + Self::Meeting, + Self::PublicExpose, + Self::Ipo, + Self::TenderOffer, + ]; + + /// What one stock's history covers by default: everything but its IPO. + pub const STOCK_DEFAULT: [Self; 10] = [ + Self::Dividend, + Self::StockDividend, + Self::Bonus, + Self::Rights, + Self::Warrant, + Self::Split, + Self::ReverseSplit, + Self::Meeting, + Self::PublicExpose, + Self::TenderOffer, + ]; + + /// The CLI's name (`--kind`), also used in MCP inputs. + pub fn as_str(self) -> &'static str { + match self { + Self::Dividend => "dividend", + Self::StockDividend => "stock-dividend", + Self::Bonus => "bonus", + Self::Rights => "rights", + Self::Warrant => "warrant", + Self::Split => "split", + Self::ReverseSplit => "reverse-split", + Self::Meeting => "meeting", + Self::PublicExpose => "expose", + Self::Ipo => "ipo", + Self::TenderOffer => "tender", + } + } + + /// Whether the action entitles holders on a cum date (and so moves the + /// price on the ex date). + pub fn is_entitlement(self) -> bool { + matches!( + self, + Self::Dividend + | Self::StockDividend + | Self::Bonus + | Self::Rights + | Self::Split + | Self::ReverseSplit + ) + } +} + +impl std::str::FromStr for ActionKind { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + let wanted = value.trim().to_ascii_lowercase().replace('_', "-"); + // The names JSON carried before they matched `--kind`, which an + // agent may pass back from an earlier answer. + let wanted = match wanted.as_str() { + "public-expose" => "expose", + "tender-offer" => "tender", + other => other, + }; + Self::ALL + .into_iter() + .find(|kind| kind.as_str() == wanted) + .ok_or_else(|| { + let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect(); + IdxError::InvalidInput(format!( + "invalid kind '{value}' (expected one of: {})", + names.join(", ") + )) + }) + } +} + +/// `old : new` as Stockbit states it: a 1:5 split turns 1 share into 5; a +/// 655:104 rights issue gives 104 rights per 655 shares held; a 6:4 bonus +/// gives 4 bonus shares per 6 held. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Ratio { + pub old: u64, + pub new: u64, +} + +/// Inclusive dates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct DateRange { + pub start: NaiveDate, + pub end: NaiveDate, +} + +/// One corporate action of one stock. Which fields are present depends on +/// `kind`; absent ones are left out of JSON. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CorporateAction { + /// `BBCA.JK`. + pub symbol: String, + pub kind: ActionKind, + /// Where the action sits on a calendar: the cum date for entitlements + /// (the last day to buy and be entitled), else the meeting or expose + /// day, the listing day, a warrant's first trading day, or a tender + /// offer's first day. An entitlement Stockbit gives no cum date for is + /// placed on its ex date, and `cum_date` is then absent. + pub date: NaiveDate, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cum_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ex_date: Option, + /// Recording date; for a meeting, the date that decides who may attend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub record_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pay_date: Option, + /// Cash dividend per share as Stockbit gives it: IDR to the sen (e.g. + /// 3.8), another `currency` to 6 decimals (e.g. USD 0.0012). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cash_per_share: Option, + /// The dividend's currency (ISO code, e.g. `USD`) when it is not IDR; + /// absent for IDR, which every recorded dividend was. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub currency: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ratio: Option, + /// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a + /// 6:4 bonus. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub factor: Option, + /// Whole IDR: the rights exercise price, a warrant's exercise price, the + /// tender offer price or the final IPO price. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price: Option, + /// When rights or warrants trade, a tender offer runs, or an IPO is + /// offered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub period: Option, + /// When warrants can be exercised. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exercise: Option, + /// Shares offered (IPO) or sought (tender offer). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shares: Option, + /// Share of the company a tender offer seeks, basis points. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub percentage_bps: Option, + /// Meeting or expose time, as Stockbit writes it (WIB). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub venue: Option, + /// The issuer's name, when Stockbit gives it (IPO, tender offer). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl CorporateAction { + pub fn new(symbol: String, kind: ActionKind, date: NaiveDate) -> Self { + Self { + symbol, + kind, + date, + cum_date: None, + ex_date: None, + record_date: None, + pay_date: None, + cash_per_share: None, + currency: None, + ratio: None, + factor: None, + price: None, + period: None, + exercise: None, + shares: None, + percentage_bps: None, + time: None, + venue: None, + name: None, + } + } +} + +/// Default calendar span, days from today. +pub const CALENDAR_DEFAULT_DAYS: i64 = 30; +/// Longest calendar range, days. +pub const CALENDAR_MAX_DAYS: i64 = 366; + +/// The dates a market-wide calendar covers, inclusive. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CalendarRange { + pub from: NaiveDate, + pub to: NaiveDate, +} + +impl CalendarRange { + /// `from` defaults to today, `to` to 30 days after `from`; at most + /// `CALENDAR_MAX_DAYS` days, counting both ends. + pub fn from_args( + from: Option<&str>, + to: Option<&str>, + today: NaiveDate, + ) -> Result { + let parse = |name: &str, value: &str| { + NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| { + IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD")) + }) + }; + let from = from.map(|v| parse("from", v)).transpose()?.unwrap_or(today); + let to = match to { + Some(v) => parse("to", v)?, + None => from + .checked_add_signed(Duration::days(CALENDAR_DEFAULT_DAYS)) + .ok_or_else(|| IdxError::InvalidInput(format!("from ({from}) is out of range")))?, + }; + if to < from { + return Err(IdxError::InvalidInput(format!( + "to ({to}) is before from ({from})" + ))); + } + if (to - from).num_days() >= CALENDAR_MAX_DAYS { + return Err(IdxError::InvalidInput(format!( + "a calendar covers at most {CALENDAR_MAX_DAYS} days" + ))); + } + Ok(Self { from, to }) + } + + pub fn contains(&self, date: NaiveDate) -> bool { + (self.from..=self.to).contains(&date) + } + + /// Whether `action` belongs on this calendar: dated in it, or an offer + /// still open in it (rights trading, a tender offer, an IPO offer), so a + /// calendar shows what can still be acted on. A warrant's windows run + /// for years, so a warrant counts by its date alone. + pub fn includes(&self, action: &CorporateAction) -> bool { + let open_offer = matches!( + action.kind, + ActionKind::Rights | ActionKind::TenderOffer | ActionKind::Ipo + ) && action + .period + .is_some_and(|period| period.start <= self.to && period.end >= self.from); + self.contains(action.date) || open_offer + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn day(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() + } + + /// A calendar shows what is dated in it and the offers still open in it; + /// a warrant's years-long windows do not put it on every calendar. + #[test] + fn test_calendar_includes_offers_still_open() { + let range = CalendarRange { + from: day("2026-11-03"), + to: day("2026-11-12"), + }; + let with_period = |kind, date: &str, start: &str, end: &str| { + let mut action = CorporateAction::new("AUTO.JK".into(), kind, day(date)); + action.period = Some(DateRange { + start: day(start), + end: day(end), + }); + action + }; + let tender = with_period( + ActionKind::TenderOffer, + "2026-10-13", + "2026-10-13", + "2026-11-11", + ); + assert!(range.includes(&tender), "open until 11-11"); + let rights = with_period(ActionKind::Rights, "2026-10-20", "2026-10-28", "2026-11-03"); + assert!(range.includes(&rights), "trades on the first day"); + let closed = with_period( + ActionKind::TenderOffer, + "2026-09-01", + "2026-09-01", + "2026-11-02", + ); + assert!(!range.includes(&closed), "closed the day before"); + let warrant = with_period( + ActionKind::Warrant, + "2025-01-10", + "2025-01-10", + "2028-01-10", + ); + assert!(!range.includes(&warrant), "a warrant counts by its date"); + let dated = CorporateAction::new("BBCA.JK".into(), ActionKind::Dividend, day("2026-11-12")); + assert!(range.includes(&dated)); + } + + #[test] + fn test_calendar_range_defaults_and_bounds() { + let today = day("2026-11-03"); + let range = CalendarRange::from_args(None, None, today).unwrap(); + assert_eq!((range.from, range.to), (today, day("2026-12-03"))); + let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap(); + assert_eq!(range.to, day("2026-12-10")); + // 366 days counting both ends is the most. + let range = CalendarRange::from_args(Some("2028-01-01"), Some("2028-12-31"), today); + assert!(range.is_ok()); + for (from, to) in [ + (Some("2026-11-10"), Some("2026-11-09")), + (Some("2026-01-01"), Some("2027-06-01")), + (Some("2028-01-01"), Some("2029-01-01")), + (Some("11/10/2026"), None), + ] { + assert!(matches!( + CalendarRange::from_args(from, to, today), + Err(IdxError::InvalidInput(_)) + )); + } + } + + /// The last date there is: 30 days on is an error, not a panic. + #[test] + fn test_calendar_range_at_the_end_of_time() { + let last = NaiveDate::MAX.format("%Y-%m-%d").to_string(); + let err = CalendarRange::from_args(Some(&last), None, day("2026-11-03")).unwrap_err(); + assert!(err.to_string().contains("out of range"), "{err}"); + } + + #[test] + fn test_action_kind_names_round_trip() { + for kind in ActionKind::ALL { + assert_eq!(kind.as_str().parse::().unwrap(), kind); + } + assert!("rups".parse::().is_err()); + assert_eq!( + "stock_dividend".parse::().unwrap(), + ActionKind::StockDividend + ); + } + + /// JSON's `kind` is the name `--kind` and MCP take. + #[test] + fn test_action_kind_json_is_the_input_name() { + for kind in ActionKind::ALL { + let json = serde_json::to_value(kind).unwrap(); + assert_eq!(json, kind.as_str(), "{kind:?}"); + assert_eq!(serde_json::from_value::(json).unwrap(), kind); + } + // Entries cached before the names changed still read. + let old: ActionKind = serde_json::from_str("\"public_expose\"").unwrap(); + assert_eq!(old, ActionKind::PublicExpose); + // And as input, so a kind copied from an older answer works. + assert_eq!("public_expose".parse::().unwrap(), old); + assert_eq!( + "tender_offer".parse::().unwrap(), + ActionKind::TenderOffer + ); + } +} diff --git a/crates/idx-core/src/error.rs b/crates/idx-core/src/error.rs index d991c6b..84279a9 100644 --- a/crates/idx-core/src/error.rs +++ b/crates/idx-core/src/error.rs @@ -34,6 +34,10 @@ pub enum IdxError { InvalidInput(String), #[error("no market data: {0}")] NoMarketData(String), + /// The upstream refused this client outright (e.g. a CDN's HTTP 403 + /// block page). Retrying does not help; the message says what might. + #[error("upstream blocked: {0}")] + UpstreamBlocked(String), } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] @@ -53,6 +57,7 @@ pub enum ErrorCode { PdfParseError, InvalidInput, NoMarketData, + UpstreamBlocked, } /// The stable wire form frontends emit, e.g. `SYMBOLNOTFOUND`. @@ -80,6 +85,7 @@ impl IdxError { Self::PdfParseError(_) => ErrorCode::PdfParseError, Self::InvalidInput(_) => ErrorCode::InvalidInput, Self::NoMarketData(_) => ErrorCode::NoMarketData, + Self::UpstreamBlocked(_) => ErrorCode::UpstreamBlocked, } } @@ -93,7 +99,8 @@ impl IdxError { | ErrorCode::RateLimited | ErrorCode::ProviderUnavailable | ErrorCode::Offline - | ErrorCode::AuthError => 4, + | ErrorCode::AuthError + | ErrorCode::UpstreamBlocked => 4, ErrorCode::Unsupported => 5, ErrorCode::Io | ErrorCode::ParseError @@ -119,4 +126,11 @@ mod tests { let unsupported = IdxError::Unsupported("history unavailable".to_string()); assert_eq!(unsupported.code(), ErrorCode::Unsupported); } + + #[test] + fn upstream_blocked_has_its_own_wire_code_and_network_exit() { + let blocked = IdxError::UpstreamBlocked("stockbit.com: HTTP 403".to_string()); + assert_eq!(blocked.code().to_string(), "UPSTREAMBLOCKED"); + assert_eq!(blocked.exit_code(), 4); + } } diff --git a/crates/idx-core/src/lib.rs b/crates/idx-core/src/lib.rs index bd01c1c..1bd3434 100644 --- a/crates/idx-core/src/lib.rs +++ b/crates/idx-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod broker; pub mod cache; mod client; pub mod config; +pub mod corporate; pub mod curl_impersonate; pub mod error; pub mod financial; @@ -15,6 +16,7 @@ pub mod flow; #[cfg(feature = "ownership")] pub mod investor; pub mod market; +pub mod movements; #[cfg(feature = "ownership")] pub mod ownership; pub mod text; @@ -22,12 +24,40 @@ pub mod text; pub use client::{CachePolicy, Fetched, Idx}; pub mod runtime { + use std::cell::RefCell; use std::fmt::Display; use std::sync::atomic::{AtomicBool, Ordering}; static QUIET: AtomicBool = AtomicBool::new(false); static JSON: AtomicBool = AtomicBool::new(false); + type Sink = Box; + + thread_local! { + static SINK: RefCell> = const { RefCell::new(None) }; + } + + /// Sends this thread's warnings to `sink` too, until the guard drops: a + /// frontend whose stderr nobody reads (idx-mcp) hands them on this way. + /// Warnings still go to stderr, and `set_quiet` does not silence `sink`. + pub fn on_warning(sink: impl Fn(&str) + 'static) -> WarningSink { + let previous = SINK.with(|slot| slot.replace(Some(Box::new(sink)))); + WarningSink { previous } + } + + /// Restores the thread's previous sink when dropped. + #[must_use = "warnings reach the sink only while this guard lives"] + pub struct WarningSink { + previous: Option, + } + + impl Drop for WarningSink { + fn drop(&mut self) { + let previous = self.previous.take(); + SINK.with(|slot| *slot.borrow_mut() = previous); + } + } + pub fn set_quiet(quiet: bool) { QUIET.store(quiet, Ordering::Relaxed); } @@ -62,6 +92,40 @@ pub mod runtime { } pub fn warn(message: impl Display) { + let message = message.to_string(); + SINK.with(|slot| { + if let Some(sink) = slot.borrow().as_ref() { + sink(&message); + } + }); emit("warning", message); } + + #[cfg(test)] + mod tests { + use super::*; + use std::rc::Rc; + + /// A sink sees this thread's warnings while its guard lives, and an + /// inner one hands back to the outer when it drops. + #[test] + fn warnings_reach_the_sink_while_its_guard_lives() { + let seen = Rc::new(RefCell::new(Vec::new())); + let outer = { + let seen = seen.clone(); + on_warning(move |m| seen.borrow_mut().push(format!("outer {m}"))) + }; + warn("one"); + { + let seen = seen.clone(); + let _inner = on_warning(move |m| seen.borrow_mut().push(format!("inner {m}"))); + warn("two"); + } + warn("three"); + drop(outer); + warn("four"); + info("not a warning"); + assert_eq!(*seen.borrow(), ["outer one", "inner two", "outer three"]); + } + } } diff --git a/crates/idx-core/src/market.rs b/crates/idx-core/src/market.rs index 748458a..5282919 100644 --- a/crates/idx-core/src/market.rs +++ b/crates/idx-core/src/market.rs @@ -6,8 +6,10 @@ use std::str::FromStr; use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday}; -use crate::api::types::{Interval, Ohlc, Period, Quote}; -use crate::api::{history_provider, history_source, idx_offset}; +use crate::api::types::{Interval, Intraday, Ohlc, OrderBook, Period, Quote}; +use crate::api::{ + history_provider, history_source, idx_offset, intraday_provider, orderbook_provider, stockbit, +}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::config::{HistoryProviderKind, ProviderKind}; use crate::error::IdxError; @@ -34,6 +36,10 @@ impl Market<'_> { ) -> Result>, IdxError> { let idx = self.idx; let bucket = idx.bucket("quote"); + let ttl_secs = match idx.provider.kind() { + ProviderKind::Stockbit => idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS), + _ => idx.config.quote_ttl, + }; let session = latest_session(Utc::now()); let mut quotes = Vec::with_capacity(symbols.len()); for symbol in symbols { @@ -43,7 +49,7 @@ impl Market<'_> { CacheSpec { bucket: &bucket, key: &resolved, - ttl_secs: idx.config.quote_ttl, + ttl_secs, subject: &resolved, source: idx.provider.kind().as_str(), }, @@ -60,12 +66,51 @@ impl Market<'_> { Ok(quotes) } + /// Today's session of one IDX symbol from Stockbit's public page, + /// whichever market provider is selected: the quote (bid/offer only while + /// the market is open) and the price per minute. + pub fn intraday( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, IdxError> { + let idx = self.idx; + let resolved = idx.resolve(symbol)?; + let provider = intraday_provider(); + let mut day = cached_fetch( + &idx.cache, + CacheSpec { + bucket: "stockbit-intraday", + key: &resolved, + ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS), + subject: &resolved, + source: stockbit::SOURCE, + }, + policy, + || provider.intraday(&resolved), + Ok, + )?; + // Stockbit's page keeps the last session's chart up after the close, + // so a cached or lagging day reads as current unless it is marked. + mark_behind_session(&mut day.data.quote, latest_session(Utc::now())); + Ok(day) + } + /// Resolves the history source for an optional `--history-provider` - /// override, without fetching. - pub fn history_source(&self, mode: Option) -> HistorySource { + /// override and the window asked for, without fetching. + pub fn history_source( + &self, + mode: Option, + period: &Period, + interval: &Interval, + ) -> HistorySource { let mode = mode.unwrap_or(self.idx.config.history_provider); let selected = self.idx.provider.kind(); - let provider = history_source(selected, mode); + // Only `auto` under the stockbit provider looks for a session. + let session = mode == HistoryProviderKind::Auto + && selected == ProviderKind::Stockbit + && stockbit::has_session(); + let provider = history_source(selected, mode, period, interval, session); HistorySource { provider, mode, @@ -82,22 +127,72 @@ impl Market<'_> { 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!("{}-history", provider_kind.as_str()); let resolved = idx.resolve(symbol)?; let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str()); + self.with_history_fallback(source, |source| { + let provider = history_provider(source.provider, idx.config.verbose); + let bucket = format!("{}-history", source.provider.as_str()); + cached_fetch( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &key, + ttl_secs: idx.config.quote_ttl, + subject: &resolved, + source: source.provider.as_str(), + }, + policy, + || provider.history(&resolved, period, interval), + Ok, + ) + }) + } + + /// Runs `fetch` on `source`; when `auto` picked Stockbit because a + /// session file exists and Stockbit then refuses that session, runs it + /// again on Yahoo instead of failing (`has_session` sees only the file). + pub(crate) fn with_history_fallback( + &self, + source: HistorySource, + fetch: impl Fn(HistorySource) -> Result, IdxError>, + ) -> Result, IdxError> { + match fetch(source) { + Err(IdxError::AuthError(reason)) + if source.mode == HistoryProviderKind::Auto + && source.provider == ProviderKind::Stockbit => + { + crate::runtime::warn(format!("{reason}; history falls back to yahoo meanwhile")); + fetch(HistorySource { + provider: ProviderKind::Yahoo, + is_fallback: self.idx.provider.kind() != ProviderKind::Yahoo, + ..source + }) + } + other => other, + } + } + + /// The order book of one IDX stock from the user's Stockbit session, + /// whichever market provider is selected. + pub fn orderbook( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, IdxError> { + let idx = self.idx; + let resolved = idx.resolve(symbol)?; + let provider = orderbook_provider(); cached_fetch( &idx.cache, CacheSpec { - bucket: &bucket, - key: &key, - ttl_secs: idx.config.quote_ttl, + bucket: "stockbit-orderbook", + key: &resolved, + ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS), subject: &resolved, - source: provider_kind.as_str(), + source: stockbit::SOURCE, }, policy, - || provider.history(&resolved, period, interval), + || provider.orderbook(&resolved), Ok, ) } @@ -380,6 +475,10 @@ mod tests { avg_volume: None, as_of: None, behind_session: false, + bid: None, + offer: None, + market_status: None, + session_note: None, } } diff --git a/crates/idx-core/src/movements/mod.rs b/crates/idx-core/src/movements/mod.rs new file mode 100644 index 0000000..293d0a7 --- /dev/null +++ b/crates/idx-core/src/movements/mod.rs @@ -0,0 +1,165 @@ +//! Shareholder movements from the user's Stockbit session: reported changes +//! in insiders' and major holders' stakes (IDX insider disclosures and KSEI's +//! 5% holder changes), one stock's or the market's, newest first. Each page +//! is 50 rows and one request (paced 1 s apart). A query's pages are read +//! together and cached together for an hour, so they come from one moment +//! of a feed that gains rows at the top. +//! +//! These are the reports as filed. Whether they predict anything is not +//! claimed here: share plans read as a cluster of buys at one price, and a +//! row appears days after the trade (see `docs/STOCKBIT.md`). + +pub mod types; + +use std::cell::RefCell; + +use chrono::Utc; + +use crate::api::{ShareholderMovementProvider, shareholder_movement_provider, stockbit}; +use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::error::IdxError; +use crate::runtime; +use types::{MAX_PAGES, MovementList, MovementQuery}; + +/// The feed gains rows through the day; an hour keeps a session's repeated +/// queries cheap without hiding a day's filings for long. +const TTL_SECS: u64 = 3600; +const BUCKET: &str = "stockbit-movements"; + +pub struct Movements<'a> { + pub(crate) idx: &'a Idx, +} + +impl Movements<'_> { + /// Up to `query.pages` pages of the feed (at most `MAX_PAGES`), newest + /// first; `more` says whether rows remain past them. + /// + /// When a page after the first fails and no cached answer is at hand, + /// the rows read before it are returned with `incomplete` saying why, + /// and are not cached. + pub fn list( + &self, + query: &MovementQuery, + policy: CachePolicy, + ) -> Result, IdxError> { + let mut query = query.clone(); + query.pages = query.pages.clamp(1, MAX_PAGES); + if let Some(symbol) = &query.symbol { + let (resolved, _) = self.idx.idx_code(symbol, "shareholder movements")?; + query.symbol = Some(resolved); + } + let provider = shareholder_movement_provider(); + let subject = query + .symbol + .clone() + .unwrap_or_else(|| "shareholder movements".to_string()); + let key = cache_key(&query); + let partial = RefCell::new(None); + let result = cached_fetch( + &self.idx.cache, + CacheSpec { + bucket: BUCKET, + key: &key, + ttl_secs: TTL_SECS, + subject: &subject, + source: stockbit::SOURCE, + }, + policy, + || read_pages(provider.as_ref(), &query, &partial), + Ok, + ); + match (result, partial.into_inner()) { + (Err(err), Some(mut list)) => { + runtime::warn(format!( + "{err}; showing the {} rows read before it", + list.movements.len() + )); + list.incomplete = Some(err.to_string()); + Ok(Fetched { + data: list, + source: stockbit::SOURCE, + fetched_at: Utc::now(), + stale: false, + }) + } + (result, _) => result, + } + } +} + +/// Reads the query's pages in turn. A failure after the first page leaves +/// the rows read so far in `partial` and fails, so they are not cached. +fn read_pages( + provider: &dyn ShareholderMovementProvider, + query: &MovementQuery, + partial: &RefCell>, +) -> Result { + let mut list = MovementList::default(); + for page in 1..=query.pages { + match provider.shareholder_movements(query, page) { + Ok(part) => { + list.movements.extend(part.movements); + list.more = part.more; + if !part.more { + break; + } + } + Err(err) if page > 1 => { + list.more = true; + *partial.borrow_mut() = Some(list); + return Err(err); + } + Err(err) => return Err(err), + } + } + Ok(list) +} + +/// Every filter and the page count, so no two queries share an entry. +fn cache_key(query: &MovementQuery) -> String { + let opt = |value: Option| value.unwrap_or_else(|| "-".to_string()); + format!( + "{}:{}:{}:{}:{}:{}:{}p", + query.symbol.as_deref().unwrap_or("market"), + opt(query.from.map(|d| d.to_string())), + opt(query.to.map(|d| d.to_string())), + query.holder_id.as_deref().unwrap_or("-"), + query.action.map(|a| a.as_str()).unwrap_or("-"), + query.source.map(|s| s.as_str()).unwrap_or("-"), + query.pages, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use types::{ActionFilter, MovementSource}; + + #[test] + fn cache_keys_differ_by_every_filter() { + let base = MovementQuery::from_args(None, None, None, None, 1).unwrap(); + let mut two = base.clone(); + two.pages = 2; + let mut keys = vec![cache_key(&base), cache_key(&two)]; + let mut q = base.clone(); + q.symbol = Some("BBCA.JK".to_string()); + keys.push(cache_key(&q)); + let mut q = base.clone(); + q.from = chrono::NaiveDate::from_ymd_opt(2026, 10, 1); + keys.push(cache_key(&q)); + let mut q = base.clone(); + q.to = chrono::NaiveDate::from_ymd_opt(2026, 10, 1); + keys.push(cache_key(&q)); + let mut q = base.clone(); + q.holder_id = Some("14074".to_string()); + keys.push(cache_key(&q)); + let mut q = base.clone(); + q.action = Some(ActionFilter::Sell); + keys.push(cache_key(&q)); + let mut q = base; + q.source = Some(MovementSource::Ksei); + keys.push(cache_key(&q)); + let unique: std::collections::HashSet<_> = keys.iter().collect(); + assert_eq!(unique.len(), keys.len(), "{keys:?}"); + } +} diff --git a/crates/idx-core/src/movements/types.rs b/crates/idx-core/src/movements/types.rs new file mode 100644 index 0000000..6c974b4 --- /dev/null +++ b/crates/idx-core/src/movements/types.rs @@ -0,0 +1,339 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +use crate::error::IdxError; + +/// What a holder's row records, as Stockbit labels it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MovementAction { + Buy, + Sell, + /// Shares moved between accounts or parties, not a market trade. + Transfer, + /// A crossing: a negotiated trade between two parties. + Cross, + RightsIssue, + PrivatePlacement, + TenderOffer, + BonusStock, + StockDividend, + CashDividend, + /// A label Stockbit added after this release. + Other, +} + +impl MovementAction { + pub fn as_str(self) -> &'static str { + match self { + Self::Buy => "buy", + Self::Sell => "sell", + Self::Transfer => "transfer", + Self::Cross => "cross", + Self::RightsIssue => "rights_issue", + Self::PrivatePlacement => "private_placement", + Self::TenderOffer => "tender_offer", + Self::BonusStock => "bonus_stock", + Self::StockDividend => "stock_dividend", + Self::CashDividend => "cash_dividend", + Self::Other => "other", + } + } + + /// Whether `filter` (Stockbit's `action_type`) selects this action. + pub fn matches(self, filter: ActionFilter) -> bool { + match filter { + ActionFilter::Buy => self == Self::Buy, + ActionFilter::Sell => self == Self::Sell, + ActionFilter::Transfer => self == Self::Transfer, + ActionFilter::Cross => self == Self::Cross, + ActionFilter::CorporateAction => matches!( + self, + Self::RightsIssue + | Self::PrivatePlacement + | Self::TenderOffer + | Self::BonusStock + | Self::StockDividend + | Self::CashDividend + ), + } + } +} + +/// The action filters Stockbit's feed takes (`action_type`). Corporate +/// action covers rights issues, private placements and the like. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "kebab-case")] +pub enum ActionFilter { + Buy, + Sell, + Transfer, + Cross, + #[cfg_attr(feature = "clap", value(name = "corporate-action"))] + CorporateAction, +} + +impl ActionFilter { + pub const ALL: [Self; 5] = [ + Self::Buy, + Self::Sell, + Self::Transfer, + Self::Cross, + Self::CorporateAction, + ]; + + /// The CLI's name (`--action`), also used in MCP inputs. + pub fn as_str(self) -> &'static str { + match self { + Self::Buy => "buy", + Self::Sell => "sell", + Self::Transfer => "transfer", + Self::Cross => "cross", + Self::CorporateAction => "corporate-action", + } + } +} + +impl std::str::FromStr for ActionFilter { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + let wanted = value.trim().to_ascii_lowercase().replace('_', "-"); + Self::ALL + .into_iter() + .find(|filter| filter.as_str() == wanted) + .ok_or_else(|| { + IdxError::InvalidInput(format!( + "unknown action '{value}'; use one of: {}", + Self::ALL.map(Self::as_str).join(", ") + )) + }) + } +} + +/// Who reported the change: IDX insider disclosures (directors, +/// commissioners and other insiders, any size), or KSEI's daily changes of +/// holders at or above 5%. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "lowercase")] +pub enum MovementSource { + Idx, + Ksei, +} + +impl MovementSource { + pub fn as_str(self) -> &'static str { + match self { + Self::Idx => "idx", + Self::Ksei => "ksei", + } + } +} + +impl std::str::FromStr for MovementSource { + type Err = IdxError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "idx" => Ok(Self::Idx), + "ksei" => Ok(Self::Ksei), + _ => Err(IdxError::InvalidInput(format!( + "unknown source '{value}'; use idx or ksei" + ))), + } + } +} + +/// One reported change in a holder's stake. Share counts are exact; +/// percentages are of the company's shares, in basis points as Stockbit +/// rounds them, so a small insider's stake can read 0. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ShareholderMovement { + pub symbol: String, + /// The day of the change (the trade or the register date). + pub date: NaiveDate, + pub holder: String, + /// Stockbit's id for the holder; `--holder` takes it. + pub holder_id: String, + pub action: MovementAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + /// `Local` or `Foreign`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub locality: Option, + /// Insider roles at the company: `director`, `commissioner`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub roles: Vec, + pub shares_before: i64, + pub shares_after: i64, + /// Signed: negative for a sale. + pub shares_change: i64, + pub percentage_before_bps: i64, + pub percentage_after_bps: i64, + pub percentage_change_bps: i64, + /// Whole IDR per share. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price: Option, + /// The price is Stockbit's estimate (KSEI rows report no price). + pub price_estimated: bool, + /// Whole IDR: shares changed times price. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + /// The stated purpose, free text (`Investasi`, `Divestasi`, ...). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub purpose: Option, +} + +/// One page of Stockbit's feed (50 rows), newest first. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct MovementPage { + pub movements: Vec, + /// Whether a further page exists. + pub more: bool, +} + +/// Most pages one query reads (500 rows). +pub const MAX_PAGES: u32 = 10; + +/// What to list. `symbol` is resolved (`BBCA.JK`) once it reaches a +/// provider; `None` is the whole market. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MovementQuery { + pub symbol: Option, + pub from: Option, + pub to: Option, + pub holder_id: Option, + pub action: Option, + pub source: Option, + /// Pages to read, 1 to `MAX_PAGES`. + pub pages: u32, +} + +impl MovementQuery { + /// Validates user input: dates as YYYY-MM-DD with `from` not after + /// `to`, a numeric holder id, and 1 to `MAX_PAGES` pages. + pub fn from_args( + symbol: Option<&str>, + from: Option<&str>, + to: Option<&str>, + holder_id: Option<&str>, + pages: u32, + ) -> Result { + let parse = |name: &str, value: &str| { + NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| { + IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD")) + }) + }; + let from = from.map(|v| parse("from", v)).transpose()?; + let to = to.map(|v| parse("to", v)).transpose()?; + if let (Some(from), Some(to)) = (from, to) + && to < from + { + return Err(IdxError::InvalidInput(format!( + "to ({to}) is before from ({from})" + ))); + } + let holder_id = holder_id.map(str::trim).filter(|id| !id.is_empty()); + if let Some(id) = holder_id + && !id.bytes().all(|b| b.is_ascii_digit()) + { + return Err(IdxError::InvalidInput(format!( + "holder '{id}' is not a holder id; ids are digits, shown in the HOLDER ID \ + column and as holder_id in JSON" + ))); + } + if !(1..=MAX_PAGES).contains(&pages) { + return Err(IdxError::InvalidInput(format!( + "pages must be 1 to {MAX_PAGES}" + ))); + } + Ok(Self { + symbol: symbol + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string), + from, + to, + holder_id: holder_id.map(str::to_string), + action: None, + source: None, + pages, + }) + } + + /// Whether `movement` passes the query's filters (what Stockbit applies + /// on its side; the mock provider uses it). + pub fn admits(&self, movement: &ShareholderMovement) -> bool { + self.from.is_none_or(|from| movement.date >= from) + && self.to.is_none_or(|to| movement.date <= to) + && self + .holder_id + .as_ref() + .is_none_or(|id| &movement.holder_id == id) + && self + .action + .is_none_or(|filter| movement.action.matches(filter)) + && self + .source + .is_none_or(|source| movement.source == Some(source)) + } +} + +/// The rows a query read, newest first. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct MovementList { + pub movements: Vec, + /// More rows exist past the pages read (raise `--pages`, or narrow the + /// dates). + pub more: bool, + /// Why reading stopped before the pages asked for: a later page failed, + /// and `movements` holds the rows read before it. Absent when every page + /// asked for was read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub incomplete: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_validates_dates_holder_and_pages() { + let query = + MovementQuery::from_args(Some(" bbca "), Some("2026-10-01"), None, Some("14074"), 2) + .unwrap(); + assert_eq!(query.symbol.as_deref(), Some("bbca")); + assert_eq!(query.from, NaiveDate::from_ymd_opt(2026, 10, 1)); + assert_eq!(query.holder_id.as_deref(), Some("14074")); + + for (from, to, holder, pages) in [ + (Some("01-10-2026"), None, None, 1), + (Some("2026-10-02"), Some("2026-10-01"), None, 1), + (None, None, Some("RUDI"), 1), + (None, None, Some("1&page=9"), 1), + (None, None, None, 0), + (None, None, None, MAX_PAGES + 1), + ] { + assert!( + MovementQuery::from_args(None, from, to, holder, pages).is_err(), + "{from:?} {to:?} {holder:?} {pages}" + ); + } + } + + #[test] + fn action_filter_names_round_trip() { + for filter in ActionFilter::ALL { + assert_eq!(filter.as_str().parse::().unwrap(), filter); + } + assert_eq!( + "corporate_action".parse::().unwrap(), + ActionFilter::CorporateAction + ); + assert!("dividend".parse::().is_err()); + assert!(MovementAction::RightsIssue.matches(ActionFilter::CorporateAction)); + assert!(!MovementAction::Transfer.matches(ActionFilter::CorporateAction)); + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_blocked_403.html b/crates/idx-core/tests/fixtures/stockbit_blocked_403.html new file mode 100644 index 0000000..9ad7b8a --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_blocked_403.html @@ -0,0 +1,20 @@ + + +ERROR: The request could not be satisfied + +

403 ERROR

+

The request could not be satisfied.

+
+Request blocked. +We can't connect to the server for this app or website at this time. There might be too much traffic or a configuration error. Try again later, or contact the app or website owner. +
+If you provide content to customers through CloudFront, you can find steps to troubleshoot and help prevent this error by reviewing the CloudFront documentation. +
+
+
+Generated by cloudfront (CloudFront)
+Request ID: VajZoZu7lzhasdjnxU37dHaztROLXHQ6DMpJzTPZoDhkd4I2dVc2mw==
+
+
+
+ diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json b/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json new file mode 100644 index 0000000..7e1e1c1 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json @@ -0,0 +1 @@ +{"data":{"cagr":"", "change":-1225, "drawdown":"", "markingpoint":"", "percentage":"-19.14", "prices":[{"date":"1789491600000", "formatted_date":"2026-09-16", "xlabel":"", "value":"6325", "percentage":"-1.17", "change":-75, "open":"6350", "high":"6425", "low":"6325", "volume":"721574"}, {"date":"1789578000000", "formatted_date":"2026-09-17", "xlabel":"", "value":"6350", "percentage":"-0.78", "change":-50, "open":"6400", "high":"6400", "low":"6325", "volume":"618120"}, {"date":"1789664400000", "formatted_date":"2026-09-18", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6325", "high":"6350", "low":"6225", "volume":"1767537"}, {"date":"1789923600000", "formatted_date":"2026-09-21", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6300", "high":"6300", "low":"6225", "volume":"699141"}, {"date":"1790010000000", "formatted_date":"2026-09-22", "xlabel":"", "value":"6200", "percentage":"-3.12", "change":-200, "open":"6225", "high":"6250", "low":"6175", "volume":"932112"}, {"date":"1790096400000", "formatted_date":"2026-09-23", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6200", "high":"6300", "low":"6200", "volume":"968261"}, {"date":"1790182800000", "formatted_date":"2026-09-24", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6250", "high":"6275", "low":"6225", "volume":"756486"}, {"date":"1790269200000", "formatted_date":"2026-09-25", "xlabel":"", "value":"6250", "percentage":"-2.34", "change":-150, "open":"6225", "high":"6275", "low":"6200", "volume":"894474"}, {"date":"1790528400000", "formatted_date":"2026-09-28", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6225", "high":"6250", "low":"6175", "volume":"1121076"}, {"date":"1790614800000", "formatted_date":"2026-09-29", "xlabel":"", "value":"6150", "percentage":"-3.91", "change":-250, "open":"6150", "high":"6225", "low":"6125", "volume":"1497834"}, {"date":"1790701200000", "formatted_date":"2026-09-30", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6100", "high":"6150", "low":"6050", "volume":"2213357"}, {"date":"1790787600000", "formatted_date":"2026-10-01", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6050", "high":"6150", "low":"6000", "volume":"1180449"}, {"date":"1790874000000", "formatted_date":"2026-10-02", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6000", "high":"6150", "low":"5975", "volume":"1319203"}, {"date":"1791133200000", "formatted_date":"2026-10-05", "xlabel":"", "value":"6175", "percentage":"-3.52", "change":-225, "open":"6100", "high":"6175", "low":"6075", "volume":"899057"}, {"date":"1791219600000", "formatted_date":"2026-10-06", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6175", "high":"6200", "low":"6100", "volume":"1521734"}, {"date":"1791306000000", "formatted_date":"2026-10-07", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6075", "high":"6100", "low":"5975", "volume":"1626908"}, {"date":"1791392400000", "formatted_date":"2026-10-08", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6100", "high":"6150", "low":"6000", "volume":"1131403"}, {"date":"1791478800000", "formatted_date":"2026-10-09", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6025", "high":"6075", "low":"5975", "volume":"870986"}, {"date":"1791738000000", "formatted_date":"2026-10-12", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"6050", "high":"6100", "low":"5950", "volume":"979651"}, {"date":"1791824400000", "formatted_date":"2026-10-13", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"5925", "high":"5950", "low":"5850", "volume":"1227169"}, {"date":"1791910800000", "formatted_date":"2026-10-14", "xlabel":"", "value":"6125", "percentage":"-4.30", "change":-275, "open":"6000", "high":"6125", "low":"5950", "volume":"1363031"}, {"date":"1791997200000", "formatted_date":"2026-10-15", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6075", "high":"6125", "low":"6025", "volume":"1347632"}, {"date":"1792083600000", "formatted_date":"2026-10-16", "xlabel":"", "value":"5175", "percentage":"-19.14", "change":-1225, "open":"5825", "high":"5825", "low":"5175", "volume":"12410982"}], "timeframe":"1m", "xaxisopt":"", "previous":6400, "line_weight":2, "previous_timeframe_price":{"date":"", "formatted_date":"", "xlabel":"", "value":"", "percentage":"", "change":0, "open":"", "high":"", "low":"", "volume":""}, "chart_type":"PRICE_CHART_TYPE_CANDLE", "interval_in_minutes":0, "allowed_chart_type":["PRICE_CHART_TYPE_LINE", "PRICE_CHART_TYPE_CANDLE"], "max_candles":24}, "message":"Successfully retrieved company daily chart"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json b/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json new file mode 100644 index 0000000..544010b --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json @@ -0,0 +1 @@ +{"data":{"cagr":"", "change":-2075, "drawdown":"", "markingpoint":"", "percentage":"-28.62", "prices":[{"date":"1760547600000", "formatted_date":"2025-10-16", "xlabel":"", "value":"7300", "percentage":"0.69", "change":50, "open":"7250", "high":"7325", "low":"7225", "volume":"1206112"}, {"date":"1760634000000", "formatted_date":"2025-10-17", "xlabel":"", "value":"7500", "percentage":"3.45", "change":250, "open":"7300", "high":"7525", "low":"7275", "volume":"1573344"}, {"date":"1760893200000", "formatted_date":"2025-10-20", "xlabel":"", "value":"7875", "percentage":"8.62", "change":625, "open":"7700", "high":"8000", "low":"7625", "volume":"3934218"}, {"date":"1760979600000", "formatted_date":"2025-10-21", "xlabel":"", "value":"8475", "percentage":"16.90", "change":1225, "open":"8100", "high":"8475", "low":"8100", "volume":"5298026"}, {"date":"1761066000000", "formatted_date":"2025-10-22", "xlabel":"", "value":"8200", "percentage":"13.10", "change":950, "open":"8700", "high":"8700", "low":"8200", "volume":"3929572"}, {"date":"1761152400000", "formatted_date":"2025-10-23", "xlabel":"", "value":"8325", "percentage":"14.83", "change":1075, "open":"8200", "high":"8350", "low":"8150", "volume":"1870039"}, {"date":"1761238800000", "formatted_date":"2025-10-24", "xlabel":"", "value":"8275", "percentage":"14.14", "change":1025, "open":"8300", "high":"8375", "low":"8275", "volume":"1333551"}, {"date":"1761498000000", "formatted_date":"2025-10-27", "xlabel":"", "value":"8350", "percentage":"15.17", "change":1100, "open":"8325", "high":"8425", "low":"8200", "volume":"1822756"}, {"date":"1761584400000", "formatted_date":"2025-10-28", "xlabel":"", "value":"8275", "percentage":"14.14", "change":1025, "open":"8350", "high":"8400", "low":"8250", "volume":"1802700"}, {"date":"1761670800000", "formatted_date":"2025-10-29", "xlabel":"", "value":"8600", "percentage":"18.62", "change":1350, "open":"8300", "high":"8600", "low":"8275", "volume":"2391568"}, {"date":"1761757200000", "formatted_date":"2025-10-30", "xlabel":"", "value":"8525", "percentage":"17.59", "change":1275, "open":"8575", "high":"8750", "low":"8475", "volume":"2590098"}, {"date":"1761843600000", "formatted_date":"2025-10-31", "xlabel":"", "value":"8525", "percentage":"17.59", "change":1275, "open":"8550", "high":"8650", "low":"8525", "volume":"1770880"}, {"date":"1762102800000", "formatted_date":"2025-11-03", "xlabel":"", "value":"8650", "percentage":"19.31", "change":1400, "open":"8650", "high":"8650", "low":"8525", "volume":"1150071"}, {"date":"1762189200000", "formatted_date":"2025-11-04", "xlabel":"", "value":"8650", "percentage":"19.31", "change":1400, "open":"8650", "high":"8700", "low":"8625", "volume":"1552315"}, {"date":"1762275600000", "formatted_date":"2025-11-05", "xlabel":"", "value":"8700", "percentage":"20.00", "change":1450, "open":"8625", "high":"8700", "low":"8600", "volume":"1257315"}, {"date":"1762362000000", "formatted_date":"2025-11-06", "xlabel":"", "value":"8550", "percentage":"17.93", "change":1300, "open":"8625", "high":"8675", "low":"8550", "volume":"897569"}, {"date":"1762448400000", "formatted_date":"2025-11-07", "xlabel":"", "value":"8675", "percentage":"19.66", "change":1425, "open":"8550", "high":"8675", "low":"8550", "volume":"752419"}, {"date":"1762707600000", "formatted_date":"2025-11-10", "xlabel":"", "value":"8575", "percentage":"18.28", "change":1325, "open":"8675", "high":"8675", "low":"8575", "volume":"798977"}, {"date":"1762794000000", "formatted_date":"2025-11-11", "xlabel":"", "value":"8400", "percentage":"15.86", "change":1150, "open":"8575", "high":"8600", "low":"8400", "volume":"1308976"}, {"date":"1762880400000", "formatted_date":"2025-11-12", "xlabel":"", "value":"8500", "percentage":"17.24", "change":1250, "open":"8425", "high":"8550", "low":"8400", "volume":"854156"}, {"date":"1762966800000", "formatted_date":"2025-11-13", "xlabel":"", "value":"8375", "percentage":"15.52", "change":1125, "open":"8400", "high":"8475", "low":"8375", "volume":"1041138"}, {"date":"1763053200000", "formatted_date":"2025-11-14", "xlabel":"", "value":"8425", "percentage":"16.21", "change":1175, "open":"8375", "high":"8500", "low":"8375", "volume":"898153"}, {"date":"1763312400000", "formatted_date":"2025-11-17", "xlabel":"", "value":"8575", "percentage":"18.28", "change":1325, "open":"8500", "high":"8575", "low":"8475", "volume":"1294111"}, {"date":"1763398800000", "formatted_date":"2025-11-18", "xlabel":"", "value":"8400", "percentage":"15.86", "change":1150, "open":"8550", "high":"8575", "low":"8400", "volume":"1193779"}, {"date":"1763485200000", "formatted_date":"2025-11-19", "xlabel":"", "value":"8475", "percentage":"16.90", "change":1225, "open":"8350", "high":"8500", "low":"8325", "volume":"1142097"}, {"date":"1763571600000", "formatted_date":"2025-11-20", "xlabel":"", "value":"8425", "percentage":"16.21", "change":1175, "open":"8475", "high":"8600", "low":"8400", "volume":"1240874"}, {"date":"1763658000000", "formatted_date":"2025-11-21", "xlabel":"", "value":"8400", "percentage":"15.86", "change":1150, "open":"8375", "high":"8425", "low":"8350", "volume":"1039171"}, {"date":"1763917200000", "formatted_date":"2025-11-24", "xlabel":"", "value":"8475", "percentage":"16.90", "change":1225, "open":"8400", "high":"8475", "low":"8350", "volume":"2795906"}, {"date":"1764003600000", "formatted_date":"2025-11-25", "xlabel":"", "value":"8500", "percentage":"17.24", "change":1250, "open":"8475", "high":"8500", "low":"8375", "volume":"988077"}, {"date":"1764090000000", "formatted_date":"2025-11-26", "xlabel":"", "value":"8425", "percentage":"16.21", "change":1175, "open":"8400", "high":"8450", "low":"8375", "volume":"770944"}, {"date":"1764176400000", "formatted_date":"2025-11-27", "xlabel":"", "value":"8325", "percentage":"14.83", "change":1075, "open":"8425", "high":"8450", "low":"8325", "volume":"710513"}, {"date":"1764262800000", "formatted_date":"2025-11-28", "xlabel":"", "value":"8275", "percentage":"14.14", "change":1025, "open":"8325", "high":"8350", "low":"8275", "volume":"675593"}, {"date":"1764522000000", "formatted_date":"2025-12-01", "xlabel":"", "value":"8400", "percentage":"15.86", "change":1150, "open":"8450", "high":"8450", "low":"8350", "volume":"763592"}, {"date":"1764608400000", "formatted_date":"2025-12-02", "xlabel":"", "value":"8375", "percentage":"15.52", "change":1125, "open":"8350", "high":"8425", "low":"8350", "volume":"970440"}, {"date":"1764694800000", "formatted_date":"2025-12-03", "xlabel":"", "value":"8300", "percentage":"14.48", "change":1050, "open":"8350", "high":"8400", "low":"8300", "volume":"1035509"}, {"date":"1764781200000", "formatted_date":"2025-12-04", "xlabel":"", "value":"8225", "percentage":"13.45", "change":975, "open":"8325", "high":"8350", "low":"8225", "volume":"714104"}, {"date":"1764867600000", "formatted_date":"2025-12-05", "xlabel":"", "value":"8300", "percentage":"14.48", "change":1050, "open":"8200", "high":"8300", "low":"8200", "volume":"711623"}, {"date":"1765126800000", "formatted_date":"2025-12-08", "xlabel":"", "value":"8300", "percentage":"14.48", "change":1050, "open":"8350", "high":"8350", "low":"8250", "volume":"617452"}, {"date":"1765213200000", "formatted_date":"2025-12-09", "xlabel":"", "value":"8100", "percentage":"11.72", "change":850, "open":"8300", "high":"8300", "low":"8100", "volume":"1130572"}, {"date":"1765299600000", "formatted_date":"2025-12-10", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"8100", "high":"8175", "low":"8025", "volume":"1434183"}, {"date":"1765386000000", "formatted_date":"2025-12-11", "xlabel":"", "value":"8000", "percentage":"10.34", "change":750, "open":"8175", "high":"8200", "low":"7900", "volume":"1407660"}, {"date":"1765472400000", "formatted_date":"2025-12-12", "xlabel":"", "value":"8000", "percentage":"10.34", "change":750, "open":"7900", "high":"8000", "low":"7850", "volume":"1000068"}, {"date":"1765731600000", "formatted_date":"2025-12-15", "xlabel":"", "value":"8300", "percentage":"14.48", "change":1050, "open":"7925", "high":"8300", "low":"7925", "volume":"1431057"}, {"date":"1765818000000", "formatted_date":"2025-12-16", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"8375", "high":"8375", "low":"8025", "volume":"1223320"}, {"date":"1765904400000", "formatted_date":"2025-12-17", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8000", "high":"8125", "low":"7975", "volume":"898027"}, {"date":"1765990800000", "formatted_date":"2025-12-18", "xlabel":"", "value":"8175", "percentage":"12.76", "change":925, "open":"8025", "high":"8250", "low":"8025", "volume":"884972"}, {"date":"1766077200000", "formatted_date":"2025-12-19", "xlabel":"", "value":"8050", "percentage":"11.03", "change":800, "open":"8200", "high":"8225", "low":"8050", "volume":"1310698"}, {"date":"1766336400000", "formatted_date":"2025-12-22", "xlabel":"", "value":"8175", "percentage":"12.76", "change":925, "open":"8050", "high":"8175", "low":"8025", "volume":"724242"}, {"date":"1766422800000", "formatted_date":"2025-12-23", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8100", "high":"8125", "low":"7975", "volume":"850441"}, {"date":"1766509200000", "formatted_date":"2025-12-24", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8025", "high":"8075", "low":"8000", "volume":"712605"}, {"date":"1766941200000", "formatted_date":"2025-12-29", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8025", "high":"8025", "low":"7950", "volume":"783994"}, {"date":"1767027600000", "formatted_date":"2025-12-30", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"7950", "high":"8175", "low":"7950", "volume":"1019956"}, {"date":"1767286800000", "formatted_date":"2026-01-02", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8075", "high":"8075", "low":"8000", "volume":"686124"}, {"date":"1767546000000", "formatted_date":"2026-01-05", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"8000", "high":"8100", "low":"8000", "volume":"734088"}, {"date":"1767632400000", "formatted_date":"2026-01-06", "xlabel":"", "value":"8175", "percentage":"12.76", "change":925, "open":"8175", "high":"8175", "low":"8075", "volume":"976243"}, {"date":"1767718800000", "formatted_date":"2026-01-07", "xlabel":"", "value":"8150", "percentage":"12.41", "change":900, "open":"8125", "high":"8175", "low":"8025", "volume":"931525"}, {"date":"1767805200000", "formatted_date":"2026-01-08", "xlabel":"", "value":"8050", "percentage":"11.03", "change":800, "open":"8100", "high":"8150", "low":"8050", "volume":"798237"}, {"date":"1767891600000", "formatted_date":"2026-01-09", "xlabel":"", "value":"8125", "percentage":"12.07", "change":875, "open":"8125", "high":"8175", "low":"8050", "volume":"794847"}, {"date":"1768150800000", "formatted_date":"2026-01-12", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8075", "high":"8100", "low":"8000", "volume":"829357"}, {"date":"1768237200000", "formatted_date":"2026-01-13", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"8025", "high":"8075", "low":"8000", "volume":"805363"}, {"date":"1768323600000", "formatted_date":"2026-01-14", "xlabel":"", "value":"8000", "percentage":"10.34", "change":750, "open":"8025", "high":"8050", "low":"7950", "volume":"1057243"}, {"date":"1768410000000", "formatted_date":"2026-01-15", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"8000", "high":"8175", "low":"7975", "volume":"1517531"}, {"date":"1768755600000", "formatted_date":"2026-01-19", "xlabel":"", "value":"8125", "percentage":"12.07", "change":875, "open":"8075", "high":"8125", "low":"7975", "volume":"1721000"}, {"date":"1768842000000", "formatted_date":"2026-01-20", "xlabel":"", "value":"8000", "percentage":"10.34", "change":750, "open":"8050", "high":"8125", "low":"8000", "volume":"826725"}, {"date":"1768928400000", "formatted_date":"2026-01-21", "xlabel":"", "value":"7700", "percentage":"6.21", "change":450, "open":"7925", "high":"7950", "low":"7700", "volume":"3018256"}, {"date":"1769014800000", "formatted_date":"2026-01-22", "xlabel":"", "value":"7650", "percentage":"5.52", "change":400, "open":"7700", "high":"7775", "low":"7600", "volume":"2680259"}, {"date":"1769101200000", "formatted_date":"2026-01-23", "xlabel":"", "value":"7650", "percentage":"5.52", "change":400, "open":"7600", "high":"7675", "low":"7550", "volume":"2028786"}, {"date":"1769360400000", "formatted_date":"2026-01-26", "xlabel":"", "value":"7650", "percentage":"5.52", "change":400, "open":"7550", "high":"7675", "low":"7525", "volume":"2130441"}, {"date":"1769446800000", "formatted_date":"2026-01-27", "xlabel":"", "value":"7500", "percentage":"3.45", "change":250, "open":"7575", "high":"7625", "low":"7500", "volume":"2598309"}, {"date":"1769533200000", "formatted_date":"2026-01-28", "xlabel":"", "value":"7025", "percentage":"-3.10", "change":-225, "open":"7050", "high":"7400", "low":"6925", "volume":"10272875"}, {"date":"1769619600000", "formatted_date":"2026-01-29", "xlabel":"", "value":"7200", "percentage":"-0.69", "change":-50, "open":"6675", "high":"7275", "low":"6375", "volume":"14329934"}, {"date":"1769706000000", "formatted_date":"2026-01-30", "xlabel":"", "value":"7400", "percentage":"2.07", "change":150, "open":"7200", "high":"7600", "low":"7200", "volume":"5284349"}, {"date":"1769965200000", "formatted_date":"2026-02-02", "xlabel":"", "value":"7600", "percentage":"4.83", "change":350, "open":"7600", "high":"7600", "low":"7325", "volume":"3269487"}, {"date":"1770051600000", "formatted_date":"2026-02-03", "xlabel":"", "value":"7650", "percentage":"5.52", "change":400, "open":"7600", "high":"7650", "low":"7375", "volume":"2060154"}, {"date":"1770138000000", "formatted_date":"2026-02-04", "xlabel":"", "value":"7800", "percentage":"7.59", "change":550, "open":"7650", "high":"7825", "low":"7600", "volume":"1933708"}, {"date":"1770224400000", "formatted_date":"2026-02-05", "xlabel":"", "value":"7800", "percentage":"7.59", "change":550, "open":"7800", "high":"7925", "low":"7800", "volume":"1423324"}, {"date":"1770310800000", "formatted_date":"2026-02-06", "xlabel":"", "value":"7675", "percentage":"5.86", "change":425, "open":"7700", "high":"7775", "low":"7600", "volume":"1763526"}, {"date":"1770570000000", "formatted_date":"2026-02-09", "xlabel":"", "value":"7500", "percentage":"3.45", "change":250, "open":"7675", "high":"7725", "low":"7450", "volume":"1747583"}, {"date":"1770656400000", "formatted_date":"2026-02-10", "xlabel":"", "value":"7475", "percentage":"3.10", "change":225, "open":"7500", "high":"7550", "low":"7450", "volume":"1277042"}, {"date":"1770742800000", "formatted_date":"2026-02-11", "xlabel":"", "value":"7450", "percentage":"2.76", "change":200, "open":"7475", "high":"7525", "low":"7400", "volume":"2135050"}, {"date":"1770829200000", "formatted_date":"2026-02-12", "xlabel":"", "value":"7325", "percentage":"1.03", "change":75, "open":"7450", "high":"7475", "low":"7300", "volume":"2374653"}, {"date":"1770915600000", "formatted_date":"2026-02-13", "xlabel":"", "value":"7200", "percentage":"-0.69", "change":-50, "open":"7325", "high":"7325", "low":"7200", "volume":"3560153"}, {"date":"1771347600000", "formatted_date":"2026-02-18", "xlabel":"", "value":"7275", "percentage":"0.34", "change":25, "open":"7225", "high":"7375", "low":"7225", "volume":"1253012"}, {"date":"1771434000000", "formatted_date":"2026-02-19", "xlabel":"", "value":"7175", "percentage":"-1.03", "change":-75, "open":"7325", "high":"7325", "low":"7175", "volume":"1334634"}, {"date":"1771520400000", "formatted_date":"2026-02-20", "xlabel":"", "value":"7225", "percentage":"-0.34", "change":-25, "open":"7175", "high":"7250", "low":"7150", "volume":"1229595"}, {"date":"1771779600000", "formatted_date":"2026-02-23", "xlabel":"", "value":"7300", "percentage":"0.69", "change":50, "open":"7300", "high":"7350", "low":"7275", "volume":"1020183"}, {"date":"1771866000000", "formatted_date":"2026-02-24", "xlabel":"", "value":"7225", "percentage":"-0.34", "change":-25, "open":"7275", "high":"7325", "low":"7225", "volume":"1592254"}, {"date":"1771952400000", "formatted_date":"2026-02-25", "xlabel":"", "value":"7325", "percentage":"1.03", "change":75, "open":"7275", "high":"7375", "low":"7250", "volume":"1332780"}, {"date":"1772038800000", "formatted_date":"2026-02-26", "xlabel":"", "value":"7300", "percentage":"0.69", "change":50, "open":"7350", "high":"7375", "low":"7200", "volume":"1278119"}, {"date":"1772125200000", "formatted_date":"2026-02-27", "xlabel":"", "value":"7175", "percentage":"-1.03", "change":-75, "open":"7200", "high":"7275", "low":"7175", "volume":"1931047"}, {"date":"1772384400000", "formatted_date":"2026-03-02", "xlabel":"", "value":"7025", "percentage":"-3.10", "change":-225, "open":"7075", "high":"7150", "low":"6975", "volume":"1561827"}, {"date":"1772470800000", "formatted_date":"2026-03-03", "xlabel":"", "value":"7075", "percentage":"-2.41", "change":-175, "open":"7075", "high":"7125", "low":"7025", "volume":"1030311"}, {"date":"1772557200000", "formatted_date":"2026-03-04", "xlabel":"", "value":"6875", "percentage":"-5.17", "change":-375, "open":"7075", "high":"7075", "low":"6825", "volume":"2165198"}, {"date":"1772643600000", "formatted_date":"2026-03-05", "xlabel":"", "value":"7100", "percentage":"-2.07", "change":-150, "open":"6975", "high":"7125", "low":"6950", "volume":"1102582"}, {"date":"1772730000000", "formatted_date":"2026-03-06", "xlabel":"", "value":"7000", "percentage":"-3.45", "change":-250, "open":"7075", "high":"7100", "low":"7000", "volume":"1027257"}, {"date":"1772989200000", "formatted_date":"2026-03-09", "xlabel":"", "value":"6875", "percentage":"-5.17", "change":-375, "open":"6900", "high":"6950", "low":"6825", "volume":"1504264"}, {"date":"1773075600000", "formatted_date":"2026-03-10", "xlabel":"", "value":"6975", "percentage":"-3.79", "change":-275, "open":"6950", "high":"7050", "low":"6925", "volume":"1299779"}, {"date":"1773162000000", "formatted_date":"2026-03-11", "xlabel":"", "value":"6825", "percentage":"-5.86", "change":-425, "open":"6975", "high":"7000", "low":"6825", "volume":"919246"}, {"date":"1773248400000", "formatted_date":"2026-03-12", "xlabel":"", "value":"6900", "percentage":"-4.83", "change":-350, "open":"6825", "high":"7025", "low":"6825", "volume":"987874"}, {"date":"1773334800000", "formatted_date":"2026-03-13", "xlabel":"", "value":"6875", "percentage":"-5.17", "change":-375, "open":"6850", "high":"7025", "low":"6850", "volume":"1079406"}, {"date":"1773594000000", "formatted_date":"2026-03-16", "xlabel":"", "value":"6750", "percentage":"-6.90", "change":-500, "open":"6850", "high":"6875", "low":"6700", "volume":"1277575"}, {"date":"1773680400000", "formatted_date":"2026-03-17", "xlabel":"", "value":"6775", "percentage":"-6.55", "change":-475, "open":"6750", "high":"6825", "low":"6750", "volume":"1609039"}, {"date":"1774371600000", "formatted_date":"2026-03-25", "xlabel":"", "value":"6900", "percentage":"-4.83", "change":-350, "open":"6675", "high":"6925", "low":"6675", "volume":"3143539"}, {"date":"1774458000000", "formatted_date":"2026-03-26", "xlabel":"", "value":"6875", "percentage":"-5.17", "change":-375, "open":"6900", "high":"6975", "low":"6850", "volume":"1459349"}, {"date":"1774544400000", "formatted_date":"2026-03-27", "xlabel":"", "value":"6700", "percentage":"-7.59", "change":-550, "open":"6775", "high":"6850", "low":"6700", "volume":"2835565"}, {"date":"1774803600000", "formatted_date":"2026-03-30", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6400", "high":"6500", "low":"6350", "volume":"2422781"}, {"date":"1774890000000", "formatted_date":"2026-03-31", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6450", "high":"6550", "low":"6450", "volume":"1506578"}, {"date":"1774976400000", "formatted_date":"2026-04-01", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6550", "high":"6650", "low":"6500", "volume":"1327981"}, {"date":"1775062800000", "formatted_date":"2026-04-02", "xlabel":"", "value":"6575", "percentage":"-9.31", "change":-675, "open":"6550", "high":"6600", "low":"6500", "volume":"727254"}, {"date":"1775408400000", "formatted_date":"2026-04-06", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6600", "high":"6625", "low":"6450", "volume":"579146"}, {"date":"1775494800000", "formatted_date":"2026-04-07", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6500", "high":"6525", "low":"6450", "volume":"651988"}, {"date":"1775581200000", "formatted_date":"2026-04-08", "xlabel":"", "value":"6750", "percentage":"-6.90", "change":-500, "open":"6650", "high":"6850", "low":"6625", "volume":"1635940"}, {"date":"1775667600000", "formatted_date":"2026-04-09", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6775", "high":"6800", "low":"6450", "volume":"1824098"}, {"date":"1775754000000", "formatted_date":"2026-04-10", "xlabel":"", "value":"6700", "percentage":"-7.59", "change":-550, "open":"6525", "high":"6700", "low":"6475", "volume":"1673361"}, {"date":"1776013200000", "formatted_date":"2026-04-13", "xlabel":"", "value":"6575", "percentage":"-9.31", "change":-675, "open":"6650", "high":"6700", "low":"6575", "volume":"819553"}, {"date":"1776099600000", "formatted_date":"2026-04-14", "xlabel":"", "value":"6750", "percentage":"-6.90", "change":-500, "open":"6675", "high":"6800", "low":"6675", "volume":"1050109"}, {"date":"1776186000000", "formatted_date":"2026-04-15", "xlabel":"", "value":"6550", "percentage":"-9.66", "change":-700, "open":"6775", "high":"6800", "low":"6550", "volume":"1048806"}, {"date":"1776272400000", "formatted_date":"2026-04-16", "xlabel":"", "value":"6525", "percentage":"-10.00", "change":-725, "open":"6575", "high":"6650", "low":"6500", "volume":"1224161"}, {"date":"1776358800000", "formatted_date":"2026-04-17", "xlabel":"", "value":"6425", "percentage":"-11.38", "change":-825, "open":"6500", "high":"6550", "low":"6425", "volume":"1568270"}, {"date":"1776618000000", "formatted_date":"2026-04-20", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6450", "high":"6575", "low":"6425", "volume":"1374357"}, {"date":"1776704400000", "formatted_date":"2026-04-21", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6475", "high":"6525", "low":"6425", "volume":"1121519"}, {"date":"1776790800000", "formatted_date":"2026-04-22", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6475", "high":"6525", "low":"6450", "volume":"956737"}, {"date":"1776877200000", "formatted_date":"2026-04-23", "xlabel":"", "value":"6425", "percentage":"-11.38", "change":-825, "open":"6450", "high":"6550", "low":"6425", "volume":"959134"}, {"date":"1776963600000", "formatted_date":"2026-04-24", "xlabel":"", "value":"6050", "percentage":"-16.55", "change":-1200, "open":"6400", "high":"6425", "low":"6050", "volume":"4350048"}, {"date":"1777222800000", "formatted_date":"2026-04-27", "xlabel":"", "value":"5975", "percentage":"-17.59", "change":-1275, "open":"6025", "high":"6050", "low":"5950", "volume":"2732631"}, {"date":"1777309200000", "formatted_date":"2026-04-28", "xlabel":"", "value":"6000", "percentage":"-17.24", "change":-1250, "open":"6025", "high":"6075", "low":"5950", "volume":"1740912"}, {"date":"1777395600000", "formatted_date":"2026-04-29", "xlabel":"", "value":"5975", "percentage":"-17.59", "change":-1275, "open":"6000", "high":"6050", "low":"5950", "volume":"1760509"}, {"date":"1777482000000", "formatted_date":"2026-04-30", "xlabel":"", "value":"5850", "percentage":"-19.31", "change":-1400, "open":"5950", "high":"5975", "low":"5800", "volume":"3034054"}, {"date":"1777827600000", "formatted_date":"2026-05-04", "xlabel":"", "value":"5900", "percentage":"-18.62", "change":-1350, "open":"5800", "high":"5950", "low":"5800", "volume":"2075667"}, {"date":"1777914000000", "formatted_date":"2026-05-05", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"5900", "high":"6075", "low":"5875", "volume":"1799319"}, {"date":"1778000400000", "formatted_date":"2026-05-06", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"5950", "high":"6000", "low":"5900", "volume":"908431"}, {"date":"1778086800000", "formatted_date":"2026-05-07", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6000", "high":"6325", "low":"6000", "volume":"2387070"}, {"date":"1778173200000", "formatted_date":"2026-05-08", "xlabel":"", "value":"6175", "percentage":"-14.83", "change":-1075, "open":"6225", "high":"6425", "low":"6175", "volume":"2017847"}, {"date":"1778432400000", "formatted_date":"2026-05-11", "xlabel":"", "value":"6150", "percentage":"-15.17", "change":-1100, "open":"6225", "high":"6275", "low":"6100", "volume":"1263013"}, {"date":"1778518800000", "formatted_date":"2026-05-12", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6125", "high":"6150", "low":"6050", "volume":"1073944"}, {"date":"1778605200000", "formatted_date":"2026-05-13", "xlabel":"", "value":"6100", "percentage":"-15.86", "change":-1150, "open":"6125", "high":"6200", "low":"6025", "volume":"1473396"}, {"date":"1779037200000", "formatted_date":"2026-05-18", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"5950", "high":"6125", "low":"5900", "volume":"2087484"}, {"date":"1779123600000", "formatted_date":"2026-05-19", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"6050", "high":"6200", "low":"5950", "volume":"1848301"}, {"date":"1779210000000", "formatted_date":"2026-05-20", "xlabel":"", "value":"5975", "percentage":"-17.59", "change":-1275, "open":"5975", "high":"6100", "low":"5875", "volume":"1951071"}, {"date":"1779296400000", "formatted_date":"2026-05-21", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"6000", "high":"6150", "low":"5925", "volume":"2109761"}, {"date":"1779382800000", "formatted_date":"2026-05-22", "xlabel":"", "value":"5900", "percentage":"-18.62", "change":-1350, "open":"5900", "high":"5975", "low":"5825", "volume":"2889197"}, {"date":"1779642000000", "formatted_date":"2026-05-25", "xlabel":"", "value":"6100", "percentage":"-15.86", "change":-1150, "open":"5950", "high":"6175", "low":"5925", "volume":"2631792"}, {"date":"1779728400000", "formatted_date":"2026-05-26", "xlabel":"", "value":"5975", "percentage":"-17.59", "change":-1275, "open":"6050", "high":"6100", "low":"5950", "volume":"2251587"}, {"date":"1779987600000", "formatted_date":"2026-05-29", "xlabel":"", "value":"5700", "percentage":"-21.38", "change":-1550, "open":"5750", "high":"5875", "low":"5700", "volume":"10152966"}, {"date":"1780333200000", "formatted_date":"2026-06-02", "xlabel":"", "value":"5825", "percentage":"-19.66", "change":-1425, "open":"5750", "high":"5875", "low":"5700", "volume":"3463052"}, {"date":"1780419600000", "formatted_date":"2026-06-03", "xlabel":"", "value":"5525", "percentage":"-23.79", "change":-1725, "open":"5775", "high":"5825", "low":"5525", "volume":"3533323"}, {"date":"1780506000000", "formatted_date":"2026-06-04", "xlabel":"", "value":"5425", "percentage":"-25.17", "change":-1825, "open":"5475", "high":"5600", "low":"5300", "volume":"5500595"}, {"date":"1780592400000", "formatted_date":"2026-06-05", "xlabel":"", "value":"5075", "percentage":"-30.00", "change":-2175, "open":"5325", "high":"5375", "low":"5075", "volume":"5726120"}, {"date":"1780851600000", "formatted_date":"2026-06-08", "xlabel":"", "value":"4850", "percentage":"-33.10", "change":-2400, "open":"4950", "high":"5050", "low":"4850", "volume":"7050933"}, {"date":"1780938000000", "formatted_date":"2026-06-09", "xlabel":"", "value":"5150", "percentage":"-28.97", "change":-2100, "open":"4850", "high":"5150", "low":"4820", "volume":"7728980"}, {"date":"1781024400000", "formatted_date":"2026-06-10", "xlabel":"", "value":"5650", "percentage":"-22.07", "change":-1600, "open":"5175", "high":"5700", "low":"5175", "volume":"7473660"}, {"date":"1781110800000", "formatted_date":"2026-06-11", "xlabel":"", "value":"5825", "percentage":"-19.66", "change":-1425, "open":"5650", "high":"5875", "low":"5550", "volume":"5432954"}, {"date":"1781197200000", "formatted_date":"2026-06-12", "xlabel":"", "value":"5925", "percentage":"-18.28", "change":-1325, "open":"6000", "high":"6100", "low":"5900", "volume":"4158723"}, {"date":"1781456400000", "formatted_date":"2026-06-15", "xlabel":"", "value":"6275", "percentage":"-13.45", "change":-975, "open":"6100", "high":"6425", "low":"6100", "volume":"4803950"}, {"date":"1781629200000", "formatted_date":"2026-06-17", "xlabel":"", "value":"6275", "percentage":"-13.45", "change":-975, "open":"6400", "high":"6550", "low":"6275", "volume":"4674943"}, {"date":"1781715600000", "formatted_date":"2026-06-18", "xlabel":"", "value":"6075", "percentage":"-16.21", "change":-1175, "open":"6250", "high":"6275", "low":"6075", "volume":"2324893"}, {"date":"1781802000000", "formatted_date":"2026-06-19", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6050", "high":"6300", "low":"6050", "volume":"3665955"}, {"date":"1782061200000", "formatted_date":"2026-06-22", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6400", "high":"6400", "low":"6125", "volume":"1934016"}, {"date":"1782147600000", "formatted_date":"2026-06-23", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6150", "high":"6200", "low":"6075", "volume":"1445619"}, {"date":"1782234000000", "formatted_date":"2026-06-24", "xlabel":"", "value":"5925", "percentage":"-18.28", "change":-1325, "open":"6125", "high":"6200", "low":"5925", "volume":"1449288"}, {"date":"1782320400000", "formatted_date":"2026-06-25", "xlabel":"", "value":"6025", "percentage":"-16.90", "change":-1225, "open":"5900", "high":"6175", "low":"5900", "volume":"1979350"}, {"date":"1782406800000", "formatted_date":"2026-06-26", "xlabel":"", "value":"6175", "percentage":"-14.83", "change":-1075, "open":"6050", "high":"6175", "low":"6025", "volume":"1633787"}, {"date":"1782666000000", "formatted_date":"2026-06-29", "xlabel":"", "value":"5925", "percentage":"-18.28", "change":-1325, "open":"6175", "high":"6200", "low":"5925", "volume":"1898866"}, {"date":"1782752400000", "formatted_date":"2026-06-30", "xlabel":"", "value":"5550", "percentage":"-23.45", "change":-1700, "open":"5775", "high":"5825", "low":"5550", "volume":"4407383"}, {"date":"1782838800000", "formatted_date":"2026-07-01", "xlabel":"", "value":"5600", "percentage":"-22.76", "change":-1650, "open":"5550", "high":"5750", "low":"5550", "volume":"1241238"}, {"date":"1782925200000", "formatted_date":"2026-07-02", "xlabel":"", "value":"5800", "percentage":"-20.00", "change":-1450, "open":"5650", "high":"5875", "low":"5625", "volume":"1451194"}, {"date":"1783011600000", "formatted_date":"2026-07-03", "xlabel":"", "value":"6050", "percentage":"-16.55", "change":-1200, "open":"5925", "high":"6150", "low":"5900", "volume":"2004652"}, {"date":"1783270800000", "formatted_date":"2026-07-06", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6050", "high":"6175", "low":"5975", "volume":"1486849"}, {"date":"1783357200000", "formatted_date":"2026-07-07", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6200", "high":"6300", "low":"6150", "volume":"1834721"}, {"date":"1783443600000", "formatted_date":"2026-07-08", "xlabel":"", "value":"6175", "percentage":"-14.83", "change":-1075, "open":"6300", "high":"6325", "low":"6175", "volume":"1812890"}, {"date":"1783530000000", "formatted_date":"2026-07-09", "xlabel":"", "value":"6200", "percentage":"-14.48", "change":-1050, "open":"6100", "high":"6200", "low":"6050", "volume":"1811558"}, {"date":"1783616400000", "formatted_date":"2026-07-10", "xlabel":"", "value":"6175", "percentage":"-14.83", "change":-1075, "open":"6300", "high":"6300", "low":"6125", "volume":"1380505"}, {"date":"1783875600000", "formatted_date":"2026-07-13", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6150", "high":"6250", "low":"6075", "volume":"1800616"}, {"date":"1783962000000", "formatted_date":"2026-07-14", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6225", "high":"6250", "low":"6075", "volume":"1598688"}, {"date":"1784048400000", "formatted_date":"2026-07-15", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6125", "high":"6200", "low":"6075", "volume":"1408249"}, {"date":"1784134800000", "formatted_date":"2026-07-16", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6125", "high":"6225", "low":"6100", "volume":"942351"}, {"date":"1784221200000", "formatted_date":"2026-07-17", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6225", "high":"6500", "low":"6200", "volume":"3191913"}, {"date":"1784480400000", "formatted_date":"2026-07-20", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6525", "high":"6625", "low":"6475", "volume":"2981833"}, {"date":"1784566800000", "formatted_date":"2026-07-21", "xlabel":"", "value":"6525", "percentage":"-10.00", "change":-725, "open":"6550", "high":"6550", "low":"6425", "volume":"1460647"}, {"date":"1784653200000", "formatted_date":"2026-07-22", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6525", "high":"6550", "low":"6400", "volume":"1121789"}, {"date":"1784739600000", "formatted_date":"2026-07-23", "xlabel":"", "value":"6275", "percentage":"-13.45", "change":-975, "open":"6450", "high":"6475", "low":"6250", "volume":"2070379"}, {"date":"1784826000000", "formatted_date":"2026-07-24", "xlabel":"", "value":"6275", "percentage":"-13.45", "change":-975, "open":"6200", "high":"6300", "low":"6125", "volume":"1914289"}, {"date":"1785085200000", "formatted_date":"2026-07-27", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6225", "high":"6325", "low":"6125", "volume":"1251425"}, {"date":"1785171600000", "formatted_date":"2026-07-28", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6250", "high":"6300", "low":"6200", "volume":"1229203"}, {"date":"1785258000000", "formatted_date":"2026-07-29", "xlabel":"", "value":"6275", "percentage":"-13.45", "change":-975, "open":"6175", "high":"6300", "low":"6175", "volume":"1223796"}, {"date":"1785344400000", "formatted_date":"2026-07-30", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6325", "high":"6450", "low":"6250", "volume":"1526632"}, {"date":"1785430800000", "formatted_date":"2026-07-31", "xlabel":"", "value":"6325", "percentage":"-12.76", "change":-925, "open":"6425", "high":"6450", "low":"6325", "volume":"1531748"}, {"date":"1785690000000", "formatted_date":"2026-08-03", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6300", "high":"6325", "low":"6275", "volume":"681437"}, {"date":"1785776400000", "formatted_date":"2026-08-04", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6375", "high":"6500", "low":"6350", "volume":"1389531"}, {"date":"1785862800000", "formatted_date":"2026-08-05", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6500", "high":"6500", "low":"6425", "volume":"1016661"}, {"date":"1785949200000", "formatted_date":"2026-08-06", "xlabel":"", "value":"6350", "percentage":"-12.41", "change":-900, "open":"6450", "high":"6475", "low":"6350", "volume":"807340"}, {"date":"1786035600000", "formatted_date":"2026-08-07", "xlabel":"", "value":"6375", "percentage":"-12.07", "change":-875, "open":"6375", "high":"6450", "low":"6325", "volume":"1119308"}, {"date":"1786294800000", "formatted_date":"2026-08-10", "xlabel":"", "value":"6375", "percentage":"-12.07", "change":-875, "open":"6400", "high":"6450", "low":"6350", "volume":"886661"}, {"date":"1786381200000", "formatted_date":"2026-08-11", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6375", "high":"6400", "low":"6200", "volume":"1124908"}, {"date":"1786467600000", "formatted_date":"2026-08-12", "xlabel":"", "value":"6350", "percentage":"-12.41", "change":-900, "open":"6300", "high":"6350", "low":"6225", "volume":"871771"}, {"date":"1786554000000", "formatted_date":"2026-08-13", "xlabel":"", "value":"6375", "percentage":"-12.07", "change":-875, "open":"6350", "high":"6375", "low":"6250", "volume":"832080"}, {"date":"1786640400000", "formatted_date":"2026-08-14", "xlabel":"", "value":"6350", "percentage":"-12.41", "change":-900, "open":"6300", "high":"6350", "low":"6275", "volume":"569535"}, {"date":"1786986000000", "formatted_date":"2026-08-18", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6375", "high":"6450", "low":"6300", "volume":"1149455"}, {"date":"1787072400000", "formatted_date":"2026-08-19", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6300", "high":"6425", "low":"6300", "volume":"681050"}, {"date":"1787158800000", "formatted_date":"2026-08-20", "xlabel":"", "value":"6400", "percentage":"-11.72", "change":-850, "open":"6350", "high":"6400", "low":"6300", "volume":"677498"}, {"date":"1787245200000", "formatted_date":"2026-08-21", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6400", "high":"6475", "low":"6400", "volume":"1006843"}, {"date":"1787504400000", "formatted_date":"2026-08-24", "xlabel":"", "value":"6400", "percentage":"-11.72", "change":-850, "open":"6450", "high":"6450", "low":"6350", "volume":"812956"}, {"date":"1787677200000", "formatted_date":"2026-08-26", "xlabel":"", "value":"6350", "percentage":"-12.41", "change":-900, "open":"6450", "high":"6450", "low":"6350", "volume":"861159"}, {"date":"1787763600000", "formatted_date":"2026-08-27", "xlabel":"", "value":"6400", "percentage":"-11.72", "change":-850, "open":"6350", "high":"6425", "low":"6325", "volume":"605560"}, {"date":"1787850000000", "formatted_date":"2026-08-28", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6425", "high":"6525", "low":"6400", "volume":"1564452"}, {"date":"1788109200000", "formatted_date":"2026-08-31", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6450", "high":"6525", "low":"6400", "volume":"2309045"}, {"date":"1788195600000", "formatted_date":"2026-09-01", "xlabel":"", "value":"6600", "percentage":"-8.97", "change":-650, "open":"6450", "high":"6600", "low":"6425", "volume":"1179699"}, {"date":"1788282000000", "formatted_date":"2026-09-02", "xlabel":"", "value":"6675", "percentage":"-7.93", "change":-575, "open":"6675", "high":"6725", "low":"6600", "volume":"1085374"}, {"date":"1788368400000", "formatted_date":"2026-09-03", "xlabel":"", "value":"6775", "percentage":"-6.55", "change":-475, "open":"6700", "high":"6800", "low":"6625", "volume":"1123299"}, {"date":"1788454800000", "formatted_date":"2026-09-04", "xlabel":"", "value":"6700", "percentage":"-7.59", "change":-550, "open":"6800", "high":"6850", "low":"6675", "volume":"939235"}, {"date":"1788714000000", "formatted_date":"2026-09-07", "xlabel":"", "value":"6625", "percentage":"-8.62", "change":-625, "open":"6750", "high":"6750", "low":"6600", "volume":"651180"}, {"date":"1788800400000", "formatted_date":"2026-09-08", "xlabel":"", "value":"6675", "percentage":"-7.93", "change":-575, "open":"6600", "high":"6775", "low":"6575", "volume":"1081276"}, {"date":"1788886800000", "formatted_date":"2026-09-09", "xlabel":"", "value":"6525", "percentage":"-10.00", "change":-725, "open":"6700", "high":"6700", "low":"6500", "volume":"1933751"}, {"date":"1788973200000", "formatted_date":"2026-09-10", "xlabel":"", "value":"6425", "percentage":"-11.38", "change":-825, "open":"6575", "high":"6600", "low":"6425", "volume":"1968192"}, {"date":"1789059600000", "formatted_date":"2026-09-11", "xlabel":"", "value":"6325", "percentage":"-12.76", "change":-925, "open":"6400", "high":"6400", "low":"6250", "volume":"1879227"}, {"date":"1789318800000", "formatted_date":"2026-09-14", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6325", "high":"6575", "low":"6275", "volume":"2180172"}, {"date":"1789405200000", "formatted_date":"2026-09-15", "xlabel":"", "value":"6400", "percentage":"-11.72", "change":-850, "open":"6500", "high":"6500", "low":"6350", "volume":"956082"}, {"date":"1789491600000", "formatted_date":"2026-09-16", "xlabel":"", "value":"6325", "percentage":"-12.76", "change":-925, "open":"6350", "high":"6425", "low":"6325", "volume":"721574"}, {"date":"1789578000000", "formatted_date":"2026-09-17", "xlabel":"", "value":"6350", "percentage":"-12.41", "change":-900, "open":"6400", "high":"6400", "low":"6325", "volume":"618120"}, {"date":"1789664400000", "formatted_date":"2026-09-18", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6325", "high":"6350", "low":"6225", "volume":"1767537"}, {"date":"1789923600000", "formatted_date":"2026-09-21", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6300", "high":"6300", "low":"6225", "volume":"699141"}, {"date":"1790010000000", "formatted_date":"2026-09-22", "xlabel":"", "value":"6200", "percentage":"-14.48", "change":-1050, "open":"6225", "high":"6250", "low":"6175", "volume":"932112"}, {"date":"1790096400000", "formatted_date":"2026-09-23", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6200", "high":"6300", "low":"6200", "volume":"968261"}, {"date":"1790182800000", "formatted_date":"2026-09-24", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6250", "high":"6275", "low":"6225", "volume":"756486"}, {"date":"1790269200000", "formatted_date":"2026-09-25", "xlabel":"", "value":"6250", "percentage":"-13.79", "change":-1000, "open":"6225", "high":"6275", "low":"6200", "volume":"894474"}, {"date":"1790528400000", "formatted_date":"2026-09-28", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6225", "high":"6250", "low":"6175", "volume":"1121076"}, {"date":"1790614800000", "formatted_date":"2026-09-29", "xlabel":"", "value":"6150", "percentage":"-15.17", "change":-1100, "open":"6150", "high":"6225", "low":"6125", "volume":"1497834"}, {"date":"1790701200000", "formatted_date":"2026-09-30", "xlabel":"", "value":"6075", "percentage":"-16.21", "change":-1175, "open":"6100", "high":"6150", "low":"6050", "volume":"2213357"}, {"date":"1790787600000", "formatted_date":"2026-10-01", "xlabel":"", "value":"6000", "percentage":"-17.24", "change":-1250, "open":"6050", "high":"6150", "low":"6000", "volume":"1180449"}, {"date":"1790874000000", "formatted_date":"2026-10-02", "xlabel":"", "value":"6100", "percentage":"-15.86", "change":-1150, "open":"6000", "high":"6150", "low":"5975", "volume":"1319203"}, {"date":"1791133200000", "formatted_date":"2026-10-05", "xlabel":"", "value":"6175", "percentage":"-14.83", "change":-1075, "open":"6100", "high":"6175", "low":"6075", "volume":"899057"}, {"date":"1791219600000", "formatted_date":"2026-10-06", "xlabel":"", "value":"6100", "percentage":"-15.86", "change":-1150, "open":"6175", "high":"6200", "low":"6100", "volume":"1521734"}, {"date":"1791306000000", "formatted_date":"2026-10-07", "xlabel":"", "value":"6050", "percentage":"-16.55", "change":-1200, "open":"6075", "high":"6100", "low":"5975", "volume":"1626908"}, {"date":"1791392400000", "formatted_date":"2026-10-08", "xlabel":"", "value":"6000", "percentage":"-17.24", "change":-1250, "open":"6100", "high":"6150", "low":"6000", "volume":"1131403"}, {"date":"1791478800000", "formatted_date":"2026-10-09", "xlabel":"", "value":"6050", "percentage":"-16.55", "change":-1200, "open":"6025", "high":"6075", "low":"5975", "volume":"870986"}, {"date":"1791738000000", "formatted_date":"2026-10-12", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"6050", "high":"6100", "low":"5950", "volume":"979651"}, {"date":"1791824400000", "formatted_date":"2026-10-13", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"5925", "high":"5950", "low":"5850", "volume":"1227169"}, {"date":"1791910800000", "formatted_date":"2026-10-14", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6000", "high":"6125", "low":"5950", "volume":"1363031"}, {"date":"1791997200000", "formatted_date":"2026-10-15", "xlabel":"", "value":"6075", "percentage":"-16.21", "change":-1175, "open":"6075", "high":"6125", "low":"6025", "volume":"1347632"}, {"date":"1792083600000", "formatted_date":"2026-10-16", "xlabel":"", "value":"5175", "percentage":"-28.62", "change":-2075, "open":"5825", "high":"5825", "low":"5175", "volume":"12410982"}], "timeframe":"1y", "xaxisopt":"", "previous":7250, "line_weight":1.8, "previous_timeframe_price":{"date":"", "formatted_date":"", "xlabel":"", "value":"", "percentage":"", "change":0, "open":"", "high":"", "low":"", "volume":""}, "chart_type":"PRICE_CHART_TYPE_CANDLE", "interval_in_minutes":0, "allowed_chart_type":["PRICE_CHART_TYPE_LINE", "PRICE_CHART_TYPE_CANDLE"], "max_candles":260}, "message":"Successfully retrieved company daily chart"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_bonus.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_bonus.json new file mode 100644 index 0000000..f673ef1 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_bonus.json @@ -0,0 +1 @@ +{"data":{"bonus":[{"company_id":"1000003000","company_symbol":"PPGL","corp_action_active":true,"sahambonus_id":"7732","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"6 : 4","stocksplit_created":"","stocksplit_cumdate":"2026-11-02","stocksplit_exdate":"2026-11-03","stocksplit_factor":"1.6667","stocksplit_new":"4","stocksplit_old":"6","stocksplit_paymentdate":"2026-11-25","stocksplit_recdate":"2026-11-04","event_note":""},{"company_id":"397","company_symbol":"SPMA","corp_action_active":false,"sahambonus_id":"7730","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"100 : 30","stocksplit_created":"","stocksplit_cumdate":"2026-07-08","stocksplit_exdate":"2026-07-09","stocksplit_factor":"1.3","stocksplit_new":"30","stocksplit_old":"100","stocksplit_paymentdate":"2026-07-30","stocksplit_recdate":"2026-07-10","event_note":""},{"company_id":"457","company_symbol":"WINS","corp_action_active":false,"sahambonus_id":"7731","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"71 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-05-25","stocksplit_exdate":"2026-05-26","stocksplit_factor":"1.0141","stocksplit_new":"1","stocksplit_old":"71","stocksplit_paymentdate":"2026-06-18","stocksplit_recdate":"2026-05-29","event_note":""},{"company_id":"1000004517","company_symbol":"MEJA","corp_action_active":false,"sahambonus_id":"7727","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"6 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-16","stocksplit_exdate":"2026-04-17","stocksplit_factor":"1.1667","stocksplit_new":"1","stocksplit_old":"6","stocksplit_paymentdate":"2026-05-08","stocksplit_recdate":"2026-04-20","event_note":""},{"company_id":"1000004196","company_symbol":"CNMA","corp_action_active":false,"sahambonus_id":"7728","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"50 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-14","stocksplit_exdate":"2026-04-15","stocksplit_factor":"1.02","stocksplit_new":"1","stocksplit_old":"50","stocksplit_paymentdate":"2026-04-28","stocksplit_recdate":"2026-04-16","event_note":""},{"company_id":"284","company_symbol":"MEGA","corp_action_active":false,"sahambonus_id":"7723","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"1 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-09","stocksplit_exdate":"2026-04-10","stocksplit_factor":"2","stocksplit_new":"1","stocksplit_old":"1","stocksplit_paymentdate":"2026-04-30","stocksplit_recdate":"2026-04-13","event_note":""},{"company_id":"1000003506","company_symbol":"WGSH","corp_action_active":false,"sahambonus_id":"7722","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"1 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-02","stocksplit_exdate":"2026-04-06","stocksplit_factor":"2","stocksplit_new":"1","stocksplit_old":"1","stocksplit_paymentdate":"2026-04-24","stocksplit_recdate":"2026-04-07","event_note":""},{"company_id":"1000000034","company_symbol":"RISE","corp_action_active":false,"sahambonus_id":"7721","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"25 : 12","stocksplit_created":"","stocksplit_cumdate":"2026-01-19","stocksplit_exdate":"2026-01-20","stocksplit_factor":"1.48","stocksplit_new":"12","stocksplit_old":"25","stocksplit_paymentdate":"2026-02-09","stocksplit_recdate":"2026-01-21","event_note":""},{"company_id":"43","company_symbol":"ASRM","corp_action_active":false,"sahambonus_id":"7720","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"20 : 1","stocksplit_created":"","stocksplit_cumdate":"2025-11-12","stocksplit_exdate":"2025-11-13","stocksplit_factor":"1.0500","stocksplit_new":"1","stocksplit_old":"20","stocksplit_paymentdate":"2025-12-05","stocksplit_recdate":"2025-11-14","event_note":""},{"company_id":"397","company_symbol":"SPMA","corp_action_active":false,"sahambonus_id":"7719","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"100 : 30","stocksplit_created":"","stocksplit_cumdate":"2025-11-07","stocksplit_exdate":"2025-11-10","stocksplit_factor":"1.3000","stocksplit_new":"30","stocksplit_old":"100","stocksplit_paymentdate":"2025-11-25","stocksplit_recdate":"2025-11-11","event_note":""}]},"message":"Successfully retrieved stock bonus data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend.json new file mode 100644 index 0000000..e1d6256 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend.json @@ -0,0 +1 @@ +{"data":{"dividend":[{"company_id":"290","company_symbol":"MIDI","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2026-11-11","dividend_datahash":"","dividend_exdate":"2026-11-12","dividend_id":"118120","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-12-01","dividend_recdate":"2026-11-13","dividend_value":"3.8","lastprice":"244","event_note":"","dividend_value_formatted":"Rp 3.8","lastprice_formatted":"244","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"1000002614","company_symbol":"IFII","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2026-11-11","dividend_datahash":"","dividend_exdate":"2026-11-12","dividend_id":"118119","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-26","dividend_recdate":"2026-11-13","dividend_value":"5","lastprice":"198","event_note":"","dividend_value_formatted":"Rp 5","lastprice_formatted":"198","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"224","company_symbol":"ITMG","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-10","dividend_datahash":"","dividend_exdate":"2026-11-11","dividend_id":"118118","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-18","dividend_recdate":"2026-11-12","dividend_value":"1191.09","lastprice":"24725","event_note":"","dividend_value_formatted":"Rp 1191.09","lastprice_formatted":"24,725","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"107","company_symbol":"BWPT","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-10","dividend_datahash":"","dividend_exdate":"2026-11-11","dividend_id":"118117","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-25","dividend_recdate":"2026-11-12","dividend_value":"1","lastprice":"75","event_note":"","dividend_value_formatted":"Rp 1","lastprice_formatted":"75","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"501","company_symbol":"SSMS","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-10","dividend_datahash":"","dividend_exdate":"2026-11-11","dividend_id":"118116","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-12-02","dividend_recdate":"2026-11-12","dividend_value":"52.49","lastprice":"970","event_note":"","dividend_value_formatted":"Rp 52.49","lastprice_formatted":"970","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"283","company_symbol":"MEDC","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-10","dividend_datahash":"","dividend_exdate":"2026-11-11","dividend_id":"118115","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-12-02","dividend_recdate":"2026-11-12","dividend_value":"21.65","lastprice":"1295","event_note":"","dividend_value_formatted":"Rp 21.65","lastprice_formatted":"1,295","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"1000003271","company_symbol":"TAPG","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-10","dividend_datahash":"","dividend_exdate":"2026-11-11","dividend_id":"118114","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-27","dividend_recdate":"2026-11-12","dividend_value":"70","lastprice":"1875","event_note":"","dividend_value_formatted":"Rp 70","lastprice_formatted":"1,875","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"1000003485","company_symbol":"AVIA","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-10","dividend_datahash":"","dividend_exdate":"2026-11-11","dividend_id":"118113","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-19","dividend_recdate":"2026-11-12","dividend_value":"12","lastprice":"296","event_note":"","dividend_value_formatted":"Rp 12","lastprice_formatted":"296","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"394","company_symbol":"SMSM","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-09","dividend_datahash":"","dividend_exdate":"2026-11-10","dividend_id":"118112","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-26","dividend_recdate":"2026-11-11","dividend_value":"45","lastprice":"1585","event_note":"","dividend_value_formatted":"Rp 45","lastprice_formatted":"1,585","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"1000004196","company_symbol":"CNMA","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-06","dividend_datahash":"","dividend_exdate":"2026-11-09","dividend_id":"118110","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-19","dividend_recdate":"2026-11-10","dividend_value":"4","lastprice":"75","event_note":"","dividend_value_formatted":"Rp 4","lastprice_formatted":"75","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"1000004325","company_symbol":"PTPS","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-05","dividend_datahash":"","dividend_exdate":"2026-11-06","dividend_id":"118111","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-18","dividend_recdate":"2026-11-09","dividend_value":"2","lastprice":"123","event_note":"","dividend_value_formatted":"Rp 2","lastprice_formatted":"123","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"231","company_symbol":"JPFA","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-05","dividend_datahash":"","dividend_exdate":"2026-11-06","dividend_id":"118109","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-12","dividend_recdate":"2026-11-09","dividend_value":"90","lastprice":"1700","event_note":"","dividend_value_formatted":"Rp 90","lastprice_formatted":"1,700","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"447","company_symbol":"UNTR","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-05","dividend_datahash":"","dividend_exdate":"2026-11-06","dividend_id":"118108","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-23","dividend_recdate":"2026-11-09","dividend_value":"430","lastprice":"23225","event_note":"","dividend_value_formatted":"Rp 430","lastprice_formatted":"23,225","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"502","company_symbol":"SIDO","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-11-02","dividend_datahash":"","dividend_exdate":"2026-11-03","dividend_id":"118107","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-20","dividend_recdate":"2026-11-04","dividend_value":"10","lastprice":"318","event_note":"","dividend_value_formatted":"Rp 10","lastprice_formatted":"318","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"441","company_symbol":"TSPC","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-10-30","dividend_datahash":"","dividend_exdate":"2026-11-02","dividend_id":"118106","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-20","dividend_recdate":"2026-11-03","dividend_value":"75","lastprice":"2240","event_note":"","dividend_value_formatted":"Rp 75","lastprice_formatted":"2,240","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"532","company_symbol":"DMAS","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-10-28","dividend_datahash":"","dividend_exdate":"2026-10-29","dividend_id":"118105","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-16","dividend_recdate":"2026-10-30","dividend_value":"29","lastprice":"161","event_note":"","dividend_value_formatted":"Rp 29","lastprice_formatted":"161","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"1000003710","company_symbol":"AXIO","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-10-28","dividend_datahash":"","dividend_exdate":"2026-10-29","dividend_id":"118104","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-19","dividend_recdate":"2026-10-30","dividend_value":"8","lastprice":"123","event_note":"","dividend_value_formatted":"Rp 8","lastprice_formatted":"123","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"1000002303","company_symbol":"XSPI","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-10-28","dividend_datahash":"","dividend_exdate":"2026-10-29","dividend_id":"118103","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-05","dividend_recdate":"2026-10-30","dividend_value":"5.94","lastprice":"426","event_note":"","dividend_value_formatted":"Rp 5.94","lastprice_formatted":"426","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"371","company_symbol":"SCMA","corp_action_active":true,"dividend_created":"","dividend_cumdate":"2026-10-27","dividend_datahash":"","dividend_exdate":"2026-10-28","dividend_id":"118102","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-11-18","dividend_recdate":"2026-10-29","dividend_value":"8.4","lastprice":"126","event_note":"","dividend_value_formatted":"Rp 8.4","lastprice_formatted":"126","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"224","company_symbol":"ITMG","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2026-04-27","dividend_datahash":"","dividend_exdate":"2026-04-28","dividend_id":"117678","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-05-19","dividend_recdate":"2026-04-29","dividend_value":"992","lastprice":"24725","event_note":"","dividend_value_formatted":"Rp 992","lastprice_formatted":"24,725","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"224","company_symbol":"ITMG","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2025-11-12","dividend_datahash":"","dividend_exdate":"2025-11-13","dividend_id":"117592","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2025-11-26","dividend_recdate":"2025-11-14","dividend_value":"738","lastprice":"24725","event_note":"","dividend_value_formatted":"Rp 738","lastprice_formatted":"24,725","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0}],"today":"2026-11-03"},"message":"Successfully retrieved dividend data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend_bbca.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend_bbca.json new file mode 100644 index 0000000..f11d089 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend_bbca.json @@ -0,0 +1 @@ +{"data":{"dividend":[{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2026-08-28","dividend_datahash":"","dividend_exdate":"2026-08-31","dividend_id":"118073","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-09-16","dividend_recdate":"2026-09-01","dividend_value":"25","lastprice":"","event_note":"","dividend_value_formatted":"Rp 25","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2026-06-15","dividend_datahash":"","dividend_exdate":"2026-06-17","dividend_id":"117860","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-06-26","dividend_recdate":"2026-06-18","dividend_value":"20","lastprice":"","event_note":"","dividend_value_formatted":"Rp 20","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2026-03-27","dividend_datahash":"","dividend_exdate":"2026-03-30","dividend_id":"117649","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2026-04-08","dividend_recdate":"2026-03-31","dividend_value":"281","lastprice":"","event_note":"","dividend_value_formatted":"Rp 281","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2025-12-02","dividend_datahash":"","dividend_exdate":"2025-12-03","dividend_id":"117616","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2025-12-22","dividend_recdate":"2025-12-04","dividend_value":"55","lastprice":"","event_note":"","dividend_value_formatted":"Rp 55","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2025-03-20","dividend_datahash":"","dividend_exdate":"2025-03-21","dividend_id":"117119","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2025-04-11","dividend_recdate":"2025-03-24","dividend_value":"250","lastprice":"","event_note":"","dividend_value_formatted":"Rp 250","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2024-11-20","dividend_datahash":"","dividend_exdate":"2024-11-21","dividend_id":"117067","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2024-12-11","dividend_recdate":"2024-11-22","dividend_value":"50","lastprice":"","event_note":"","dividend_value_formatted":"Rp 50","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2024-03-22","dividend_datahash":"","dividend_exdate":"2024-03-25","dividend_id":"114270","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2024-04-04","dividend_recdate":"2024-03-26","dividend_value":"227.5","lastprice":"","event_note":"","dividend_value_formatted":"Rp 227.5","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2023-12-01","dividend_datahash":"","dividend_exdate":"2023-12-04","dividend_id":"114214","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2023-12-20","dividend_recdate":"2023-12-05","dividend_value":"42.5","lastprice":"","event_note":"","dividend_value_formatted":"Rp 42.5","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2023-03-28","dividend_datahash":"","dividend_exdate":"2023-03-29","dividend_id":"113604","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2023-04-14","dividend_recdate":"2023-03-30","dividend_value":"170","lastprice":"","event_note":"","dividend_value_formatted":"Rp 170","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2022-12-01","dividend_datahash":"","dividend_exdate":"2022-12-02","dividend_id":"113554","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2022-12-20","dividend_recdate":"2022-12-05","dividend_value":"35","lastprice":"","event_note":"","dividend_value_formatted":"Rp 35","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2022-03-25","dividend_datahash":"","dividend_exdate":"2022-03-28","dividend_id":"113048","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2022-04-19","dividend_recdate":"2022-03-29","dividend_value":"120","lastprice":"","event_note":"","dividend_value_formatted":"Rp 120","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2021-11-16","dividend_datahash":"","dividend_exdate":"2021-11-17","dividend_id":"96673","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2021-12-07","dividend_recdate":"2021-11-18","dividend_value":"25","lastprice":"","event_note":"","dividend_value_formatted":"Rp 25","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2021-04-07","dividend_datahash":"","dividend_exdate":"2021-04-08","dividend_id":"84523","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2021-04-28","dividend_recdate":"2021-04-09","dividend_value":"432","lastprice":"","event_note":"","dividend_value_formatted":"Rp 432","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2020-12-07","dividend_datahash":"","dividend_exdate":"2020-12-08","dividend_id":"83964","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2020-12-22","dividend_recdate":"2020-12-10","dividend_value":"98","lastprice":"","event_note":"","dividend_value_formatted":"Rp 98","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2020-04-20","dividend_datahash":"","dividend_exdate":"2020-04-21","dividend_id":"68067","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2020-05-11","dividend_recdate":"2020-04-22","dividend_value":"455","lastprice":"","event_note":"","dividend_value_formatted":"Rp 455","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2019-12-05","dividend_datahash":"","dividend_exdate":"2019-12-06","dividend_id":"58204","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2019-12-20","dividend_recdate":"2019-12-09","dividend_value":"100","lastprice":"","event_note":"","dividend_value_formatted":"Rp 100","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2019-04-23","dividend_datahash":"","dividend_exdate":"2019-04-24","dividend_id":"33852","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2019-04-30","dividend_recdate":"2019-04-25","dividend_value":"255","lastprice":"","event_note":"","dividend_value_formatted":"Rp 255","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2018-11-30","dividend_datahash":"","dividend_exdate":"2018-12-03","dividend_id":"23024","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2018-12-21","dividend_recdate":"2018-12-04","dividend_value":"85","lastprice":"","event_note":"","dividend_value_formatted":"Rp 85","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2018-04-12","dividend_datahash":"","dividend_exdate":"2018-04-13","dividend_id":"4874","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2018-04-30","dividend_recdate":"2018-04-17","dividend_value":"175","lastprice":"","event_note":"","dividend_value_formatted":"Rp 175","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2017-11-28","dividend_datahash":"","dividend_exdate":"2017-11-29","dividend_id":"4531","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2017-12-20","dividend_recdate":"2017-12-04","dividend_value":"80","lastprice":"","event_note":"","dividend_value_formatted":"Rp 80","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2017-04-13","dividend_datahash":"","dividend_exdate":"2017-04-17","dividend_id":"2831","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2017-04-28","dividend_recdate":"2017-04-20","dividend_value":"130","lastprice":"","event_note":"","dividend_value_formatted":"Rp 130","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2016-11-30","dividend_datahash":"","dividend_exdate":"2016-12-01","dividend_id":"2599","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2016-12-22","dividend_recdate":"2016-12-05","dividend_value":"70","lastprice":"","event_note":"","dividend_value_formatted":"Rp 70","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2016-04-14","dividend_datahash":"","dividend_exdate":"2016-04-15","dividend_id":"1283","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2016-04-29","dividend_recdate":"2016-04-19","dividend_value":"105","lastprice":"","event_note":"","dividend_value_formatted":"Rp 105","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2015-11-12","dividend_datahash":"","dividend_exdate":"2015-11-13","dividend_id":"1109","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2015-12-09","dividend_recdate":"2015-11-17","dividend_value":"55","lastprice":"","event_note":"","dividend_value_formatted":"Rp 55","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2015-04-16","dividend_datahash":"","dividend_exdate":"2015-04-17","dividend_id":"996","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2015-05-13","dividend_recdate":"2015-04-21","dividend_value":"98","lastprice":"","event_note":"","dividend_value_formatted":"Rp 98","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2014-12-04","dividend_datahash":"","dividend_exdate":"2014-12-05","dividend_id":"11230","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2014-12-23","dividend_recdate":"2014-12-09","dividend_value":"50","lastprice":"","event_note":"","dividend_value_formatted":"Rp 50","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2014-04-29","dividend_datahash":"","dividend_exdate":"2014-04-30","dividend_id":"11019","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2014-05-20","dividend_recdate":"2014-05-05","dividend_value":"75","lastprice":"","event_note":"","dividend_value_formatted":"Rp 75","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2013-11-28","dividend_datahash":"","dividend_exdate":"2013-11-29","dividend_id":"10978","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2013-12-17","dividend_recdate":"2013-12-03","dividend_value":"45","lastprice":"","event_note":"","dividend_value_formatted":"Rp 45","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2013-05-28","dividend_datahash":"","dividend_exdate":"2013-05-29","dividend_id":"10806","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2013-06-17","dividend_recdate":"2013-05-31","dividend_value":"71","lastprice":"","event_note":"","dividend_value_formatted":"Rp 71","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2012-12-03","dividend_datahash":"","dividend_exdate":"2012-12-04","dividend_id":"10744","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2012-12-20","dividend_recdate":"2012-12-06","dividend_value":"43.5","lastprice":"","event_note":"","dividend_value_formatted":"Rp 43.5","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2012-06-08","dividend_datahash":"","dividend_exdate":"2012-06-11","dividend_id":"10604","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2012-06-27","dividend_recdate":"2012-06-13","dividend_value":"70","lastprice":"","event_note":"","dividend_value_formatted":"Rp 70","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2011-12-06","dividend_datahash":"","dividend_exdate":"2011-12-07","dividend_id":"10538","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2011-12-23","dividend_recdate":"2011-12-09","dividend_value":"43.5","lastprice":"","event_note":"","dividend_value_formatted":"Rp 43.5","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2011-06-06","dividend_datahash":"","dividend_exdate":"2011-06-07","dividend_id":"10364","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2011-06-23","dividend_recdate":"2011-06-09","dividend_value":"70","lastprice":"","event_note":"","dividend_value_formatted":"Rp 70","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2010-11-19","dividend_datahash":"","dividend_exdate":"2010-11-22","dividend_id":"10299","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2010-12-09","dividend_recdate":"2010-11-24","dividend_value":"42.5","lastprice":"","event_note":"","dividend_value_formatted":"Rp 42.5","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2010-05-31","dividend_datahash":"","dividend_exdate":"2010-06-01","dividend_id":"10149","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2010-06-17","dividend_recdate":"2010-06-03","dividend_value":"70","lastprice":"","event_note":"","dividend_value_formatted":"Rp 70","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2009-11-12","dividend_datahash":"","dividend_exdate":"2009-11-13","dividend_id":"10104","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2009-12-02","dividend_recdate":"2009-11-17","dividend_value":"40","lastprice":"","event_note":"","dividend_value_formatted":"Rp 40","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"dividend_created":"","dividend_cumdate":"2009-06-09","dividend_datahash":"","dividend_exdate":"2009-06-10","dividend_id":"9986","dividend_iqp_id":"","dividend_lastupdate":"","dividend_lock":0,"dividend_paydate":"2009-06-26","dividend_recdate":"2009-06-12","dividend_value":"65","lastprice":"","event_note":"","dividend_value_formatted":"Rp 65","lastprice_formatted":"","dividend_currency":"CURRENCY_IDR","dividend_fiscal_year":0,"dividend_value_adjusted":0}],"today":"2026-11-03"},"message":"Successfully retrieved dividend data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_ipo.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_ipo.json new file mode 100644 index 0000000..fe606a4 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_ipo.json @@ -0,0 +1 @@ +{"data":{"ipo":[{"ipo_id":"6851","company_symbol":"XKGS","company_name":"Reksa Dana Syariah KIM Gold ETF Sharia","ipo_listing_date":"2026-09-26","ipo_data":"{\"%\":\"0\",\"Allotment Date\":\"\",\"Offering End\":\"0001-01-01 00:00:00\",\"Offering Start\":\"0001-01-01 00:00:00\",\"Price\":\"0\",\"Refund Date\":\"\",\"Shares\":\"\",\"board\":\"\"}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"0","shares":"","percentage":"0","offering_start":"0001-01-01 00:00:00","offering_end":"0001-01-01 00:00:00","allotment_date":"","refund_date":"","price_formatted":"0","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":0}},{"ipo_id":"6847","company_symbol":"XDES","company_name":"Reksa Dana Syariah BRI MI Gold ETF Syariah","ipo_listing_date":"2026-08-10","ipo_data":"{\"%\":\"0\",\"Allotment Date\":\"\",\"Offering End\":\"0001-01-01 00:00:00\",\"Offering Start\":\"0001-01-01 00:00:00\",\"Price\":\"0\",\"Refund Date\":\"\",\"Shares\":\"\",\"board\":\"\"}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"0","shares":"","percentage":"0","offering_start":"0001-01-01 00:00:00","offering_end":"0001-01-01 00:00:00","allotment_date":"","refund_date":"","price_formatted":"0","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":0}},{"ipo_id":"6848","company_symbol":"XMES","company_name":"Reksa Dana Syariah Mandiri ETF Emas","ipo_listing_date":"2026-08-10","ipo_data":"{\"%\":\"0\",\"Allotment Date\":\"\",\"Offering End\":\"0001-01-01 00:00:00\",\"Offering Start\":\"0001-01-01 00:00:00\",\"Price\":\"0\",\"Refund Date\":\"\",\"Shares\":\"\",\"board\":\"\"}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"0","shares":"","percentage":"0","offering_start":"0001-01-01 00:00:00","offering_end":"0001-01-01 00:00:00","allotment_date":"","refund_date":"","price_formatted":"0","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":0}},{"ipo_id":"6849","company_symbol":"XSGO","company_name":"Reksa Dana Syariah Syailendra ETF Sharia Gold","ipo_listing_date":"2026-08-10","ipo_data":"{\"%\":\"0\",\"Allotment Date\":\"\",\"Offering End\":\"0001-01-01 00:00:00\",\"Offering Start\":\"0001-01-01 00:00:00\",\"Price\":\"0\",\"Refund Date\":\"\",\"Shares\":\"\",\"board\":\"\"}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"0","shares":"","percentage":"0","offering_start":"0001-01-01 00:00:00","offering_end":"0001-01-01 00:00:00","allotment_date":"","refund_date":"","price_formatted":"0","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":0}},{"ipo_id":"6850","company_symbol":"XTRA","company_name":"Reksa Dana Syariah Trimegah Syariah ETF Emas","ipo_listing_date":"2026-08-10","ipo_data":"{\"%\":\"0\",\"Allotment Date\":\"\",\"Offering End\":\"0001-01-01 00:00:00\",\"Offering Start\":\"0001-01-01 00:00:00\",\"Price\":\"0\",\"Refund Date\":\"\",\"Shares\":\"\",\"board\":\"\"}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"0","shares":"","percentage":"0","offering_start":"0001-01-01 00:00:00","offering_end":"0001-01-01 00:00:00","allotment_date":"","refund_date":"","price_formatted":"0","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":0}},{"ipo_id":"6834","company_symbol":"RANS","company_name":"PT Rans Entertainmen Indonesia","ipo_listing_date":"2026-07-10","ipo_data":"{\"%\":\"20.02\",\"Allotment Date\":\"\",\"Offering End\":\"2026-07-08 00:00:00\",\"Offering Start\":\"2026-07-02 00:00:00\",\"Price\":\"170\",\"Refund Date\":\"\",\"Shares\":\"2,525,000,000\",\"board\":\"\",\"bureau_administration\":\"PT Adimitra Jasa Korpora\",\"underwriter\":[\"LG - TRIMEGAH SEKURITAS INDONESIA TBK.\"]}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"170","shares":"2,525,000,000","percentage":"20.02","offering_start":"2026-07-02 00:00:00","offering_end":"2026-07-08 00:00:00","allotment_date":"","refund_date":"","price_formatted":"170","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":170}},{"ipo_id":"6826","company_symbol":"PRDL","company_name":"PT Prodia Diagnostic Line","ipo_listing_date":"2026-07-09","ipo_data":"{\"%\":\"30\",\"Allotment Date\":\"\",\"Offering End\":\"2026-07-07 00:00:00\",\"Offering Start\":\"2026-07-01 00:00:00\",\"Price\":\"120\",\"Refund Date\":\"\",\"Shares\":\"522,900,000\",\"board\":\"\",\"bureau_administration\":\"PT Adimitra Jasa Korpora\",\"underwriter\":[\"AZ - SUCOR SEKURITAS\"]}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"120","shares":"522,900,000","percentage":"30","offering_start":"2026-07-01 00:00:00","offering_end":"2026-07-07 00:00:00","allotment_date":"","refund_date":"","price_formatted":"120","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":120}},{"ipo_id":"6828","company_symbol":"EMMI","company_name":"PT Esa Medika Mandiri","ipo_listing_date":"2026-07-08","ipo_data":"{\"%\":\"30\",\"Allotment Date\":\"\",\"Offering End\":\"2026-07-06 00:00:00\",\"Offering Start\":\"2026-07-02 00:00:00\",\"Price\":\"470\",\"Refund Date\":\"\",\"Shares\":\"522,857,000\",\"board\":\"\",\"bureau_administration\":\"PT Datindo Entrycom\",\"underwriter\":[\"OD - BRI DANAREKSA SEKURITAS\",\"RB - INA SEKURITAS INDONESIA\",\"RS - YULIE SEKURITAS INDONESIA TBK.\",\"IN - INVESTINDO NUSANTARA SEKURITAS\"]}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"470","shares":"522,857,000","percentage":"30","offering_start":"2026-07-02 00:00:00","offering_end":"2026-07-06 00:00:00","allotment_date":"","refund_date":"","price_formatted":"470","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":470}},{"ipo_id":"6830","company_symbol":"BACH","company_name":"PT Bach Multi Global","ipo_listing_date":"2026-07-08","ipo_data":"{\"%\":\"15.06\",\"Allotment Date\":\"\",\"Offering End\":\"2026-07-06 00:00:00\",\"Offering Start\":\"2026-07-02 00:00:00\",\"Price\":\"442\",\"Refund Date\":\"\",\"Shares\":\"615,000,000\",\"board\":\"\",\"bureau_administration\":\"PT Adimitra Jasa Korpora\",\"underwriter\":[\"AO - ERDIKHA ELIT SEKURITAS\",\"PO - PILARMAS INVESTINDO SEKURITAS\"]}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"442","shares":"615,000,000","percentage":"15.06","offering_start":"2026-07-02 00:00:00","offering_end":"2026-07-06 00:00:00","allotment_date":"","refund_date":"","price_formatted":"442","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":442}},{"ipo_id":"6824","company_symbol":"JELI","company_name":"PT Niramas Utama","ipo_listing_date":"2026-07-07","ipo_data":"{\"%\":\"21.01\",\"Allotment Date\":\"\",\"Offering End\":\"2026-07-03 00:00:00\",\"Offering Start\":\"2026-07-01 00:00:00\",\"Price\":\"900\",\"Refund Date\":\"\",\"Shares\":\"266,000,000\",\"board\":\"\",\"bureau_administration\":\"PT Adimitra Jasa Korpora\",\"underwriter\":[\"AZ - SUCOR SEKURITAS\"]}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"900","shares":"266,000,000","percentage":"21.01","offering_start":"2026-07-01 00:00:00","offering_end":"2026-07-03 00:00:00","allotment_date":"","refund_date":"","price_formatted":"900","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":900}},{"ipo_id":"6829","company_symbol":"JECX","company_name":"PT Nitrasanata Dharma","ipo_listing_date":"2026-07-07","ipo_data":"{\"%\":\"15\",\"Allotment Date\":\"\",\"Offering End\":\"2026-07-03 00:00:00\",\"Offering Start\":\"2026-07-01 00:00:00\",\"Price\":\"1250\",\"Refund Date\":\"\",\"Shares\":\"487,983,500\",\"board\":\"\",\"bureau_administration\":\"PT Adimitra Jasa Korpora\",\"underwriter\":[\"LG - TRIMEGAH SEKURITAS INDONESIA TBK.\"]}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"1,250","shares":"487,983,500","percentage":"15","offering_start":"2026-07-01 00:00:00","offering_end":"2026-07-03 00:00:00","allotment_date":"","refund_date":"","price_formatted":"1,250","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":1250}},{"ipo_id":"6821","company_symbol":"WBSA","company_name":"PT BSA Logistics Indonesia","ipo_listing_date":"2026-04-10","ipo_data":"{\"%\": \"20.75\", \"Price\": \"168\", \"board\": \"\", \"Shares\": \"1,800,000,000\", \"Refund Date\": \"\", \"underwriter\": [\"OCBC Sekuritas Indonesia\", \"Semesta Indovest Sekuritas\", \"Indo Capital Sekuritas\"], \"Offering End\": \"2026-04-08 00:00:00\", \"Allotment Date\": \"\", \"Offering Start\": \"2026-04-02 00:00:00\", \"bureau_administration\": \"PT Adimitra Jasa Korpora\"}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"168","shares":"1,800,000,000","percentage":"20.75","offering_start":"2026-04-02 00:00:00","offering_end":"2026-04-08 00:00:00","allotment_date":"","refund_date":"","price_formatted":"168","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":168}},{"ipo_id":"6816","company_symbol":"SUPA","company_name":"PT Super Bank Indonesia","ipo_listing_date":"2025-12-17","ipo_data":"{\"%\": \"13\", \"Price\": \"635\", \"board\": \"\", \"Shares\": \"4,406,612,300\", \"Refund Date\": \"\", \"underwriter\": [\"Mandiri Sekuritas\", \"CLSA Sekuritas Indonesia\", \"Trimegah Sekuritas Indonesia Tbk.\", \"Sucor Sekuritas\", \"Bahana Sekuritas\", \"Korea Investment and Sekuritas Indonesia\"], \"Offering End\": \"2025-12-15 00:00:00\", \"Allotment Date\": \"\", \"Offering Start\": \"2025-12-10 00:00:00\", \"bureau_administration\": \"PT Bima Registra\"}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"635","shares":"4,406,612,300","percentage":"13","offering_start":"2025-12-10 00:00:00","offering_end":"2025-12-15 00:00:00","allotment_date":"","refund_date":"","price_formatted":"635","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":635}},{"ipo_id":"6814","company_symbol":"RLCO","company_name":"PT Abadi Lestari Indonesia","ipo_listing_date":"2025-12-08","ipo_data":"{\"%\": \"20\", \"Price\": \"168\", \"board\": \"\", \"Shares\": \"625,000,000\", \"Refund Date\": \"\", \"underwriter\": [\"Samuel Sekuritas Indonesia\"], \"Offering End\": \"2025-12-04 00:00:00\", \"Allotment Date\": \"\", \"Offering Start\": \"2025-12-02 00:00:00\", \"bureau_administration\": \"PT Adimitra Jasa Korpora\"}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"168","shares":"625,000,000","percentage":"20","offering_start":"2025-12-02 00:00:00","offering_end":"2025-12-04 00:00:00","allotment_date":"","refund_date":"","price_formatted":"168","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":168}},{"ipo_id":"6811","company_symbol":"PJHB","company_name":"PT Pelayaran Jaya Hidup Baru","ipo_listing_date":"2025-11-06","ipo_data":"{\"%\":\"25\",\"Allotment Date\":\"\",\"Offering End\":\"2025-11-04 00:00:00\",\"Offering Start\":\"2025-10-31 00:00:00\",\"Price\":\"330\",\"Refund Date\":\"\",\"Shares\":\"480,000,000\",\"board\":\"\",\"bureau_administration\":\"PT Sinartama Gunita\",\"underwriter\":[\"Pilarmas Investindo Sekuritas\"]}","ipo_created":"","ipo_iqp_id":"","ipo_data_detail":{"price":"330","shares":"480,000,000","percentage":"25","offering_start":"2025-10-31 00:00:00","offering_end":"2025-11-04 00:00:00","allotment_date":"","refund_date":"","price_formatted":"330","listing_board":"","underwriter":[],"bureau_administration":""},"corp_action_active":false,"company_id":"0","ipo_price":{"minimum":0,"maximum":0,"final":330}}]},"message":"Successfully retrieved IPO data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex.json new file mode 100644 index 0000000..dab4693 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex.json @@ -0,0 +1 @@ +{"data":{"pubex":[{"company_id":"1000004517","company_symbol":"MEJA","corp_action_active":false,"puexp_date":"2026-11-24","puexp_id":"156830","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"13:00:00","puexp_venue":"Kantor PT Harta Djaya Karya Tbk, Jl. Bintaro Raya No. 8A, Kec. Kebayoran Lama, Jakarta Selatan 12240"},{"company_id":"450","company_symbol":"VIVA","corp_action_active":false,"puexp_date":"2026-11-17","puexp_id":"156841","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"15:30:00","puexp_venue":"The Convergence Indonesia, Lantai 28, Kawasan Rasuna Epicentrum, Jl. HR. Rasuna Said, Karet Kuningan, Setiabudi, Jakarta Selatan, 12940, Indonesia atau Live event melalui aplikasi Zoom https://bit.ly/OnlinePubExVivaMdia2026"},{"company_id":"512","company_symbol":"MDIA","corp_action_active":false,"puexp_date":"2026-11-17","puexp_id":"156840","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"15:30:00","puexp_venue":"The Convergence Indonesia, Lantai 28, Kawasan Rasuna Epicentrum, Jl. HR. Rasuna Said, Karet Kuningan, Setiabudi, Jakarta Selatan, 12940, Indonesia atau Live event melalui aplikasi Zoom https://bit.ly/OnlinePubExVivaMdia2026"},{"company_id":"1000006367","company_symbol":"WBSA","corp_action_active":false,"puexp_date":"2026-11-12","puexp_id":"156836","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"14:00:00","puexp_venue":"Online/Virtual via Google Meet. Registrasi melalui link: https://bit.ly/WBSApubex2026"},{"company_id":"347","company_symbol":"PTRO","corp_action_active":false,"puexp_date":"2026-11-12","puexp_id":"156833","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"10:00:00","puexp_venue":"Zoom webinar dengan tautan registrasi: / zoom webinar with the following registration link: https://bit.ly/PTRO-PE2026"},{"company_id":"811","company_symbol":"INET","corp_action_active":false,"puexp_date":"2026-11-12","puexp_id":"156835","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"14:00:00","puexp_venue":"Sarana Media Elektronik : Para peserta Public Expose dapat menggunakan tautan akses berikut: https://us06web.zoom.us/j/81220631598?pwd=sbmlCyRX6hIhjuye rU96H4Yi04ageE.1 Meeting ID : 812 2063 1598 Passcode : inet26"},{"company_id":"1000003697","company_symbol":"CHEM","corp_action_active":false,"puexp_date":"2026-11-12","puexp_id":"156832","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"10:00:00","puexp_venue":"Jl Industri ubrug 70 Purwakarta Desa Cibinong Jatiluhur Purwakarta, Jawa Barat 41152"},{"company_id":"78","company_symbol":"BLTA","corp_action_active":false,"puexp_date":"2026-11-12","puexp_id":"156831","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"14:00:00","puexp_venue":"Live event melalui aplikasi Zoom"},{"company_id":"482","company_symbol":"ACST","corp_action_active":false,"puexp_date":"2026-11-12","puexp_id":"156837","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"11:00:00","puexp_venue":"Webinar Zoom Meeting"},{"company_id":"1000004015","company_symbol":"MENN","corp_action_active":true,"puexp_date":"2026-11-10","puexp_id":"156828","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"15:00:00","puexp_venue":"ONLINE"},{"company_id":"1000004498","company_symbol":"UNTD","corp_action_active":true,"puexp_date":"2026-11-09","puexp_id":"156794","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"13:00:00","puexp_venue":"United Bike Building, 2nd Floor (Alam Sutera Boulevard Kav. 12A, Pakulonan, Serpong Utara"},{"company_id":"304","company_symbol":"MTLA","corp_action_active":true,"puexp_date":"2026-11-09","puexp_id":"156825","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"14:00:00","puexp_venue":"Zoom Webinar Perseroan"},{"company_id":"1000000084","company_symbol":"CITY","corp_action_active":true,"puexp_date":"2026-11-09","puexp_id":"156826","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"14:00:00","puexp_venue":"Hotel Nawana, Sentul City, Kab Bogor"},{"company_id":"303","company_symbol":"MTFN","corp_action_active":true,"puexp_date":"2026-11-06","puexp_id":"156823","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"11:00:00","puexp_venue":"Kantor Taman E3.3, Unit D3A, Lantai 5 Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"company_id":"23","company_symbol":"AMAG","corp_action_active":true,"puexp_date":"2026-11-06","puexp_id":"156827","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"15:00:00","puexp_venue":"Virtual Zoom Webinar, di PT Asuransi Multi Artha Guna Tbk, Gedung TCC Batavia Tower One, Jalan KH Mas Mansyur Kav 126, Jakarta Pusat"},{"company_id":"46","company_symbol":"AUTO","corp_action_active":true,"puexp_date":"2026-11-05","puexp_id":"156821","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"10:00:00","puexp_venue":"Zoom Webinar"},{"company_id":"1000002677","company_symbol":"AMOR","corp_action_active":true,"puexp_date":"2026-11-04","puexp_id":"156820","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"17:00:00","puexp_venue":"Live Event Melalui aplikasi Zoom https://bit.ly/PUBEXASHMORE2026"},{"company_id":"416","company_symbol":"TFCO","corp_action_active":true,"puexp_date":"2026-11-03","puexp_id":"156838","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"14:00:00","puexp_venue":"Online Google Meeting"},{"company_id":"349","company_symbol":"PTSP","corp_action_active":true,"puexp_date":"2026-11-03","puexp_id":"156839","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"10:00:00","puexp_venue":"Sarana media elektronik video conference melalui aplikasi Zoom Link : https://zoom.us/j/6732025385 Meeting ID : 673 202 5385 Passcode : iCLEAN"},{"company_id":"1000000033","company_symbol":"IPCC","corp_action_active":true,"puexp_date":"2026-11-03","puexp_id":"156818","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"14:00:00","puexp_venue":"Ruang Inovation Room PT Indonesia Kendaraan Terminal Tbk, Jalan Sindang Laut No 100 Jakarta Utara"}]},"message":"Successfully retrieved Public Expose data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex_bbca.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex_bbca.json new file mode 100644 index 0000000..e8fe0d1 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex_bbca.json @@ -0,0 +1 @@ +{"data":{"pubex":[{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2026-09-09","puexp_id":"156753","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"16:00:00","puexp_venue":"Public Expose Live 2026 yang tercantum pada website idx.co.id"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2025-09-11","puexp_id":"87667","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"16:00:00","puexp_venue":"Public Expose Live 2025 yang tercantum pada website idx.co.id"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2024-08-28","puexp_id":"36803","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"15:00:00","puexp_venue":"Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan pendaftaran peserta di website www.idx.co.id"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2023-11-29","puexp_id":"20156","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"10:00:00","puexp_venue":"Public Expose Live 2023"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2022-09-14","puexp_id":"19470","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"10:00:00","puexp_venue":"Public Expose Live 2022"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2021-09-08","puexp_id":"965","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"15:00:00","puexp_venue":"Public Expose Live 2021\t\t\t"}]},"message":"Successfully retrieved Public Expose data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_reversesplit.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_reversesplit.json new file mode 100644 index 0000000..2afad37 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_reversesplit.json @@ -0,0 +1 @@ +{"data":{"stock_reverse":[]},"message":"Successfully retrieved reverse split data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rightissue.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rightissue.json new file mode 100644 index 0000000..48b2567 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rightissue.json @@ -0,0 +1 @@ +{"data":{"rightissue":[{"company_id":"492","company_symbol":"BMAS","corp_action_active":false,"rightissue_adj_factor":0,"rightissue_created":"","rightissue_cumdate":"2026-11-23","rightissue_exdate":"2026-11-24","rightissue_factor":"1.15878","rightissue_id":"11572","rightissue_iqp_id":"","rightissue_lastupdate":"","rightissue_lock":0,"rightissue_new":"104","rightissue_new_share":0,"rightissue_old":"655","rightissue_price":350,"rightissue_price_adj":0,"rightissue_price_factor":0,"rightissue_ratio":"655 : 104","rightissue_recdate":"2026-11-25","rightissue_subdate":"","rightissue_trading_end":"2026-12-03","rightissue_trading_start":"2026-11-27","event_note":"","rightissue_price_formatted":"350","rightissue_foreign_percentage":0,"rightissue_local_percentage":0,"rightissue_number_of_securities":0,"rightissue_company_id":"0","rightissue_total":0},{"company_id":"1000003328","company_symbol":"MGLV","corp_action_active":true,"rightissue_adj_factor":0,"rightissue_created":"","rightissue_cumdate":"2026-11-04","rightissue_exdate":"2026-11-05","rightissue_factor":"1.15","rightissue_id":"11583","rightissue_iqp_id":"","rightissue_lastupdate":"","rightissue_lock":0,"rightissue_new":"15","rightissue_new_share":0,"rightissue_old":"100","rightissue_price":8880,"rightissue_price_adj":0,"rightissue_price_factor":0,"rightissue_ratio":"100 : 15","rightissue_recdate":"2026-11-06","rightissue_subdate":"","rightissue_trading_end":"2026-11-23","rightissue_trading_start":"2026-11-10","event_note":"","rightissue_price_formatted":"8,880","rightissue_foreign_percentage":0,"rightissue_local_percentage":0,"rightissue_number_of_securities":0,"rightissue_company_id":"0","rightissue_total":0},{"company_id":"105","company_symbol":"BUVA","corp_action_active":false,"rightissue_adj_factor":0,"rightissue_created":"","rightissue_cumdate":"2026-10-13","rightissue_exdate":"2026-10-14","rightissue_factor":"1.25","rightissue_id":"11582","rightissue_iqp_id":"","rightissue_lastupdate":"","rightissue_lock":0,"rightissue_new":"1","rightissue_new_share":0,"rightissue_old":"4","rightissue_price":250,"rightissue_price_adj":0,"rightissue_price_factor":0,"rightissue_ratio":"4 : 1","rightissue_recdate":"2026-10-15","rightissue_subdate":"","rightissue_trading_end":"2026-10-30","rightissue_trading_start":"2026-10-19","event_note":"","rightissue_price_formatted":"250","rightissue_foreign_percentage":0,"rightissue_local_percentage":0,"rightissue_number_of_securities":0,"rightissue_company_id":"0","rightissue_total":0},{"company_id":"92","company_symbol":"BRNA","corp_action_active":false,"rightissue_adj_factor":0,"rightissue_created":"","rightissue_cumdate":"2026-10-08","rightissue_exdate":"2026-10-09","rightissue_factor":"1.5556","rightissue_id":"11581","rightissue_iqp_id":"","rightissue_lastupdate":"","rightissue_lock":0,"rightissue_new":"5","rightissue_new_share":0,"rightissue_old":"9","rightissue_price":685,"rightissue_price_adj":0,"rightissue_price_factor":0,"rightissue_ratio":"9 : 5","rightissue_recdate":"2026-10-12","rightissue_subdate":"","rightissue_trading_end":"2026-10-21","rightissue_trading_start":"2026-10-14","event_note":"","rightissue_price_formatted":"685","rightissue_foreign_percentage":0,"rightissue_local_percentage":0,"rightissue_number_of_securities":0,"rightissue_company_id":"0","rightissue_total":0},{"company_id":"12","company_symbol":"AHAP","corp_action_active":false,"rightissue_adj_factor":0,"rightissue_created":"","rightissue_cumdate":"2026-10-08","rightissue_exdate":"2026-10-09","rightissue_factor":"1.7143","rightissue_id":"11580","rightissue_iqp_id":"","rightissue_lastupdate":"","rightissue_lock":0,"rightissue_new":"5","rightissue_new_share":0,"rightissue_old":"7","rightissue_price":50,"rightissue_price_adj":0,"rightissue_price_factor":0,"rightissue_ratio":"7 : 5","rightissue_recdate":"2026-10-12","rightissue_subdate":"","rightissue_trading_end":"2026-10-20","rightissue_trading_start":"2026-10-14","event_note":"","rightissue_price_formatted":"50","rightissue_foreign_percentage":0,"rightissue_local_percentage":0,"rightissue_number_of_securities":0,"rightissue_company_id":"0","rightissue_total":0},{"company_id":"1000004126","company_symbol":"VKTR","corp_action_active":false,"rightissue_adj_factor":0,"rightissue_created":"","rightissue_cumdate":"2026-10-06","rightissue_exdate":"2026-10-07","rightissue_factor":"1.3429","rightissue_id":"11578","rightissue_iqp_id":"","rightissue_lastupdate":"","rightissue_lock":0,"rightissue_new":"12","rightissue_new_share":0,"rightissue_old":"35","rightissue_price":200,"rightissue_price_adj":0,"rightissue_price_factor":0,"rightissue_ratio":"35 : 12","rightissue_recdate":"2026-10-08","rightissue_subdate":"","rightissue_trading_end":"2026-10-23","rightissue_trading_start":"2026-10-12","event_note":"","rightissue_price_formatted":"200","rightissue_foreign_percentage":0,"rightissue_local_percentage":0,"rightissue_number_of_securities":0,"rightissue_company_id":"0","rightissue_total":0},{"company_id":"151","company_symbol":"ENRG","corp_action_active":false,"rightissue_adj_factor":0,"rightissue_created":"","rightissue_cumdate":"2026-10-02","rightissue_exdate":"2026-10-05","rightissue_factor":"1.5","rightissue_id":"11577","rightissue_iqp_id":"","rightissue_lastupdate":"","rightissue_lock":0,"rightissue_new":"1","rightissue_new_share":0,"rightissue_old":"2","rightissue_price":310,"rightissue_price_adj":0,"rightissue_price_factor":0,"rightissue_ratio":"2 : 1","rightissue_recdate":"2026-10-06","rightissue_subdate":"","rightissue_trading_end":"2026-10-21","rightissue_trading_start":"2026-10-08","event_note":"","rightissue_price_formatted":"310","rightissue_foreign_percentage":0,"rightissue_local_percentage":0,"rightissue_number_of_securities":0,"rightissue_company_id":"0","rightissue_total":0}]},"message":"Successfully retrieved rights issue data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups.json new file mode 100644 index 0000000..8054f90 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups.json @@ -0,0 +1 @@ +{"data":{"rups":[{"company_id":"35","company_symbol":"ARTI","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-12-09","rups_id":"1461637","rups_time":"10:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Gedung Ratu Prabu 1 Jl. TB. Simatupang Kav. 20 Jakarta.","rups_eligible_date":"2026-11-12","company_name":"","company_icon_url":""},{"company_id":"1000004211","company_symbol":"MSIE","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-20","rups_id":"1461587","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"IPC Room, IPH Schools 2nd Floor, Jl. Raya Kedung Baruk 112-114 Surabaya","rups_eligible_date":"2026-10-26","company_name":"","company_icon_url":""},{"company_id":"32","company_symbol":"ARII","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-20","rups_id":"1461582","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Jl. Kemang Raya No. 43 Jakarta Selatan diumumkan dan sesuai iklan)","rups_eligible_date":"2026-10-26","company_name":"","company_icon_url":""},{"company_id":"38","company_symbol":"ASGR","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-20","rups_id":"1461631","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Mandarin Oriental Hotel - Ballroom, di Jl. M.H. Thamrin, RT.1/RW.5, Menteng, Kec. Menteng, Kota Jakarta Pusat, Daerah Kh","rups_eligible_date":"2026-10-26","company_name":"","company_icon_url":""},{"company_id":"624","company_symbol":"MARK","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-19","rups_id":"1461579","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Wing Hotel Kualanamu diumumkan dan sesuai iklan)","rups_eligible_date":"2026-10-23","company_name":"","company_icon_url":""},{"company_id":"644","company_symbol":"GMFI","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-19","rups_id":"1461624","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Ruang Auditorium, Gedung Manajemen Garuda, Lantai Dasar, Garuda City, Area Perkantoran Bandar Udara International Soekar","rups_eligible_date":"2026-10-23","company_name":"","company_icon_url":""},{"company_id":"168","company_symbol":"GDYR","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-19","rups_id":"1461581","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Ruang Edelweis, Hotel Grand Savero, Bogor, Jl. Padjajaran No 27, Bogor.","rups_eligible_date":"2026-10-23","company_name":"","company_icon_url":""},{"company_id":"1000003658","company_symbol":"ASHA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-19","rups_id":"1461580","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Gedung PT Sutioso Bersaudara Lantai 3 Jl Tuna II Blok D 2, RT.20/RW.17, Penjaringan, Kec. Penjaringan, Jkt Utara, Daerah","rups_eligible_date":"2026-10-23","company_name":"","company_icon_url":""},{"company_id":"1000003819","company_symbol":"NINE","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-18","rups_id":"1461577","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"HARRIS Suites Puri Mansion Puri Mansion Estate, Jl. Puri Lkr. Luar, RT.13/RW.2, Duri Kosambi, Kec. Kembangan, Kota Jakar","rups_eligible_date":"2026-10-22","company_name":"","company_icon_url":""},{"company_id":"181","company_symbol":"GTBO","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-18","rups_id":"1461622","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Ballroom Krakatau Permata Kuningan Building Lantai 2 Jl. Kuningan Mulia Kav. 9C, Guntur, Jakarta 12960, Indonesia","rups_eligible_date":"2026-10-22","company_name":"","company_icon_url":""},{"company_id":"1000003334","company_symbol":"TRUE","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-18","rups_id":"1461578","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Kota Tangerang, Banten diumumkan dan sesuai iklan)","rups_eligible_date":"2026-10-22","company_name":"","company_icon_url":""},{"company_id":"1000003365","company_symbol":"FLMC","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-18","rups_id":"1461575","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Artotel Suites Aquila, Jl. Dr Djunjunan No 116 Bandung","rups_eligible_date":"2026-10-22","company_name":"","company_icon_url":""},{"company_id":"207","company_symbol":"INCO","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-18","rups_id":"1461574","rups_time":"09:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Sudirman Hall, Sequis Tower, Level 2, Jl. Jend. Sudirman Kav. 71, Jakarta 12190, Indonesia","rups_eligible_date":"2026-10-22","company_name":"","company_icon_url":""},{"company_id":"390","company_symbol":"SMMA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-17","rups_id":"1461567","rups_time":"16:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"The Grand Mansion, Jalan Borobudur Nomor 10, Pengangsaan , Menteng, Jakarta Pusat 10320","rups_eligible_date":"2026-10-21","company_name":"","company_icon_url":""},{"company_id":"169","company_symbol":"GEMA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-17","rups_id":"1461568","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"SOUTH78 JL BOULEVARD GADING SERPONG BLOK O NO 7-8 MEDANG PEGEDANGAN TANGERANG","rups_eligible_date":"2026-10-21","company_name":"","company_icon_url":""},{"company_id":"450","company_symbol":"VIVA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-17","rups_id":"1461570","rups_time":"13:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"The Convergence Indonesia, Lantai 28, Kawasan Rasuna Epicentrum, Jl. HR. Rasuna Said, Karet Kuningan, Setiabudi, Jakarta","rups_eligible_date":"2026-10-21","company_name":"","company_icon_url":""},{"company_id":"512","company_symbol":"MDIA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-17","rups_id":"1461569","rups_time":"11:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"The Convergence Indonesia, Lantai 28, Kawasan Rasuna Epicentrum, Jl. HR. Rasuna Said, Karet Kuningan, Setiabudi, Jakarta","rups_eligible_date":"2026-10-21","company_name":"","company_icon_url":""},{"company_id":"1000004142","company_symbol":"AMMN","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-17","rups_id":"1461571","rups_time":"10:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Mengakses fasilitas Electronic General Meeting System pada platform eASY.KSEI","rups_eligible_date":"2026-10-21","company_name":"","company_icon_url":""},{"company_id":"385","company_symbol":"SMAR","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-17","rups_id":"1461572","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Ruang Danamas, Plaza Sinar Mas Land, Menara 2, Lantai 39 Jalan MH. Thamrin No. 51, Jakarta Pusat 10350","rups_eligible_date":"2026-10-21","company_name":"","company_icon_url":""},{"company_id":"429","company_symbol":"TOTL","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-16","rups_id":"1461565","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"PT Total Bangun Persada Tbk, Jakarta dan secara elektronik melalui eASY.KSEI Kehadiran fisik yaitu hanya untuk profesi p","rups_eligible_date":"2026-10-20","company_name":"","company_icon_url":""},{"company_id":"1000003200","company_symbol":"FAPA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-16","rups_id":"1461563","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Gedung Gold Coast, Tower Liberty Lt. 16 A- H, Jl Pantai Indah Kapuk Kelurahan Kamal Muara Kecamatan Penjaringan, Jakarta","rups_eligible_date":"2026-10-20","company_name":"","company_icon_url":""},{"company_id":"111","company_symbol":"CENT","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-13","rups_id":"1461559","rups_time":"15:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Ruang Meeting Perseroan, TCC Batavia Tower One 16th Jl. KH. Mas Mansyur Kav. 126 Jakarta Pusat DKI Jakarta 10220 Indones","rups_eligible_date":"2026-10-19","company_name":"","company_icon_url":""},{"company_id":"7","company_symbol":"ADHI","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-13","rups_id":"1461561","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"ADHI Tower, Jl. M.T. Haryono Kavling 27, Cawang, Jakarta Timur","rups_eligible_date":"2026-10-19","company_name":"","company_icon_url":""},{"company_id":"1000003481","company_symbol":"ADCP","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-13","rups_id":"1461562","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Kantor Pusat ADHI Jalan Raya Pasar Minggu KM 18 Jakarta Selatan 18510","rups_eligible_date":"2026-10-19","company_name":"","company_icon_url":""},{"company_id":"1000003328","company_symbol":"MGLV","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-12","rups_id":"1461557","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Function Hall Pacific Century Place Lantai B1 Room B Jl. Jend. Sudirman Kav. 52-53, Senayan, Kebayoran Baru, Jakarta Sel","rups_eligible_date":"2026-10-16","company_name":"","company_icon_url":""},{"company_id":"1000003422","company_symbol":"IDEA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-12","rups_id":"1461551","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Aleesha Venue, Jl. Warung Sila No. 1, RT.001/RW.005, Cipedak, Kec. Jagakarsa, Kota Jakarta Selatan, Jakarta 12630","rups_eligible_date":"2026-10-16","company_name":"","company_icon_url":""},{"company_id":"1000003821","company_symbol":"KETR","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-12","rups_id":"1461538","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Secara Fisik Rosewood Room, Royal Kuningan Hotel Jl. Kuningan Persada No.2, Kelurahan Guntur, Kecamatan Setiabudi, Jaka","rups_eligible_date":"2026-10-08","company_name":"","company_icon_url":""},{"company_id":"1000003894","company_symbol":"BMBL","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-11-11","rups_id":"1461588","rups_time":"09:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Hotel Daima Suites Margonda diumumkan dan sesuai iklan)","rups_eligible_date":"2026-10-15","company_name":"","company_icon_url":""},{"company_id":"1000003621","company_symbol":"WIRG","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-10","rups_id":"1461640","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"WANG Plaza (Hall of Grace 2 Lantai 2) Jalan Panjang Kav. 17, Kedoya Utara, Kebon Jeruk, Jakarta Barat 11520","rups_eligible_date":"2026-10-28","company_name":"","company_icon_url":""},{"company_id":"361","company_symbol":"RIGS","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-10","rups_id":"1461554","rups_time":"09:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Generali Tower, Gran Rubina Business Park 19th Floor Unit C, Kawasan Rasuna Epicentrum, Jalan H.R. Rasuna Said, Keluraha","rups_eligible_date":"2026-10-14","company_name":"","company_icon_url":""},{"company_id":"1000004066","company_symbol":"DOOH","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-09","rups_id":"1461552","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Ruang Meeting Slipi, Hotel Mercure Batavia Jakarta, Jl. Kali Besar Barat No.44-46, Roa Malaka, Kec. Tambora, Jakarta Bar","rups_eligible_date":"2026-10-13","company_name":"","company_icon_url":""},{"company_id":"1000002301","company_symbol":"ITIC","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-09","rups_id":"1461548","rups_time":"11:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Teak Room 9th Floor at Mercure Hotel Jakarta Pantai Indah Kapuk, PIK Avenue Mall, Pantai Indah Kapuk Boulevard, Jakarta","rups_eligible_date":"2026-10-13","company_name":"","company_icon_url":""},{"company_id":"1000004498","company_symbol":"UNTD","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-09","rups_id":"1461549","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"United Building (Grand Hall Lantai 2), Jalan Boulevard Alam Sutera No. 12 A, Pakulonan, Kec. Serpong Utara, Kota Tangera","rups_eligible_date":"2026-10-13","company_name":"","company_icon_url":""},{"company_id":"447","company_symbol":"UNTR","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-09","rups_id":"1461553","rups_time":"09:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"United Tractors Grand Ballroom, Jl. Raya Bekasi Km 22, Cakung Barat, Kecamatan Cakung, Kota Jakarta Timur, DKI Jakarta 1","rups_eligible_date":"2026-10-13","company_name":"","company_icon_url":""},{"company_id":"1000003820","company_symbol":"KDTN","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-06","rups_id":"1461546","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"PT Puri Sentul Permai Tbk diumumkan dan sesuai iklan)","rups_eligible_date":"2026-10-12","company_name":"","company_icon_url":""},{"company_id":"5035","company_symbol":"INPS","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-06","rups_id":"1461545","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Gedung Inprase, Jl. Sunter Garden Raya Blok D8 No. 3G-3H, Sunter Agung, Tanjung Priok, Jakarta Utara, DKI Jakarta 14350","rups_eligible_date":"2026-10-07","company_name":"","company_icon_url":""},{"company_id":"172","company_symbol":"GIAA","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-06","rups_id":"1461547","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Secara elektronik melalui sistem eASY.KSEI (https//akses.ksei.co.id/)","rups_eligible_date":"2026-10-12","company_name":"","company_icon_url":""},{"company_id":"443","company_symbol":"ULTJ","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-06","rups_id":"1461528","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Kantor Perseroan, Jalan Raya Cimareme No 131 Bandung Barat, Jawa Barat Indonesia","rups_eligible_date":"2026-08-31","company_name":"","company_icon_url":""},{"company_id":"303","company_symbol":"MTFN","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-06","rups_id":"1461544","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Kantor Taman E3.3, Unit D3A, Lantai 5, Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan","rups_eligible_date":"2026-10-12","company_name":"","company_icon_url":""},{"company_id":"1000000005","company_symbol":"BRIS","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-06","rups_id":"1461596","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Jakarta Pusat, melalui fasilitas Electronic General Meeting System KSEI (eASY.KSEI) dalam tautan https//akses.ksei.co.id","rups_eligible_date":"2026-10-12","company_name":"","company_icon_url":""},{"company_id":"52","company_symbol":"BATA","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-05","rups_id":"1461542","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Gedung Ventura, lantai 7. Jl. Raden Ajeng Kartini No.26, Cilandak Barat, Jakarta Selatan","rups_eligible_date":"2026-10-09","company_name":"","company_icon_url":""},{"company_id":"1000002677","company_symbol":"AMOR","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-04","rups_id":"1461539","rups_time":"16:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Secara online melalui fasilitas Electronic General Meeting System KSEI (eASY.KSEI) dalam tautan https//akses.ksei.co.id","rups_eligible_date":"2026-10-08","company_name":"","company_icon_url":""},{"company_id":"358","company_symbol":"RDTX","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-04","rups_id":"1461537","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"RDTX SQUARE PODIUM LANTAI 2 JL. PROF. DR. SATRIO NO. 164, JAKARTA SELATAN 12930","rups_eligible_date":"2026-10-08","company_name":"","company_icon_url":""},{"company_id":"1000005447","company_symbol":"MINE","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-04","rups_id":"1461583","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"The Function Room (Ruang Anggrek 5) Lantai 3A, North Tower, Sampoerna Strategic Square, Jl. Jenderal Sudirman No.45 - 46","rups_eligible_date":"2026-10-08","company_name":"","company_icon_url":""},{"company_id":"5028","company_symbol":"BOSS","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-04","rups_id":"1461634","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Wisma 77, Tower I, Lt 8, Jl Letjend S Parman, Slipi, Jakarta Barat","rups_eligible_date":"2026-10-02","company_name":"","company_icon_url":""},{"company_id":"1000002176","company_symbol":"MTPS","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-03","rups_id":"1461612","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Meta Epsi Building Jl. Mayjen D.I Panjaitan Kav. 2, Rawa Bunga, Jatinegara - Jakarta Timur 13350","rups_eligible_date":"2026-10-22","company_name":"","company_icon_url":""},{"company_id":"1000002971","company_symbol":"EPAC","corp_action_active":true,"rups_created":"","rups_datahash":"","rups_date":"2026-11-03","rups_id":"1461536","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"IBIS Style Jakarta Tanah Abang, Jl. Fachrudin No. 22, Kp. Bali, Tanah Abang, Jakarta Pusat 10250","rups_eligible_date":"2026-10-07","company_name":"","company_icon_url":""}]},"message":"Successfully retrieved RUPS/Shareholders meeting data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups_bbca.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups_bbca.json new file mode 100644 index 0000000..80fbfce --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups_bbca.json @@ -0,0 +1 @@ +{"data":{"rups":[{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-03-12","rups_id":"1460182","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 10310","rups_eligible_date":"2026-02-09","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2025-03-12","rups_id":"860258","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta, 10310","rups_eligible_date":"2025-02-07","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2024-03-14","rups_id":"385363","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta Selatan","rups_eligible_date":"2024-02-07","company_name":"","company_icon_url":""},{"company_id":"0","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2023-03-16","rups_id":"368621","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA","rups_eligible_date":"2023-02-13","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2022-03-17","rups_id":"365637","rups_time":"09:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia\nJl. M.H. Thamrin No. 1\nJakarta","rups_eligible_date":"2022-02-14","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2021-09-23","rups_id":"356628","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia\nJl. M.H. Thamrin No. 1\nJakarta","rups_eligible_date":"2021-08-27","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2021-03-29","rups_id":"314659","rups_time":"09:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Jakarta\t\t\t\t ","rups_eligible_date":"2021-02-24","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2020-07-30","rups_id":"314133","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. Jakarta\t\t\t ","rups_eligible_date":"2020-07-03","company_name":"","company_icon_url":""}]},"message":"Successfully retrieved RUPS/Shareholders meeting data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stock_dividend.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stock_dividend.json new file mode 100644 index 0000000..11eb9e0 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stock_dividend.json @@ -0,0 +1 @@ +{"data":{"stock_dividend":[]},"message":"Successfully retrieved stock dividend data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit.json new file mode 100644 index 0000000..a1b3346 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit.json @@ -0,0 +1 @@ +{"data":{"stocksplit":[{"company_id":"601","company_symbol":"PRDA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-12-17","stocksplit_exdate":"2026-12-18","stocksplit_factor":"5","stocksplit_id":"6100","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2026-12-21","event_note":""},{"company_id":"600","company_symbol":"PBSA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-10-13","stocksplit_exdate":"2026-10-14","stocksplit_factor":"2","stocksplit_id":"6099","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2026-10-15","event_note":""},{"company_id":"488","company_symbol":"MLPT","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-07-20","stocksplit_exdate":"2026-07-21","stocksplit_factor":"25","stocksplit_id":"6095","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"25","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 25","stocksplit_recdate":"2026-07-22","event_note":""},{"company_id":"1000003487","company_symbol":"RMKE","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-07-16","stocksplit_exdate":"2026-07-17","stocksplit_factor":"5","stocksplit_id":"6094","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2026-07-20","event_note":""},{"company_id":"354","company_symbol":"RAJA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-07-15","stocksplit_exdate":"2026-07-16","stocksplit_factor":"5","stocksplit_id":"6092","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2026-07-17","event_note":""},{"company_id":"1000004216","company_symbol":"CYBR","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-05-12","stocksplit_exdate":"2026-05-13","stocksplit_factor":"2","stocksplit_id":"6091","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2026-05-18","event_note":""},{"company_id":"1000004217","company_symbol":"CYBR-W","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-05-12","stocksplit_exdate":"2026-05-13","stocksplit_factor":"2","stocksplit_id":"6093","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2026-05-18","event_note":""},{"company_id":"143","company_symbol":"DSSA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-04-08","stocksplit_exdate":"2026-04-09","stocksplit_factor":"25","stocksplit_id":"6089","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"25","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 25","stocksplit_recdate":"2026-04-10","event_note":""}]},"message":"Successfully retrieved stock split data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit_bbca.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit_bbca.json new file mode 100644 index 0000000..ecbd334 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit_bbca.json @@ -0,0 +1 @@ +{"data":{"stocksplit":[{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2021-10-12","stocksplit_exdate":"2021-10-13","stocksplit_factor":"5","stocksplit_id":"3091","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2021-10-14","event_note":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2008-01-25","stocksplit_exdate":"2008-01-28","stocksplit_factor":"2","stocksplit_id":"3","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2008-01-30","event_note":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2004-06-07","stocksplit_exdate":"2004-06-08","stocksplit_factor":"2","stocksplit_id":"1061","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2004-06-10","event_note":""}]},"message":"Successfully retrieved stock split data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_tenderoffer.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_tenderoffer.json new file mode 100644 index 0000000..8d6c778 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_tenderoffer.json @@ -0,0 +1 @@ +{"data":{"tender":[{"company_id":"46","company_name":"Astra Otoparts Tbk.","company_symbol":"AUTO","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-11-11","tender_id":"434","tender_paydate":"2026-11-18","tender_percentage":"4.95","tender_price":"3600","tender_shares":"238442293","tender_start":"2026-10-13","event_note":"","tender_price_formatted":"3,600"},{"company_id":"1000003921","company_name":"Hassana Boga Sejahtera Tbk.","company_symbol":"NAYZ","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-11-03","tender_id":"433","tender_paydate":"2026-11-10","tender_percentage":"45.84","tender_price":"75","tender_shares":"1169011472","tender_start":"2026-10-05","event_note":"","tender_price_formatted":"75"},{"company_id":"1000004066","company_name":"Era Media Sejahtera Tbk.","company_symbol":"DOOH","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-11-03","tender_id":"432","tender_paydate":"2026-11-09","tender_percentage":"33.91","tender_price":"148","tender_shares":"2624548624","tender_start":"2026-10-05","event_note":"","tender_price_formatted":"148"},{"company_id":"409","company_name":"Solusi Tunas Pratama Tbk.","company_symbol":"SUPR","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-10-21","tender_id":"431","tender_paydate":"2026-11-02","tender_percentage":"74.39","tender_price":"45000","tender_shares":"729102","tender_start":"2026-09-24","event_note":"","tender_price_formatted":"45,000"},{"company_id":"278","company_name":"Mitrabahtera Segara Sejati Tbk.","company_symbol":"MBSS","corp_action_active":false,"tender_created":"","tender_datahash":"","tender_end":"2026-10-08","tender_id":"430","tender_paydate":"2026-10-15","tender_percentage":"17.50","tender_price":"2915","tender_shares":"306259839","tender_start":"2026-09-09","event_note":"","tender_price_formatted":"2,915"},{"company_id":"194","company_name":"Inti Bangun Sejahtera Tbk.","company_symbol":"IBST","corp_action_active":false,"tender_created":"","tender_datahash":"","tender_end":"2026-10-02","tender_id":"429","tender_paydate":"2026-10-14","tender_percentage":"0.05","tender_price":"5400","tender_shares":"650832","tender_start":"2026-09-04","event_note":"","tender_price_formatted":"5,400"}]},"message":"Successfully retrieved tender offer data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_warrant.json b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_warrant.json new file mode 100644 index 0000000..04c3ecc --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_warrant.json @@ -0,0 +1 @@ +{"data":{"warrant":[{"company_id":"92","company_symbol":"BRNA","corp_action_active":true,"wrant_exc_end":"2029-10-15","wrant_exc_from":"2027-04-16","wrant_exc_price":"800","wrant_id":"3570","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2029-10-10","wrant_trading_from":"2026-10-16","event_note":"","wrant_exc_price_formatted":"800","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007050","company_symbol":"ACESYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"3000","wrant_id":"3586","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"3,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007051","company_symbol":"AKRAYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"8000","wrant_id":"3587","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"8,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007052","company_symbol":"ARTOYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"8000","wrant_id":"3588","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"8,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007053","company_symbol":"ASIIYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"12000","wrant_id":"3589","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"12,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007054","company_symbol":"CUANYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"5000","wrant_id":"3590","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"5,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007055","company_symbol":"DEWAYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"5000","wrant_id":"3591","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"5,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007056","company_symbol":"ELSAYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"5000","wrant_id":"3592","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"5,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007057","company_symbol":"EMTKYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"5000","wrant_id":"3593","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"5,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007058","company_symbol":"ERAAYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"5000","wrant_id":"3594","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"5,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007059","company_symbol":"ESSAYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"5000","wrant_id":"3595","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"5,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007060","company_symbol":"EXCLYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"8000","wrant_id":"3596","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"8,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007061","company_symbol":"HRTAYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"8000","wrant_id":"3597","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"8,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007062","company_symbol":"INKPYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"30000","wrant_id":"3598","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"30,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007063","company_symbol":"JPFAYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"8000","wrant_id":"3599","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"8,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007064","company_symbol":"MAPIYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"8000","wrant_id":"3600","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"8,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007065","company_symbol":"NCKLYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"5000","wrant_id":"3601","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"5,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007066","company_symbol":"PGASYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"5000","wrant_id":"3602","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"5,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007067","company_symbol":"TAPGYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"10000","wrant_id":"3603","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"10,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007068","company_symbol":"TLKMYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"8000","wrant_id":"3604","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"8,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007069","company_symbol":"TPIAYUCK7A","corp_action_active":true,"wrant_exc_end":"2027-05-28","wrant_exc_from":"2026-10-14","wrant_exc_price":"15000","wrant_id":"3605","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-05-25","wrant_trading_from":"2026-10-14","event_note":"","wrant_exc_price_formatted":"15,000","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007032","company_symbol":"ARTOZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"900","wrant_id":"3571","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"900","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007033","company_symbol":"ASIIZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"5094","wrant_id":"3572","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"5,094","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007034","company_symbol":"BRMSZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"700","wrant_id":"3573","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"700","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007035","company_symbol":"BRPTZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"1700","wrant_id":"3574","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"1,700","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007036","company_symbol":"CTRAZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"600","wrant_id":"3575","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"600","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007037","company_symbol":"CUANZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"1100","wrant_id":"3576","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"1,100","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007038","company_symbol":"EMTKZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"300","wrant_id":"3577","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"300","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007039","company_symbol":"ENRGZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"1300","wrant_id":"3578","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"1,300","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007040","company_symbol":"HRUMZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"1050","wrant_id":"3579","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"1,050","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007041","company_symbol":"KIJAZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"190","wrant_id":"3580","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"190","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007042","company_symbol":"MDKAZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"3300","wrant_id":"3581","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"3,300","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007043","company_symbol":"MEDCZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"1700","wrant_id":"3582","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"1,700","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007044","company_symbol":"NCKLZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"1050","wrant_id":"3583","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"1,050","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007045","company_symbol":"TOWRZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"390","wrant_id":"3584","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"390","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"},{"company_id":"1000007046","company_symbol":"WIFIZPCN7A","corp_action_active":true,"wrant_exc_end":"2027-07-30","wrant_exc_from":"2026-10-09","wrant_exc_price":"1600","wrant_id":"3585","wrant_iqp_id":"","wrant_lastupdate":"","wrant_serie":"","wrant_total":"","wrant_trading_end":"2027-07-27","wrant_trading_from":"2026-10-09","event_note":"","wrant_exc_price_formatted":"1,600","wrant_foreign_percentage":0,"wrant_local_percentage":0,"wrant_number_of_securities":0,"wrant_company_id":"0"}]},"message":"Successfully retrieved warrant data"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt b/crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt new file mode 100644 index 0000000..3039c3a --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt @@ -0,0 +1 @@ +%7B%22state%22%3A%7B%22access%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-19T04%3A43%3A28Z%22%7D%2C%22refresh%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-25T04%3A43%3A28Z%22%7D%2C%22user%22%3A%7B%22username%22%3A%22demo%22%7D%7D%2C%22version%22%3A0%7D diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json b/crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json new file mode 100644 index 0000000..276098a --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json @@ -0,0 +1,13 @@ +{ + "message": "Successfully refresh token", + "data": { + "access": { + "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU", + "expired_at": "2026-10-19T04:43:28Z" + }, + "refresh": { + "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU", + "expired_at": "2026-10-25T04:43:28Z" + } + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json new file mode 100644 index 0000000..1d9fccb --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json @@ -0,0 +1,416 @@ +{ + "message": "Successfully majorholder data", + "data": { + "is_more": true, + "movement": [ + { + "id": "1000006115", + "name": "OPTIMUS VISION GLOBAL", + "symbol": "PEGE", + "date": "02 Nov 26", + "previous": { + "value": "249,112,774", + "percentage": "6.59", + "formatted_value": "" + }, + "current": { + "value": "251,612,774", + "percentage": "6.66", + "formatted_value": "" + }, + "changes": { + "value": "+2,500,000", + "percentage": "+0.07", + "formatted_value": "2,500,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_FOREIGN", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "120", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "120" + }, + "formatted": "120" + }, + "value": { + "raw": { + "value": "300000000" + }, + "formatted": "300.00M" + }, + "is_estimated_price": false, + "purpose": "Pembelian", + "icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png" + }, + { + "id": "1000006115", + "name": "OPTIMUS VISION GLOBAL", + "symbol": "PEGE", + "date": "02 Nov 26", + "previous": { + "value": "246,212,774", + "percentage": "6.52", + "formatted_value": "" + }, + "current": { + "value": "249,112,774", + "percentage": "6.59", + "formatted_value": "" + }, + "changes": { + "value": "+2,900,000", + "percentage": "+0.08", + "formatted_value": "2,900,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_FOREIGN", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "122", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "122" + }, + "formatted": "122" + }, + "value": { + "raw": { + "value": "353800000" + }, + "formatted": "353.80M" + }, + "is_estimated_price": false, + "purpose": "Pembelian", + "icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png" + }, + { + "id": "1000006115", + "name": "OPTIMUS VISION GLOBAL", + "symbol": "PEGE", + "date": "02 Nov 26", + "previous": { + "value": "242,512,774", + "percentage": "6.42", + "formatted_value": "" + }, + "current": { + "value": "243,712,774", + "percentage": "6.45", + "formatted_value": "" + }, + "changes": { + "value": "+1,200,000", + "percentage": "+0.03", + "formatted_value": "1,200,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_FOREIGN", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "123", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "123" + }, + "formatted": "123" + }, + "value": { + "raw": { + "value": "147600000" + }, + "formatted": "147.60M" + }, + "is_estimated_price": false, + "purpose": "Pembelian", + "icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png" + }, + { + "id": "1000006115", + "name": "OPTIMUS VISION GLOBAL", + "symbol": "PEGE", + "date": "02 Nov 26", + "previous": { + "value": "241,674,474", + "percentage": "6.40", + "formatted_value": "" + }, + "current": { + "value": "242,512,774", + "percentage": "6.42", + "formatted_value": "" + }, + "changes": { + "value": "+838,300", + "percentage": "+0.02", + "formatted_value": "838,300" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_FOREIGN", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "119", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "119" + }, + "formatted": "119" + }, + "value": { + "raw": { + "value": "99757700" + }, + "formatted": "99.76M" + }, + "is_estimated_price": false, + "purpose": "Pembelian", + "icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png" + }, + { + "id": "1000006115", + "name": "OPTIMUS VISION GLOBAL", + "symbol": "PEGE", + "date": "02 Nov 26", + "previous": { + "value": "241,650,974", + "percentage": "6.40", + "formatted_value": "" + }, + "current": { + "value": "241,674,474", + "percentage": "6.40", + "formatted_value": "" + }, + "changes": { + "value": "+23,500", + "percentage": "+0.0006", + "formatted_value": "23,500" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_FOREIGN", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "118", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "118" + }, + "formatted": "118" + }, + "value": { + "raw": { + "value": "2773000" + }, + "formatted": "2.77M" + }, + "is_estimated_price": false, + "purpose": "Pembelian", + "icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png" + }, + { + "id": "1000004481", + "name": "RUDI R. SUTANTRA", + "symbol": "ATLA", + "date": "30 Oct 26", + "previous": { + "value": "2,530,254,200", + "percentage": "40.81", + "formatted_value": "" + }, + "current": { + "value": "2,520,254,200", + "percentage": "40.65", + "formatted_value": "" + }, + "changes": { + "value": "-10,000,000", + "percentage": "-0.16", + "formatted_value": "10,000,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_SELL", + "data_source": { + "label": "Sumber: KSEI", + "type": "SOURCE_TYPE_KSEI" + }, + "price_formatted": "", + "broker_detail": { + "code": "AG", + "group": "BROKER_GROUP_FOREIGN" + }, + "badges": [], + "price": { + "raw": { + "value": "21" + }, + "formatted": "21" + }, + "value": { + "raw": { + "value": "210000000" + }, + "formatted": "210.00M" + }, + "is_estimated_price": true, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/ATLA.png" + }, + { + "id": "12606", + "name": "NUSANTARA UTAMA INVESTAMA", + "symbol": "BUVA", + "date": "30 Oct 26", + "previous": { + "value": "18,508,539,715", + "percentage": "62.51", + "formatted_value": "" + }, + "current": { + "value": "18,508,539,715", + "percentage": "61.54", + "formatted_value": "" + }, + "changes": { + "value": "0", + "percentage": "-0.97", + "formatted_value": "0" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_RIGHT_ISSUE", + "data_source": { + "label": "Sumber: KSEI", + "type": "SOURCE_TYPE_KSEI" + }, + "price_formatted": "", + "broker_detail": { + "code": "YU", + "group": "BROKER_GROUP_FOREIGN" + }, + "badges": [], + "price": { + "raw": { + "value": "353" + }, + "formatted": "353" + }, + "value": { + "raw": { + "value": "0" + }, + "formatted": "-" + }, + "is_estimated_price": true, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BUVA.png?version=1785918174174738481" + }, + { + "id": "14074", + "name": "WELLY THOMAS", + "symbol": "SGER", + "date": "30 Oct 26", + "previous": { + "value": "1,285,644,762", + "percentage": "8.25", + "formatted_value": "" + }, + "current": { + "value": "1,341,269,756", + "percentage": "8.61", + "formatted_value": "" + }, + "changes": { + "value": "+55,624,994", + "percentage": "+0.36", + "formatted_value": "55,624,994" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_TRANSFER", + "data_source": { + "label": "Sumber: KSEI", + "type": "SOURCE_TYPE_KSEI" + }, + "price_formatted": "", + "broker_detail": { + "code": "OD", + "group": "BROKER_GROUP_GOVERNMENT" + }, + "badges": [], + "price": { + "raw": { + "value": "794" + }, + "formatted": "794" + }, + "value": { + "raw": { + "value": "44166245236" + }, + "formatted": "44.17B" + }, + "is_estimated_price": true, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/SGER.png" + } + ] + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json new file mode 100644 index 0000000..bf4534c --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json @@ -0,0 +1,430 @@ +{ + "message": "Successfully majorholder data", + "data": { + "is_more": true, + "movement": [ + { + "id": "1000000439", + "name": "HENDRA TANUMIHARDJA", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "193,206", + "percentage": "0.0002", + "formatted_value": "" + }, + "current": { + "value": "341,139", + "percentage": "0.0003", + "formatted_value": "" + }, + "changes": { + "value": "+147,933", + "percentage": "+0.0001", + "formatted_value": "147,933" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "1032868206" + }, + "formatted": "1.03B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "1000000438", + "name": "A. WIDODO MULYONO", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "440,838", + "percentage": "0.0004", + "formatted_value": "" + }, + "current": { + "value": "681,407", + "percentage": "0.0006", + "formatted_value": "" + }, + "changes": { + "value": "+240,569", + "percentage": "+0.0002", + "formatted_value": "240,569" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "1679652758" + }, + "formatted": "1.68B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "1000000437", + "name": "GREGORY HENDRA LEMBONG", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "1,531,282", + "percentage": "0.0012", + "formatted_value": "" + }, + "current": { + "value": "2,666,921", + "percentage": "0.0022", + "formatted_value": "" + }, + "changes": { + "value": "+1,135,639", + "percentage": "+0.0010", + "formatted_value": "1,135,639" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "7929031498" + }, + "formatted": "7.93B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "1000000209", + "name": "SANTOSO", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "3,269,028", + "percentage": "0.0027", + "formatted_value": "" + }, + "current": { + "value": "3,764,062", + "percentage": "0.0031", + "formatted_value": "" + }, + "changes": { + "value": "+495,034", + "percentage": "+0.0004", + "formatted_value": "495,034" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "3456327388" + }, + "formatted": "3.46B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14421", + "name": "TONNY KUSNADI", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "7,502,058", + "percentage": "0.0061", + "formatted_value": "" + }, + "current": { + "value": "7,819,950", + "percentage": "0.0063", + "formatted_value": "" + }, + "changes": { + "value": "+317,892", + "percentage": "+0.0002", + "formatted_value": "317,892" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_KOMISARIS" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "2219521944" + }, + "formatted": "2.22B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14418", + "name": "FRENGKY CHANDRA KUSUMA", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "2,429,926", + "percentage": "0.0020", + "formatted_value": "" + }, + "current": { + "value": "2,835,916", + "percentage": "0.0023", + "formatted_value": "" + }, + "changes": { + "value": "+405,990", + "percentage": "+0.0003", + "formatted_value": "405,990" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "2834622180" + }, + "formatted": "2.83B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14417", + "name": "HARYANTO TIARA BUDIMAN", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "1,057,378", + "percentage": "0.0009", + "formatted_value": "" + }, + "current": { + "value": "1,436,806", + "percentage": "0.0012", + "formatted_value": "" + }, + "changes": { + "value": "+379,428", + "percentage": "+0.0003", + "formatted_value": "379,428" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "2649166296" + }, + "formatted": "2.65B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14416", + "name": "VERA EVE LIM", + "symbol": "BBCA", + "date": "25 Mar 26", + "previous": { + "value": "2,731,601", + "percentage": "0.0022", + "formatted_value": "" + }, + "current": { + "value": "3,281,460", + "percentage": "0.0027", + "formatted_value": "" + }, + "changes": { + "value": "+549,859", + "percentage": "+0.0005", + "formatted_value": "549,859" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "6,982", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "6982" + }, + "formatted": "6,982" + }, + "value": { + "raw": { + "value": "3839115538" + }, + "formatted": "3.84B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + } + ] + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json new file mode 100644 index 0000000..d4bc7d3 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json @@ -0,0 +1,220 @@ +{ + "message": "Successfully majorholder data", + "data": { + "is_more": false, + "movement": [ + { + "id": "14418", + "name": "FRENGKY CHANDRA KUSUMA", + "symbol": "BBCA", + "date": "22 Mar 24", + "previous": { + "value": "1,891,049", + "percentage": "0.0015", + "formatted_value": "" + }, + "current": { + "value": "2,107,984", + "percentage": "0.0017", + "formatted_value": "" + }, + "changes": { + "value": "+216,935", + "percentage": "+0.0002", + "formatted_value": "216,935" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "10,108", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "10108" + }, + "formatted": "10,108" + }, + "value": { + "raw": { + "value": "2192778980" + }, + "formatted": "2.19B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14417", + "name": "HARYANTO TIARA BUDIMAN", + "symbol": "BBCA", + "date": "22 Mar 24", + "previous": { + "value": "561,695", + "percentage": "0.0005", + "formatted_value": "" + }, + "current": { + "value": "776,099", + "percentage": "0.0006", + "formatted_value": "" + }, + "changes": { + "value": "+214,404", + "percentage": "+0.0001", + "formatted_value": "214,404" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "10,108", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "10108" + }, + "formatted": "10,108" + }, + "value": { + "raw": { + "value": "2167195632" + }, + "formatted": "2.17B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14416", + "name": "VERA EVE LIM", + "symbol": "BBCA", + "date": "22 Mar 24", + "previous": { + "value": "1,912,261", + "percentage": "0.0016", + "formatted_value": "" + }, + "current": { + "value": "2,212,324", + "percentage": "0.0018", + "formatted_value": "" + }, + "changes": { + "value": "+300,063", + "percentage": "+0.0002", + "formatted_value": "300,063" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "10,108", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "10108" + }, + "formatted": "10,108" + }, + "value": { + "raw": { + "value": "3033036804" + }, + "formatted": "3.03B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + }, + { + "id": "14415", + "name": "LIANAWATY SUWONO", + "symbol": "BBCA", + "date": "22 Mar 24", + "previous": { + "value": "2,012,880", + "percentage": "0.0016", + "formatted_value": "" + }, + "current": { + "value": "2,264,685", + "percentage": "0.0018", + "formatted_value": "" + }, + "changes": { + "value": "+251,805", + "percentage": "+0.0002", + "formatted_value": "251,805" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_BUY", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "10,108", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [ + "SHAREHOLDER_BADGE_DIREKTUR" + ], + "price": { + "raw": { + "value": "10108" + }, + "formatted": "10,108" + }, + "value": { + "raw": { + "value": "2545244940" + }, + "formatted": "2.55B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png" + } + ] + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json new file mode 100644 index 0000000..6571c36 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json @@ -0,0 +1,314 @@ +{ + "message": "Successfully majorholder data", + "data": { + "is_more": false, + "movement": [ + { + "id": "13898", + "name": "CGS INTERNATIONAL SEKURITAS INDONESIA", + "symbol": "ENRG", + "date": "29 Oct 26", + "previous": { + "value": "3,600,545,611", + "percentage": "9.04", + "formatted_value": "" + }, + "current": { + "value": "3,100,545,614", + "percentage": "7.78", + "formatted_value": "" + }, + "changes": { + "value": "-499,999,997", + "percentage": "-1.25", + "formatted_value": "499,999,997" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_CROSS", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "715", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "715" + }, + "formatted": "715" + }, + "value": { + "raw": { + "value": "357499997855" + }, + "formatted": "357.50B" + }, + "is_estimated_price": false, + "purpose": "Reverse REPO", + "icon_url": "https://assets.stockbit.com/logos/companies/ENRG.png?version=1750036449822958153" + }, + { + "id": "12202", + "name": "MAYBANK SEKURITAS INDONESIA", + "symbol": "ENRG", + "date": "28 Oct 26", + "previous": { + "value": "2,380,252,500", + "percentage": "5.97", + "formatted_value": "" + }, + "current": { + "value": "2,442,752,500", + "percentage": "6.13", + "formatted_value": "" + }, + "changes": { + "value": "+62,500,000", + "percentage": "+0.16", + "formatted_value": "62,500,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_CROSS", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "724" + }, + "formatted": "724" + }, + "value": { + "raw": { + "value": "45250000000" + }, + "formatted": "45.25B" + }, + "is_estimated_price": true, + "purpose": "Penambahan jaminan saham untuk transaksi repo", + "icon_url": "https://assets.stockbit.com/logos/companies/ENRG.png?version=1750036449822958153" + }, + { + "id": "1000000448", + "name": "TRINUGRAHA THOHIR HARMONI", + "symbol": "VISI", + "date": "28 Oct 26", + "previous": { + "value": "246,000,000", + "percentage": "7.70", + "formatted_value": "" + }, + "current": { + "value": "270,144,900", + "percentage": "8.45", + "formatted_value": "" + }, + "changes": { + "value": "+24,144,900", + "percentage": "+0.76", + "formatted_value": "24,144,900" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_PRIVATE_PLACEMENT", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "994", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "994" + }, + "formatted": "994" + }, + "value": { + "raw": { + "value": "24000030600" + }, + "formatted": "24.00B" + }, + "is_estimated_price": false, + "purpose": "PMTHMETD", + "icon_url": "https://assets.stockbit.com/logos/companies/VISI.png" + }, + { + "id": "1000005952", + "name": "WIBOWO GROUP CAPITAL", + "symbol": "MBSS", + "date": "15 Oct 26", + "previous": { + "value": "1,443,766,800", + "percentage": "82.50", + "formatted_value": "" + }, + "current": { + "value": "1,576,605,000", + "percentage": "90.09", + "formatted_value": "" + }, + "changes": { + "value": "+132,838,200", + "percentage": "+7.59", + "formatted_value": "132,838,200" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_TENDER_OFFER", + "data_source": { + "label": "Sumber: IDX", + "type": "SOURCE_TYPE_IDX" + }, + "price_formatted": "2,915", + "broker_detail": { + "code": "", + "group": "BROKER_GROUP_UNSPECIFIED" + }, + "badges": [], + "price": { + "raw": { + "value": "2915" + }, + "formatted": "2,915" + }, + "value": { + "raw": { + "value": "387223353000" + }, + "formatted": "387.22B" + }, + "is_estimated_price": false, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/MBSS.png" + }, + { + "id": "1000006017", + "name": "JEFRI NEDI", + "symbol": "FUJI", + "date": "15 Oct 26", + "previous": { + "value": "109,431,600", + "percentage": "8.42", + "formatted_value": "" + }, + "current": { + "value": "109,251,600", + "percentage": "8.40", + "formatted_value": "" + }, + "changes": { + "value": "-180,000", + "percentage": "-0.01", + "formatted_value": "180,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_SELL", + "data_source": { + "label": "Sumber: KSEI", + "type": "SOURCE_TYPE_KSEI" + }, + "price_formatted": "", + "broker_detail": { + "code": "CP", + "group": "BROKER_GROUP_FOREIGN" + }, + "badges": [], + "price": { + "raw": { + "value": "298" + }, + "formatted": "298" + }, + "value": { + "raw": { + "value": "53640000" + }, + "formatted": "53.64M" + }, + "is_estimated_price": true, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/FUJI.png" + }, + { + "id": "1000004481", + "name": "RUDI R. SUTANTRA", + "symbol": "ATLA", + "date": "15 Oct 26", + "previous": { + "value": "2,638,254,200", + "percentage": "42.56", + "formatted_value": "" + }, + "current": { + "value": "2,610,254,200", + "percentage": "42.10", + "formatted_value": "" + }, + "changes": { + "value": "-28,000,000", + "percentage": "-0.45", + "formatted_value": "28,000,000" + }, + "marker": "", + "is_posted": false, + "cmh_id": "0", + "nationality": "NATIONALITY_TYPE_LOCAL", + "action_type": "ACTION_TYPE_SELL", + "data_source": { + "label": "Sumber: KSEI", + "type": "SOURCE_TYPE_KSEI" + }, + "price_formatted": "", + "broker_detail": { + "code": "AG", + "group": "BROKER_GROUP_FOREIGN" + }, + "badges": [], + "price": { + "raw": { + "value": "29" + }, + "formatted": "29" + }, + "value": { + "raw": { + "value": "812000000" + }, + "formatted": "812.00M" + }, + "is_estimated_price": true, + "purpose": "", + "icon_url": "https://assets.stockbit.com/logos/companies/ATLA.png" + } + ] + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_market_session.json b/crates/idx-core/tests/fixtures/stockbit_exodus_market_session.json new file mode 100644 index 0000000..ef0862f --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_market_session.json @@ -0,0 +1 @@ +{"message":"Successfully get session info data", "data":{"datetime":"2026-10-18 20:15:06", "detail":{"fca":{"session":0, "state_name":"STATE_NAME_MARKET_CLOSED", "is_last_session":false, "is_end_of_day":true, "state_start_time":"", "state_end_time":"", "time_left":{"raw":44994, "formatted":"12 jam 29 menit 54 detik"}, "suspend_info":""}, "regular":{"session":0, "state_name":"STATE_NAME_MARKET_CLOSED", "is_last_session":false, "is_end_of_day":true, "state_start_time":"", "state_end_time":"", "time_left":{"raw":44994, "formatted":"12 jam 29 menit 54 detik"}, "suspend_info":""}}}} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_market_session_break.json b/crates/idx-core/tests/fixtures/stockbit_exodus_market_session_break.json new file mode 100644 index 0000000..b2447f6 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_market_session_break.json @@ -0,0 +1 @@ +{"message":"Successfully get session info data","data":{"datetime":"2026-11-03 12:35:59","detail":{"fca":{"session":3,"state_name":"STATE_NAME_BREAK","is_last_session":false,"is_end_of_day":false,"state_start_time":"12:00:00","state_end_time":"13:59:59","time_left":{"raw":5040,"formatted":"1 jam 24 menit 0 detik"},"suspend_info":""},"regular":{"session":1,"state_name":"STATE_NAME_BREAK","is_last_session":false,"is_end_of_day":false,"state_start_time":"12:00:00","state_end_time":"13:29:59","time_left":{"raw":3240,"formatted":"0 jam 54 menit 0 detik"},"suspend_info":""}}}} \ No newline at end of file diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_market_session_trading.json b/crates/idx-core/tests/fixtures/stockbit_exodus_market_session_trading.json new file mode 100644 index 0000000..2b33457 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_market_session_trading.json @@ -0,0 +1 @@ +{"message":"Successfully get session info data","data":{"datetime":"2026-11-03 13:30:03","detail":{"fca":{"session":3,"state_name":"STATE_NAME_BREAK","is_last_session":false,"is_end_of_day":false,"state_start_time":"12:00:00","state_end_time":"13:59:59","time_left":{"raw":1796,"formatted":"0 jam 29 menit 56 detik"},"suspend_info":""},"regular":{"session":2,"state_name":"STATE_NAME_SESSION_2","is_last_session":true,"is_end_of_day":false,"state_start_time":"13:30:00","state_end_time":"15:49:59","time_left":{"raw":9896,"formatted":"2 jam 44 menit 56 detik"},"suspend_info":""}}}} \ No newline at end of file diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca.json b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca.json new file mode 100644 index 0000000..80b456d --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca.json @@ -0,0 +1 @@ +{"data":{"average":5217, "bid":[], "change":-900, "close":5175, "country":"ID", "domestic":"57.05", "down":"110", "exchange":"IDX", "fbuy":1616461977500, "fnet":-2329493087500, "foreign":"42.95", "frequency":185072, "fsell":3945955065000, "high":5825, "id":"BBCA-0", "lastprice":5175, "low":5175, "offer":[{"price":"5175", "que_num":"3284", "volume":"264462500", "change_percentage":""}, {"price":"5200", "que_num":"337", "volume":"10053400", "change_percentage":""}, {"price":"5225", "que_num":"396", "volume":"3711400", "change_percentage":""}, {"price":"5250", "que_num":"516", "volume":"8250400", "change_percentage":""}, {"price":"5275", "que_num":"358", "volume":"3488100", "change_percentage":""}, {"price":"5300", "que_num":"494", "volume":"3320500", "change_percentage":""}, {"price":"5325", "que_num":"233", "volume":"4161500", "change_percentage":""}, {"price":"5350", "que_num":"367", "volume":"2907400", "change_percentage":""}, {"price":"5375", "que_num":"330", "volume":"3350700", "change_percentage":""}, {"price":"5400", "que_num":"459", "volume":"5192400", "change_percentage":""}, {"price":"5425", "que_num":"207", "volume":"2868000", "change_percentage":""}, {"price":"5450", "que_num":"222", "volume":"2512100", "change_percentage":""}, {"price":"5475", "que_num":"95", "volume":"488400", "change_percentage":""}, {"price":"5500", "que_num":"326", "volume":"2199000", "change_percentage":""}, {"price":"5525", "que_num":"46", "volume":"272000", "change_percentage":""}, {"price":"5550", "que_num":"62", "volume":"368500", "change_percentage":""}, {"price":"5575", "que_num":"42", "volume":"140900", "change_percentage":""}, {"price":"5600", "que_num":"105", "volume":"733800", "change_percentage":""}, {"price":"5625", "que_num":"29", "volume":"177500", "change_percentage":""}, {"price":"5650", "que_num":"34", "volume":"521000", "change_percentage":""}, {"price":"5675", "que_num":"26", "volume":"65600", "change_percentage":""}, {"price":"5700", "que_num":"104", "volume":"480200", "change_percentage":""}, {"price":"5725", "que_num":"25", "volume":"60800", "change_percentage":""}, {"price":"5750", "que_num":"53", "volume":"344900", "change_percentage":""}, {"price":"5775", "que_num":"21", "volume":"48900", "change_percentage":""}, {"price":"5800", "que_num":"137", "volume":"1048000", "change_percentage":""}, {"price":"5825", "que_num":"68", "volume":"709700", "change_percentage":""}, {"price":"5850", "que_num":"24", "volume":"877700", "change_percentage":""}, {"price":"5875", "que_num":"17", "volume":"568500", "change_percentage":""}, {"price":"5900", "que_num":"43", "volume":"389200", "change_percentage":""}, {"price":"5925", "que_num":"18", "volume":"145400", "change_percentage":""}, {"price":"5950", "que_num":"22", "volume":"1780500", "change_percentage":""}, {"price":"5975", "que_num":"21", "volume":"80600", "change_percentage":""}, {"price":"6000", "que_num":"176", "volume":"1976700", "change_percentage":""}, {"price":"6025", "que_num":"17", "volume":"98700", "change_percentage":""}, {"price":"6050", "que_num":"36", "volume":"233000", "change_percentage":""}, {"price":"6075", "que_num":"52", "volume":"291100", "change_percentage":""}, {"price":"6100", "que_num":"56", "volume":"278500", "change_percentage":""}, {"price":"6125", "que_num":"14", "volume":"25600", "change_percentage":""}, {"price":"6150", "que_num":"24", "volume":"173200", "change_percentage":""}, {"price":"6175", "que_num":"17", "volume":"257200", "change_percentage":""}, {"price":"6200", "que_num":"39", "volume":"118100", "change_percentage":""}, {"price":"6225", "que_num":"13", "volume":"75500", "change_percentage":""}, {"price":"6250", "que_num":"22", "volume":"169200", "change_percentage":""}, {"price":"6275", "que_num":"12", "volume":"23300", "change_percentage":""}, {"price":"6300", "que_num":"27", "volume":"27300", "change_percentage":""}, {"price":"6325", "que_num":"8", "volume":"56500", "change_percentage":""}, {"price":"6350", "que_num":"13", "volume":"87400", "change_percentage":""}, {"price":"6375", "que_num":"11", "volume":"24900", "change_percentage":""}, {"price":"6400", "que_num":"24", "volume":"91200", "change_percentage":""}, {"price":"6425", "que_num":"6", "volume":"14700", "change_percentage":""}, {"price":"6450", "que_num":"10", "volume":"219200", "change_percentage":""}, {"price":"6475", "que_num":"9", "volume":"204000", "change_percentage":""}, {"price":"6500", "que_num":"56", "volume":"283100", "change_percentage":""}, {"price":"6525", "que_num":"7", "volume":"37700", "change_percentage":""}, {"price":"6550", "que_num":"6", "volume":"25300", "change_percentage":""}, {"price":"6575", "que_num":"7", "volume":"31700", "change_percentage":""}, {"price":"6600", "que_num":"14", "volume":"33500", "change_percentage":""}, {"price":"6625", "que_num":"11", "volume":"81800", "change_percentage":""}, {"price":"6650", "que_num":"10", "volume":"67400", "change_percentage":""}, {"price":"6675", "que_num":"3", "volume":"6200", "change_percentage":""}, {"price":"6700", "que_num":"23", "volume":"140500", "change_percentage":""}, {"price":"6725", "que_num":"7", "volume":"26600", "change_percentage":""}, {"price":"6750", "que_num":"8", "volume":"47500", "change_percentage":""}, {"price":"6775", "que_num":"8", "volume":"4600", "change_percentage":""}, {"price":"6800", "que_num":"24", "volume":"119400", "change_percentage":""}, {"price":"6825", "que_num":"8", "volume":"38300", "change_percentage":""}, {"price":"6850", "que_num":"11", "volume":"95200", "change_percentage":""}, {"price":"6875", "que_num":"7", "volume":"12800", "change_percentage":""}, {"price":"6900", "que_num":"20", "volume":"111300", "change_percentage":""}, {"price":"6925", "que_num":"1", "volume":"100", "change_percentage":""}, {"price":"6950", "que_num":"9", "volume":"37500", "change_percentage":""}, {"price":"6975", "que_num":"5", "volume":"7000", "change_percentage":""}, {"price":"7000", "que_num":"39", "volume":"172300", "change_percentage":""}, {"price":"7025", "que_num":"4", "volume":"6000", "change_percentage":""}, {"price":"7050", "que_num":"3", "volume":"70200", "change_percentage":""}, {"price":"7075", "que_num":"4", "volume":"9300", "change_percentage":""}, {"price":"7100", "que_num":"12", "volume":"26500", "change_percentage":""}, {"price":"7125", "que_num":"2", "volume":"10400", "change_percentage":""}, {"price":"7150", "que_num":"1", "volume":"2500", "change_percentage":""}, {"price":"7175", "que_num":"2", "volume":"500", "change_percentage":""}, {"price":"7200", "que_num":"4", "volume":"3100", "change_percentage":""}, {"price":"7225", "que_num":"2", "volume":"200", "change_percentage":""}, {"price":"7250", "que_num":"8", "volume":"74300", "change_percentage":""}, {"price":"7275", "que_num":"36", "volume":"100500", "change_percentage":""}], "open":5825, "percentage_change":-14.81, "previous":6075, "status":"Active", "symbol":"BBCA", "symbol_2":"BBCA", "symbol_3":"BBCA", "tradable":true, "unchanged":"1118", "up":"265", "value":6474934315000, "volume":1241098200, "corp_action":{"active":false, "icon":"https://assets.stockbit.com/images/corp_action_event_icon.svg", "text":"Perusahaan Memiliki Corporate Action"}, "notation":[], "uma":false, "has_foreign_bs":true, "iepiev":{"symbol":"", "status":"STATUS_UNSPECIFIED", "iep":{"raw":0, "formatted":""}, "iev":{"raw":0, "formatted":""}, "time_left_seconds":0, "best_bid_offer":{"bid":{"price":{"raw":0, "formatted":"0"}, "quantity":{"raw":0, "formatted":"0"}, "change_percentage":""}, "offer":{"price":{"raw":5175, "formatted":"5,175"}, "quantity":{"raw":2244775, "formatted":"2,244,775"}, "change_percentage":"-14.81%"}, "time_left_seconds":13503}, "title":"", "iep_changes":{"price":{"raw":0, "formatted":""}, "percentage":{"raw":0, "formatted":""}}}, "market_data":[{"label":"All Market", "frequency":{"raw":"185072", "formatted":"185 K"}, "volume":{"raw":"1241098200", "formatted":"1.24 B"}, "value":{"raw":"6474934315000", "formatted":"6.47 T"}}, {"label":"Regular", "frequency":{"raw":"185072", "formatted":"185 K"}, "volume":{"raw":"1241098200", "formatted":"1.24 B"}, "value":{"raw":"6474934315000", "formatted":"6.47 T"}}, {"label":"Nego", "frequency":{"raw":"0", "formatted":"0"}, "volume":{"raw":"0", "formatted":"0"}, "value":{"raw":"0", "formatted":"0"}}, {"label":"Cash", "frequency":{"raw":"0", "formatted":"0"}, "volume":{"raw":"0", "formatted":"0"}, "value":{"raw":"0", "formatted":"0"}}], "name":"Bank Central Asia Tbk.", "icon_url":"https://assets.stockbit.com/logos/companies/BBCA.png", "ara":{"value":"6,200", "visible":true}, "arb":{"value":"4,400", "visible":true}, "company_type":"Saham", "total_bid_offer":{"bid":{"freq":"0", "lot":"0", "raw_lot":"0", "raw_freq":"0"}, "offer":{"freq":"9,469", "lot":"331,908,100", "raw_lot":"331908100", "raw_freq":"9469"}, "bid_percent":0}, "next_ara":{"value":"6,200", "visible":true}, "next_arb":{"value":"4,400", "visible":true}, "autoreject_time_left_in_sec":0, "auto_reject_estimation":[{"value":488400, "change_to_prev":{"value":81400, "percentage":20}, "change_to_base":{"value":483225, "percentage":9337.68}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":407000, "change_to_prev":{"value":67825, "percentage":20}, "change_to_base":{"value":401825, "percentage":7764.73}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":339175, "change_to_prev":{"value":56525, "percentage":20}, "change_to_base":{"value":334000, "percentage":6454.11}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":282650, "change_to_prev":{"value":47100, "percentage":20}, "change_to_base":{"value":277475, "percentage":5361.84}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":235550, "change_to_prev":{"value":39250, "percentage":19.99}, "change_to_base":{"value":230375, "percentage":4451.69}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":196300, "change_to_prev":{"value":32700, "percentage":19.99}, "change_to_base":{"value":191125, "percentage":3693.24}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":163600, "change_to_prev":{"value":27250, "percentage":19.99}, "change_to_base":{"value":158425, "percentage":3061.35}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":136350, "change_to_prev":{"value":22725, "percentage":20}, "change_to_base":{"value":131175, "percentage":2534.78}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":113625, "change_to_prev":{"value":18925, "percentage":19.98}, "change_to_base":{"value":108450, "percentage":2095.65}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":94700, "change_to_prev":{"value":15775, "percentage":19.99}, "change_to_base":{"value":89525, "percentage":1729.95}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":78925, "change_to_prev":{"value":13150, "percentage":19.99}, "change_to_base":{"value":73750, "percentage":1425.12}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":65775, "change_to_prev":{"value":10950, "percentage":19.97}, "change_to_base":{"value":60600, "percentage":1171.01}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":54825, "change_to_prev":{"value":9125, "percentage":19.97}, "change_to_base":{"value":49650, "percentage":959.42}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":45700, "change_to_prev":{"value":7600, "percentage":19.95}, "change_to_base":{"value":40525, "percentage":783.09}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":38100, "change_to_prev":{"value":6350, "percentage":20}, "change_to_base":{"value":32925, "percentage":636.23}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":31750, "change_to_prev":{"value":5275, "percentage":19.92}, "change_to_base":{"value":26575, "percentage":513.53}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":26475, "change_to_prev":{"value":4400, "percentage":19.93}, "change_to_base":{"value":21300, "percentage":411.59}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":22075, "change_to_prev":{"value":3675, "percentage":19.97}, "change_to_base":{"value":16900, "percentage":326.57}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":18400, "change_to_prev":{"value":3050, "percentage":19.87}, "change_to_base":{"value":13225, "percentage":255.56}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":15350, "change_to_prev":{"value":2550, "percentage":19.92}, "change_to_base":{"value":10175, "percentage":196.62}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":12800, "change_to_prev":{"value":2125, "percentage":19.91}, "change_to_base":{"value":7625, "percentage":147.34}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":10675, "change_to_prev":{"value":1775, "percentage":19.94}, "change_to_base":{"value":5500, "percentage":106.28}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":8900, "change_to_prev":{"value":1475, "percentage":19.87}, "change_to_base":{"value":3725, "percentage":71.98}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":7425, "change_to_prev":{"value":1225, "percentage":19.76}, "change_to_base":{"value":2250, "percentage":43.48}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":6200, "change_to_prev":{"value":1025, "percentage":19.81}, "change_to_base":{"value":1025, "percentage":19.81}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":5175, "change_to_prev":{"value":0, "percentage":0}, "change_to_base":{"value":0, "percentage":0}, "type":"AUTO_REJECT_TYPE_BASE"}, {"value":4400, "change_to_prev":{"value":-775, "percentage":-14.98}, "change_to_base":{"value":-775, "percentage":-14.98}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":3740, "change_to_prev":{"value":-660, "percentage":-15}, "change_to_base":{"value":-1435, "percentage":-27.73}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":3180, "change_to_prev":{"value":-560, "percentage":-14.97}, "change_to_base":{"value":-1995, "percentage":-38.55}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":2710, "change_to_prev":{"value":-470, "percentage":-14.78}, "change_to_base":{"value":-2465, "percentage":-47.63}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":2310, "change_to_prev":{"value":-400, "percentage":-14.76}, "change_to_base":{"value":-2865, "percentage":-55.36}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":1965, "change_to_prev":{"value":-345, "percentage":-14.94}, "change_to_base":{"value":-3210, "percentage":-62.03}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":1675, "change_to_prev":{"value":-290, "percentage":-14.76}, "change_to_base":{"value":-3500, "percentage":-67.63}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":1425, "change_to_prev":{"value":-250, "percentage":-14.93}, "change_to_base":{"value":-3750, "percentage":-72.46}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":1215, "change_to_prev":{"value":-210, "percentage":-14.74}, "change_to_base":{"value":-3960, "percentage":-76.52}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":1035, "change_to_prev":{"value":-180, "percentage":-14.81}, "change_to_base":{"value":-4140, "percentage":-80}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":880, "change_to_prev":{"value":-155, "percentage":-14.98}, "change_to_base":{"value":-4295, "percentage":-83}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":750, "change_to_prev":{"value":-130, "percentage":-14.77}, "change_to_base":{"value":-4425, "percentage":-85.51}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":640, "change_to_prev":{"value":-110, "percentage":-14.67}, "change_to_base":{"value":-4535, "percentage":-87.63}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":545, "change_to_prev":{"value":-95, "percentage":-14.84}, "change_to_base":{"value":-4630, "percentage":-89.47}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":464, "change_to_prev":{"value":-81, "percentage":-14.86}, "change_to_base":{"value":-4711, "percentage":-91.03}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":396, "change_to_prev":{"value":-68, "percentage":-14.66}, "change_to_base":{"value":-4779, "percentage":-92.35}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":338, "change_to_prev":{"value":-58, "percentage":-14.65}, "change_to_base":{"value":-4837, "percentage":-93.47}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":288, "change_to_prev":{"value":-50, "percentage":-14.79}, "change_to_base":{"value":-4887, "percentage":-94.43}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":246, "change_to_prev":{"value":-42, "percentage":-14.58}, "change_to_base":{"value":-4929, "percentage":-95.25}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":210, "change_to_prev":{"value":-36, "percentage":-14.63}, "change_to_base":{"value":-4965, "percentage":-95.94}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":179, "change_to_prev":{"value":-31, "percentage":-14.76}, "change_to_base":{"value":-4996, "percentage":-96.54}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":153, "change_to_prev":{"value":-26, "percentage":-14.53}, "change_to_base":{"value":-5022, "percentage":-97.04}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":131, "change_to_prev":{"value":-22, "percentage":-14.38}, "change_to_base":{"value":-5044, "percentage":-97.47}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":112, "change_to_prev":{"value":-19, "percentage":-14.5}, "change_to_base":{"value":-5063, "percentage":-97.84}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":96, "change_to_prev":{"value":-16, "percentage":-14.29}, "change_to_base":{"value":-5079, "percentage":-98.14}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}], "orderbook_active_feature_mobile":"ORDER_BOOK_FEATURE_FOREIGN_BS"}, "message":"Successfully get company orderbook"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca_in_session.json b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca_in_session.json new file mode 100644 index 0000000..8c89d42 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca_in_session.json @@ -0,0 +1 @@ +{"data":{"average":4950,"bid":[{"price":"4850","que_num":"1649","volume":"7255100","change_percentage":""},{"price":"4840","que_num":"273","volume":"1417100","change_percentage":""},{"price":"4830","que_num":"255","volume":"1642200","change_percentage":""},{"price":"4820","que_num":"370","volume":"2501200","change_percentage":""},{"price":"4810","que_num":"343","volume":"1469800","change_percentage":""},{"price":"4800","que_num":"1586","volume":"11375100","change_percentage":""},{"price":"4790","que_num":"101","volume":"271500","change_percentage":""},{"price":"4780","que_num":"106","volume":"268500","change_percentage":""},{"price":"4770","que_num":"71","volume":"105100","change_percentage":""},{"price":"4760","que_num":"96","volume":"252000","change_percentage":""},{"price":"4750","que_num":"439","volume":"1606500","change_percentage":""},{"price":"4740","que_num":"171","volume":"519700","change_percentage":""},{"price":"4730","que_num":"47","volume":"198600","change_percentage":""},{"price":"4720","que_num":"62","volume":"102800","change_percentage":""},{"price":"4710","que_num":"61","volume":"124400","change_percentage":""},{"price":"4700","que_num":"455","volume":"1743900","change_percentage":""},{"price":"4690","que_num":"46","volume":"68500","change_percentage":""},{"price":"4680","que_num":"70","volume":"262900","change_percentage":""},{"price":"4670","que_num":"34","volume":"127100","change_percentage":""},{"price":"4660","que_num":"62","volume":"281200","change_percentage":""},{"price":"4650","que_num":"275","volume":"1441900","change_percentage":""},{"price":"4640","que_num":"548","volume":"3830900","change_percentage":""}],"change":-120,"close":4860,"country":"ID","domestic":"39.93","down":"110","exchange":"IDX","fbuy":534507825000,"fnet":-116143206500,"foreign":"60.07","frequency":30935,"fsell":650651031500,"high":5050,"id":"BBCA-0","lastprice":4860,"low":4850,"offer":[{"price":"4860","que_num":"1","volume":"86800","change_percentage":""},{"price":"4870","que_num":"9","volume":"282500","change_percentage":""},{"price":"4880","que_num":"20","volume":"499700","change_percentage":""},{"price":"4890","que_num":"17","volume":"681500","change_percentage":""},{"price":"4900","que_num":"39","volume":"328200","change_percentage":""},{"price":"4910","que_num":"10","volume":"1255800","change_percentage":""},{"price":"4920","que_num":"33","volume":"175200","change_percentage":""},{"price":"4930","que_num":"18","volume":"73800","change_percentage":""},{"price":"4940","que_num":"57","volume":"645800","change_percentage":""},{"price":"4950","que_num":"128","volume":"1703400","change_percentage":""},{"price":"4960","que_num":"46","volume":"230800","change_percentage":""},{"price":"4970","que_num":"60","volume":"384400","change_percentage":""},{"price":"4980","que_num":"67","volume":"784700","change_percentage":""},{"price":"4990","que_num":"75","volume":"800000","change_percentage":""},{"price":"5000","que_num":"297","volume":"1980500","change_percentage":""},{"price":"5025","que_num":"518","volume":"2942300","change_percentage":""},{"price":"5050","que_num":"781","volume":"5752100","change_percentage":""},{"price":"5075","que_num":"465","volume":"3892600","change_percentage":""},{"price":"5100","que_num":"1014","volume":"7926500","change_percentage":""},{"price":"5125","que_num":"328","volume":"3467400","change_percentage":""},{"price":"5150","que_num":"419","volume":"3256100","change_percentage":""},{"price":"5175","que_num":"238","volume":"1402900","change_percentage":""},{"price":"5200","que_num":"701","volume":"4462900","change_percentage":""},{"price":"5225","que_num":"224","volume":"1621200","change_percentage":""},{"price":"5250","que_num":"404","volume":"3036500","change_percentage":""},{"price":"5275","que_num":"216","volume":"1837900","change_percentage":""},{"price":"5300","que_num":"610","volume":"4584500","change_percentage":""},{"price":"5325","que_num":"208","volume":"1329500","change_percentage":""},{"price":"5350","que_num":"273","volume":"1823400","change_percentage":""},{"price":"5375","que_num":"101","volume":"632600","change_percentage":""},{"price":"5400","que_num":"238","volume":"1122800","change_percentage":""},{"price":"5425","que_num":"61","volume":"303200","change_percentage":""},{"price":"5450","que_num":"89","volume":"284800","change_percentage":""},{"price":"5475","que_num":"74","volume":"387200","change_percentage":""},{"price":"5500","que_num":"274","volume":"2019900","change_percentage":""},{"price":"5525","que_num":"54","volume":"109200","change_percentage":""},{"price":"5550","que_num":"55","volume":"272400","change_percentage":""},{"price":"5575","que_num":"35","volume":"96500","change_percentage":""},{"price":"5600","que_num":"97","volume":"748300","change_percentage":""},{"price":"5625","que_num":"22","volume":"178400","change_percentage":""},{"price":"5650","que_num":"28","volume":"67300","change_percentage":""},{"price":"5675","que_num":"21","volume":"60000","change_percentage":""},{"price":"5700","que_num":"56","volume":"239800","change_percentage":""},{"price":"5725","que_num":"17","volume":"26500","change_percentage":""},{"price":"5750","que_num":"36","volume":"37400","change_percentage":""},{"price":"5775","que_num":"28","volume":"130000","change_percentage":""},{"price":"5800","que_num":"71","volume":"1256700","change_percentage":""},{"price":"5825","que_num":"25","volume":"79900","change_percentage":""},{"price":"5850","que_num":"29","volume":"177900","change_percentage":""},{"price":"5875","que_num":"19","volume":"255000","change_percentage":""},{"price":"5900","que_num":"46","volume":"266200","change_percentage":""},{"price":"5925","que_num":"19","volume":"82600","change_percentage":""},{"price":"5950","que_num":"40","volume":"104200","change_percentage":""},{"price":"5975","que_num":"38","volume":"149700","change_percentage":""},{"price":"6000","que_num":"227","volume":"1724500","change_percentage":""},{"price":"6025","que_num":"30","volume":"84700","change_percentage":""},{"price":"6050","que_num":"22","volume":"70800","change_percentage":""},{"price":"6075","que_num":"15","volume":"488900","change_percentage":""},{"price":"6100","que_num":"68","volume":"159000","change_percentage":""},{"price":"6125","que_num":"34","volume":"83100","change_percentage":""},{"price":"6150","que_num":"46","volume":"80700","change_percentage":""},{"price":"6175","que_num":"54","volume":"277400","change_percentage":""},{"price":"6200","que_num":"132","volume":"823900","change_percentage":""},{"price":"6225","que_num":"123","volume":"436800","change_percentage":""}],"open":5050,"percentage_change":-2.41,"previous":4980,"status":"Active","symbol":"BBCA","symbol_2":"BBCA","symbol_3":"BBCA","tradable":true,"unchanged":"1118","up":"265","value":666978580500,"volume":134744100,"corp_action":{"active":false,"icon":"https://assets.stockbit.com/images/corp_action_event_icon.svg","text":"Perusahaan Memiliki Corporate Action"},"notation":[],"uma":false,"has_foreign_bs":false,"iepiev":{"symbol":"","status":"STATUS_UNSPECIFIED","iep":{"raw":0,"formatted":""},"iev":{"raw":0,"formatted":""},"time_left_seconds":0,"best_bid_offer":null,"title":"","iep_changes":{"price":{"raw":0,"formatted":""},"percentage":{"raw":0,"formatted":""}}},"market_data":[{"label":"All Market","frequency":{"raw":"30935","formatted":"30.9 K"},"volume":{"raw":"134744100","formatted":"135 M"},"value":{"raw":"666978580500","formatted":"667 B"}},{"label":"Regular","frequency":{"raw":"30935","formatted":"30.9 K"},"volume":{"raw":"134744100","formatted":"135 M"},"value":{"raw":"666978580500","formatted":"667 B"}},{"label":"Nego","frequency":{"raw":"0","formatted":"0"},"volume":{"raw":"0","formatted":"0"},"value":{"raw":"0","formatted":"0"}},{"label":"Cash","frequency":{"raw":"0","formatted":"0"},"volume":{"raw":"0","formatted":"0"},"value":{"raw":"0","formatted":"0"}}],"name":"Bank Central Asia Tbk.","icon_url":"https://assets.stockbit.com/logos/companies/BBCA.png","ara":{"value":"6,225","visible":true},"arb":{"value":"4,640","visible":true},"company_type":"Saham","total_bid_offer":{"bid":{"freq":"7,120","lot":"36,866,000","raw_lot":"36866000","raw_freq":"7120"},"offer":{"freq":"9,600","lot":"70,567,200","raw_lot":"70567200","raw_freq":"9600"},"bid_percent":34.3},"next_ara":{"value":"6,225","visible":true},"next_arb":{"value":"4,640","visible":true},"autoreject_time_left_in_sec":0,"auto_reject_estimation":[{"value":489675,"change_to_prev":{"value":81600,"percentage":20},"change_to_base":{"value":484695,"percentage":9732.83},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":408075,"change_to_prev":{"value":68000,"percentage":20},"change_to_base":{"value":403095,"percentage":8094.28},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":340075,"change_to_prev":{"value":56675,"percentage":20},"change_to_base":{"value":335095,"percentage":6728.82},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":283400,"change_to_prev":{"value":47225,"percentage":20},"change_to_base":{"value":278420,"percentage":5590.76},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":236175,"change_to_prev":{"value":39350,"percentage":19.99},"change_to_base":{"value":231195,"percentage":4642.47},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":196825,"change_to_prev":{"value":32800,"percentage":20},"change_to_base":{"value":191845,"percentage":3852.31},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":164025,"change_to_prev":{"value":27325,"percentage":19.99},"change_to_base":{"value":159045,"percentage":3193.67},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":136700,"change_to_prev":{"value":22775,"percentage":19.99},"change_to_base":{"value":131720,"percentage":2644.98},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":113925,"change_to_prev":{"value":18975,"percentage":19.98},"change_to_base":{"value":108945,"percentage":2187.65},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":94950,"change_to_prev":{"value":15825,"percentage":20},"change_to_base":{"value":89970,"percentage":1806.63},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":79125,"change_to_prev":{"value":13175,"percentage":19.98},"change_to_base":{"value":74145,"percentage":1488.86},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":65950,"change_to_prev":{"value":10975,"percentage":19.96},"change_to_base":{"value":60970,"percentage":1224.3},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":54975,"change_to_prev":{"value":9150,"percentage":19.97},"change_to_base":{"value":49995,"percentage":1003.92},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":45825,"change_to_prev":{"value":7625,"percentage":19.96},"change_to_base":{"value":40845,"percentage":820.18},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":38200,"change_to_prev":{"value":6350,"percentage":19.94},"change_to_base":{"value":33220,"percentage":667.07},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":31850,"change_to_prev":{"value":5300,"percentage":19.96},"change_to_base":{"value":26870,"percentage":539.56},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":26550,"change_to_prev":{"value":4425,"percentage":20},"change_to_base":{"value":21570,"percentage":433.13},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":22125,"change_to_prev":{"value":3675,"percentage":19.92},"change_to_base":{"value":17145,"percentage":344.28},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":18450,"change_to_prev":{"value":3075,"percentage":20},"change_to_base":{"value":13470,"percentage":270.48},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":15375,"change_to_prev":{"value":2550,"percentage":19.88},"change_to_base":{"value":10395,"percentage":208.73},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":12825,"change_to_prev":{"value":2125,"percentage":19.86},"change_to_base":{"value":7845,"percentage":157.53},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":10700,"change_to_prev":{"value":1775,"percentage":19.89},"change_to_base":{"value":5720,"percentage":114.86},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":8925,"change_to_prev":{"value":1475,"percentage":19.8},"change_to_base":{"value":3945,"percentage":79.22},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":7450,"change_to_prev":{"value":1225,"percentage":19.68},"change_to_base":{"value":2470,"percentage":49.6},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":6225,"change_to_prev":{"value":1245,"percentage":25},"change_to_base":{"value":1245,"percentage":25},"type":"AUTO_REJECT_TYPE_POSITIVE"},{"value":4980,"change_to_prev":{"value":0,"percentage":0},"change_to_base":{"value":0,"percentage":0},"type":"AUTO_REJECT_TYPE_BASE"},{"value":4640,"change_to_prev":{"value":-340,"percentage":-6.83},"change_to_base":{"value":-340,"percentage":-6.83},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":4320,"change_to_prev":{"value":-320,"percentage":-6.9},"change_to_base":{"value":-660,"percentage":-13.25},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":4020,"change_to_prev":{"value":-300,"percentage":-6.94},"change_to_base":{"value":-960,"percentage":-19.28},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":3740,"change_to_prev":{"value":-280,"percentage":-6.97},"change_to_base":{"value":-1240,"percentage":-24.9},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":3480,"change_to_prev":{"value":-260,"percentage":-6.95},"change_to_base":{"value":-1500,"percentage":-30.12},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":3240,"change_to_prev":{"value":-240,"percentage":-6.9},"change_to_base":{"value":-1740,"percentage":-34.94},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":3020,"change_to_prev":{"value":-220,"percentage":-6.79},"change_to_base":{"value":-1960,"percentage":-39.36},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":2810,"change_to_prev":{"value":-210,"percentage":-6.95},"change_to_base":{"value":-2170,"percentage":-43.57},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":2620,"change_to_prev":{"value":-190,"percentage":-6.76},"change_to_base":{"value":-2360,"percentage":-47.39},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":2440,"change_to_prev":{"value":-180,"percentage":-6.87},"change_to_base":{"value":-2540,"percentage":-51},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":2270,"change_to_prev":{"value":-170,"percentage":-6.97},"change_to_base":{"value":-2710,"percentage":-54.42},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":2120,"change_to_prev":{"value":-150,"percentage":-6.61},"change_to_base":{"value":-2860,"percentage":-57.43},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":1975,"change_to_prev":{"value":-145,"percentage":-6.84},"change_to_base":{"value":-3005,"percentage":-60.34},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":1840,"change_to_prev":{"value":-135,"percentage":-6.84},"change_to_base":{"value":-3140,"percentage":-63.05},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":1715,"change_to_prev":{"value":-125,"percentage":-6.79},"change_to_base":{"value":-3265,"percentage":-65.56},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":1595,"change_to_prev":{"value":-120,"percentage":-7},"change_to_base":{"value":-3385,"percentage":-67.97},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":1485,"change_to_prev":{"value":-110,"percentage":-6.9},"change_to_base":{"value":-3495,"percentage":-70.18},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":1385,"change_to_prev":{"value":-100,"percentage":-6.73},"change_to_base":{"value":-3595,"percentage":-72.19},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":1290,"change_to_prev":{"value":-95,"percentage":-6.86},"change_to_base":{"value":-3690,"percentage":-74.1},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":1200,"change_to_prev":{"value":-90,"percentage":-6.98},"change_to_base":{"value":-3780,"percentage":-75.9},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":1120,"change_to_prev":{"value":-80,"percentage":-6.67},"change_to_base":{"value":-3860,"percentage":-77.51},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":1045,"change_to_prev":{"value":-75,"percentage":-6.7},"change_to_base":{"value":-3935,"percentage":-79.02},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":975,"change_to_prev":{"value":-70,"percentage":-6.7},"change_to_base":{"value":-4005,"percentage":-80.42},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":910,"change_to_prev":{"value":-65,"percentage":-6.67},"change_to_base":{"value":-4070,"percentage":-81.73},"type":"AUTO_REJECT_TYPE_NEGATIVE"},{"value":850,"change_to_prev":{"value":-60,"percentage":-6.59},"change_to_base":{"value":-4130,"percentage":-82.93},"type":"AUTO_REJECT_TYPE_NEGATIVE"}],"orderbook_active_feature_mobile":"ORDER_BOOK_FEATURE_UNSPECIFIED"},"message":"Successfully get company orderbook"} \ No newline at end of file diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_ihsg.json b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_ihsg.json new file mode 100644 index 0000000..2e9593d --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_ihsg.json @@ -0,0 +1 @@ +{"data":{"average":0, "bid":[], "change":-678.67, "close":5185.105, "country":"ID", "domestic":"59.01", "down":"110", "exchange":"IDX", "fbuy":9176233289300, "fnet":-3397459968400, "foreign":"40.99", "frequency":2130259, "fsell":12573693257700, "high":5404.508, "id":"IHSG-0", "lastprice":5185.105, "low":5185.105, "offer":[], "open":5393.924, "percentage_change":-11.57, "previous":5863.78, "status":"Active", "symbol":"IHSG", "symbol_2":"IHSG", "symbol_3":"IHSG", "tradable":false, "unchanged":"1118", "up":"265", "value":26532049500300, "volume":66022512700, "corp_action":{"active":false, "icon":"https://assets.stockbit.com/images/corp_action_event_icon.svg", "text":"Perusahaan Memiliki Corporate Action"}, "notation":[], "uma":false, "has_foreign_bs":true, "iepiev":{"symbol":"", "status":"STATUS_UNSPECIFIED", "iep":{"raw":0, "formatted":""}, "iev":{"raw":0, "formatted":""}, "time_left_seconds":0, "best_bid_offer":{"bid":{"price":{"raw":0, "formatted":"0"}, "quantity":{"raw":0, "formatted":"0"}, "change_percentage":""}, "offer":{"price":{"raw":0, "formatted":"0"}, "quantity":{"raw":0, "formatted":"0"}, "change_percentage":""}, "time_left_seconds":11921}, "title":"", "iep_changes":{"price":{"raw":0, "formatted":""}, "percentage":{"raw":0, "formatted":""}}}, "market_data":[{"label":"All Market", "frequency":{"raw":"2133195", "formatted":"2.13 M"}, "volume":{"raw":"68613461946", "formatted":"68.6 B"}, "value":{"raw":"32013813258468", "formatted":"32 T"}}, {"label":"Regular", "frequency":{"raw":"2130259", "formatted":"2.13 M"}, "volume":{"raw":"66022512700", "formatted":"66 B"}, "value":{"raw":"26532049500300", "formatted":"26.5 T"}}, {"label":"Nego", "frequency":{"raw":"511", "formatted":"511"}, "volume":{"raw":"2524183446", "formatted":"2.52 B"}, "value":{"raw":"5475654641968", "formatted":"5.48 T"}}, {"label":"Cash", "frequency":{"raw":"2425", "formatted":"2,425"}, "volume":{"raw":"66765800", "formatted":"66.8 M"}, "value":{"raw":"6109116200", "formatted":"6.11 B"}}], "name":"Index Harga Saham Gabungan", "icon_url":"https://assets.stockbit.com/logos/companies/IHSG.png?version=1784250340348189864", "ara":{"value":"0", "visible":false}, "arb":{"value":"0", "visible":false}, "company_type":"Index", "total_bid_offer":{"bid":{"freq":"0", "lot":"0", "raw_lot":"0", "raw_freq":"0"}, "offer":{"freq":"0", "lot":"0", "raw_lot":"0", "raw_freq":"0"}, "bid_percent":0}, "next_ara":{"value":"", "visible":false}, "next_arb":{"value":"", "visible":false}, "autoreject_time_left_in_sec":0, "auto_reject_estimation":[], "orderbook_active_feature_mobile":"ORDER_BOOK_FEATURE_FOREIGN_BS"}, "message":"Successfully get company orderbook"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_zzzz.json b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_zzzz.json new file mode 100644 index 0000000..8d2183e --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_zzzz.json @@ -0,0 +1 @@ +{"message":"Company not found","error_type":"NOT_FOUND"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_unauthorized.json b/crates/idx-core/tests/fixtures/stockbit_exodus_unauthorized.json new file mode 100644 index 0000000..2918020 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_unauthorized.json @@ -0,0 +1 @@ +{"error_type":"UNAUTHORIZED","message":"Unauthorized"} diff --git a/crates/idx-core/tests/fixtures/stockbit_symbol_bbca.html b/crates/idx-core/tests/fixtures/stockbit_symbol_bbca.html new file mode 100644 index 0000000..33945bb --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_symbol_bbca.html @@ -0,0 +1 @@ +Stockbit
diff --git a/crates/idx-core/tests/fixtures/stockbit_symbol_ihsg.html b/crates/idx-core/tests/fixtures/stockbit_symbol_ihsg.html new file mode 100644 index 0000000..a6ed659 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_symbol_ihsg.html @@ -0,0 +1 @@ +Stockbit
diff --git a/crates/idx-core/tests/fixtures/stockbit_symbol_maba.html b/crates/idx-core/tests/fixtures/stockbit_symbol_maba.html new file mode 100644 index 0000000..446208f --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_symbol_maba.html @@ -0,0 +1 @@ +Stockbit
diff --git a/crates/idx-core/tests/fixtures/stockbit_symbol_myrx.html b/crates/idx-core/tests/fixtures/stockbit_symbol_myrx.html new file mode 100644 index 0000000..d24b432 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_symbol_myrx.html @@ -0,0 +1 @@ +Stockbit
diff --git a/crates/idx-core/tests/fixtures/stockbit_symbol_zzzz_404.html b/crates/idx-core/tests/fixtures/stockbit_symbol_zzzz_404.html new file mode 100644 index 0000000..acfc10e --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_symbol_zzzz_404.html @@ -0,0 +1 @@ +Error | Stockbit - Investasi Saham Bersama Komunitas Saham Terbesar di Indonesia
diff --git a/crates/idx-mcp/Cargo.toml b/crates/idx-mcp/Cargo.toml index 4ad4c0d..dcd9a76 100644 --- a/crates/idx-mcp/Cargo.toml +++ b/crates/idx-mcp/Cargo.toml @@ -23,7 +23,7 @@ rmcp = { version = "3.5", default-features = false, features = ["server", "macro schemars = "1" serde.workspace = true serde_json.workspace = true -tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "signal", "sync"] } [dev-dependencies] # Tests drive the binary with the mock provider; release builds exclude it. diff --git a/crates/idx-mcp/src/main.rs b/crates/idx-mcp/src/main.rs index 19b2c10..eb1e127 100644 --- a/crates/idx-mcp/src/main.rs +++ b/crates/idx-mcp/src/main.rs @@ -6,6 +6,7 @@ mod server; use std::process::ExitCode; use clap::Parser; +use idx_core::api::stockbit::signin; use idx_core::config::CoreConfig; use rmcp::ServiceExt; @@ -41,11 +42,42 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } }; - match running.waiting().await { - Ok(_) => ExitCode::SUCCESS, - Err(err) => { - eprintln!("error: {err}"); - ExitCode::FAILURE + tokio::select! { + finished = running.waiting() => { + let _ = tokio::task::spawn_blocking(signin::shutdown).await; + match finished { + Ok(_) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err}"); + ExitCode::FAILURE + } + } + } + () = terminated() => { + let _ = tokio::task::spawn_blocking(signin::shutdown).await; + // The stdin reader is a blocking thread the runtime would wait for. + std::process::exit(0); } } } + +/// Resolves when the process is told to stop. +async fn terminated() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + match signal(SignalKind::terminate()) { + Ok(mut term) => tokio::select! { + _ = term.recv() => {} + _ = tokio::signal::ctrl_c() => {} + }, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + } + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 88ee860..774c635 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -4,8 +4,10 @@ use idx_core::analysis::bandar::TIERS; use idx_core::api::types::{Interval, Period}; use idx_core::broker::types::{BrokerBoard, BrokerInvestor}; +use idx_core::corporate::types::ActionKind; use idx_core::flow::types::FlowSource; use idx_core::market::{ScreenFilter, ScreenRegion}; +use idx_core::movements::types::{ActionFilter, MAX_PAGES}; use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::Deserialize; @@ -29,6 +31,53 @@ pub struct HistoryParams { pub interval: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct IntradayParams { + /// IDX ticker, e.g. "BBCA"; indices as "^JKSE" or "IHSG". + pub symbol: String, + /// Only the latest N minutes (default: the whole session, at most 400). + #[serde(default)] + pub tail: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OrderBookParams { + /// IDX ticker, e.g. "BBCA". + pub symbol: String, + /// Price levels per side (default 10, at most 50). + #[serde(default)] + pub depth: Option, +} + +/// How `stockbit_login` signs in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LoginMethod { + Auto, + Browser, + Page, +} + +impl LoginMethod { + pub const ALL: [Self; 3] = [Self::Auto, Self::Browser, Self::Page]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Browser => "browser", + Self::Page => "page", + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct LoginParams { + /// "auto" (default): a browser window when idx-mcp's machine has a display and no SSH, else the local page; "browser" or "page" picks one. + #[serde(default)] + #[schemars(schema_with = "login_method_schema")] + pub method: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct ScreenParams { /// Screen to run (default "top-performers"). @@ -181,6 +230,69 @@ pub struct FlowParams { pub source: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CorporateActionsParams { + /// IDX ticker, e.g. "BBCA". + pub symbol: String, + /// Kinds to list (default: every kind but "ipo"). Each kind is one request to Stockbit. + #[serde(default)] + #[schemars(schema_with = "action_kinds_schema")] + pub kinds: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CorporateCalendarParams { + /// First day, YYYY-MM-DD (default today). + #[serde(default)] + pub from: Option, + /// Last day, YYYY-MM-DD (default 30 days after `from`; at most 366 days). + #[serde(default)] + pub to: Option, + /// Kinds to list (default: every kind). Each kind is one request to Stockbit. + #[serde(default)] + #[schemars(schema_with = "action_kinds_schema")] + pub kinds: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MovementsParams { + /// IDX ticker, e.g. "BBCA"; omit for the whole market. + #[serde(default)] + pub symbol: Option, + /// First day, YYYY-MM-DD. + #[serde(default)] + pub from: Option, + /// Last day, YYYY-MM-DD. + #[serde(default)] + pub to: Option, + /// One holder's movements: the `holder_id` of a row. + #[serde(default)] + pub holder_id: Option, + /// Only this kind of movement; "corporate-action" covers rights issues, private placements and the like. + #[serde(default)] + #[schemars(schema_with = "movement_action_schema")] + pub action: Option, + /// "idx" (insider disclosures, any size) or "ksei" (holders at 5% and above). + #[serde(default)] + #[schemars(schema_with = "movement_source_schema")] + pub source: Option, + /// Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit. + #[serde(default)] + #[schemars(schema_with = "movement_pages_schema")] + pub pages: Option, +} + +/// Parses `kinds`, or `default` when absent. +pub fn action_kinds( + kinds: Option>, + default: &[ActionKind], +) -> Result, idx_core::error::IdxError> { + match kinds { + None => Ok(default.to_vec()), + Some(kinds) => kinds.iter().map(|k| k.parse()).collect(), + } +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct ShareholdersParams { /// IDX ticker, e.g. "BBCA". @@ -258,6 +370,27 @@ fn flow_source_schema(_: &mut SchemaGenerator) -> Schema { string_enum(FlowSource::ALL.map(FlowSource::as_str).to_vec()) } +fn action_kinds_schema(_: &mut SchemaGenerator) -> Schema { + let kinds: Vec<&str> = ActionKind::ALL.iter().map(|k| k.as_str()).collect(); + json_schema!({ "type": "array", "items": { "type": "string", "enum": kinds } }) +} + +fn movement_action_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(ActionFilter::ALL.map(ActionFilter::as_str).to_vec()) +} + +fn movement_source_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(vec!["idx", "ksei"]) +} + +fn movement_pages_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "type": "integer", "minimum": 1, "maximum": MAX_PAGES }) +} + +fn login_method_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(LoginMethod::ALL.map(LoginMethod::as_str).to_vec()) +} + fn top_schema(_: &mut SchemaGenerator) -> Schema { json_schema!({ "type": "integer", "enum": TIERS }) } diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 08580f9..2fa6dd0 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -2,20 +2,25 @@ //! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk //! cache and ownership database. +use std::cell::RefCell; use std::collections::BTreeMap; +use std::rc::Rc; use idx_core::analysis::BandarQuery; use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP}; use idx_core::analysis::fundamental::FundamentalReport; +use idx_core::api::stockbit::{self, signin}; use idx_core::broker::types::BrokerRange; use idx_core::config::CoreConfig; +use idx_core::corporate::types::{ActionKind, CalendarRange}; use idx_core::error::IdxError; use idx_core::financial::{EarningsFilter, StatementFilter}; use idx_core::flow::types::FlowRange; use idx_core::investor::{HolderSource, Investor}; use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion}; +use idx_core::movements::types::MovementQuery; use idx_core::ownership::types::ChangeRow; -use idx_core::{CachePolicy, Fetched, Idx}; +use idx_core::{CachePolicy, Fetched, Idx, runtime}; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig}; @@ -26,8 +31,9 @@ use tokio::sync::Semaphore; use crate::params::{ AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams, - CompanySection, EntityParams, FlowParams, HistoryParams, ScreenParams, SearchParams, - ShareholdersParams, SymbolsParams, SyncParams, + CompanySection, CorporateActionsParams, CorporateCalendarParams, EntityParams, FlowParams, + HistoryParams, IntradayParams, LoginMethod, LoginParams, MovementsParams, OrderBookParams, + ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds, }; const SYNC_TOOL: &str = "ownership_sync"; @@ -43,13 +49,19 @@ const MAX_SEARCH_LIMIT: usize = 100; // Pages above a few hundred rows (~40 KB) overflow some agents' tool output. const MAX_CHANGES_LIMIT: usize = 300; const CHANGES_TOP_TICKERS: usize = 10; +// A session has about 340 minutes, so this is the whole day. +const MAX_INTRADAY_POINTS: usize = 400; +const BOOK_DEPTH: usize = 10; +const MAX_BOOK_DEPTH: usize = 50; /// Tool calls running at once; the rest wait their turn. static CALL_SLOTS: Semaphore = Semaphore::const_new(4); const INSTRUCTIONS: &str = "Indonesian stock market (IDX) data. Market and financial \ tools fetch live data (cached on disk) and wrap it as {data, source, fetched_at, stale}; \ -stale=true means an expired cache entry was served. Ownership tools read a local KSEI \ +stale=true means an expired cache entry was served. An answer (or error) may carry \ +`warnings`: what idx worked around, such as a refused Stockbit session that made history \ +fall back to yahoo, or the failure behind a stale entry; pass on any that need the user. Ownership tools read a local KSEI \ database of holders above 1%: call ownership_status first to see which release dates \ exist. Percentages ending in _bps are basis points (5494 = 54.94%). Errors carry a \ stable code, e.g. SYMBOLNOTFOUND, RATELIMITED, INVALIDINPUT."; @@ -117,7 +129,11 @@ impl IdxServer { description = "Latest quotes (price, change, volume, 52-week range, market cap) \ for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true \ when that is before the latest IDX session, so the price is not that session's close: the \ -provider lags, the stock did not trade, or the day was an exchange holiday." +provider lags, the stock did not trade, or the day was an exchange holiday. When the server \ +runs with the stockbit provider, quotes also carry `market_status` (\"open\" while the \ +regular market trades, else \"close\", with or without a login), `bid`/`offer` (best price, only while the market is open) and \ +`session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 \ +minutes." )] async fn get_quotes(&self, params: Parameters) -> ToolResult { let symbols = match checked_symbols(params.0.symbols) { @@ -131,6 +147,78 @@ provider lags, the stock did not trade, or the day was an exchange holiday." .await } + #[tool( + description = "Today's session of one IDX stock or index from Stockbit's public page \ +(no login), whichever provider the server uses: `quote` (price, change, volume, \ +`market_status`, `bid`/`offer` only while the market is open, `session_note`) and \ +`points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no \ +per-minute volume. Empty `points` means the stock did not trade this session (see \ +`session_note`), not an error. `quote.behind_session` is true when the chart is from \ +before the latest IDX session (a holiday, or Stockbit lagging), as in get_quotes. \ +UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once." + )] + async fn get_intraday(&self, params: Parameters) -> ToolResult { + let IntradayParams { symbol, tail } = params.0; + self.live(move |idx| { + let tail = bounded_limit("tail", tail, MAX_INTRADAY_POINTS, MAX_INTRADAY_POINTS)?; + let mut day = idx.market().intraday(&symbol, CachePolicy::Default)?; + let points = &mut day.data.points; + points.drain(..points.len().saturating_sub(tail)); + Ok(day) + }) + .await + } + + #[tool( + description = "Order book of one IDX stock from the user's own Stockbit session: \ +`bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, \ +volume (shares), value and frequency, and foreign buy/sell/net in IDR (null while Stockbit \ +marks them not current, as during trading; get_foreign_flow has them per session). \ +`market_status` is Stockbit's state, verbatim: STATE_NAME_SESSION_1 or _2 is trading; in any \ +other state the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL \ +the user can open." + )] + async fn get_orderbook(&self, params: Parameters) -> ToolResult { + let OrderBookParams { symbol, depth } = params.0; + self.live(move |idx| { + let depth = bounded_limit("depth", depth, BOOK_DEPTH, MAX_BOOK_DEPTH)?; + let mut book = idx.market().orderbook(&symbol, CachePolicy::Default)?; + book.data.bids.truncate(depth); + book.data.offers.truncate(depth); + Ok(book) + }) + .await + } + + #[tool(description = "Whether a Stockbit session is stored (or seeded by \ +IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, \ +get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the \ +user adds one with stockbit_login, or `idx stockbit login` in a terminal. After \ +stockbit_login, `sign_in.state` says whether that sign-in is waiting, signed_in or failed \ +(with `sign_in.error`). While it waits, `sign_in.warning` is the latest problem it hit, such \ +as Stockbit refusing the session the user signed in with: pass it on, since the user may need \ +to sign in again.")] + async fn stockbit_status(&self) -> ToolResult { + blocking(login_status).await + } + + #[tool( + description = "Start a Stockbit sign-in and return at once; this call does not wait \ +for it. With method auto (the default), idx-mcp opens a browser window on Stockbit's own \ +sign-in page when its machine has a display and is not reached over SSH (`method: \ +\"browser\"` in the answer): the user signs in there and the window closes by itself. \ +Otherwise it serves a page on 127.0.0.1 (`method: \"page\"`, with `url` and an `ssh -L` \ +`forward` for a browser on another machine) that takes stockbit.com's session cookie from the \ +user's own browser. Never ask the user for their password, and never type it. Then poll \ +stockbit_status until `sign_in.state` is signed_in or failed. A session that already works \ +answers state signed_in without opening anything; a second call while one is waiting returns \ +it again (`already_open`)." + )] + async fn stockbit_login(&self, params: Parameters) -> ToolResult { + let LoginParams { method } = params.0; + blocking(move || start_login(method)).await + } + #[tool(description = "OHLCV price history for one IDX ticker.")] async fn get_price_history(&self, params: Parameters) -> ToolResult { let HistoryParams { @@ -141,7 +229,7 @@ provider lags, the stock did not trade, or the day was an exchange holiday." self.live(move |idx| { let period = period.as_deref().unwrap_or("3mo").parse()?; let interval = interval.as_deref().unwrap_or("1d").parse()?; - let source = idx.market().history_source(None); + let source = idx.market().history_source(None, &period, &interval); idx.market() .history(&symbol, &period, &interval, source, CachePolicy::Default) }) @@ -273,6 +361,94 @@ average price, the close, `change_bps` and `non_regular_bps`." .await } + #[tool( + description = "Corporate actions of one IDX stock from the user's own Stockbit \ +session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, \ +reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, \ +rights and splits, i.e. the last day to buy and be entitled, or the ex date when `cum_date` \ +is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind \ +`cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), \ +`ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), \ +`factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), \ +`period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent \ +fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached \ +6 hours). AUTHERROR means there is no session: call stockbit_status, then stockbit_login." + )] + async fn get_corporate_actions( + &self, + params: Parameters, + ) -> ToolResult { + let CorporateActionsParams { symbol, kinds } = params.0; + self.live(move |idx| { + let kinds = action_kinds(kinds, &ActionKind::STOCK_DEFAULT)?; + idx.corporate() + .actions(&symbol, &kinds, CachePolicy::Default) + }) + .await + } + + #[tool( + description = "Corporate actions across IDX from the user's own Stockbit session, \ +oldest first, dated from `from` to `to` (default today and the next 30 days), plus rights \ +trading, tender offers and IPO offers still open in that range (their `period` overlaps it, \ +though `date` may be earlier): upcoming cum dates, meetings, exposes, rights, warrants, IPOs \ +and tender offers, and the past year. Each \ +item has the same fields as get_corporate_actions, with `symbol`. One Stockbit request per \ +kind (about 1 s each on a cold cache, then cached 6 hours); pass `kinds` to ask for less. \ +AUTHERROR means there is no session: call stockbit_status, then stockbit_login." + )] + async fn get_corporate_calendar( + &self, + params: Parameters, + ) -> ToolResult { + let CorporateCalendarParams { from, to, kinds } = params.0; + self.live(move |idx| { + let corporate = idx.corporate(); + let range = + CalendarRange::from_args(from.as_deref(), to.as_deref(), corporate.today())?; + let kinds = action_kinds(kinds, &ActionKind::ALL)?; + corporate.calendar(range, &kinds, CachePolicy::Default) + }) + .await + } + + #[tool( + description = "Reported changes in insiders' and major holders' stakes in IDX stocks, \ +from the user's own Stockbit session, newest first: IDX insider disclosures (directors, \ +commissioners and other insiders, any size, with the trade price) and KSEI changes of holders \ +at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without \ +`symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis \ +points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain \ +past `pages`; `incomplete`, when present, says a later page failed and the rows are those read \ +before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is \ +usually a share plan, and rows appear days after the change. AUTHERROR means there is no \ +session: call stockbit_status, then stockbit_login." + )] + async fn get_shareholder_movements(&self, params: Parameters) -> ToolResult { + let MovementsParams { + symbol, + from, + to, + holder_id, + action, + source, + pages, + } = params.0; + self.live(move |idx| { + let mut query = MovementQuery::from_args( + symbol.as_deref(), + from.as_deref(), + to.as_deref(), + holder_id.as_deref(), + pages.unwrap_or(1), + )?; + query.action = action.as_deref().map(str::parse).transpose()?; + query.source = source.as_deref().map(str::parse).transpose()?; + idx.movements().list(&query, CachePolicy::Default) + }) + .await + } + #[tool( description = "Company data for one IDX ticker: profile, financial statements, \ earnings, analyst sentiment, insights, and news. News items carry `relevance`: \ @@ -338,7 +514,7 @@ are listed under `errors`; the call fails only if every analysis fails." let name = kind.as_str(); match kind { AnalysisKind::Technical => { - let source = idx.market().history_source(None); + let source = analysis.technical_source(None); report.add(name, analysis.technical(&symbol, source, policy)) } AnalysisKind::Growth => report.add(name, analysis.growth(&symbol, policy)), @@ -523,6 +699,49 @@ impl SectionReport { } } +/// Starts idx-core's background sign-in and adds what the agent does next. +fn start_login(method: Option) -> Result { + let options = signin::Options { + method: match method { + None | Some(LoginMethod::Auto) => signin::Method::Auto, + Some(LoginMethod::Browser) => signin::Method::Browser, + Some(LoginMethod::Page) => signin::Method::Page, + }, + port: None, + // The server cannot tell whether the user sits at this machine. + open_page: false, + }; + let progress = signin::start_background(&options)?; + let next = match (&progress.state, &progress.started) { + (signin::State::SignedIn, _) => { + "A stored session already works: nothing to sign in to. Call get_orderbook." + } + (_, signin::Started::Browser { .. }) => { + "A browser window is open on Stockbit's sign-in page, on the machine running \ + idx-mcp. Ask the user to sign in there; never ask for their password. Then poll \ + stockbit_status until sign_in.state is signed_in or failed." + } + _ => { + "Give the user the URL (with `forward` when their browser is on another machine). \ + With stockbit.com open and signed in, they click the page's bookmarklet; never ask \ + for their password. Then poll stockbit_status until sign_in.state is signed_in or \ + failed." + } + }; + let mut value = to_json(progress)?; + value["next"] = json!(next); + Ok(value) +} + +/// The session's state, plus this server's sign-in when one has run. +fn login_status() -> Result { + let mut value = to_json(stockbit::session_status()?)?; + if let Some(progress) = signin::progress() { + value["sign_in"] = to_json(progress)?; + } + Ok(value) +} + async fn blocking(f: F) -> ToolResult where T: Serialize + Send + 'static, @@ -532,13 +751,43 @@ where .acquire() .await .map_err(|err| ErrorData::internal_error(err.to_string(), None))?; - let result = tokio::task::spawn_blocking(move || f().and_then(to_json)) - .await - .map_err(|err| ErrorData::internal_error(err.to_string(), None))?; - Ok(match result { - Ok(value) => CallToolResult::structured(value), - Err(err) => CallToolResult::structured_error(Value::Object(error_body(&err))), + let (result, warnings) = tokio::task::spawn_blocking(move || { + // idx-core warns on stderr, which the agent never sees; what this + // call warns about (a fallback, a stale cache, a partial answer) + // goes into its answer instead. The call runs on this thread. + let warnings = Rc::new(RefCell::new(Vec::::new())); + let sink = { + let warnings = warnings.clone(); + runtime::on_warning(move |message| { + let mut warnings = warnings.borrow_mut(); + if !warnings.iter().any(|seen| seen == message) { + warnings.push(message.to_string()); + } + }) + }; + let result = f().and_then(to_json); + drop(sink); + (result, warnings.take()) }) + .await + .map_err(|err| ErrorData::internal_error(err.to_string(), None))?; + Ok(match result { + Ok(value) => CallToolResult::structured(with_warnings(value, warnings)), + Err(err) => CallToolResult::structured_error(with_warnings( + Value::Object(error_body(&err)), + warnings, + )), + }) +} + +/// Adds `warnings` to an answer when the call had any. +fn with_warnings(mut value: Value, warnings: Vec) -> Value { + if !warnings.is_empty() + && let Some(object) = value.as_object_mut() + { + object.insert("warnings".into(), json!(warnings)); + } + value } /// Per-symbol failures as error bodies carrying their symbol. diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index d2ac9b4..6506f18 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -235,6 +235,91 @@ }, "name": "get_company" }, + { + "description": "Corporate actions of one IDX stock from the user's own Stockbit session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, rights and splits, i.e. the last day to buy and be entitled, or the ex date when `cum_date` is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), `ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), `factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), `period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours). AUTHERROR means there is no session: call stockbit_status, then stockbit_login.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "kinds": { + "default": null, + "description": "Kinds to list (default: every kind but \"ipo\"). Each kind is one request to Stockbit.", + "items": { + "enum": [ + "dividend", + "stock-dividend", + "bonus", + "rights", + "warrant", + "split", + "reverse-split", + "meeting", + "expose", + "ipo", + "tender" + ], + "type": "string" + }, + "type": "array" + }, + "symbol": { + "description": "IDX ticker, e.g. \"BBCA\".", + "type": "string" + } + }, + "required": [ + "symbol" + ], + "type": "object" + }, + "name": "get_corporate_actions" + }, + { + "description": "Corporate actions across IDX from the user's own Stockbit session, oldest first, dated from `from` to `to` (default today and the next 30 days), plus rights trading, tender offers and IPO offers still open in that range (their `period` overlaps it, though `date` may be earlier): upcoming cum dates, meetings, exposes, rights, warrants, IPOs and tender offers, and the past year. Each item has the same fields as get_corporate_actions, with `symbol`. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours); pass `kinds` to ask for less. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "from": { + "default": null, + "description": "First day, YYYY-MM-DD (default today).", + "type": [ + "string", + "null" + ] + }, + "kinds": { + "default": null, + "description": "Kinds to list (default: every kind). Each kind is one request to Stockbit.", + "items": { + "enum": [ + "dividend", + "stock-dividend", + "bonus", + "rights", + "warrant", + "split", + "reverse-split", + "meeting", + "expose", + "ipo", + "tender" + ], + "type": "string" + }, + "type": "array" + }, + "to": { + "default": null, + "description": "Last day, YYYY-MM-DD (default 30 days after `from`; at most 366 days).", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "name": "get_corporate_calendar" + }, { "description": "Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` \"ipot\" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With \"idx\": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`.", "inputSchema": { @@ -287,6 +372,33 @@ }, "name": "get_foreign_flow" }, + { + "description": "Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. `quote.behind_session` is true when the chart is from before the latest IDX session (a holiday, or Stockbit lagging), as in get_quotes. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "symbol": { + "description": "IDX ticker, e.g. \"BBCA\"; indices as \"^JKSE\" or \"IHSG\".", + "type": "string" + }, + "tail": { + "default": null, + "description": "Only the latest N minutes (default: the whole session, at most 400).", + "format": "uint", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "symbol" + ], + "type": "object" + }, + "name": "get_intraday" + }, { "description": "Every IDX holding (above 1%) of one investor, matched by name.", "inputSchema": { @@ -304,6 +416,33 @@ }, "name": "get_investor_holdings" }, + { + "description": "Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR (null while Stockbit marks them not current, as during trading; get_foreign_flow has them per session). `market_status` is Stockbit's state, verbatim: STATE_NAME_SESSION_1 or _2 is trading; in any other state the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL the user can open.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "depth": { + "default": null, + "description": "Price levels per side (default 10, at most 50).", + "format": "uint", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "symbol": { + "description": "IDX ticker, e.g. \"BBCA\".", + "type": "string" + } + }, + "required": [ + "symbol" + ], + "type": "object" + }, + "name": "get_orderbook" + }, { "description": "Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last.", "inputSchema": { @@ -397,7 +536,7 @@ "name": "get_price_history" }, { - "description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday.", + "description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (\"open\" while the regular market trades, else \"close\", with or without a login), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -416,6 +555,76 @@ }, "name": "get_quotes" }, + { + "description": "Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`; `incomplete`, when present, says a later page failed and the rows are those read before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "action": { + "default": null, + "description": "Only this kind of movement; \"corporate-action\" covers rights issues, private placements and the like.", + "enum": [ + "buy", + "sell", + "transfer", + "cross", + "corporate-action" + ], + "type": "string" + }, + "from": { + "default": null, + "description": "First day, YYYY-MM-DD.", + "type": [ + "string", + "null" + ] + }, + "holder_id": { + "default": null, + "description": "One holder's movements: the `holder_id` of a row.", + "type": [ + "string", + "null" + ] + }, + "pages": { + "default": null, + "description": "Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit.", + "maximum": 10, + "minimum": 1, + "type": "integer" + }, + "source": { + "default": null, + "description": "\"idx\" (insider disclosures, any size) or \"ksei\" (holders at 5% and above).", + "enum": [ + "idx", + "ksei" + ], + "type": "string" + }, + "symbol": { + "default": null, + "description": "IDX ticker, e.g. \"BBCA\"; omit for the whole market.", + "type": [ + "string", + "null" + ] + }, + "to": { + "default": null, + "description": "Last day, YYYY-MM-DD.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "name": "get_shareholder_movements" + }, { "description": "Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).", "inputSchema": { @@ -529,5 +738,32 @@ "type": "object" }, "name": "search_investors" + }, + { + "description": "Start a Stockbit sign-in and return at once; this call does not wait for it. With method auto (the default), idx-mcp opens a browser window on Stockbit's own sign-in page when its machine has a display and is not reached over SSH (`method: \"browser\"` in the answer): the user signs in there and the window closes by itself. Otherwise it serves a page on 127.0.0.1 (`method: \"page\"`, with `url` and an `ssh -L` `forward` for a browser on another machine) that takes stockbit.com's session cookie from the user's own browser. Never ask the user for their password, and never type it. Then poll stockbit_status until `sign_in.state` is signed_in or failed. A session that already works answers state signed_in without opening anything; a second call while one is waiting returns it again (`already_open`).", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "method": { + "description": "\"auto\" (default): a browser window when idx-mcp's machine has a display and no SSH, else the local page; \"browser\" or \"page\" picks one.", + "enum": [ + "auto", + "browser", + "page" + ], + "type": "string" + } + }, + "type": "object" + }, + "name": "stockbit_login" + }, + { + "description": "Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with stockbit_login, or `idx stockbit login` in a terminal. After stockbit_login, `sign_in.state` says whether that sign-in is waiting, signed_in or failed (with `sign_in.error`). While it waits, `sign_in.warning` is the latest problem it hit, such as Stockbit refusing the session the user signed in with: pass it on, since the user may need to sign in again.", + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "stockbit_status" } ] diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index 928b160..fa27569 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -9,23 +9,36 @@ use idx_core::config::CoreConfig; use idx_core::investor::{ImportSource, Investor}; use serde_json::{Value, json}; -const TOOLS: [&str; 14] = [ +const TOOLS: [&str; 21] = [ "analyze_bandar", "analyze_stock", "compare_stocks", "get_broker_summary", "get_company", + "get_corporate_actions", + "get_corporate_calendar", "get_foreign_flow", + "get_intraday", "get_investor_holdings", + "get_orderbook", "get_ownership_changes", "get_price_history", "get_quotes", + "get_shareholder_movements", "get_shareholders", "ownership_status", "screen_stocks", "search_investors", + "stockbit_login", + "stockbit_status", ]; +/// An unsigned test token expiring in 2100, as a seeded Stockbit session. +const TEST_SESSION: (&str, &str) = ( + "IDX_STOCKBIT_REFRESH_TOKEN", + "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", +); + struct Client { child: Child, stdin: ChildStdin, @@ -36,6 +49,10 @@ struct Client { impl Client { fn start(args: &[&str], seed: bool) -> Self { + Self::start_with_env(args, seed, &[]) + } + + fn start_with_env(args: &[&str], seed: bool, env: &[(&str, &str)]) -> Self { let home = tempdir::Dir::new(); if seed { seed_ownership_db(&home.path().join("data/idx/ownership.db")); @@ -50,6 +67,7 @@ impl Client { .env("XDG_CACHE_HOME", home.path().join("cache")) .env("XDG_DATA_HOME", home.path().join("data")) .env("IDX_USE_MOCK_PROVIDER", "1") + .envs(env.iter().copied()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -299,6 +317,355 @@ fn live_tools_wrap_data_with_freshness() { assert_eq!(compared["failures"], json!([])); } +#[test] +fn intraday_is_stockbit_whichever_provider_and_empty_sessions_are_data() { + let mut client = Client::start(&[], false); + + let day = client.ok("get_intraday", json!({"symbol": "BBCA"})); + assert_fetched(&day); + assert_eq!(day["source"], "stockbit"); + let quote = &day["data"]["quote"]; + assert_eq!(quote["symbol"], "BBCA.JK"); + assert_eq!(quote["market_status"], "close"); + // The closing book is not reported as a live one. + assert!( + quote.get("bid").is_none() && quote.get("offer").is_none(), + "{quote}" + ); + assert_eq!(day["data"]["points"].as_array().unwrap().len(), 335); + + let tail = client.ok("get_intraday", json!({"symbol": "BBCA", "tail": 2})); + let points = tail["data"]["points"].as_array().unwrap(); + assert_eq!(points.len(), 2); + assert_eq!(points[1]["time"], "2026-10-22T16:14:00+07:00"); + + let suspended = client.ok("get_intraday", json!({"symbol": "MABA"})); + assert_eq!(suspended["data"]["points"], json!([])); + assert_eq!( + suspended["data"]["quote"]["session_note"], + "Stockbit lists the stock as suspended" + ); + + for (arguments, code) in [ + (json!({"symbol": "ZZZZ"}), "SYMBOLNOTFOUND"), + (json!({"symbol": "BBCA", "tail": 0}), "INVALIDINPUT"), + (json!({"symbol": "BBCA", "tail": 401}), "INVALIDINPUT"), + ] { + let (is_error, body) = client.call("get_intraday", arguments.clone()); + assert!(is_error, "{arguments}: {body}"); + assert_eq!(body["code"], code, "{arguments}: {body}"); + } +} + +#[test] +fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() { + let mut client = Client::start_with_env(&[], false, &[("IDX_PROVIDER", "stockbit")]); + let quotes = client.ok("get_quotes", json!({"symbols": ["BBCA"]})); + assert_eq!(quotes["quotes"][0]["source"], "stockbit"); + assert_eq!(quotes["quotes"][0]["data"]["market_status"], "close"); + + let mut blocked = Client::start_with_env(&[], false, &[("IDX_MOCK_STOCKBIT_STATUS", "403")]); + let (is_error, body) = blocked.call("get_intraday", json!({"symbol": "BBCA"})); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "UPSTREAMBLOCKED"); + assert!( + body["message"].as_str().unwrap().contains("does not retry"), + "{body}" + ); +} + +/// What idx works around reaches the agent as `warnings`, since it never +/// sees idx-mcp's stderr: a refused session that made history fall back to +/// yahoo, and a movements query cut short. A clean answer has none. +#[test] +fn workarounds_reach_the_agent_as_warnings() { + let mut client = Client::start_with_env( + &[], + false, + &[ + TEST_SESSION, + ("IDX_PROVIDER", "stockbit"), + ("IDX_MOCK_STOCKBIT_STATUS", "401"), + ], + ); + let history = client.ok( + "get_price_history", + json!({"symbol": "BBCA", "period": "1mo"}), + ); + assert_eq!(history["source"], "yahoo"); + let warnings = history["warnings"].as_array().expect("warnings"); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!( + warnings[0] + .as_str() + .unwrap() + .contains("Stockbit rejected the session") + && warnings[0] + .as_str() + .unwrap() + .contains("falls back to yahoo"), + "{warnings:?}" + ); + let analysis = client.ok("analyze_stock", json!({"symbol": "BBCA"})); + assert!( + analysis["warnings"][0] + .as_str() + .unwrap() + .contains("falls back to yahoo") + ); + // The next call starts with none of the last one's warnings. + let (is_error, body) = client.call("get_orderbook", json!({"symbol": "BBCA"})); + assert!(is_error && body["code"] == "AUTHERROR", "{body}"); + assert!(body.get("warnings").is_none(), "{body}"); + + let mut client = Client::start_with_env( + &[], + false, + &[TEST_SESSION, ("IDX_MOCK_MOVEMENTS_FAIL_PAGE", "2")], + ); + let partial = client.ok( + "get_shareholder_movements", + json!({"symbol": "BBCA", "pages": 3}), + ); + assert_eq!(partial["data"]["incomplete"], "provider rate limited"); + assert!( + partial["warnings"][0] + .as_str() + .unwrap() + .contains("showing the 8 rows read before it") + ); + + let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]); + let clean = client.ok("get_shareholder_movements", json!({"symbol": "BBCA"})); + assert!(clean.get("warnings").is_none(), "{clean}"); +} + +/// The sign-in tool answers at once. With no browser to open (the mock +/// provider never starts one), auto falls back to the page and says why; the +/// page takes the cookie the way the browser would, and stockbit_status +/// reports the sign-in finishing. +#[test] +fn stockbit_login_returns_a_page_that_takes_the_cookie() { + let mut client = Client::start(&[], false); + let login = client.ok("stockbit_login", json!({})); + assert_eq!(login["method"], "page", "{login}"); + assert_eq!(login["state"], "waiting", "{login}"); + assert!(login["reason"].is_string(), "auto says why: {login}"); + let url = login["url"].as_str().expect("url").to_string(); + assert!(url.starts_with("http://127.0.0.1:"), "{login}"); + assert!( + login["forward"].as_str().unwrap().starts_with("ssh -L "), + "{login}" + ); + assert_eq!(login["already_open"], false, "{login}"); + assert!(login["next"].as_str().unwrap().contains("stockbit_status")); + + // A second call hands back the page that is already up. + let again = client.ok("stockbit_login", json!({})); + assert_eq!(again["url"], login["url"]); + assert_eq!(again["already_open"], true, "{again}"); + + let waiting = client.ok("stockbit_status", json!({})); + assert_eq!(waiting["logged_in"], false, "{waiting}"); + assert_eq!(waiting["sign_in"]["state"], "waiting", "{waiting}"); + + let cookie = std::fs::read_to_string( + core_dir().join("tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt"), + ) + .expect("cookie fixture"); + let (status, body) = post(&url, cookie.trim()); + assert_eq!(status, 200, "{body}"); + assert!(body.contains("\"ok\":true"), "{body}"); + + // The sign-in's thread records the outcome just after the page answers. + let mut status = client.ok("stockbit_status", json!({})); + for _ in 0..50 { + if status["sign_in"]["state"] != "waiting" { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + status = client.ok("stockbit_status", json!({})); + } + assert_eq!(status["logged_in"], true, "{status}"); + assert_eq!(status["sign_in"]["state"], "signed_in", "{status}"); + + // Now there is nothing to sign in to. + let done = client.ok("stockbit_login", json!({})); + assert_eq!(done["state"], "signed_in", "{done}"); + assert_eq!(done["method"], "existing", "{done}"); +} + +/// Asking for the browser where there is none fails, instead of quietly +/// serving the page. +#[test] +fn stockbit_login_with_the_browser_forced_fails_without_one() { + let mut client = Client::start(&[], false); + let (is_error, body) = client.call("stockbit_login", json!({"method": "browser"})); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "UNSUPPORTED", "{body}"); + let status = client.ok("stockbit_status", json!({})); + assert!(status.get("sign_in").is_none(), "nothing started: {status}"); +} + +/// POSTs to the sign-in page the way its own script does. +fn post(url: &str, body: &str) -> (u16, String) { + use std::io::Read; + use std::net::TcpStream; + + let rest = url.trim_start_matches("http://"); + let (authority, path) = rest.split_once('/').expect("path"); + let mut stream = TcpStream::connect(authority).expect("connect to the page"); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .unwrap(); + write!( + stream, + "POST /{path} HTTP/1.1\r\nHost: {authority}\r\nContent-Type: text/plain\r\n\ + Content-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(|code| code.parse().ok()) + .unwrap_or(0); + ( + status, + raw.split("\r\n\r\n").nth(1).unwrap_or_default().to_string(), + ) +} + +#[test] +fn the_order_book_needs_a_session_and_status_never_shows_tokens() { + let mut client = Client::start(&[], false); + let status = client.ok("stockbit_status", json!({})); + assert_eq!(status["logged_in"], false); + let (is_error, body) = client.call("get_orderbook", json!({"symbol": "BBCA"})); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "AUTHERROR"); + + let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]); + let status = client.ok("stockbit_status", json!({})); + assert_eq!(status["logged_in"], true); + assert!(!status.to_string().contains(TEST_SESSION.1)); + + let book = client.ok("get_orderbook", json!({"symbol": "BBCA", "depth": 2})); + assert_fetched(&book); + assert_eq!(book["source"], "stockbit"); + let offers = book["data"]["offers"].as_array().unwrap(); + assert_eq!(offers.len(), 2); + assert_eq!( + offers[0], + json!({"price": 5175, "shares": 264_462_500, "orders": 3284}) + ); + assert_eq!(book["data"]["foreign_net"], -2_329_493_087_500_i64); + assert_eq!(book["data"]["market_status"], "STATE_NAME_MARKET_CLOSED"); + for depth in [0, 51] { + let (is_error, body) = + client.call("get_orderbook", json!({"symbol": "BBCA", "depth": depth})); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "INVALIDINPUT"); + } +} + +#[test] +fn corporate_actions_need_a_session_and_keep_kinds_apart() { + let mut client = Client::start(&[], false); + let (is_error, body) = client.call("get_corporate_actions", json!({"symbol": "BBCA"})); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "AUTHERROR"); + + let mut client = Client::start_with_env( + &[], + false, + &[TEST_SESSION, ("IDX_MOCK_TODAY", "2026-11-03")], + ); + let actions = client.ok( + "get_corporate_actions", + json!({"symbol": "BBCA", "kinds": ["dividend", "split"]}), + ); + assert_fetched(&actions); + assert_eq!(actions["source"], "stockbit"); + let rows = actions["data"].as_array().unwrap(); + assert_eq!(rows.len(), 40, "37 dividends and 3 splits"); + assert_eq!(rows[0]["kind"], "dividend"); + assert_eq!(rows[0]["date"], "2026-08-28"); + assert_eq!(rows[0]["cash_per_share"], 25.0); + assert!(rows[0].get("ratio").is_none(), "absent fields are omitted"); + let split = rows.iter().find(|r| r["kind"] == "split").unwrap(); + assert_eq!(split["ratio"], json!({"old": 1, "new": 5})); + + let calendar = client.ok( + "get_corporate_calendar", + json!({"to": "2026-11-12", "kinds": ["rights", "dividend"]}), + ); + let rows = calendar["data"].as_array().unwrap(); + assert_eq!(rows[0]["symbol"], "MGLV.JK"); + assert_eq!(rows[0]["price"], 8880); + assert!( + rows.windows(2) + .all(|w| w[0]["date"].as_str() <= w[1]["date"].as_str()) + ); + + let (is_error, body) = client.call( + "get_corporate_actions", + json!({"symbol": "BBCA", "kinds": ["rups"]}), + ); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "INVALIDINPUT"); +} + +#[test] +fn shareholder_movements_page_and_filter() { + let mut client = Client::start(&[], false); + let (is_error, body) = client.call("get_shareholder_movements", json!({})); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "AUTHERROR"); + + let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]); + let market = client.ok("get_shareholder_movements", json!({"pages": 2})); + assert_fetched(&market); + assert_eq!(market["source"], "stockbit"); + let rows = market["data"]["movements"].as_array().unwrap(); + assert_eq!(rows.len(), 14, "8 rows on page 1, 6 on the last page"); + assert_eq!(market["data"]["more"], false); + assert!( + rows.windows(2) + .all(|w| w[0]["date"].as_str() >= w[1]["date"].as_str()), + "newest first" + ); + + let sells = client.ok( + "get_shareholder_movements", + json!({"action": "sell", "source": "ksei", "holder_id": "1000004481", "pages": 3}), + ); + let rows = sells["data"]["movements"].as_array().unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| r["symbol"] == "ATLA" + && r["action"] == "sell" + && r["shares_change"].as_i64() < Some(0) + && r["price_estimated"] == true)); + + let bbca = client.ok("get_shareholder_movements", json!({"symbol": "bbca"})); + assert_eq!(bbca["data"]["more"], true, "BBCA has a second page"); + assert_eq!(bbca["data"]["movements"][0]["roles"], json!(["director"])); + + for bad in [ + json!({"action": "dividend"}), + json!({"source": "bing"}), + json!({"holder_id": "RUDI"}), + json!({"pages": 11}), + json!({"from": "2026-11-02", "to": "2026-11-01"}), + ] { + let (is_error, body) = client.call("get_shareholder_movements", bad.clone()); + assert!(is_error, "{bad} -> {body}"); + assert_eq!(body["code"], "INVALIDINPUT", "{bad} -> {body}"); + } +} + #[test] fn errors_carry_cli_codes() { let mut client = Client::start(&[], false); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e9f3215..f537a8d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -55,6 +55,9 @@ command parses arguments, makes one idx-core call, and renders the result. (`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries). - **IDX (idx.co.id)** — the daily trading summary behind `stocks flow --source idx` (`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow). +- **Stockbit (public page)** — `stocks intraday` whichever provider is + selected (`IntradayProvider`), and quotes under the opt-in + `--provider stockbit`. See [Stockbit](#stockbit). ### MSN Endpoints - `https://assets.msn.com/service/`: quotes, charts, equities, earnings, @@ -79,7 +82,7 @@ WIB as the fallback. `behind_session` when `as_of` is before the latest weekday whose session has ended (16:15 WIB). It is computed on return, not cached; MSN can lag the close by hours, and on an exchange holiday every quote reads as behind. Only `.JK` - quotes and `--region id` screens are marked; other exchanges keep other hours. + quotes, `--region id` screens and `stocks intraday` are marked; other exchanges keep other hours. Table output notes behind quotes, and calls those over seven days old not current. - `change`, `change_pct`, and `volume` are nullable when the provider omits them; @@ -151,6 +154,163 @@ WIB as the fallback. range fails on one. `foreign_net_value` is net shares at the session's average price (value over volume), an estimate. +### Stockbit +- `GET https://stockbit.com/symbol/{CODE}` needs no login. Next.js + server-renders the quote and today's chart into the page's `__NEXT_DATA__` + JSON; `api/stockbit/parse.rs` reads it purely. `props.pageProps.company` + is the quote (numbers mostly as strings, an index price as a float string), + `orderbook.{bid,offer}.price` the best bid and offer, `market_hour.status` + the market's state, and `status` `STATUS_ACTIVE|SUSPENDED|DELISTED`. + `chart.prices[]` holds one price per minute in WIB with no offset, after a + synthetic first row at the previous close; per-minute OHLC and volume are + empty. An unknown code is HTTP 404 with the 404 page. +- Stockbit's JSON API (`exodus.stockbit.com`) answers 401 without a user's + bearer token; it is used only with the user's own session (below). +- Bid and offer are kept only while `market_hour.status` is `open`, so a + closing book never reads as a live one. Observed 2026-10-23: `open` during + trading (bid/offer set), `break` from 11:30 on a Friday, `close` after + hours; any other value fails closed. In session the chart gained a point + every minute and the last trade was within a minute of the clock, although + Stockbit's terms call its data delayed at least 10 minutes. Order-book + sizes are not shown; they are shares (BBCA's best bid on 2026-10-23 was + 1,357,500 on the page and 13,575 lots in the session API's book). `as_of` + comes from `date`/`time` + only when the stock traded (a suspended stock carries today's date). + `session_note` repeats what the page says (suspended, delisted) or "no + trades this session"; it is never inferred. +- CloudFront in front of the page answers a static 403 to some user agents + (`curl/`, `axios/`, `Go-http-client/`, empty) and does not fingerprint TLS, + so a plain ureq request with `idx-cli/ (+repo)` passes. A 403 is + `UPSTREAMBLOCKED` and is not retried. Requests are at least 1 s apart + across the user's idx processes (the last request's time sits in + `/stockbit.pace` under a file lock, so a shell loop of `idx` calls is + paced too), and snapshots are cached for 5 s (at most `quote_ttl`), which + coalesces an agent's quote-then-intraday burst across MCP calls. +- Stockbit's terms call its data delayed at least 10 minutes and for + personal use; frontends say so. Fixtures (`tests/fixtures/stockbit_*`) are + pages recorded 2026-10-22 after the close, cut to the fields read. + +### Stockbit session (opt-in login) +- `idx stockbit login` stores the user's session from a pasted + `credentialStorage` cookie (or a bare refresh token) in + `stockbit-session.json`, written atomically with mode 0600, in + `auth::session_dir()`: the config directory, or on macOS the other default + location when the session or profile is there, because a shell with + `XDG_CONFIG_HOME=~/.config` and an app started from the Dock resolve to + different places (a custom `XDG_CONFIG_HOME` stands alone, which keeps + isolated runs off the user's session); + `IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` fill fields the + file lacks. Stockbit publishes no OAuth client a third party can register, + and its sign-in is a first-party flow guarded by reCAPTCHA, so idx cannot + ask for a token itself; every way in ends with the cookie's two tokens. +- What the cookie is (read from Stockbit's web bundle, 2026-10-29): the web + 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 sign-in + (password, Google or Apple, a new device's one-time code, recovery) and the + app's own `POST /login/refresh` go through one `setCredentials`, so the + cookie always holds the newest pair. A signed-out page writes the store + without tokens, so a cookie counts only when `auth::session_from_paste` + finds tokens in it. The app refreshes by itself when it loads with a stale + access token, which is why idx never leaves a session in a browser it does + not own. +- `api/stockbit/signin.rs` is the one sign-in flow; `idx stockbit login` waits + on it in the foreground, and idx-mcp's `stockbit_login` runs it on a thread + (`start_background`) that `stockbit_status` reports on (`sign_in`: waiting, + signed_in or failed, with the error's code). The thread's warnings go into + `sign_in.warning` through `runtime::on_warning`, since nobody reads + idx-mcp's stderr, and the error that ends a browser wait names a refusal + it hit. A thread does not outlive + `main`, so idx-mcp calls `signin::shutdown` when stdin closes or on SIGTERM + or Ctrl-C, which closes a waiting browser (SIGKILL cannot). `Method::Auto` uses the browser + unless the shell is over SSH or has no display, and falls back to the page + only when the browser cannot start (none found, or it exits at launch); a + sign-in that started and then timed out or was closed is an error. Both + ways end in `signin::accept`, which is `stockbit::login`: store, check with + one request, restore the previous session if refused. A stored session that + still works means nothing to sign in to: both frontends verify it with one + request first, which is the common case, because every refresh extends the + session. +- The browser way (`api/stockbit/browser.rs`): Chrome, Chromium, Edge, Brave + or Helium on `stockbit.com/login`, with a profile of its own at + `/stockbit-browser` (mode 0700) and + `--remote-debugging-port=0`. A profile belongs to one browser (its cookies + use that browser's key, and an older Chromium may refuse a newer profile), + so `/idx-browser` records the executable and the next run reuses + it; `IDX_STOCKBIT_BROWSER` overrides that (and is the only browser the mock + provider will start). The + browser writes the port and WebSocket path to `/DevToolsActivePort`; + idx connects and polls `Storage.getCookies`. On a session `accept` takes, it + opens a blank tab, closes every other one (no Stockbit script may write the + cookie back), deletes `credentialStorage` by the domain and path the browser + reported, reads the cookies again to confirm it is gone, and asks the + browser to close. Stockbit's device cookies stay, so a later + sign-in needs no new one-time code. The window waits 15 minutes; closing it + or the browser exiting ends the wait at once. The password, one-time codes + and reCAPTCHA stay in the browser. Google refused its own sign-in in this + window on 2026-10-31 ("This browser or app may not be secure"), most likely + because of the debugging flag, so password sign-in is the supported way + here and Google users go through the page. +- The page way (`api/stockbit/loopback.rs`), for `--no-browser`, SSH, no + display or no browser: a page on 127.0.0.1 (7878 when free, so an `ssh -L` + forward stays the same) with a bookmarklet that reads the cookie in the + user's own browser and opens the page with it in the URL fragment, or a + clipboard button; a per-run secret guards the path, the page is `no-store` + with a strict CSP, and the server stops on success or after five minutes. + Piped stdin keeps the paste path. Sign-in progress goes to stderr (as JSON + lines with `-o json`), so stdout holds only the result. +- `api/stockbit/auth.rs` reads expiries from the JWTs (`exp`, unverified) + and refreshes an access token within an hour of expiry with + `POST /login/refresh` (bearer = refresh token), which rotates both tokens. + Because a refresh token is spent once, refreshes run under an exclusive + lock on `stockbit-session.lock` and re-read the file first, so concurrent + idx processes never spend the same token. A data request answered 401 (or + a JSON 403) refreshes once and retries once; a second refusal is + `AUTHERROR`. Tokens never reach output, errors, logs or cache keys + (`Session`'s `Debug` redacts them). +- `api/stockbit/exodus.rs` (endpoints and units mapped by fzn0x/idx, MIT; + its recordings are `tests/fixtures/stockbit_exodus_*`): + `/company-price-feed/v2/orderbook/companies/{CODE}` gives the depth ladder + (`volume` in shares, `que_num` orders), OHLC, value, frequency and foreign + buy/sell/net (kept only when `has_foreign_bs` is true: on 2026-11-03 in + session it was false and the figures were the previous session's); + `/company-price-feed/market-time/session` the market state of the `regular` + board (`STATE_NAME_SESSION_2` while trading, `STATE_NAME_BREAK`, + `STATE_NAME_MARKET_CLOSED`; cached 30 s per process; the `fca` board's own + state is not read); `/charts/{CODE}/daily` + daily candles for `1m`/`3m`/`1y` windows (volume in lots, returned in + shares). Requests share the public page's cross-process pacing. +- With a session, `--provider stockbit` quotes come from the order book (bid + and offer only in a `STATE_NAME_SESSION_` state; session 2 was observed + on 2026-11-03, session 1 is assumed to match), `stocks orderbook` works, and `auto` history under the stockbit + provider uses Stockbit's daily windows (`5d` to `1y`, trimmed from `1m`, + `3m` or `1y`), Yahoo otherwise. Without one, quotes use the public page and + the order book is `AUTHERROR`. +- Corporate actions (`corporate/`, `api/stockbit/corpaction.rs`): + `/corpaction/{type}` per kind (`dividend`, `stock_dividend`, `bonus`, + `rightissue`, `warrant`, `stocksplit`, `reversesplit`, `rups`, `pubex`, + `ipo`, `tenderoffer`), with `?symbol=` for one stock's history or without + for the market (about a year back and what is announced ahead). Each type + has its own field prefix and the list key is not always the path's, so + entitlements are read by field suffix (`_cumdate`, `_exdate`, ...). One + request per kind, cached 6 hours under `stockbit-corpaction` + (`:` or `:market`); `stocks actions` / + `get_corporate_actions` sort newest first, `stocks calendar` / + `get_corporate_calendar` keep what `CalendarRange::includes`: a + `CorporateAction::date` (the cum date for entitlements) in the range, or a + rights, tender or IPO `period` that overlaps it (not a warrant's, which + runs for years), sorted oldest first. Endpoint notes and the wider + design: `docs/STOCKBIT.md`. +- Shareholder movements (`movements`, `api/stockbit/movement.rs`) read + `/insider/company/majorholder`: IDX insider disclosures and KSEI 5% holder + changes, 50 rows a page, filtered by Stockbit (`symbol`, `date_start`, + `date_end`, `insider` (holder id), `action_type`, `source_type`). + `ownership movements` and `get_shareholder_movements` read up to 10 pages + as one answer, cached an hour under `stockbit-movements`, keyed by every + filter and the page count. If a page after the first fails, the rows read + before it come back with `incomplete` set and nothing is cached. The KSEI + register in SQLite is not involved. + ### Capability Gating ``` MarketDataProvider = QuoteProvider + FundamentalsProvider @@ -158,11 +318,20 @@ HistoryProvider = separate trait, not all providers implement Factory functions: default_provider(kind) → Box - history_provider(kind, mode, verbose) → Result<(ProviderKind, Box)> + history_provider(source, verbose) → Box (source from history_source) broker_provider() → Box trading_summary_provider() → Box + intraday_provider() → Box + orderbook_provider() → Box + corporate_action_provider() → Box + shareholder_movement_provider() → Box ``` +`ProviderKind::Stockbit` serves quotes (and, with a session, daily history): +fundamentals are `UNSUPPORTED`, the MSN-only capabilities say "requires +--provider msn", and `auto` history uses Yahoo unless a session covers the +window. + ## Ownership Module (SQLite-backed) Unlike the `stocks` module (live-fetch), ownership is **import-then-query**: @@ -240,7 +409,9 @@ CLI flags > environment variables > config file > defaults bidirectional overrides replaced before it reaches a terminal table. - Exit codes by class: `0` success, `2` `INVALIDINPUT`/`CONFIGERROR` and clap usage errors, `3` `SYMBOLNOTFOUND`/`NOMARKETDATA`/`CACHEMISS`, `4` `HTTP`/`RATELIMITED`/ - `PROVIDERUNAVAILABLE`/`OFFLINE`/`AUTHERROR`, `5` `UNSUPPORTED`, `1` everything else + `PROVIDERUNAVAILABLE`/`OFFLINE`/`AUTHERROR`/`UPSTREAMBLOCKED`, `5` `UNSUPPORTED`, + `1` everything else. `UPSTREAMBLOCKED` is an upstream refusing this client + outright (a CDN 403 page); retrying at once does not help. - A totally failed `stocks compare` emits one structured error in JSON mode; per-symbol warnings are retained for partial success, not printed ahead of a total-failure error. ## CLI Output and Offline Cache diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fbb6a02..ba75a77 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -93,7 +93,9 @@ Key decisions: - **`Fetched` envelope** for live data: `{ data, source, fetched_at, stale }`, so agents and API clients can see freshness. idx-core still emits the stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI - stderr stays identical. + stderr stays identical. idx-mcp also catches a call's warnings with + `runtime::on_warning` (the call runs on one blocking thread) and returns + them as `warnings`, since an agent never reads the server's stderr. - **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` / `--no-cache` flag handling now spread through `cli/stocks.rs`. - **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads @@ -184,7 +186,7 @@ is another 20-30% smaller than pretty JSON. | 1 | other failure | `IO`, `DATABASEERROR`, `PARSEERROR`, ... | | 2 | invalid usage or input | clap errors, `INVALIDINPUT`, `CONFIGERROR` | | 3 | not found or no data | `SYMBOLNOTFOUND`, `NOMARKETDATA`, `CACHEMISS` | - | 4 | upstream unavailable, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE`, `AUTHERROR` | + | 4 | upstream unavailable or blocked, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE`, `AUTHERROR`, `UPSTREAMBLOCKED` | | 5 | unsupported | `UNSUPPORTED` | - JSON keeps today's top-level shape: no `{data, meta}` wrapper, so `jq` paths diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 5f56439..5faad19 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -42,12 +42,12 @@ contents. The native macOS installer job runs separately to exercise BSD tools. ## Live drift check -Fixtures cannot tell when MSN, Yahoo, or IDX change their responses. +Fixtures cannot tell when MSN, Yahoo, IPOT, IDX, or Stockbit change their responses. `scripts/live-smoke.sh` runs shipped commands against the real upstreams and validates each JSON output shape. It is opt-in and never part of the gate. ```bash -nix develop --command scripts/live-smoke.sh # stocks + ownership +nix develop --command scripts/live-smoke.sh # stocks + stockbit + ownership nix develop --command scripts/live-smoke.sh --group stocks --symbol BBRI nix develop --command scripts/live-smoke.sh --bin ./result/bin/idx nix develop --command scripts/live-smoke.sh --compare ./old/bin/idx # A/B against an older build @@ -58,10 +58,24 @@ scripts/live-smoke.sh --dry-run `flow` and `flow --source idx` on the new binary only, so an older `--compare` build that predates them is fine), plus `compare BUMI,ADRO,AIMS`, whose MSN key ratios contain non-finite values. +- `stockbit`: `intraday` for the ticker and `^JKSE`, and + `--provider stockbit stocks quote`, on the new binary only. A run outside the + session sees `market_status: close` and no bid/offer. These run without a + Stockbit login: each side has its own config, and seeding it with a real + refresh token would rotate (and so retire) that token. When + `idx stockbit status` reports a session, `stocks orderbook`, `stocks actions`, + `stocks calendar` and `ownership movements` (market-wide, one page) also run, on the new binary with your real config + directory (the session file is shared under its lock, never copied; cache + and data stay isolated). Check the rest by hand: + `idx --provider stockbit stocks quote BBCA` and + `idx --provider stockbit stocks history BBCA --period 1y`. - `ownership`: `discover`, then imports the newest supported report into a temp DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many datacenter IPs, including GitHub-hosted runners; run it from a network IDX accepts. +A case whose upstream refuses us (`UPSTREAMBLOCKED`, an HTTP 403) prints +`BLOCKED (HTTP 403)` and counts as a failure, never as a pass. + `--compare ` runs every case with both binaries back to back, each with its own config and cache, and fails on a different exit code, any added, missing, or retyped JSON key, or changed values for `profile`, `financials`, diff --git a/docs/STOCKBIT.md b/docs/STOCKBIT.md new file mode 100644 index 0000000..6895228 --- /dev/null +++ b/docs/STOCKBIT.md @@ -0,0 +1,287 @@ +# Stockbit Session Features: Research and Design + +What the user's Stockbit session (`idx stockbit login`, see the Stockbit +sections of `docs/ARCHITECTURE.md`) can serve beyond the order book, live +quotes and daily history, and how idx should add it. Endpoints were found in +Stockbit's web bundle (`stockbit.com/_next/static/chunks/*.js`, the API client +module) and probed with a real session on Tue 2026-11-03 during trading hours, +one GET at a time at least 1 s apart. Nothing here ships yet unless marked. + +## Rules for every session feature + +1. **Read-only by construction.** The session client (`api/stockbit/exodus.rs`) + issues GET only on an allowlist of path prefixes (`READ_PATHS`), plus + `POST /login/refresh`. + The bundle also holds trading (orders, day trade, PIN, cash withdrawal, + portfolio), chat, posting and account endpoints; idx never calls them, and + the allowlist makes a stray call fail before it leaves the process. +2. **One client.** Every endpoint goes through `Exodus::get`: rotating refresh + under the file lock, one retry after a 401 / JSON 403, the shared 1 s + pacing, `AUTHERROR` / `UPSTREAMBLOCKED` / `RATELIMITED` mapping, and no + token in logs, errors or cache keys. +3. **Stockbit-only commands use Stockbit whatever `--provider` says**, like + `stocks intraday`; without a session they answer `AUTHERROR` (exit code as + today). All endpoints below answer `401` without a session. +4. **Pure parsers, typed output.** Stockbit sends numbers as display strings + (`"193,206"`, `"+0.0001"`, `"1.03B"`), dates as `"25 Mar 26"` or + `YYYY-MM-DD`, and placeholders (`""`, `0`, `"0001-01-01 00:00:00"`). Parsers + turn them into integers, decimals, `NaiveDate` and `None`, and are tested + against recorded fixtures (`tests/fixtures/stockbit_exodus_*`). +5. **Units checked live** per endpoint before release (chart volume is lots, + the order book is shares). +6. **Cache per endpoint**: book and quote 5 s, market state 30 s, movements + 1 h, corporate actions 6 h. +7. **Terms**: Stockbit's data is for personal, non-commercial use; the user + docs (`web/src/content/docs/cli/stockbit.md`) say so for every feature. + +## Endpoint inventory + +All on `https://exodus.stockbit.com`, bearer access token, HTTP 200 on +2026-11-03 unless noted. + +| Endpoint | Serves | Plan | +| --- | --- | --- | +| `/company-price-feed/v2/orderbook/companies/{SYM}` | quote, OHLC, foreign flow, depth | shipped (#74) | +| `/company-price-feed/market-time/session` | market state | shipped (#74) | +| `/charts/{SYM}/daily?timeframe=1m\|3m\|1y` | daily candles | shipped (#74) | +| `/charts/{SYM}/daily?timeframe=1w` | hourly candles, 5 sessions | P2 | +| `/corpaction/{type}[?symbol=SYM]` | corporate actions and calendar | shipped: `stocks actions`, `stocks calendar` | +| `/insider/company/majorholder` | insider / major-holder movements | **P1** | +| `/insider/shareholding/composition/companies/{SYM}` | holder composition by period | P2 | +| `/marketdetectors/{SYM}` | broker summary, Stockbit's bandar detector | P2 | +| `/analyst-ratings/{SYM}/consensus`, `/analyst-ratings/{SYM}` | estimates, ratings | P3 | +| `/order-trade/running-trade`, `/order-trade/trade-book` | trade tape | P3 | +| `/emitten-metadata/shareholders/{SYM}/chart` | composition chart | not probed: needs a second token from `POST /emitten-metadata/shareholders/token` | +| `/keystats/ratio/v1/{SYM}`, `/findata-view/company/financial` | ratios, statements | skip for now (MSN covers them) | + +### Market state (`market-time/session`) + +`data.detail` has two boards, `regular` and `fca` (full call auction), each +with `session`, `state_name`, `state_start_time`, `state_end_time`, +`time_left`, `is_last_session`, `is_end_of_day`. Observed: + +| Time (WIB) | `regular` | `fca` | +| --- | --- | --- | +| Sat 2026-10-18 20:15 | `STATE_NAME_MARKET_CLOSED`, session 0, end of day | same | +| Tue 2026-11-03 12:35 | `STATE_NAME_BREAK`, session 1, 12:00:00-13:29:59 | `STATE_NAME_BREAK`, session 3, 12:00:00-13:59:59 | +| Tue 2026-11-03 13:30 | `STATE_NAME_SESSION_2`, session 2, 13:30:00-15:49:59 | still `STATE_NAME_BREAK` | +| Tue 2026-11-03 14:00 | `STATE_NAME_SESSION_2` | `STATE_NAME_SESSION_4` | + +The order book and session quotes treat any numbered `STATE_NAME_SESSION_` +as trading (#74; the assumed `STATE_NAME_MARKET_OPEN` never appeared). idx +reads `regular` only, so FCA-board stocks can show the regular board's state +around the break. + +In session the order book's `fbuy`/`fsell`/`fnet` were the previous +session's (equal to IPOT's 2026-11-02 net for BBCA, BBRI, TLKM) with +`has_foreign_bs: false`; idx keeps them only when the flag is true (#74). +Other order-book fields not used yet: `total_bid_offer` (queued lots and +orders on each side), `market_data` (volume and value by board: regular, +negotiated, cash), `iepiev` (indicative equilibrium price and volume, for the +auctions), `corp_action.active`. + +### Charts + +- `timeframe=1d`, `PRICE_CHART_TYPE_CANDLE`: empty `prices` during the break. +- `timeframe=1d`, `PRICE_CHART_TYPE_LINE`: a rolling ~350 minutes of + per-minute closes across two sessions, no OHLC or volume + (`xaxisopt: "intraday"`). Adds nothing over the public `stocks intraday`. +- `timeframe=1w`, candle: hourly bars for 5 sessions, `interval_in_minutes: + 60`, volume in lots. The newest bar is stamped with its last update + (`11:59:00`), not the hour. BBCA's three bars on 2026-11-03 summed to + 101.2M shares against 105.1M in the order book (pre-open auction or the + negotiated market, unconfirmed). + +Hourly history needs a datetime bar (`Ohlc.date` is a `NaiveDate`) and an +hourly `Interval`, across Yahoo, MSN, output, goldens and the MCP schema; that +is its own change. + +## P1: corporate actions (shipped) + +Shipped as `idx stocks actions`, `idx stocks calendar` (not a top-level +`calendar`: it sits beside `stocks screen`) and the MCP tools +`get_corporate_actions`, `get_corporate_calendar`; the notes below are the +endpoint as recorded. Before, idx had none (only the `high-dividend` screen and Yahoo's dividend +yield). They explain price gaps in history (splits, bonuses, rights) and give +a calendar of cum dates. + +### Endpoint + +`GET /corpaction/{type}?symbol=SYM` gives one stock's history (BBCA: 37 cash dividends, cum dates 2009-06-09 to 2026-08-28); `GET /corpaction/{type}` without `symbol` is the +market-wide list, newest cum/event date first, upcoming events included. The +bundle passes `limit` (default 30); `rightissue` honoured it loosely (28 rows +for `limit=5`), `warrant` ignored it (1329 rows for `limit=3`), so idx filters +by date itself. + +| `type` | Response key | Field prefix | Fields that matter | +| --- | --- | --- | --- | +| `dividend` | `dividend` | `dividend_` | `cumdate`, `exdate`, `recdate`, `paydate`, `value` (per share), `currency` | +| `stock_dividend` | `stock_dividend` | — | empty on 2026-11-03; record a row before parsing | +| `bonus` | `bonus` | `sahambonus_`, **`stocksplit_`** | `ratio` (`"6 : 4"`), `stocksplit_{cumdate,exdate,recdate,paymentdate,old,new,factor}` | +| `rightissue` | `rightissue` | `rightissue_` | `cumdate`, `exdate`, `recdate`, `old`, `new`, `ratio`, `price`, `factor`, `trading_start`, `trading_end` | +| `warrant` | `warrant` | `wrant_` | `trading_from`, `trading_end`, `exc_from`, `exc_end`, `exc_price` | +| `stocksplit` | `stocksplit` | `stocksplit_` | `cumdate`, `exdate`, `recdate`, `old`, `new`, `ratio`, `factor` | +| `reversesplit` | **`stock_reverse`** | — | empty on 2026-11-03; record a row before parsing | +| `rups` | `rups` | `rups_` | `date`, `time`, `venue`, `eligible_date` | +| `pubex` | `pubex` | `puexp_` | `date`, `time`, `venue` | +| `ipo` | `ipo` | `ipo_` | `listing_date`, `company_name`, `ipo_price.{minimum,maximum,final}`, `ipo_data_detail` (placeholders common) | +| `tenderoffer` | **`tender`** | `tender_` | `start`, `end`, `paydate`, `price`, `shares`, `percentage` | +| `economic` | — | — | macro calendar, not a corporate action; out of scope | + +Examples from 2026-11-03: PPGL bonus 6:4 ex 2026-11-03; PRDA split 1:5 cum +2026-12-17; MGLV rights 100:15 at 8,880 cum 2026-11-04; BMAS rights 655:104 at +350 cum 2026-11-23, rights trading 2026-11-27 to 12-03; AUTO tender offer at +3,600 until 2026-11-11. + +### Types + +As shipped (`corporate/types.rs`), one flat record with a calendar `date` and +optional per-kind fields; JSON leaves absent ones out: + +```rust +pub struct CorporateAction { + pub symbol: String, // BBCA.JK + pub kind: ActionKind, // JSON: dividend, stock-dividend, bonus, rights, warrant, + // split, reverse-split, meeting, expose, ipo, tender + pub date: NaiveDate, // cum date for entitlements (ex date without one), + // else the event's first day + pub cum_date: Option, + pub ex_date: Option, + pub record_date: Option, + pub pay_date: Option, + pub cash_per_share: Option, // dividends; IDR to the sen, else up to 6 decimals + pub currency: Option, // dividends not paid in IDR + pub ratio: Option, // old : new + pub factor: Option, // Stockbit's price adjustment + pub price: Option, // rights, warrant, tender or IPO price (IDR) + pub period: Option, // rights or warrant trading, tender, IPO offer + pub exercise: Option, // warrant exercise + pub shares: Option, // offered (IPO) or sought (tender) + pub percentage_bps: Option, // tender + pub time: Option, // meeting or expose + pub venue: Option, + pub name: Option, // IPO or tender issuer +} +``` + +One parser per type maps its prefix into this record; unknown types and empty +lists are not errors. A row whose dates are all placeholders is dropped. + +### Surface + +- `idx stocks actions SYM [--kind dividend,rights,...]`: one stock's + history, newest first; every kind but `ipo` by default. +- `idx stocks calendar [--kind ...] [--from DATE] [--to DATE]`: market-wide, + default today to +30 days, oldest first by `date`; rights trading, tender + offers and IPO offers still open in the range are included too. +- MCP: `get_corporate_actions {symbol, kinds?}`, + `get_corporate_calendar {kinds?, from?, to?}`. +- Later: `stocks history` notes ex-dates of splits, bonuses and rights in the + window, so a gap is not read as a crash. + +## P1: shareholder movements + +### Endpoint + +Rows shipped as `idx ownership movements` and `get_shareholder_movements`; +the summary score is next. + +`GET /insider/company/majorholder?symbol=&insider=&date_start=&date_end=&action_type=&source_type=&page=`; +all parameters optional, all filtered by Stockbit (checked 2026-11-03). +`date_start` / `date_end` are YYYY-MM-DD; `insider` is the holder id, which is +the row's `id` (not unique per row); `action_type` takes `ACTION_TYPE_BUY`, +`_SELL`, `_TRANSFER`, `_CROSS` or `_CORPACTION` (the app's filter list); +`source_type` takes `SOURCE_TYPE_IDX` or `_KSEI`. `page` counts from 1. 50 rows +a page, `data.is_more` for the next page; a busy day has more than 50 rows. +Without `symbol` it is market-wide, newest first: on 2026-11-03 the newest rows +were from 2026-11-02. BBCA's 50 rows span 2024-03-22 to 2026-03-25. + +Each row (`data.movement[]`): `name`, `symbol`, `date` (`"25 Mar 26"`), +`previous` / `current` / `changes` `{value: shares as "193,206", percentage}`, +`action_type` (`ACTION_TYPE_BUY`, `_SELL`, `_TRANSFER`, `_CROSS`, +`_RIGHT_ISSUE`, `_PRIVATE_PLACEMENT`, `_TENDER_OFFER`; the bundle also names +`_BONUS_STOCK`, `_DIVIDEND_STOCK`, `_DIVIDEND_CASH`), `nationality` +(`NATIONALITY_TYPE_LOCAL|FOREIGN`), `badges` (`SHAREHOLDER_BADGE_DIREKTUR`, +`..._KOMISARIS`), `price.raw.value`, `value.raw.value` (IDR), +`is_estimated_price`, `purpose` (free text such as "Pembelian", "Investasi"), +`data_source.type` (`SOURCE_TYPE_IDX` for insider disclosures, +`SOURCE_TYPE_KSEI` for 5% holder changes), `broker_detail` (empty so far). +KSEI rows carry an estimated price (`is_estimated_price`: true); IDX rows +mostly the trade price, though crossings can be estimated too. `CROSS` rows +name securities firms as the holder. A rights issue can change the stake +with no change in shares (dilution). + +### Relation to `ownership` + +The KSEI register idx imports is a monthly snapshot of holders above 1%. This +feed is the individual changes, days after the trade, including insiders far +below 1%. It joins the ownership entities by name (the same resolution +`ownership resolve` maintains), but needs no SQLite import: it is live. + +### Signal quality + +Discretionary insider buying, above all by several insiders together, is the +form of this data with predictive power in the literature; selling much less. +The raw feed is noisy: + +- Program-like rows: BBCA's 2026-03-25 rows are every director and + commissioner "buying" at the same 6,982, which reads as a share plan, not + open-market conviction. Flag many holders on one date at one price. +- `TRANSFER` rows and estimated prices (`is_estimated_price`). +- The reporting lag: part of the move can be priced before the row appears. + +So the command shows the rows faithfully, and a separate summary scores them: +net value by symbol, the number of distinct buyers (cluster), value against +average daily value, holders crossing 5% / 10%, and the program flag. Calling +it alpha waits for an event study (feed history back to 2024 per symbol, +Yahoo prices, forward 5/20/60-session returns against IHSG). + +### Surface + +- `idx ownership movements [SYM] [--from DATE] [--to DATE] [--holder ID] + [--action buy|sell|transfer|cross|corporate-action] [--source idx|ksei] + [--pages N]`: rows, newest first (shipped). `--holder` takes the holder id + rather than a name: Stockbit filters by id, and a name search would only see + the pages already read. +- MCP: `get_shareholder_movements {symbol?, from?, to?, holder_id?, action?, + source?, pages?}` (shipped). +- Next: `idx ownership movements --summary [--from DATE]`, the per-symbol + score. + +## P2 and P3 + +- **Composition by period** (`/insider/shareholding/composition/companies/{SYM}`, + optional `period_start`, `period_end`, `view`): per `report_date` (latest + 2026-10-30 on 2026-11-03), total shares and each holder's shares, + percentage and previous shares. Fresher than waiting on the IDX register; + could feed `ownership changes` as a source. +- **Market detector** (`/marketdetectors/{SYM}`): `bandar_detector` with + `accdist` labels per top-N, broker counts, average price. A second source + for `stocks broker` / `bandar` beside IPOT (`--source ipot|stockbit`), as + `flow` has `--source ipot|idx`; IPOT answered 502 on 2026-10-31. +- **Hourly candles** (`timeframe=1w`), see "Charts". +- **Analyst consensus**: revenue and operating profit actuals and estimates + by year; ratings per broker. +- **Trade tape**: running trade and trade book; large and only meaningful in + session; wait for `--watch`. + +## Order of work + +1. Close #74: the open-state name in trading hours, fixtures for the observed + states (done 2026-11-03). +2. Corporate actions (parsers, `stocks actions`, `stocks calendar`, MCP, + goldens) (done). +3. Shareholder movements, rows first (done), then `--summary`. +4. Event study for the movement score; P2 by demand. + +## Open questions + +- The `regular` state in session 1 (assumed `STATE_NAME_SESSION_1`), the + pre-open (08:45-09:00) and the closing auction (15:50-16:00). +- When `has_foreign_bs` turns true, and whether the figures are then the + day's. +- `stock_dividend` and `reversesplit` row shapes (both empty on 2026-11-03). +- Whether `/corpaction/{type}` takes a date range (the bundle sends only + `limit` and `symbol`). +- The reporting lag between a movement's `date` and its appearance in the + feed (sample the market-wide first page daily). diff --git a/flake.nix b/flake.nix index b3b9b45..08df8e6 100644 --- a/flake.nix +++ b/flake.nix @@ -288,7 +288,11 @@ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' sleep 2 } | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl - jq -se 'map(select(.id == 2))[0].result.tools | length == 14' out.jsonl + # Count from the committed golden, minus the opt-in sync tool, + # so adding a tool updates one place. + count=$(jq '[.[] | select(.name != "ownership_sync")] | length' \ + ${./crates/idx-mcp/tests/golden/tools.json}) + jq -se "map(select(.id == 2))[0].result.tools | length == $count" out.jsonl touch "$out" ''; # The website's generated pages match the CLI help and the MCP diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 98dbfb9..47c2c60 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash # Live provider drift check: runs shipped commands against real MSN, Yahoo, -# and IDX endpoints and validates the JSON output shape. Deterministic CLI -# behavior is covered by tests/cli.rs and tests/golden.rs; this answers "do the -# upstreams still look the way our parsers expect?" +# IPOT, IDX, and Stockbit endpoints and validates the JSON output shape. +# Deterministic CLI behavior is covered by tests/cli.rs and tests/golden.rs; +# this answers "do the upstreams still look the way our parsers expect?" +# An upstream that refuses us (UPSTREAMBLOCKED, HTTP 403) is reported as +# BLOCKED, never as a pass. # # With --compare , every case also runs the old binary back to back # and fails on any difference in exit code or JSON structure, and on any value @@ -26,7 +28,8 @@ usage() { Usage: scripts/live-smoke.sh [options] Options: - --group Restrict to a group. Repeatable. Default: both + --group stocks, stockbit, or ownership. Repeatable. + Default: all three --symbol Primary ticker. Default: BBCA --bin Use an existing idx binary (skips cargo build) --compare Also run an older idx binary and diff the results @@ -46,12 +49,12 @@ while [[ $# -gt 0 ]]; do *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; esac done -[[ ${#GROUPS_SELECTED[@]} -eq 0 ]] && GROUPS_SELECTED=(stocks ownership) +[[ ${#GROUPS_SELECTED[@]} -eq 0 ]] && GROUPS_SELECTED=(stocks stockbit ownership) for group in "${GROUPS_SELECTED[@]}"; do case "$group" in - stocks|ownership) ;; - *) echo "invalid --group '$group' (expected stocks or ownership)" >&2; exit 2 ;; + stocks|stockbit|ownership) ;; + *) echo "invalid --group '$group' (expected stocks, stockbit, or ownership)" >&2; exit 2 ;; esac done @@ -68,10 +71,14 @@ LOG_DIR="$WORKDIR/logs" COMMON_ENV=("IDX_PROVIDER=msn" "IDX_HISTORY_PROVIDER=auto" "IDX_NO_COLOR=1") # Each binary gets its own config, cache, and data so both really fetch. +# Stockbit session cases keep the user's real config directory, which holds +# the session: a copy would refresh on its own and spend the user's rotating +# refresh token, while the real file is shared under its lock. +REAL_CONFIG=0 side_env() { local home="$WORKDIR/$1" - printf '%s\n' "XDG_CONFIG_HOME=$home/config" "XDG_CACHE_HOME=$home/cache" \ - "XDG_DATA_HOME=$home/data" "${COMMON_ENV[@]}" + (( REAL_CONFIG )) || printf '%s\n' "XDG_CONFIG_HOME=$home/config" + printf '%s\n' "XDG_CACHE_HOME=$home/cache" "XDG_DATA_HOME=$home/data" "${COMMON_ENV[@]}" } # Values of these commands should not move between two back-to-back runs. @@ -99,8 +106,10 @@ def types: [paths(scalars) as $p | select(getpath($p) != null) # jq filter describing the JSON shape each command must keep. contract() { case "$1" in - "stocks quote "*|"stocks screen"*) + "stocks quote "*|"stocks screen"*|"--provider stockbit stocks quote "*) echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.price | type == "number"))' ;; + "stocks intraday "*) + echo '(.quote.symbol | type == "string") and (.quote.price | type == "number") and (.quote.market_status | type == "string") and (.points | type == "array") and all(.points[]; (.time | type == "string") and (.price | type == "number"))' ;; "stocks history "*) echo 'type == "array" and length > 0 and all(.[]; (.date | type == "string") and (.close | type == "number"))' ;; "stocks technical "*) @@ -123,6 +132,16 @@ contract() { echo '(.highlights | type == "array") and (.risks | type == "array")' ;; "stocks news "*) echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;; + "stocks orderbook "*) + echo '(.symbol | type == "string") and (.price | type == "number") and (.market_status | type == "string") and (.bids | type == "array") and (.offers | type == "array")' ;; + # The smoke symbol always has dividends on record; a quiet month can + # leave the calendar empty. + "stocks actions "*) + echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;; + "stocks calendar"*) + echo 'type == "array" and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;; + "ownership movements"*) + echo '(.more | type == "boolean") and (.movements | length > 0) and all(.movements[]; (.symbol | type == "string") and (.date | type == "string") and (.holder_id | type == "string") and (.action | type == "string") and (.shares_change | type == "number"))' ;; "stocks broker "*) echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;; "stocks bandar "*) @@ -172,6 +191,11 @@ run_case() { fi fi if (( status != 0 )); then + if grep -q 'UPSTREAMBLOCKED' "$log.stderr"; then + FAILURES+=("$name: BLOCKED, upstream refused us with HTTP 403 (see $log.stderr)") + echo " BLOCKED (HTTP 403)" + return + fi FAILURES+=("$name: exit $status (see $log.stderr)") echo " FAIL exit" return @@ -238,6 +262,24 @@ if selected stocks; then run_new_case flow-idx "stocks flow $SYMBOL --days 3 --source idx" fi +if selected stockbit; then + # Stockbit's public page (no login): one request per case, 1 s apart. + run_new_case intraday "stocks intraday $SYMBOL" + run_new_case intraday-index "stocks intraday ^JKSE" + run_new_case quote-stockbit "--provider stockbit stocks quote $SYMBOL" + # With the user's own session (`idx stockbit login`), if there is one. + if (( DRY_RUN )) || "${BIN_PATH:-idx}" -o json stockbit status 2>/dev/null | jq -e '.logged_in' >/dev/null; then + REAL_CONFIG=1 + run_new_case orderbook "stocks orderbook $SYMBOL --depth 5" + run_new_case actions "stocks actions $SYMBOL --kind dividend,split" + run_new_case calendar "stocks calendar --kind dividend,rights" + run_new_case movements "ownership movements" + REAL_CONFIG=0 + else + echo " no Stockbit session: orderbook, actions, calendar and movements not run" + fi +fi + if selected ownership; then run_case discover "ownership discover --limit 1" if (( ! DRY_RUN )) && [[ -s "$LOG_DIR/$(printf '%02d' "$TOTAL")_discover.stdout" ]]; then diff --git a/web/astro.config.mjs b/web/astro.config.mjs index b9a0091..5a07446 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -31,7 +31,7 @@ export default defineConfig({ }, { label: 'CLI', - items: ['cli/stocks', 'cli/ownership', 'cli/output', 'cli/reference'], + items: ['cli/stocks', 'cli/stockbit', 'cli/ownership', 'cli/output', 'cli/reference'], }, { label: 'MCP server', @@ -56,7 +56,7 @@ export default defineConfig({ projectName: 'idx-cli', details: [ '- `idx` is a CLI; `idx-mcp` is an MCP server over the same library. Both read the same config, cache and ownership database.', - '- Output is JSON whenever stdout is not a terminal. Errors are one JSON object on stderr; exit codes: 2 invalid input, 3 not found, 4 upstream unavailable, 5 unsupported, 1 other.', + '- Output is JSON whenever stdout is not a terminal. Errors are one JSON object on stderr; exit codes: 2 invalid input, 3 not found, 4 upstream unavailable or blocked, 5 unsupported, 1 other.', '- Every page is also available as Markdown by appending `.md` to its path, for example `/agents/install.md`.', ].join('\n'), }), diff --git a/web/scripts/gen-mcp-tools.sh b/web/scripts/gen-mcp-tools.sh index fefb17f..ad687e7 100755 --- a/web/scripts/gen-mcp-tools.sh +++ b/web/scripts/gen-mcp-tools.sh @@ -19,7 +19,9 @@ description: Every idx-mcp tool and its inputs, generated from the server's tool idx-mcp serves these tools. `ownership_sync` is listed only when the server starts with `--allow-sync`. Results that come from a live provider are wrapped as `{data, source, fetched_at, stale}`; failures return `{error, code, -message}` with `isError` set. +message}` with `isError` set. Either may carry `warnings`, what idx worked +around (such as a history fallback or a stale cache entry), when there was +any. EOF diff --git a/web/src/content/docs/agents/index.md b/web/src/content/docs/agents/index.md index 6e4a460..78f4470 100644 --- a/web/src/content/docs/agents/index.md +++ b/web/src/content/docs/agents/index.md @@ -19,10 +19,14 @@ What an agent needs to know: - On failure stdout is empty and stderr holds one JSON object, `{"error": true, "code": "...", "message": "..."}`. - Exit codes say what to do next: `2` fix the input, `3` not found (or no - cached data offline), `4` provider unavailable, retry later, `5` + cached data offline), `4` provider unavailable or blocked, retry later, `5` unsupported, `1` other. - Tickers are IDX codes (`BBCA`); `.JK` is optional. Several tickers can be passed at once to `stocks quote` and `stocks compare`. +- `stocks intraday` and `--provider stockbit` read Stockbit's public page, + paced to one request a second across idx processes. Ask for the symbols + you need rather than looping over the market; on `UPSTREAMBLOCKED`, stop + instead of retrying. - Ownership queries need `idx ownership sync` once; after that they run offline. `idx -o json ownership releases` shows which months exist. diff --git a/web/src/content/docs/cli/output.md b/web/src/content/docs/cli/output.md index ed72dba..60a5659 100644 --- a/web/src/content/docs/cli/output.md +++ b/web/src/content/docs/cli/output.md @@ -32,7 +32,7 @@ stdout is not a terminal, and with `--no-color`. | `0` | success | | | `2` | invalid input or config | fix the command | | `3` | not found, no data, or no cached data offline | check the ticker or name; go online | -| `4` | upstream unavailable | retry later | +| `4` | upstream unavailable or blocked | retry later; for `UPSTREAMBLOCKED`, wait or change network | | `5` | unsupported | use another command or provider | | `1` | anything else | read the message | diff --git a/web/src/content/docs/cli/ownership.md b/web/src/content/docs/cli/ownership.md index 07b1037..7258b2e 100644 --- a/web/src/content/docs/cli/ownership.md +++ b/web/src/content/docs/cli/ownership.md @@ -36,6 +36,34 @@ two releases. `ticker` also reports concentration: the top holder's share, the top three, the Herfindahl index (HHI) and the free float (shares held by holders below 1%). +## Insider and major holder movements + +With your own Stockbit login (`idx stockbit login`, see [Stockbit](/cli/stockbit/)), +`movements` lists reported changes in holders' stakes, newest first. It reads +Stockbit live and needs no import: + +```bash +idx ownership movements BBCA # one stock, 50 rows +idx ownership movements # the whole market +idx ownership movements --action sell --source ksei --pages 3 +idx ownership movements --from 2026-10-01 --to 2026-10-31 +idx ownership movements --holder 1000004481 # one holder's history +``` + +Two sources share the list. `IDX` rows are insider disclosures: directors, +commissioners and other insiders, at any size, with the trade price. `KSEI` +rows are changes of holders at 5% and above, which report no price; Stockbit +estimates one, shown with `~`. `--holder` takes the number in the HOLDER ID +column. Shares are exact. Stakes are as Stockbit rounds them, so a director's +small holding reads `<0.01%`. + +These are filings, not signals. Many insiders buying at one price on one day +is usually a share plan, transfers are not trades, and a row appears days +after the change. Each page is 50 rows and one request to Stockbit (`--pages` +up to 10); a query's pages are read and cached together, for an hour. JSON's +`more` says rows remain past them. If a later page fails, you get the rows +read before it, with `incomplete` saying why, and nothing is cached. + ## Import reports yourself The snapshot covers most needs. To load a month it lacks, or older history, diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 5a89111..9f816dd 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -22,6 +22,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) @@ -41,6 +42,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -60,6 +66,10 @@ 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 @@ -80,13 +90,14 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks quote` @@ -100,19 +111,244 @@ Arguments: ... One or more symbols, comma-separated or space-separated; `-` reads them from stdin Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help Examples: idx stocks quote BBCA idx stocks quote BBCA,BBRI,BMRI idx -o json stocks quote BBCA echo BBCA BBRI | idx stocks quote - + idx --provider stockbit stocks quote BBCA # with bid/offer while the market is open +``` + +### `idx stocks intraday` + +```text +Today's session of one IDX stock or index, by the minute, from Stockbit's public page (no login): the quote, the best bid/offer while the market is open, a sparkline, and the last minutes. Delayed at least 10 minutes per Stockbit's terms; prices only, no per-minute volume. A stock that did not trade has no minutes, which is not an error. Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45). + +Usage: idx stocks intraday [OPTIONS] + +Arguments: + + Single ticker symbol (e.g. BBCA) + +Options: + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --no-color + Disable colored output + + -q, --quiet + Suppress non-essential output + + -v, --verbose... + Increase verbosity (-v, -vv) + + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stocks intraday BBCA + idx stocks intraday ^JKSE + idx -o json stocks intraday BBCA | jq '.points[-1]' +``` + +### `idx stocks orderbook` + +```text +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. + +Usage: idx stocks orderbook [OPTIONS] + +Arguments: + + Single ticker symbol (e.g. BBCA) + +Options: + --depth + Price levels per side (1-50) + + [default: 10] + + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --no-color + Disable colored output + + -q, --quiet + Suppress non-essential output + + -v, --verbose... + Increase verbosity (-v, -vv) + + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stocks orderbook BBCA + idx stocks orderbook BBRI --depth 5 + idx -o json stocks orderbook TLKM | jq '.offers[0]' +``` + +### `idx stocks actions` + +```text +One IDX stock's corporate actions from your own Stockbit session (`idx stockbit login`), newest first: cash and stock dividends, bonus shares, rights issues, warrants, splits and reverse splits, shareholders' meetings, public exposes and tender offers (`--kind ipo` adds its IPO). Stockbit serves one kind per request, 1 s apart, so the first call takes about a second per kind; answers are cached for 6 hours. + +Usage: idx stocks actions [OPTIONS] + +Arguments: + + Single ticker symbol (e.g. BBCA) + +Options: + --kind ... + Kinds to list (default: all but ipo) + + Possible values: + - dividend: Cash dividend + - stock-dividend + - bonus: Bonus shares + - rights: Rights issue (HMETD) + - warrant + - split: Stock split + - reverse-split + - meeting: Shareholders' meeting (RUPS) + - expose: Public expose + - ipo + - tender + + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --no-color + Disable colored output + + -q, --quiet + Suppress non-essential output + + -v, --verbose... + Increase verbosity (-v, -vv) + + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stocks actions BBCA + idx stocks actions TLKM --kind dividend + idx -o json stocks actions BBRI --kind dividend,split | jq '.[0]' +``` + +### `idx stocks calendar` + +```text +Corporate actions across IDX from your own Stockbit session (`idx stockbit login`), oldest first, dated within --from and --to (default: today and the next 30 days), plus rights trading, tender offers and IPO offers still open then. An action's date is its cum date for dividends, bonuses, rights and splits (its ex date when Stockbit gives no cum date), else the event's first day. Stockbit lists about a year back and what is announced ahead. One request per kind, 1 s apart; answers are cached for 6 hours. + +Usage: idx stocks calendar [OPTIONS] + +Options: + --from + First day, YYYY-MM-DD. Default: today + + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --no-color + Disable colored output + + --to + Last day, YYYY-MM-DD. Default: 30 days after --from (at most 366) + + --kind ... + Kinds to list (default: all) + + Possible values: + - dividend: Cash dividend + - stock-dividend + - bonus: Bonus shares + - rights: Rights issue (HMETD) + - warrant + - split: Stock split + - reverse-split + - meeting: Shareholders' meeting (RUPS) + - expose: Public expose + - ipo + - tender + + -q, --quiet + Suppress non-essential output + + -v, --verbose... + Increase verbosity (-v, -vv) + + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stocks calendar + idx stocks calendar --kind dividend,rights + idx stocks calendar --from 2026-12-01 --to 2026-12-31 --kind meeting ``` ### `idx stocks history` @@ -135,11 +371,13 @@ Options: --no-color Disable colored output --history-provider - [possible values: auto, yahoo, msn] + [possible values: auto, yahoo, msn, stockbit] -q, --quiet Suppress non-essential output -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] --offline Run without network requests --no-cache @@ -164,7 +402,7 @@ Arguments: Options: --history-provider - [possible values: auto, yahoo, msn] + [possible values: auto, yahoo, msn, stockbit] -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] --no-color @@ -173,6 +411,8 @@ Options: Suppress non-essential output -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] --offline Run without network requests --no-cache @@ -196,13 +436,14 @@ Arguments: Single ticker symbol (e.g. BBCA) Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help Examples: idx stocks growth BBCA @@ -220,13 +461,14 @@ Arguments: Single ticker symbol (e.g. BBCA) Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help Examples: idx stocks valuation BBCA @@ -244,13 +486,14 @@ Arguments: Single ticker symbol (e.g. BBCA) Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help Examples: idx stocks risk BBCA @@ -268,13 +511,14 @@ Arguments: Single ticker symbol (e.g. BBCA) Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help Examples: idx stocks fundamental BBCA @@ -337,6 +581,11 @@ Options: [default: all] + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -409,17 +658,22 @@ Options: [default: all] + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests - --no-cache - Bypass local cache - --top Tier behind the headline signal: 1, 3, 5 or 10 brokers per side [default: 5] + --no-cache + Bypass local cache + -h, --help Print help (see a summary with '-h') @@ -474,6 +728,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -502,13 +761,14 @@ Arguments: Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks financials` @@ -527,6 +787,7 @@ Options: --no-color Disable colored output -q, --quiet Suppress non-essential output -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] --offline Run without network requests --no-cache Bypass local cache -h, --help Print help @@ -548,17 +809,18 @@ Arguments: Options: - --forecast Only include forward earnings rows - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --history Only include historical earnings rows - --no-color Disable colored output - --annual Only include annual periods - -q, --quiet Suppress non-essential output - --quarterly Only include quarterly periods - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + --forecast Only include forward earnings rows + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --history Only include historical earnings rows + --no-color Disable colored output + --annual Only include annual periods + -q, --quiet Suppress non-essential output + --quarterly Only include quarterly periods + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help Examples: idx stocks earnings BBCA @@ -577,13 +839,14 @@ Arguments: Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks insights` @@ -597,13 +860,14 @@ Arguments: Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks news` @@ -617,14 +881,15 @@ Arguments: Options: - --limit [default: 10] - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + --limit [default: 10] + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks screen` @@ -635,16 +900,17 @@ MSN screener Usage: idx stocks screen [OPTIONS] Options: - --filter [default: top-performers] - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - --region [default: id] - --limit [default: 50] - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + --filter [default: top-performers] + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + --region [default: id] + --limit [default: 50] + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks compare` @@ -658,13 +924,14 @@ Arguments: ... One or more symbols, comma-separated or space-separated; `-` reads them from stdin Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help Examples: idx stocks compare BBCA BBRI BMRI @@ -704,6 +971,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -733,13 +1005,14 @@ Create default configuration file Usage: idx config init [OPTIONS] Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx config get` @@ -753,13 +1026,14 @@ Arguments: Key such as general.provider (see `idx config --help`) Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx config set` @@ -774,13 +1048,14 @@ Arguments: Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx config path` @@ -791,13 +1066,14 @@ Print configuration file path Usage: idx config path [OPTIONS] Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ## `idx cache` @@ -813,13 +1089,14 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx cache info` @@ -830,13 +1107,14 @@ Show cache usage information Usage: idx cache info [OPTIONS] Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx cache clear` @@ -847,13 +1125,14 @@ Clear all cached files Usage: idx cache clear [OPTIONS] Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ## `idx ownership` @@ -879,6 +1158,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 @@ -899,6 +1179,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -924,15 +1209,16 @@ Discover the latest IDX-hosted ownership report URLs Usage: idx ownership discover [OPTIONS] Options: - --family Report family to discover: above1 (default), all, above5, or investor-type [default: above1] - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --limit Maximum number of discovered report URLs to print [default: 6] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + --family Report family to discover: above1 (default), all, above5, or investor-type [default: above1] + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --limit Maximum number of discovered report URLs to print [default: 6] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help Examples: idx ownership discover --limit 1 @@ -977,6 +1263,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -1028,6 +1319,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -1056,14 +1352,15 @@ Arguments: Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --source [default: all] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --source [default: all] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx ownership entity` @@ -1077,13 +1374,14 @@ Arguments: Entity name or part of one, case-insensitive; the shortest match is shown Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help Examples: idx ownership entity "ASTRA INTERNATIONAL" @@ -1121,6 +1419,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -1149,6 +1452,7 @@ Options: --no-color Disable colored output -q, --quiet Suppress non-essential output -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] --offline Run without network requests --no-cache Bypass local cache -h, --help Print help @@ -1162,15 +1466,16 @@ Rank tickers by ownership concentration Usage: idx ownership concentration [OPTIONS] Options: - --by [default: hhi] - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - --top [default: 20] - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + --by [default: hhi] + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + --top [default: 20] + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx ownership flow` @@ -1184,13 +1489,14 @@ Arguments: Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx ownership changes` @@ -1223,6 +1529,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -1237,6 +1548,79 @@ Examples: idx ownership changes --from 2026-08-31 --to 2026-09-30 ``` +### `idx ownership movements` + +```text +Reported changes in holders' stakes from your own Stockbit session (`idx stockbit login`), newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI's changes of holders at 5% and above (Stockbit estimates their price). One stock's, or the whole market's without SYMBOL. Each page is 50 rows and one request; answers are cached for an hour. + +These are filings, not signals: a share plan shows as many insiders buying at one price, and a row appears days after the change. + +Usage: idx ownership movements [OPTIONS] [SYMBOL] + +Arguments: + [SYMBOL] + Single ticker symbol (e.g. BBCA); omit for the whole market + +Options: + --from + First day, YYYY-MM-DD + + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --no-color + Disable colored output + + --to + Last day, YYYY-MM-DD + + --holder + One holder's movements, by the id in the HOLDER ID column + + -q, --quiet + Suppress non-essential output + + --action + Only this kind of movement + + [possible values: buy, sell, transfer, cross, corporate-action] + + -v, --verbose... + Increase verbosity (-v, -vv) + + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --source + Only IDX insider disclosures or KSEI 5% holder changes + + [possible values: idx, ksei] + + --offline + Run without network requests + + --pages + Pages of 50 rows to read (1-10) + + [default: 1] + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx ownership movements BBCA + idx ownership movements --action sell --source ksei + idx ownership movements --from 2026-10-01 --to 2026-10-31 --pages 5 + idx ownership movements --holder 1000004481 # one holder's history (HOLDER ID column) +``` + ### `idx ownership graph` ```text @@ -1248,15 +1632,16 @@ Arguments: Ticker code or entity name to start from Options: - --depth Traversal depth (default 2) [default: 2] - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --format Output format: table or dot (Graphviz) [default: table] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + --depth Traversal depth (default 2) [default: 2] + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --format Output format: table or dot (Graphviz) [default: table] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx ownership resolve` @@ -1273,13 +1658,14 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` #### `idx ownership resolve unresolved` @@ -1290,14 +1676,15 @@ List unresolved or low-confidence entity aliases Usage: idx ownership resolve unresolved [OPTIONS] Options: - --limit [default: 50] - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + --limit [default: 50] + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` #### `idx ownership resolve map` @@ -1312,13 +1699,14 @@ Arguments: The canonical entity name to map to Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` #### `idx ownership resolve merge` @@ -1333,13 +1721,14 @@ Arguments: Entity ID to merge and delete Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx ownership releases` @@ -1350,13 +1739,143 @@ List imported KSEI releases Usage: idx ownership releases [OPTIONS] Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help +``` + +## `idx stockbit` + +```text +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. + +Usage: idx stockbit [OPTIONS] + +Commands: + login Sign in through your browser, or from stdin + status Show whether a session is stored and when its tokens expire + logout Delete the stored session + help Print this message or the help of the given subcommand(s) + +Options: + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --no-color + Disable colored output + + -q, --quiet + Suppress non-essential output + + -v, --verbose... + Increase verbosity (-v, -vv) + + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stockbit login # sign in through a browser + idx stockbit login --no-browser # local page: bookmarklet or paste + pbpaste | idx stockbit login # the credentialStorage cookie on stdin + idx stockbit status + idx stockbit logout +``` + +### `idx stockbit login` + +```text +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. + +Usage: idx stockbit login [OPTIONS] + +Options: + --no-browser + Use the local page instead of a browser sign-in + + -o, --output + Output format [default: auto = table on a terminal, json when piped] + + [possible values: auto, table, json, jsonl] + + --no-color + Disable colored output + + --port + Port for the local page (for `ssh -L`); default 7878, or any free one + + -q, --quiet + Suppress non-essential output + + -v, --verbose... + Increase verbosity (-v, -vv) + + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') +``` + +### `idx stockbit status` + +```text +Show whether a session is stored and when its tokens expire + +Usage: idx stockbit status [OPTIONS] + +Options: + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help +``` + +### `idx stockbit logout` + +```text +Delete the stored session + +Usage: idx stockbit logout [OPTIONS] + +Options: + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ## `idx completions` @@ -1370,13 +1889,14 @@ Arguments: [possible values: bash, zsh, fish] Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ## `idx version` @@ -1387,11 +1907,12 @@ Show idx-cli version Usage: idx version [OPTIONS] Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - -q, --quiet Suppress non-essential output - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` diff --git a/web/src/content/docs/cli/stockbit.md b/web/src/content/docs/cli/stockbit.md new file mode 100644 index 0000000..17aa96c --- /dev/null +++ b/web/src/content/docs/cli/stockbit.md @@ -0,0 +1,239 @@ +--- +title: Stockbit +description: Today's session by the minute and quotes with best bid/offer from Stockbit's public page, plus the order book and live quotes with your own Stockbit login. Both opt-in. +--- + +[Stockbit](https://stockbit.com)'s public symbol page carries a quote, the +best bid and offer, and today's price for every minute of the session. idx +reads that page directly. It needs no Stockbit account, no login and no +helper tools. With your own Stockbit account, idx can also read the order +book and live quotes; see [With your Stockbit login](#with-your-stockbit-login). + +```bash +idx stocks intraday BBCA # today's session, by the minute +idx stocks intraday ^JKSE # an index (or IHSG, LQ45) +idx --provider stockbit stocks quote BBCA # quote with bid/offer +idx -o json stocks intraday BBCA | jq '.points | length' +``` + +## What you get + +| | `stocks intraday` | `--provider stockbit stocks quote` | +| --- | --- | --- | +| Price, change, previous close, volume | yes | yes | +| Best bid and offer | while the market is open | while the market is open | +| Market status (`market_status`) | yes | yes | +| Price per minute (`points`) | yes | no | +| Market cap, 52-week range | no | no | + +- **Delayed.** Stockbit's terms describe its market data as "a real-time + snapshot, delayed at least 10 minutes". It is not a live feed. +- **Bid and offer only while trading.** Outside the session, Stockbit still + shows the book as it closed. idx leaves `bid` and `offer` out then, so the + closing book never passes for a live one. +- **Top of the book only.** One bid and one offer price, no depth. The full + order book needs your [Stockbit login](#with-your-stockbit-login). +- **Prices only.** The page has no per-minute volume or OHLC. +- **Empty sessions are data.** A suspended or delisted stock answers with + `points: []` and a `session_note` saying so (`Stockbit lists the stock as + suspended`). An active stock with no volume says `no trades this session`, + even when its chart is a flat line at the previous close. Neither is an + error. + +`--provider stockbit` (or `IDX_PROVIDER=stockbit`, or +`idx config set general.provider stockbit`) changes only where quotes come +from. Without a login, history still comes from Yahoo. Company data, +fundamentals and screening answer `UNSUPPORTED` under it, so use MSN for +those. + +## How idx talks to Stockbit + +- One request per symbol to `https://stockbit.com/symbol/`, at least one + second apart across all your idx processes (a shell loop of `idx` calls + included), cached for 5 seconds. +- An honest user agent, `idx-cli/ (+https://github.com/0xrsydn/idx-cli)`, + not a browser disguise. Stockbit's CDN refuses some generic agents + (`curl/…`) outright but lets this one through. No curl-impersonate + needed. +- No retries. If Stockbit's CDN refuses your machine, the command fails at + once with `UPSTREAMBLOCKED` (exit `4`): + + ```json + {"error":true,"code":"UPSTREAMBLOCKED","message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); ..."} + ``` + + Wait, or try from another network. `--provider msn` serves quotes without + Stockbit. + +## With your Stockbit login + +Opt-in, and only with your own account. idx then talks to Stockbit's own API +(`exodus.stockbit.com`) with your session's tokens. + +```bash +idx stockbit login # paste the credentialStorage cookie +idx stockbit status # logged in? when do the tokens expire? +idx stocks orderbook BBCA # depth, queued orders, foreign flow +idx stocks orderbook BBRI --depth 5 +idx stocks actions BBCA # dividends, splits, meetings, ... +idx stocks calendar --kind dividend # cum dates in the next 30 days +idx ownership movements BBCA # insider and major holder buys and sells +idx --provider stockbit stocks quote BBCA # live, not delayed +idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily candles +idx stockbit logout +``` + +**Logging in.** `idx stockbit login` opens Chrome (or Chromium, Edge, Brave, +Helium) on Stockbit's own sign-in page, in a browser profile only idx uses. Sign in +there as usual, including any one-time code: idx reads the +session cookie over the DevTools protocol, checks it with one request, and +closes the window. Your password never reaches idx. The profile sits beside +`config.toml`; idx takes the session cookie out of it once the session is +stored, so the profile keeps only Stockbit's device cookies. The profile +stays with the browser that made it: idx opens the same one next time, unless +`IDX_STOCKBIT_BROWSER` names another. Choose your browser that way on the +first sign-in if you have several. + +Use your Stockbit password in that window. Google can refuse to sign in there +with "This browser or app may not be secure", most likely because idx starts +the browser with remote debugging on. If you only sign in with Google, use the +local page below: sign in from a private window, paste the cookie, then close +that window. + +Run `login` again while the session is still good and there is nothing to +do: idx checks it with one request and exits without opening a browser. The +window it does open waits 15 minutes, and closes at once if you close it +yourself. + +**How long a session lasts.** Stockbit's refresh token is good for about a +week, and idx spends it on every refresh, which extends the session. Use idx +at least once a week and you stay signed in. A longer gap, a `logout`, or +another tool spending the same token means one more sign-in. + +Over SSH, on a machine with no display, without such a browser, or with +`--no-browser`, idx serves a small page on your own machine (127.0.0.1, port +7878 when it is free) instead. With +stockbit.com open and logged in, click the page's **Send my Stockbit +session** bookmarklet: it reads Stockbit's `credentialStorage` cookie in your +browser and hands it to the page, which checks it with one request and says +"Connected". If bookmarklets are blocked, click **Paste from clipboard** +after copying the cookie, or paste it into the box. `--port` picks another +port. Piping the value in still works +(`pbpaste | idx stockbit login`), as does a refresh token on its own. On a +server, set `IDX_STOCKBIT_REFRESH_TOKEN`. Set `IDX_STOCKBIT_BROWSER` to use a +browser idx does not find on its own. + +The session lands in `~/.config/idx/stockbit-session.json`, readable only by +you. On macOS without `XDG_CONFIG_HOME` that is +`~/Library/Application Support/idx/` instead; idx finds a session in either +one, so the CLI and an MCP client started from the Dock share it. Tokens never appear in output, logs or errors. The page takes only the +session value, keeps it in memory, and closes when it is done (or after five +minutes). A session Stockbit refuses leaves a previously stored one in place. + +## Remote and headless + +The page listens on this machine's loopback interface only, and the browser +handoff needs a browser on this machine too. On a server, in a container, or +in a WSL distro, `127.0.0.1` is a different machine and neither will load. +Three ways around that, in order of how little they ask of you. + +**Pipe the cookie over SSH.** No tunnel, one command. Copy the +`credentialStorage` value on the machine with the browser (DevTools, +Application, Cookies), then: + +```bash +pbpaste | ssh "idx stockbit login" +``` + +idx reads stdin, verifies the session, and stores it on the server. The value +never reaches a shell history or a file. + +**Forward the page.** Run the page on the server and forward its port from the +machine with the browser: + +```bash +idx stockbit login # on the server: over SSH, it serves the page +ssh -L 7878:127.0.0.1:7878 # where the browser is, keep it open +``` + +Then open the printed URL there and click the bookmarklet. Over SSH, idx +notices `SSH_CONNECTION`, skips the browser, and prints the exact forward +itself. + +**Seed a token.** For anything unattended, keep the refresh token in a secret +store and pass it at run time: + +```bash +op run --env-file=stockbit.env -- idx stocks orderbook BBCA +``` + +where the file holds `IDX_STOCKBIT_REFRESH_TOKEN=op://Private/stockbit/refresh`. +Only the refresh token is needed; idx mints its own access token. + +**Agents.** Over MCP, an agent calls `stockbit_login`, which answers at once +and does the same as `idx stockbit login`. Where idx-mcp runs on your desktop, +a browser window opens on Stockbit's sign-in page (`"method": "browser"`) and +you sign in there. Over SSH or without a display, the answer carries the page +URL and its `ssh -L` forward (`"method": "page"`) for the agent to hand you. +The agent then polls `stockbit_status` until `sign_in.state` is `signed_in` +or `failed`. While it waits, `sign_in.warning` carries the latest problem, such +as Stockbit refusing the session you signed in with, so the agent can tell you +to sign in again. A session that already works answers `signed_in` without opening +anything, a second call while one is waiting returns it again, and +`{"method": "page"}` or `{"method": "browser"}` picks the way. If the server +stops while the window waits, the window closes with it. + +One rule for all three: only one machine may own a session. Stockbit retires +the refresh token on every refresh, so a token copied to two machines signs the +other one out. Two machines need two sign-ins. + +**One refresher per session.** Stockbit replaces the refresh token every time +it is used. Once idx refreshes your session, a browser holding the same +session has to log in again, and two tools sharing one session log each +other out. `idx stockbit login` keeps its own browser profile, so this only +affects a browser you use yourself. idx processes on one machine take turns +through a lock, so they never spend the token twice. + +What the login adds: + +| | Without a login | With your login | +| --- | --- | --- | +| `--provider stockbit` quotes | public page, delayed | Stockbit's API, live | +| Bid and offer in quotes | best price, while open | best price, while open | +| `market_status` in quotes | `open` or `close` | `open` or `close` | +| `stocks orderbook` | `AUTHERROR` | every level with lots and queued orders, OHLC, value, trades; foreign buy/sell/net once Stockbit marks them current | +| `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows | +| `stocks intraday` | public page | public page | +| `stocks actions`, `stocks calendar` | `AUTHERROR` | corporate actions: one stock's history, or the market's calendar | +| `ownership movements` | `AUTHERROR` | insider and major holder buys and sells, as reported to IDX and KSEI | + +The order book is what Stockbit's own app shows. Outside trading it is the +book as the market left it, and the output says so. Foreign buy, sell and net +appear only when Stockbit marks them current: during trading its figures are +the previous session's, so idx leaves them out (`stocks flow` has foreign +flow session by session). Sizes are lots in the +table and shares in JSON. `--history-provider stockbit` asks for Stockbit's +candles explicitly; windows it charts only weekly or hourly (`2y`, `5y`, +`1wk`) answer `UNSUPPORTED`. If Stockbit refuses a stored session, `auto` +history falls back to Yahoo with a warning; an explicit +`--history-provider stockbit` fails with `AUTHERROR`. + +`market_status` in quotes is `open` while the regular market trades and +`close` otherwise, including the lunch break and auctions, with or without a +login. `stocks orderbook` keeps Stockbit's state verbatim +(`STATE_NAME_SESSION_2`, `STATE_NAME_BREAK`, ...). + +## Terms + +Stockbit's [terms](https://stockbit.com/terms) allow its content for personal, +non-commercial use. They prohibit data mining, robots and redistribution +without Stockbit's written consent, which includes serving the data to other +people through `idx-mcp`. idx is not affiliated with or endorsed by +Stockbit. Use this for looking at a stock now and then, not for crawling the +market or republishing prices. With your login, automated use is tied to your +account: keep it human-paced. + +Over MCP, the same data comes from `get_intraday`, `get_orderbook`, +`get_corporate_actions`, `get_corporate_calendar`, `get_shareholder_movements` and `stockbit_status`, and from `get_quotes` and `get_price_history` when the +server runs with `IDX_PROVIDER=stockbit`. The server never logs in; run +`idx stockbit login` in a terminal. diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index e7ad744..71b1810 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -19,6 +19,61 @@ session, the table says so under it, and JSON sets `behind_session`: the provider may still show the previous close. `history` takes `--period` (`1d` to `5y`) and `--interval` (`1d`, `1wk`, `1mo`). +## Today by the minute + +```bash +idx stocks intraday BBCA +idx stocks intraday ^JKSE +idx -o json stocks intraday BBCA | jq '.points[-1]' +``` + +`intraday` shows today's session from Stockbit's public page, without a +login: the quote, the best bid and offer while the market is open, a +sparkline and the last five minutes. JSON gives every minute in `points`. It +is delayed at least 10 minutes. A chart from before the latest session is +noted under the header and sets `quote.behind_session`, as with quotes. See [Stockbit](/cli/stockbit/) for what it +covers and the terms it comes with. + +## Order book (Stockbit login) + +```bash +idx stocks orderbook BBCA +idx stocks orderbook BBRI --depth 5 +``` + +With your own Stockbit session (`idx stockbit login`), `orderbook` shows +every bid and offer level with lots and queued orders, the session's OHLC, +value and trades, and foreign buy, sell and net once Stockbit marks them +current (not during trading, when they lag a day). Without one it answers +`AUTHERROR`. See [Stockbit](/cli/stockbit/#with-your-stockbit-login). + +## Corporate actions (Stockbit login) + +```bash +idx stocks actions BBCA # newest first +idx stocks actions TLKM --kind dividend +idx stocks calendar # today and the next 30 days +idx stocks calendar --kind dividend,rights --to 2026-12-31 +``` + +`actions` lists one stock's cash and stock dividends, bonus shares, rights +issues, warrants, splits and reverse splits, shareholders' meetings, public +exposes and tender offers (`--kind ipo` adds its IPO). `calendar` lists them +across the market between `--from` and `--to` (at most 366 days), including +what is announced ahead, plus rights trading, tender offers and IPO offers +still open in that range, so an offer that began earlier still shows while +you can act on it. An action's date is its cum date for dividends, +bonuses, rights and splits: buy by then to be entitled. For the others it is +the event's first day. When Stockbit gives no cum date, the action sits on +its ex date, marked `(ex)` in the table, and JSON leaves `cum_date` out. +Dividends per share keep their decimals (`Rp 3.8`); one not paid in Rupiah +carries `currency` (e.g. `USD`). JSON's `kind` is the name `--kind` takes. + +Both need your Stockbit session and answer `AUTHERROR` without one. Stockbit +serves one kind per request, 1 s apart, so a first call with every kind takes +about ten seconds; answers are then cached for 6 hours. `--kind` asks for +less. + ## Company data | Command | What it shows | @@ -142,6 +197,10 @@ idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compa MSN is the default for quotes, company data and screening; Yahoo serves price history. Indo Premier (IPOT) serves broker summaries and foreign flow -(IDX's own site with `flow --source idx`), whichever provider is selected. Switch with `IDX_PROVIDER=msn|yahoo` and -`IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the matching -[config keys](/start/config/). +(IDX's own site with `flow --source idx`), and Stockbit serves `intraday` +and, with your login, `orderbook`, whichever provider is selected. Switch +with `--provider msn|yahoo|stockbit` (or `IDX_PROVIDER`) and +`IDX_HISTORY_PROVIDER=auto|yahoo|msn|stockbit`, or the matching +[config keys](/start/config/). `--provider stockbit` serves quotes only, with +the best bid/offer, live when you are logged in; see +[Stockbit](/cli/stockbit/). diff --git a/web/src/content/docs/mcp/index.md b/web/src/content/docs/mcp/index.md index b95f58b..940e93a 100644 --- a/web/src/content/docs/mcp/index.md +++ b/web/src/content/docs/mcp/index.md @@ -5,9 +5,10 @@ description: idx-mcp serves idx's stock and ownership data to AI agents over MCP `idx-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio. It gives an agent the same data as the `idx` CLI, as -[14 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, price history, screening, company data, -analysis, comparison, broker summaries, bandarmology, foreign flow, and the KSEI -ownership queries. +[21 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's +session by the minute, the order book, corporate actions and insider movements (with a Stockbit +login), price history, screening, company data, analysis, comparison, broker summaries, +bandarmology, foreign flow, and the KSEI ownership queries. It shares idx's [config, cache and ownership database](/start/config/#files-idx-keeps), so `idx ownership sync` in a terminal also feeds the server. @@ -27,11 +28,26 @@ Tools that fetch live data return: } ``` -`source` is the provider (`msn` or `yahoo`). Results are cached on disk; +`source` is the provider (`msn`, `yahoo`, `ipot`, `idx` or `stockbit`). Results are cached on disk; `stale` is true when an expired cache entry was served because a fresh fetch failed. Ownership tools return their data directly, from the local database. +What `idx` prints as a warning in a terminal comes back in `warnings`, on an +answer or an error, when there was any. For example, history that fell back to +Yahoo because Stockbit refused your session says so, and a stale answer names +the failure behind it: + +```json +{ + "data": [{ "date": "2026-11-03", "close": 9875, "...": "..." }], + "source": "yahoo", + "fetched_at": "2026-11-04T03:20:11Z", + "stale": false, + "warnings": ["Stockbit rejected the session; run `idx stockbit login` again; history falls back to yahoo meanwhile"] +} +``` + Errors come back as a tool result with `isError` set and the CLI's error body, so the agent can decide whether to retry: @@ -44,6 +60,8 @@ body, so the agent can decide whether to retry: | Input | Limit | | --- | --- | | tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed | +| `get_intraday` `tail` | 400 minutes (default: the whole session) | +| `get_orderbook` `depth` | 50 levels per side (default 10) | | `screen_stocks` `limit` | 200 | | `search_investors` `limit` | 100 | | `get_ownership_changes` `limit` | 300 per page (default 200); page with `offset` | diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 13aa94d..e6dbb65 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -8,7 +8,9 @@ description: Every idx-mcp tool and its inputs, generated from the server's tool idx-mcp serves these tools. `ownership_sync` is listed only when the server starts with `--allow-sync`. Results that come from a live provider are wrapped as `{data, source, fetched_at, stale}`; failures return `{error, code, -message}` with `isError` set. +message}` with `isError` set. Either may carry `warnings`, what idx worked +around (such as a history fallback or a stale cache entry), when there was +any. ## `analyze_bandar` @@ -63,6 +65,25 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys | `sections` | array of `profile` / `financials` / `earnings` / `sentiment` / `insights` / `news` | no | Sections to fetch (default: all). | | `symbol` | string | yes | IDX ticker, e.g. "BBCA". | +## `get_corporate_actions` + +Corporate actions of one IDX stock from the user's own Stockbit session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, rights and splits, i.e. the last day to buy and be entitled, or the ex date when `cum_date` is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), `ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), `factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), `period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours). AUTHERROR means there is no session: call stockbit_status, then stockbit_login. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `kinds` | array of `dividend` / `stock-dividend` / `bonus` / `rights` / `warrant` / `split` / `reverse-split` / `meeting` / `expose` / `ipo` / `tender` | no | Kinds to list (default: every kind but "ipo"). Each kind is one request to Stockbit. | +| `symbol` | string | yes | IDX ticker, e.g. "BBCA". | + +## `get_corporate_calendar` + +Corporate actions across IDX from the user's own Stockbit session, oldest first, dated from `from` to `to` (default today and the next 30 days), plus rights trading, tender offers and IPO offers still open in that range (their `period` overlaps it, though `date` may be earlier): upcoming cum dates, meetings, exposes, rights, warrants, IPOs and tender offers, and the past year. Each item has the same fields as get_corporate_actions, with `symbol`. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours); pass `kinds` to ask for less. AUTHERROR means there is no session: call stockbit_status, then stockbit_login. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `from` | string | no | First day, YYYY-MM-DD (default today). | +| `kinds` | array of `dividend` / `stock-dividend` / `bonus` / `rights` / `warrant` / `split` / `reverse-split` / `meeting` / `expose` / `ipo` / `tender` | no | Kinds to list (default: every kind). Each kind is one request to Stockbit. | +| `to` | string | no | Last day, YYYY-MM-DD (default 30 days after `from`; at most 366 days). | + ## `get_foreign_flow` Foreign investors' buying and selling of one IDX ticker, session by session (oldest first), with range totals; end of day. With `source` "ipot" (default): `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value` in IDR across all boards, `value` traded, and `foreign_share_bps` (foreign share of trading); share counts, close and `non_regular_bps` are null, and `rounded` = true means one decimal of precision. With "idx": exact foreign share counts on the regular board, values estimated at the session's average price, the close, `change_bps` and `non_regular_bps`. @@ -75,6 +96,15 @@ Foreign investors' buying and selling of one IDX ticker, session by session (old | `symbol` | string | yes | IDX ticker, e.g. "BBCA". | | `to` | string | no | Last session of the range, YYYY-MM-DD (default today). | +## `get_intraday` + +Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. `quote.behind_session` is true when the chart is from before the latest IDX session (a holiday, or Stockbit lagging), as in get_quotes. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `symbol` | string | yes | IDX ticker, e.g. "BBCA"; indices as "^JKSE" or "IHSG". | +| `tail` | integer | no | Only the latest N minutes (default: the whole session, at most 400). | + ## `get_investor_holdings` Every IDX holding (above 1%) of one investor, matched by name. @@ -83,6 +113,15 @@ Every IDX holding (above 1%) of one investor, matched by name. | --- | --- | --- | --- | | `entity` | string | yes | Investor name or part of it, e.g. "DWIMURIA"; the closest match is used. | +## `get_orderbook` + +Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR (null while Stockbit marks them not current, as during trading; get_foreign_flow has them per session). `market_status` is Stockbit's state, verbatim: STATE_NAME_SESSION_1 or _2 is trading; in any other state the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL the user can open. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `depth` | integer | no | Price levels per side (default 10, at most 50). | +| `symbol` | string | yes | IDX ticker, e.g. "BBCA". | + ## `get_ownership_changes` Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last. @@ -107,12 +146,26 @@ OHLCV price history for one IDX ticker. ## `get_quotes` -Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. +Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` ("open" while the regular market trades, else "close", with or without a login), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes. | Input | Type | Required | Description | | --- | --- | --- | --- | | `symbols` | array of string | yes | IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional. At most 20. | +## `get_shareholder_movements` + +Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`; `incomplete`, when present, says a later page failed and the rows are those read before it (retry later for the rest). These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `action` | `buy` / `sell` / `transfer` / `cross` / `corporate-action` | no | Only this kind of movement; "corporate-action" covers rights issues, private placements and the like. | +| `from` | string | no | First day, YYYY-MM-DD. | +| `holder_id` | string | no | One holder's movements: the `holder_id` of a row. | +| `pages` | integer | no | Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit. | +| `source` | `idx` / `ksei` | no | "idx" (insider disclosures, any size) or "ksei" (holders at 5% and above). | +| `symbol` | string | no | IDX ticker, e.g. "BBCA"; omit for the whole market. | +| `to` | string | no | Last day, YYYY-MM-DD. | + ## `get_shareholders` Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float). @@ -153,3 +206,17 @@ Search investor names in the KSEI ownership database. | --- | --- | --- | --- | | `limit` | integer | no | Maximum results (default 20, at most 100). | | `query` | string | yes | Full-text query over investor names. | + +## `stockbit_login` + +Start a Stockbit sign-in and return at once; this call does not wait for it. With method auto (the default), idx-mcp opens a browser window on Stockbit's own sign-in page when its machine has a display and is not reached over SSH (`method: "browser"` in the answer): the user signs in there and the window closes by itself. Otherwise it serves a page on 127.0.0.1 (`method: "page"`, with `url` and an `ssh -L` `forward` for a browser on another machine) that takes stockbit.com's session cookie from the user's own browser. Never ask the user for their password, and never type it. Then poll stockbit_status until `sign_in.state` is signed_in or failed. A session that already works answers state signed_in without opening anything; a second call while one is waiting returns it again (`already_open`). + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `method` | `auto` / `browser` / `page` | no | "auto" (default): a browser window when idx-mcp's machine has a display and no SSH, else the local page; "browser" or "page" picks one. | + +## `stockbit_status` + +Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with stockbit_login, or `idx stockbit login` in a terminal. After stockbit_login, `sign_in.state` says whether that sign-in is waiting, signed_in or failed (with `sign_in.error`). While it waits, `sign_in.warning` is the latest problem it hit, such as Stockbit refusing the session the user signed in with: pass it on, since the user may need to sign in again. + +No inputs. diff --git a/web/src/content/docs/start/config.md b/web/src/content/docs/start/config.md index 4304b4d..93335d7 100644 --- a/web/src/content/docs/start/config.md +++ b/web/src/content/docs/start/config.md @@ -28,8 +28,8 @@ in scripts. Ask for JSON with `-o json`, `IDX_OUTPUT=json`, or ```toml [general] -provider = "msn" # msn | yahoo -history_provider = "auto" # auto | yahoo | msn +provider = "msn" # msn | yahoo | stockbit +history_provider = "auto" # auto | yahoo | msn | stockbit exchange = "JK" output = "auto" # auto | table | json | jsonl color = true @@ -56,6 +56,8 @@ fundamental_ttl = 3600 | `IDX_CACHE_FUNDAMENTAL_TTL` | `cache.fundamental_ttl` | | `IDX_OWNERSHIP_SNAPSHOT_MANIFEST` | `ownership.snapshot_manifest` | | `IDX_CURL_IMPERSONATE_BIN` | path to a `curl_chrome*` binary | +| `IDX_STOCKBIT_REFRESH_TOKEN` | seeds a [Stockbit session](/cli/stockbit/#with-your-stockbit-login) when none is stored | +| `IDX_STOCKBIT_ACCESS_TOKEN` | seeds the session's access token | ## Files idx keeps @@ -64,6 +66,7 @@ fundamental_ttl = 3600 | Config | `~/.config/idx/config.toml` | | Cache | `~/.cache/idx/` (`idx cache info`, `idx cache clear`) | | Ownership database | `~/.local/share/idx/ownership.db` | +| Stockbit session (after `idx stockbit login`) | `~/.config/idx/stockbit-session.json`, owner-only | The MCP server reads the same config, cache and database. diff --git a/web/src/content/docs/start/quickstart.md b/web/src/content/docs/start/quickstart.md index 6a85719..33ebefe 100644 --- a/web/src/content/docs/start/quickstart.md +++ b/web/src/content/docs/start/quickstart.md @@ -11,6 +11,7 @@ Tickers are IDX codes; the `.JK` suffix is optional. idx stocks quote BBCA # one quote idx stocks quote BBCA BBRI BMRI # several (spaces or commas) idx stocks history BBCA --period 1y # daily OHLCV +idx stocks intraday BBCA # today by the minute (Stockbit) idx stocks technical BBCA # trend, RSI, MACD, moving averages idx stocks fundamental BBCA # growth, valuation and risk together idx stocks compare BBCA BBRI BMRI # fundamentals side by side diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 191d008..e7f68b1 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -127,7 +127,7 @@ TLKM.JK 2,250 +0.45% 87,159,900

Stocks

-

Quotes, OHLC history, financial statements and a screener. Technical, growth, valuation and risk analysis, and side-by-side compare.

+

Quotes, today's session by the minute, OHLC history, financial statements and a screener. Technical, growth, valuation and risk analysis, and side-by-side compare.

idx stocks fundamental BBRI
@@ -139,7 +139,7 @@ TLKM.JK 2,250 +0.45% 87,159,900

Agents

-

JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 11 tools for any MCP client.

+

JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 17 tools for any MCP client.

npx -y idx-mcp
@@ -219,7 +219,7 @@ TLKM.JK 2,250 +0.45% 87,159,900
2
invalid input or config
3
not found, or no cached data offline
-
4
upstream unavailable, retry later
+
4
upstream unavailable or blocked, retry later
5
unsupported