# idx documentation > Indonesian stock market (IDX / IHSG) analysis for people and AI agents: one open-source binary with a CLI that prints tables, a JSON mode for scripts (`idx -o json`) and an MCP server for agents (`idx mcp`), built on free public data with no API keys. Version 0.8.2. Generated from README.md and docs/*.md of https://github.com/fzn0x/idx. --- Source: https://ihsg.midoriresearch.com/docs/index.md # idx > Indonesian stock market (IDX / IHSG) analysis for people and AI agents. One binary: a CLI that prints tables, a JSON mode for scripts, and an MCP server for agents. ```sh idx stocks quote BBCA,BBRI,TLKM ``` ``` ╭────────┬──────────────────────────────┬───────┬──────┬─────────┬───────┬───────┬───────┬───────────────┬─────────┬───────┬────────╮ │ Symbol ┆ Name ┆ Price ┆ Chg ┆ Chg % ┆ Open ┆ High ┆ Low ┆ Volume ┆ Mkt Cap ┆ P/E ┆ Div % │ ╞════════╪══════════════════════════════╪═══════╪══════╪═════════╪═══════╪═══════╪═══════╪═══════════════╪═════════╪═══════╪════════╡ │ BBCA ┆ PT Bank Central Asia Tbk ┆ 5,175 ┆ -900 ┆ -14.81% ┆ 5,825 ┆ 5,825 ┆ 5,175 ┆ 1,241,098,200 ┆ 635.7T ┆ 10.96 ┆ 6.27% │ │ BBRI ┆ PT Bank Rakyat Indonesia (P… ┆ 2,640 ┆ -460 ┆ -14.84% ┆ 2,640 ┆ 2,890 ┆ 2,640 ┆ 1,193,916,400 ┆ 397.8T ┆ 6.49 ┆ 13.48% │ │ TLKM ┆ Perusahaan Perseroan (Perse… ┆ 1,900 ┆ -330 ┆ -14.80% ┆ 1,900 ┆ 1,985 ┆ 1,900 ┆ 963,614,200 ┆ 187.3T ┆ 10.98 ┆ 10.01% │ ╰────────┴──────────────────────────────┴───────┴──────┴─────────┴───────┴───────┴───────┴───────────────┴─────────┴───────┴────────╯ Last trade 2026-10-16 16:14 WIB · market closed · delayed data ``` ```sh idx -o json stocks quote BBCA | jq '.data[0] | {price, change_pct, pe_trailing, dividend_yield_pct}' ``` ```json { "price": 5175.0, "change_pct": -14.814815, "pe_trailing": 10.963983, "dividend_yield_pct": 6.27 } ``` - **Free data, no keys.** Yahoo Finance and MSN Money for prices and fundamentals, the exchange's own idx.co.id summaries for foreign flow, member activity, indices and the corporate-action calendar, IDX's monthly KSEI register for ownership, Indo Premier's broker summary for who bought and sold (read as bandarmology by `idx bandar`) and for foreign flow in rupiah (`stocks foreign --source ipot`), its corporate-action pages, Kontan and CNBC Indonesia feeds for news. Every source sits behind a `Provider` trait, so one breaking does not take the tool down, and `auto` fills gaps in one source's fundamentals from the other. - **Built for agents.** `-o json` emits a stable, schema-documented envelope (`idx schema quotes`), `-o jsonl` one record per line. Exit codes mean something, and `-` reads tickers from stdin so commands chain. `idx mcp` exposes every command as a typed MCP tool that returns the very same JSON. - **Works offline.** Responses are cached with per-endpoint TTLs; `--offline` serves the cache instead of failing when the network is gone. - **Ownership, not just prices.** KSEI's monthly register of holders above 1 % is imported into local SQLite with normalised entity names, so "who owns more than 1 % of BBCA" and "what did this fund change last month" are one command each. The monthly IDX files are mirrored to GitHub, so neither you nor the web terminal imports anything by hand: `idx ownership sync` installs every month in one download. - **Single static binary.** Rust, no runtime, Linux / macOS / Windows. ## Install The package is called **`ihsg-cli`**; the command it installs is **`idx`** (npm also adds `ihsg-cli` as an alias). ```sh cargo install --locked ihsg-cli # crates.io, builds from source (Rust 1.89+) npm install -g ihsg-cli # npm npx ihsg-cli stocks quote BBCA # npm, no install curl -fsSL https://raw.githubusercontent.com/fzn0x/idx/main/install.sh | sh # Linux, macOS nix profile install github:fzn0x/idx # Nix (Linux; Apple silicon macOS untested), with shell completions nix run github:fzn0x/idx -- stocks quote BBCA # Nix, no install ``` npm installs a small launcher; the first `idx` command downloads the prebuilt binary for your platform from the matching GitHub release and checks it against the release's `SHA256SUMS` (so does `install.sh`). The binary is stored next to the launcher, or in a per-user cache when the global npm folder is not writable. `npm install -g github:fzn0x/idx` installs the same launcher from this repository's `main`; npm 12 only installs from git with `--allow-git=all`. Windows without npm: download `idx--x86_64-pc-windows-msvc.zip` from the [releases page](https://github.com/fzn0x/idx/releases). Static Linux binaries are musl builds and run on any distribution. The development version installs with `cargo install --git https://github.com/fzn0x/idx`. Nix builds idx from source on your machine (there is no binary cache), so the first `nix profile install` or `nix run` takes several minutes. The bare `github:fzn0x/idx` ref follows `main`; add a release tag to pin one, as in `github:fzn0x/idx/v0.8.2` (the first release with the flake). For MCP clients, install once and register `$HOME/.nix-profile/bin/idx`, written as an absolute path, with the argument `mcp`: `idx mcp setup` prints the binary's resolved `/nix/store` path, which an upgrade followed by garbage collection removes. `nix run github:fzn0x/idx#mcp` starts the same server without installing, for trying it from a terminal; do not make it a client's server command, because a cold start (the first run, or the first after `main` moves) builds idx before the server answers, which takes longer than clients wait. `overlays.default` adds `pkgs.idx` for NixOS and home-manager configurations, and `nix develop` in a clone gives the toolchain from `rust-toolchain.toml` plus Node for `web/`. On NixOS, point `idx stockbit login` at a browser with `IDX_BROWSER=$(command -v chromium)`. Shell completions: ```sh idx completions bash > ~/.local/share/bash-completion/completions/idx idx completions zsh > ~/.zfunc/_idx idx completions fish > ~/.config/fish/completions/idx.fish idx completions powershell | Out-String | Invoke-Expression ``` ## Commands | command | what it does | | --- | --- | | `stocks quote ` | Delayed quote for one or many tickers (`BBCA,BBRI` or `BBCA BBRI`; `.JK` optional; `-` reads them from stdin; another Yahoo Finance market by its suffix, e.g. `D05.SI`) | | `stocks history [--period 1y] [--interval 1d] [--limit 30]` | OHLCV candles with a summary line. Periods `1d 5d 1mo 3mo 6mo 1y 2y 5y 10y ytd max`, intervals `1m 2m 5m 15m 30m 1h 1d 1wk 1mo` (`1m` covers at most `5d`, `2m` at most `1mo`) | | `stocks intraday [--interval 1m] [--limit 15]` | The latest session by the minute (today's while the market trades, otherwise the last one): OHLCV bars, the quote with its previous close and change, and a summary of the bars with VWAP computed by idx. Yahoo's chart, no login, about 10 minutes behind the market; works for IHSG (`^JKSE`) too. `--provider stockbit` makes the bars realtime | | `stocks profile ` | Sector, industry, description, size, headquarters, management | | `stocks financials [--period annual\|quarterly\|ttm] [--statement income,balance,cash]` | Income statement, balance sheet, cash flow; `--statement` (comma-separated or repeated, `all` by default) picks the tables shown | | `stocks earnings [--annual\|--quarterly] [--reported\|--estimates]` | Quarterly EPS vs estimates with surprise, yearly and quarterly revenue/earnings, analyst consensus for this and next quarter and fiscal year, next earnings date; the flags keep only those rows in every format (`--history`/`--forecast` are aliases) | | `stocks dividends ` | Every cash dividend (ex-date, split-adjusted amount) and split; yearly totals, TTM total and yield, payout streak | | `stocks returns [--benchmark ^JKSE]` | 1w/1m/3m/6m/YTD/1y/3y/5y returns, CAGR, volatility, max drawdown, best/worst day, 52-week position, and excess over IHSG | | `stocks brief ` | One page: quote, verdict, signal, returns vs IHSG, dividends, top holders, headlines, with plain-language key points | | `stocks fundamental ` | P/E, P/B, PEG, EV/EBITDA, ROE, ROA, margins, leverage, growth, dividend, analyst targets, plus a plain-language verdict that lists every rule it applied (negative equity counts against a company; multiples that mix USD statements with an IDR price are marked and not scored) | | `stocks growth ` | Growth lens: latest-quarter revenue and earnings growth, forward EPS against trailing, and revenue and net income across the annual statements (CAGR, how often revenue grew, a turn from profit to loss); every rule with its points, every skipped rule with the reason | | `stocks valuation ` | Valuation lens: P/E, PEG, P/B, P/S, EV/EBITDA, dividend yield and the analyst target (3 or more analysts), each scored; multiples that mix USD statements with an IDR price are shown, not scored | | `stocks risk ` | Risk lens: negative equity, debt/equity, current ratio, losses, and beta, volatility and largest fall against IHSG over a year of daily closes (computed by idx, not the source's beta). `stocks fundamental --lens growth\|valuation\|risk` gives the same three readings | | `stocks technical [--period 1y]` | RSI, MACD, SMA/EMA 20/50/200, Bollinger, ATR, volume, support/resistance, trend, buy/sell/neutral signal with reasons | | `stocks compare ` | Side-by-side fundamentals for two or more tickers | | `stocks screen [--preset P] [-f "pe<15,roe>15,sector=financial"] [--sort KEY] [--asc\|--desc] [--returns]` | Screener over ~950 IDX tickers: one batch quote for valuation/size/dividend keys and `range` (position in the 52-week range), one request for MSN's whole-market list for the return keys `ret1w ret1m ret3m ret6m retytd ret1y`, per-company fundamentals for the rest (capped, largest first). Presets defined by idx: `top-performers`, `worst-performers`, `high-dividend`, `low-pe`, `52w-high`, `52w-low`, `high-volume`, `large-cap`. A P/B over statements in another currency than the price (USD reporters such as BSSR) is marked with `*`, and `pb` and `ps` filters and sorts read such P/B and P/S as missing | | `stocks news [SYMBOL] [--company-only] [--source kontan\|cnbcindonesia\|all]` | Headlines from Kontan and CNBC Indonesia RSS. With a ticker, the company's headlines come first, then market context fills the limit | | `stocks sentiment ` | Aggregate lexicon sentiment over the matching headlines, with counts, terms and the strongest headlines | | `stocks insights [--all]` | MSN Money's rule-based statements (about 30, in Valuation, Earnings, Growth, Performance, Health), each rated good, bad, neutral or not rated against the peers, the industry, the company's own history or the price, with the peer tickers | | `stocks actions [--since D]` | Corporate actions from IPOT, upcoming ones included: cash and stock dividends, splits, bonus shares, rights issues (amount, ratio old:new, cum, ex, recording and payment dates, status, announcement), shareholders' meetings (RUPS: time, venue, agenda), listed warrants and rights | | `stocks foreign [--days 20 \| --from D --to D] [--source idx\|ipot]` | Foreign buying and selling per session, totals and the net-buy/net-sell streak: from idx.co.id by default (shares on the regular board, as IDX publishes them, net and net × close), or from IPOT with `--source ipot` (rupiah across all boards, value traded and foreign share; works where idx.co.id refuses the connection) | | `stocks list [--board B] [--search S]` | Listed stocks from idx.co.id with listing board, listing date and shares; the bundled universe stands in offline | | `ownership import --latest\|--all [--family composition]\|--file F\|--url U` | Import KSEI's monthly ">1 % holders" register (XLSX or PDF) into local SQLite. `--latest`/`--all` read idx.co.id and fall back to the CI-maintained GitHub mirror. `--family composition`, or a ZIP/TXT given to `--file`/`--url`, imports KSEI's composition archive into its own tables | | `ownership sync [--force]` · `ownership status` | Install the prebuilt SQLite snapshot CI builds from the mirrored files (one download, SHA-256 checked), then any newer month; a database holding months the snapshot lacks is kept unless `--force` (a `.bak` copy is kept). `status` shows the local database, the mirror and what sync would do | | `ownership ticker [--as-of D]` | Holders above 1 % of a ticker (a holder's accounts summed; controller/affiliate flag from the 30 Oct 2026 register) | | `ownership entity "" [--as-of D] [--exact]` | Every position of a person or institution, spelling variants grouped (`--exact`: one holder by its key) | | `ownership search "" [--limit N] [--as-of D]` | Find holders by name in every imported month: every spelling KSEI printed, words in any order, with the key for `entity --exact` | | `ownership cross-holders [--min-issuers N] [--type T] [--origin foreign\|local]` | Holders ranked by the number of issuers they own more than 1 % of, with every position | | `ownership concentration [SYMBOLS] [--by hhi\|top1\|top3] [--ascending]` | Issuers ranked by disclosed ownership concentration: HHI over the listed holders, largest and top-3 stakes, undisclosed percent, controller stake | | `ownership graph [--depth N] [--min-pct P] [--max-nodes N] [--dot]` | Holder-issuer network around a ticker or holder, as a table, JSON or Graphviz DOT | | `ownership resolve candidates\|merge\|map\|list\|undo` | Suggest, merge and split holder spellings KSEI printed differently between months (rules stored in the local database, undoable) | | `broker [--date D \| --from D --to D] [--investor all\|foreign\|domestic] [--board all\|regular\|cash\|negotiated] [--source auto\|ipot\|stockbit]` | Broker summary: which brokers bought and sold a ticker, ranked by lots, with broker name, class (local, foreign, BUMN), lots, value and average price, plus total value and foreign net (for one investor side, what it bought and sold). IPOT publishes the top 10 per side; once `idx stockbit login` has stored a working session, `auto` (the default) lists every broker from Stockbit instead, and JSON says how many brokers each side had and whether its list is complete. Latest session with trades by default; `-o csv` gives one row per broker | | `bandar detect [--period 1d\|1w\|1m\|3m] [--top 1\|3\|5\|10] [--daily]` | Bandarmology: are the top brokers accumulating or distributing? The top 1/3/5/10 buyers against as many sellers (bandar value and volume), net as a share of the value traded, the phase against the price move, the top buyers' average against the price, foreign flow; `--daily` adds the bandar movement session by session. See [Bandarmology](https://ihsg.midoriresearch.com/docs/index.md#bandarmology) | | `bandar screen [--preset P] [-f FILTER] [--period P] [--scan N]` | Bandarmology screener over the 100 most traded stocks by value: presets `big-accumulation`, `accumulation`, `distribution`, `big-distribution`, `quiet-accumulation`, `quiet-distribution`, `foreign-accumulation`, `below-bandar`, or filters on `acc`, `net`, `fnet`, `fpct`, `gain`, `chg`, `turnover`, `conc`, `signal`, `phase` | | `bandar broker [--period P] [--scan N \| --symbols S]` | Broker activity: where one broker (e.g. `YP`) sits among the top 10 buyers or sellers of the 100 most traded stocks, with rank, lots, value, average, share of the stock's value and net where both sides are visible | | `market overview [--top 10]` | IHSG, breadth, turnover, gainers, losers, most traded, heavyweights from one pass over the universe | | `market foreign [--date D] [--top 10] [--sort value\|shares] [--source idx\|ipot]` | Top foreign net buying and net selling across every stock for one session (idx.co.id, in shares), with the market's foreign totals; `--source ipot` ranks the 100 most traded stocks (not every stock) by each one's foreign net in rupiah from IPOT's broker summaries, for the latest session IPOT has published, with the market's value traded and foreign buying, selling and net from IPOT's market statistics (its own shape, `idx.market.foreign.value.v1`) | | `market brokers [--date D] [--top 20] [--sort value\|volume\|frequency] [--source auto\|idx\|ipot]` | Every exchange member's trading for one session, ranked, with each member's share of all members' value: from idx.co.id, or from IPOT when idx.co.id refuses, cannot be reached, fails or has not published the session yet and IPOT holds that session (`auto`, the default), or with `--source ipot` (its latest session), which adds each member's class and net to the table, and its value bought and sold to `-o json`, `csv` and `jsonl` | | `market indices [CODE...] [--date D]` | Every IDX index (IHSG, LQ45, IDX30, sector and thematic) for one session: close, change, range, constituents, value, market cap | | `market calendar [--from D --to D \| --date D] [--symbol S] [--type T]` | The exchange calendar: dividend and rights dates (cum, ex, recording, payment), RUPS, public exposes, listings; bonds and buyback reports with `--type all`. Default: the next 14 days | | `market holidays [--year Y \| --all] [--upcoming]` | Trading holidays (Kalender Libur Bursa, bundled), the year's trading days, whether today is a trading day and the next one | | `market astro [--date D] [--days N] [--natal launch\|base\|none] [--backtest [--period P]]` | Traditional astrology for IHSG, computed offline: the Sun, Moon and planets (sign, degree, speed, retrograde) at 16:00 WIB, Moon phase, Mercury retrograde, Javanese weton, Chinese shio, phases, stations and ingresses ahead, aspects to IHSG's chart; `--backtest` tests them on IHSG's daily returns. See [Astrology](https://ihsg.midoriresearch.com/docs/index.md#astrology) | | `market members [--search S]` | Exchange members (broker codes) with names and licences | | `ownership changes --from D --to D [--symbol S] [--min-change PP] [--limit N --offset N]` | New holders, exits and position changes between two months, paged, with a summary (counts, foreign vs local net, by investor type, issuer flow, top moves) over every match | | `ownership foreign [SYMBOL] [--from D --to D]` | Disclosed foreign (KSEI `A`) vs local stake: one issuer month by month, or every issuer's change between two months | | `ownership composition [--as-of D]` | KSEI's monthly "Kepemilikan Efek" archive: local vs foreign shares in nine investor types, as a percent of the scripless shares, with the scripless share of listed shares and the trend over imported months. Downloads the newest month on first use | | `ownership institutions ` | Institutional and fund holders from MSN Money (live, no import): shares, % of shares outstanding, the change since each holder's last report and a `new`/`buyer`/`seller`/`exited`/`unchanged` signal, with institutional, fund and free-float percentages and counts per list. See [docs/ownership.md](https://ihsg.midoriresearch.com/docs/ownership.md#institutional-and-fund-holders-msn) | | `stockbit login [--page [--port N]] \| status \| logout` | Opt-in: store your own Stockbit session, signed in in a browser idx opens or, over SSH and for Google sign-in, through a page on 127.0.0.1 for your own browser (idx never sees the password; every session is checked before it is stored); check when its tokens expire; delete it. See [docs/stockbit.md](https://ihsg.midoriresearch.com/docs/stockbit.md) | | `stockbit orderbook ` | The live depth ladder and session foreign flow (your Stockbit session) | | `stockbit actions [--kind K] [--from D --to D]` · `stockbit calendar [--from D --to D \| --days N] [--kind K]` | Corporate actions from your Stockbit session, one structured row each: a stock's whole history (dividends since 2009 for BBCA, splits, bonus, rights, warrants, RUPS, public exposes, tender offers), or the market's calendar with rights, tender and IPO offers still open | | `stockbit movements [SYMBOL] [--from D --to D] [--holder ID] [--action A] [--source idx\|ksei] [--pages N]` | Insider and 5 % holder movements from your Stockbit session: IDX insider disclosures (with the trade price) and KSEI's 5 % holder changes (Stockbit's estimated price kept apart), shares exact and signed | | `watch [add\|rm\|list\|clear\|returns]` | Plain-text watchlist next to the config; `idx watch` quotes it | | `ownership releases\|discover [--family F --limit N]\|path` | Imported months, files published on idx.co.id and ksei.co.id (families `above1`, `above5`, `investor-type`, `classification`, `composition`, `all`, each marked `importable`), database path | | `mcp [--http ADDR --token T]` · `mcp setup ` | MCP server over stdio or streamable HTTP, and the registration recipe for 25 agent harnesses. 47 tools: `idx_brief`, `idx_market`, `idx_quote`, `idx_history`, `idx_intraday`, `idx_profile`, `idx_financials`, `idx_earnings`, `idx_dividends`, `idx_returns`, `idx_fundamental`, `idx_technical`, `idx_compare`, `idx_screen`, `idx_news`, `idx_sentiment`, `idx_insights`, `idx_corporate_actions`, `idx_foreign`, `idx_instruments`, `idx_market_foreign`, `idx_market_brokers`, `idx_indices`, `idx_calendar`, `idx_holidays`, `idx_astro`, `idx_members`, `idx_broker`, `idx_bandar`, `idx_bandar_screen`, `idx_bandar_broker`, `idx_ownership_ticker`, `idx_ownership_entity`, `idx_ownership_changes`, `idx_ownership_foreign`, `idx_ownership_releases`, `idx_ownership_search`, `idx_ownership_rankings`, `idx_ownership_graph`, `idx_ownership_composition`, `idx_ownership_snapshot`, `idx_ownership_institutions`, `idx_orderbook`, `idx_stockbit_status`, `idx_stockbit_login`, `idx_stockbit_actions`, `idx_stockbit_movements` | | `schema [name]` | JSON Schema for every payload (`quotes`, `history`, `intraday`, `profile`, `fundamentals`, `growth`, `valuation`, `risk`, `compare`, `financials`, `earnings`, `dividends`, `returns`, `market`, `brief`, `technical`, `screen`, `news`, `sentiment`, `insights`, `actions`, `foreign`, `foreign-value`, `instruments`, `market-foreign`, `market-brokers`, `indices`, `calendar`, `holidays`, `astro`, `members`, `broker`, `bandar`, `bandar-screen`, `bandar-broker`, `ownership-*`, `stockbit-*`, `watchlist`, `error`, ...) | | `config init\|set\|get\|path` | Config file management | | `cache stats\|clear\|path` | Response cache management | | `completions ` | bash, zsh, fish, powershell, elvish | | `version` | Version, build and default paths | Global flags work anywhere on the line: | flag | env | meaning | | --- | --- | --- | | `-o, --output table\|json\|jsonl\|csv\|auto` | `IDX_OUTPUT` | Output format: tables for people, the JSON envelope for programs, one JSON record per line (`jsonl`), CSV rows of the payload's main list for spreadsheets; `auto` is a table on a terminal and the JSON envelope when piped (opt-in: the default stays `table`) | | `--provider auto\|yahoo\|msn\|stockbit` | `IDX_PROVIDER` | Data source (`auto` tries Yahoo, then MSN, and merges fundamentals; `stockbit` is opt-in realtime from your own session, see [docs/stockbit.md](https://ihsg.midoriresearch.com/docs/stockbit.md)) | | `--history-provider auto\|yahoo\|stockbit` | `IDX_HISTORY_PROVIDER` | Where candles come from; `auto` follows `--provider` (Yahoo for `msn`; under `stockbit`, Stockbit where its bars match the request and Yahoo otherwise or when Stockbit fails). See [docs/configuration.md](https://ihsg.midoriresearch.com/docs/configuration.md#history-provider) | | `--offline` | `IDX_OFFLINE=1` | Never touch the network | | `--no-cache` | `IDX_NO_CACHE=1` | Ignore cached responses | | `--timeout SECS` | `IDX_TIMEOUT_SECS` | Per-request timeout (default 15) | | `--no-color` | `NO_COLOR` | Plain tables | | `-v, -vv, -vvv` | `IDX_LOG` | Diagnostics on stderr, including every URL requested | | `-q, --quiet` | `IDX_QUIET=1` | stderr carries errors only: no warnings, notes or loading line. stdout is unchanged and `-o json` keeps `warnings` in the envelope | | | `IDX_EXCHANGE=SI` | Yahoo Finance suffix a bare ticker gets in `stocks quote`, `history`, `profile`, `financials`, `earnings`, `dividends`, `returns`, `fundamental`, `technical`, `compare` and `watch` (default `JK`, IDX). An explicit suffix (`D05.SI`) always works there; every other command reads IDX tickers only | | | `IDX_PROGRESS=never\|always` | The loading line on stderr (`⠹ 6s · waiting on investasi.kontan.co.id 6s`) shows on a terminal only; `never` hides it, `always` forces it | ## For scripts and agents ```sh idx -o json stocks history TLKM --period 1y --interval 1wk \ | jq '.data.summary.change_pct' idx -o jsonl market holidays --year 2026 | jq -r .date # one record per line idx -o jsonl stocks screen -f 'pe<10,roe>15' -n 5 | idx stocks compare - echo BBCA BBRI | idx stocks quote - # tickers from stdin idx -q -o csv stocks quote BBCA BBRI > quotes.csv # stderr: errors only ``` - stdout carries exactly one JSON document (`-o json`) or one JSON record per line (`-o jsonl`); everything else goes to stderr. Errors are one JSON line on stderr in both modes. - `-o auto` (or `IDX_OUTPUT=auto`) prints a table on a terminal and the JSON envelope when stdout is piped or captured. - `-` in a ticker list (`stocks quote|returns|compare`, `broker`, `bandar`, `watch add|rm`) reads tickers from stdin: plain text (spaces, commas or lines, `#` comments) or idx's own `-o json` / `-o jsonl` output. - The envelope records `provider`, `fetched_at`, `cached`, `stale`, `stale_reason` (`offline` or `fetch_failed`, only when stale) and `warnings`. - Fields the source did not return are `null`. Nothing is estimated. - Quotes carry `behind_session`: `true` when the last trade is dated before the latest IDX session that has ended (a suspended stock, one that did not trade, or a source that lags the close), using the bundled holiday calendar. - Exit codes: `0` ok · `2` usage · `3` network/provider (JSON code `blocked` when the source's bot protection, Cloudflare, refused the request) · `4` not found · `5` offline cache miss · `6` config. Full field reference: [docs/json-output.md](https://ihsg.midoriresearch.com/docs/json-output.md). Configuration and cache: [docs/configuration.md](https://ihsg.midoriresearch.com/docs/configuration.md). Calling idx from an agent (rules, exit codes, recipes, an agent-led install): [docs/agent-guide.md](https://ihsg.midoriresearch.com/docs/agent-guide.md). ### MCP server `idx mcp` serves the Model Context Protocol over stdio, which is what every local agent launches. `idx mcp --http` serves the same tools over streamable HTTP for products that only connect to a URL. `idx mcp setup ` prints the exact command or config snippet for your agent with the absolute path of the binary filled in, because GUI apps rarely inherit your shell `PATH`: ```sh idx mcp setup --list # every supported client idx mcp setup codex # prints: codex mcp add idx -- /abs/path/idx mcp idx mcp setup muse # prints the settings.json block Meta's Muse Code expects ``` | agent | register idx | | --- | --- | | Claude Code | `claude mcp add --scope user idx -- idx mcp` | | Claude Desktop | `claude_desktop_config.json` → `"mcpServers": {"idx": {"command": "idx", "args": ["mcp"]}}` | | OpenAI Codex (CLI, IDE extension, app) | `codex mcp add idx -- idx mcp`, or `[mcp_servers.idx]` with `command`/`args` in `~/.codex/config.toml` | | Meta Muse Code | `~/.config/muse/settings.json`: `{"schema_version": 1, "mcp_servers": {"idx": {"transport": "stdio", "command": "idx", "args": ["mcp"], "mode": "optional"}}}` | | Google Antigravity (IDE, CLI) | `~/.gemini/config/mcp_config.json` → `mcpServers.idx` with an absolute `command` and no `type` field | | Gemini CLI | `gemini mcp add -s user idx idx mcp` | | GitHub Copilot CLI | `copilot mcp add idx -- idx mcp` | | VS Code (Copilot agent mode) | `code --add-mcp '{"name":"idx","command":"idx","args":["mcp"]}'`, or `.vscode/mcp.json` → `servers.idx` with `"type": "stdio"` | | Cursor · Windsurf · JetBrains · Warp · Trae · Augment | paste `{"mcpServers": {"idx": {"command": "idx", "args": ["mcp"]}}}` into the MCP settings (file paths in `idx mcp setup`) | | Kiro (IDE and CLI, formerly Amazon Q Developer CLI) | `~/.kiro/settings/mcp.json`, or `kiro-cli mcp add --name idx --command idx --args mcp` | | Zed | `settings.json` → `"context_servers": {"idx": {"command": "idx", "args": ["mcp"], "env": {}}}` | | Cline · Roo Code · Kilo Code | MCP Servers → Configure → `mcpServers.idx` with `"type": "stdio"` | | Continue | `~/.continue/mcpServers/idx.yaml` (`idx mcp setup continue` prints it) | | OpenCode | `opencode.json` → `"mcp": {"idx": {"type": "local", "command": ["idx", "mcp"], "enabled": true}}` | | Goose | `goose configure` → Add Extension → Command-line Extension → `idx mcp` | | Factory Droid | `droid mcp add idx "idx mcp"` | | Mistral Vibe | `~/.vibe/config.toml` → `[[mcp_servers]]` with `name = "idx"`, `transport = "stdio"`, `command`, `args` | | Qwen Code | `qwen mcp add idx idx mcp` | | Crush | `crush.json` → `"mcp": {"idx": {"type": "stdio", "command": "idx", "args": ["mcp"]}}` | | ChatGPT · Claude.ai · Perplexity · Devin · Manus | remote only: `idx mcp --http 127.0.0.1:8765 --token T --allowed-host '*'` behind an HTTPS tunnel, then add `https:///mcp` as a connector with header `Authorization: Bearer T` | A bare `idx` in the table assumes the binary is on `PATH`; `idx mcp setup` substitutes the absolute path, and `--npx` swaps in `npx -y ihsg-mcp`: the npm package [`ihsg-mcp`](https://github.com/fzn0x/idx/blob/main/npm-mcp/README.md) runs `idx mcp` without installing idx, fetching the release binary on first start and checking it against the release's `SHA256SUMS` (`cmd /c npx -y ihsg-mcp` on Windows; from this repository `npx -y github:fzn0x/idx mcp` still works). Each entry's remote (HTTP) form and the config file locations are in [docs/mcp.md](https://ihsg.midoriresearch.com/docs/mcp.md). ```sh claude mcp add --scope user idx -- npx -y ihsg-mcp # no idx install needed (Node 18+) npx -y ihsg-mcp setup cursor # any client's recipe, in the npx form ``` The server lists 47 tools with typed input schemas kept to the subset every harness accepts (no nullable type arrays, no `format` or `default` keywords) and one-paragraph descriptions. `idx_brief` is the one-call starting point; `idx_fundamental` takes an optional `lens` (`growth`, `valuation` or `risk`) for the focused readings of `stocks growth|valuation|risk`; each tool returns the same envelope as `-o json`, so an agent that learned the CLI already knows the tools. Failures come back as tool errors carrying `{error: {code, message}, exit_code}`. ## Bandarmology "Bandar" is market slang for the big, concentrated money that moves a stock. `idx bandar` reads it from the IPOT broker summary, whose top 10 buyers and top 10 sellers are ranked by lots. For N = 1, 3, 5 and 10 the top N buyers' value minus the top N sellers' value is the bandar value (in lots, the bandar volume); its share of all value traded is the reading, with the headline on the top 5 (`--top`): | Top-N net, % of value | Signal | Phase when the price over the range is... | |---|---|---| | +15 or more | `big_accumulation` | up more than 2 %: `markup`; otherwise `quiet_accumulation` | | +5 to +15 | `accumulation` | as above | | -5 to +5 | `neutral` | `neutral` | | -15 to -5 | `distribution` | down more than 2 %: `markdown`; otherwise `quiet_distribution` | | -15 or less | `big_distribution` | as above | Quiet accumulation is the usual "bandar buying quietly": concentrated buying while the price stalls or falls. Each reading also gives the top buyers' average price against the last price (`bandar_gain_pct`: below zero they sit under water), how much of the value the top buyers took, the foreign net flow, and the rules in plain words (`reasons`). `--daily` adds the session series with its cumulative net and streak. The thresholds were set against the 40 most traded stocks in October 2026, where about half of single sessions read neutral. They are heuristics, not advice. ```sh idx bandar detect BBCA # last week, top 5 vs top 5 idx bandar detect BBRI --period 1m --daily # with the bandar movement idx bandar screen --preset quiet-accumulation # buying while the price stalls idx bandar screen -f 'acc>=5,fpct>=5,gain<0' # with foreigners, under the bandar's average ``` ## Astrology `idx market astro` is traditional astrology done carefully: the positions are astronomy, computed on this machine from JPL's Keplerian elements (Standish), Schlyter's Jupiter-Saturn terms and Meeus's lunar theory, and checked in the tests against JPL Horizons (Sun and Moon within 0.01°, planets within 0.04°, 1800-2050) and the USNO moon phases (within 2 minutes). The Javanese weton (day, pasaran, neptu) and the Chinese shio (Imlek computed from new moons and solar terms, checked against the published dates 1924-2050) come with it, and IHSG's own chart is cast for noon WIB on its launch (1983-04-01) or base date (1982-08-10, value 100), dates from secondary sources (Antara, Bisnis.com, CEIC) not yet confirmed against an IDX publication. `--backtest` asks whether any of it shows up in the index: IHSG's daily returns grouped by lunar phase, Mercury retrograde or direct, pasaran and Sun sign, each group tested against all other days (Welch's t, Bonferroni over the 27 groups). On every session from 1990-04-09 to 2026-11-04 (8,904 returns) no group passes Bonferroni; the nominal p < 0.05 groups (full moon -0.07 % a day, Capricorn +0.19 %, Pisces -0.10 %, Leo -0.09 %) are the lunar-effect and month-of-year patterns the finance literature already knows, since Sun signs follow the calendar. v1 prints no interpretations; it is not a forecast or investment advice. ```sh idx market astro # today: planets, Moon, Mercury, weton, shio idx market astro --date 2026-12-24 --days 7 idx market astro --backtest # every session since 1990 idx -o json market astro --backtest --period 10y | jq '.data.backtest.factors[0]' ``` ## Data sources | source | used for | auth | notes | | --- | --- | --- | --- | | Yahoo Finance `v8/finance/chart` | history, intraday minute bars, quote fallback | none | IDX tickers carry the `.JK` suffix; another market's suffix (`D05.SI`) passes through for the Yahoo-backed commands. One-minute bars for up to 8 days per request and two-minute bars for the last 60 days (HTTP 422 beyond); minutes without a trade have no bar and the opening auction's volume is in no bar. `range=max` comes back monthly, so `--period max` with daily or weekly bars asks for `period1=0&period2=9999999999` | | Yahoo Finance `v7/finance/quote` | quotes | anonymous cookie + crumb, cached 6 h | Falls back to the chart endpoint (fewer fields) if rejected | | Yahoo Finance `v10/finance/quoteSummary` | profile, fundamentals, earnings | same session | Statement modules are empty at the source, so they are not used | | Yahoo Finance `fundamentals-timeseries` | financials | none | Annual, quarterly and TTM statement items | | idx.co.id "Data Kepemilikan Saham" page + `Media/*.xlsx` | ownership (KSEI monthly register) | none, but Cloudflare bot protection | Mirrored three times a day by GitHub Actions to the [`ownership-data`](https://github.com/fzn0x/idx/releases/tag/ownership-data) release (byte-identical files, SHA-256 in `index.json`). The CLI tries idx.co.id first, then the mirror; a Cloudflare refusal is the JSON error code `blocked`; `idx ownership sync` installs the prebuilt SQLite snapshot published next to the files. See [docs/ownership.md](https://ihsg.midoriresearch.com/docs/ownership.md) | | ksei.co.id "Kepemilikan Efek" page (`id/publikasi/data-dan-statistik/kepemilikan-efek`) + `storage/Download/BalanceposEfek*.zip` | ownership composition (local vs foreign by investor type, monthly) | none, no bot protection | One ZIP a month (about 125 KB, one pipe-separated TXT), scripless holdings only. The listing is cached 12 h; archives go into the local ownership database. CLI and MCP. See [docs/ownership.md](https://ihsg.midoriresearch.com/docs/ownership.md#ksei-composition-kepemilikan-efek) | | Indo Premier (IPOT) `module/saham/include/data-brokersummary.php` | broker summary, foreign flow (`--source ipot`) | none | Top 10 buyers and sellers per range, ranked by lots; figures of a million and more are rounded to one decimal by the source (`rounded: true`). Its nginx answers spurious 404s in bursts, so a 404 is retried with backoff. Over a range that reaches its latest session the source's foreign net counts that session twice, so idx rebuilds a range's foreign net from single sessions. Broker names come from IDX's member list, bundled as `data/brokers.csv`; `bandar` computes its readings from these summaries | | idx.co.id JSON endpoints behind its pages (`primary/TradingSummary/GetStockSummary`, `GetBrokerSummary`, `GetIndexSummary`, `ListedCompany/GetTradingInfoSS`, `Home/GetCalendar`, `StockData/GetSecuritiesStock`, `ExchangeMember/GetBrokerSearch`) | foreign flow, member activity, indices, the exchange calendar, listed stocks, members | none, but Cloudflare bot protection | Foreign trading is published in shares only; `foreign_net_value_at_close` (net shares × close) is a rupiah scale, not the traded value. The bot protection now and then refuses a request from any network, and data centres and CI runners more often: a challenged request is asked again for 30 s (under `market brokers --source auto`, only when IPOT cannot stand in), then a cached copy is served marked stale, and with none the `blocked` error says what to do. Closed sessions are cached for 30 days | | Indo Premier (IPOT) `module/saham/include/corporateaction.php` | corporate actions, RUPS, warrants and rights | none | Same module and spurious-404 retry as the broker summary; repeated rows are dropped | | Indo Premier (IPOT) `module/saham/include/ma-topbroker.php`, dated by `statistic.php?code=BBCA` | member activity (`market brokers --source ipot`, and `auto` when idx.co.id refuses, cannot be reached, fails or has not published the session; the web terminal's `brokers`) | none | Every broker of IPOT's latest session, ranked by value, with value bought, sold and net, shares, trades and class. The page takes no date, so past sessions cannot be asked for: the session comes from a second page, the `Today` row of BBCA's statistics, read together with the ranking. Figures of a million and more are abbreviated to two decimals (`rounded: true`); same spurious-404 retry. When the two pages roll over to a new session has not been measured, so a ranking read on a trading day from 08:58 to 19:00 WIB is refused rather than given a date it may not have, and in those hours IPOT is not asked unless a copy read outside them is cached. Until it is measured, both pages are assumed to have rolled over by 19:00 WIB: a date page naming an older session than the latest that has ended is refused (unless the pair is the one accepted for that session before), and a ranking that has not rolled over by then while its date page has is caught only when this cache remembers the previous session's ranking, from an earlier IPOT read. A broker IPOT lists apart under another member's code (`JB`, `[CC] BJB Sekuritas Jawa Barat`) is counted in that member, as idx.co.id counts it. On 2026-11-06 the two sources listed the same 88 members with trades and the same trades for each; values and volumes agreed within about one unit of IPOT's second decimal | | Indo Premier (IPOT) `module/saham/include/ma-marketstatistic.php`, dated by `statistic.php?code=BBCA` | the market's foreign totals in rupiah (`market foreign --source ipot`) | none | IPOT's latest session summed up by board (regular, negotiated, cash, total) and by foreign investors' side (bought, sold, net), in value, lots and trades; figures of a million and more are abbreviated to two decimals. Like the broker ranking it takes no date and is dated by BBCA's statistics page under the same rules (refused on a trading day from 08:58 to 19:00 WIB), with its own copy of that page. For 2026-11-06 its board values matched idx.co.id's; it does not say which boards its foreign figures cover, and its foreign lots were above idx.co.id's regular-board foreign shares | | computed on this machine: JPL Keplerian elements (Standish, Table 1; Pluto from JPL's archived table), Schlyter (Jupiter, Saturn), Meeus (Moon, nutation, aberration) | `market astro` | none | Checked against JPL Horizons and USNO recordings in `tests/fixtures/astro/`; the `--backtest` reads `^JKSE` daily history from Yahoo's chart endpoint | | idx.co.id trading-holiday calendar (published as an image per year) | trading holidays | none | Transcribed into `data/holidays.csv` (2026, 2027) and checked against IDX's monthly trading-day counts; `IDX_HOLIDAYS` overrides it | | Kontan RSS (`investasi`, `keuangan`, `industri`, `insight`) and CNBC Indonesia RSS (`market`, `research`, `news`) | news, sentiment | none | Bisnis.com refuses non-browser clients; listed as an opt-in source | | MSN Money `Finance/Quotes`, `Finance/Equities` | quotes, profile, fundamentals, analyst targets, institutional and fund holders (`ownershipSummary`) | MSN's own public web key (shipped; override with `IDX_MSN_API_KEY`) | Tickers resolve to MSN ids through its autosuggest service, filtered to exchange `XIDX` | | MSN Money `pages/finance/insights` (api.msn.com) | stocks insights | same public web key | Rule-based statements, refreshed about once a day (cached for the fundamentals TTL, 6 h by default); peers resolve to tickers through `Finance/Quotes`. MSN's crowd sentiment is not used: it has no data for IDX tickers | | MSN Money `Finance/Screener` (POST, whole Indonesian market) | screen return windows (`ret1w`..`ret1y`) | same public web key | One request, ~887 stocks, cached 15 minutes; values as MSN reports them. MSN's own preset lists are not used (stale or empty for IDX) | | Stockbit `exodus.stockbit.com` (`--provider stockbit`, opt-in) | realtime quote, history, live order book + foreign flow; corporate actions and calendar; insider and 5 % holder movements; the full broker summary (`broker`, by default once logged in) | **your own Stockbit session**, taken in by `idx stockbit login` (a browser or a local page; never the password) and stored locally | The one source that needs a credential and the one not in the `auto` provider chain; `broker --source auto` reads it only while a stored session works. Volume is realtime; the order book carries the full depth ladder. Foreign flow is left empty, with a warning, while Stockbit marks it as not current (seen during trading). Candles alone with `--history-provider stockbit`. The broker summary (`/marketdetectors/`, gross figures) lists every broker that bought and sold, exact; ordered by lots, its first ten were IPOT's top 10 on the day and the range recorded, and a second read of foreign investors gives the foreign net. Not read for domestic investors or the cash and negotiated boards yet. See [docs/stockbit.md](https://ihsg.midoriresearch.com/docs/stockbit.md) | Prices from the free sources are delayed as published; Stockbit is realtime for your own account. This tool is for research and tooling; it is not investment advice and not affiliated with IDX, KSEI, Yahoo, Microsoft or Stockbit. ## Web terminal **https://ihsg.midoriresearch.com** is the market page: IHSG and its session, breadth, the heavyweights and the most active stocks, refreshed every minute, with a stock search. It speaks Bahasa Indonesia at `/` and English at `/en/`, with the numbers in each language's style, and follows the device's light or dark setting. **https://ihsg.midoriresearch.com/terminal** (English: `/en/terminal`) runs the same commands in a browser, no install: `brief BBCA`, `quote BBCA,BBRI`, `intraday BBCA`, `returns BBCA BBRI`, `screen pe<10,div>5,mcap>5T --sort div`, `screen --preset top-performers`, `insights BBCA`, `news ASII`, `ownership ticker BBCA`, `ownership foreign`, `bandar BBCA --daily`, `bandar screen --preset big-accumulation`, `bandar broker YP`, `actions BBCA`, `foreign BBCA` (foreign flow from IPOT in rupiah, up to 20 sessions), `holidays --upcoming`, `astro`, `list --search bank`, `members`, `risk TLKM` (also `growth` and `valuation`), `brokers` (`market brokers` from IPOT's ranking of its latest session, with each member's class and net), `market foreign` (`market foreign --source ipot`: the session's 100 most traded stocks by their foreign net in rupiah, from the nightly bandar scan, with IPOT's market totals), and `tools` (every MCP tool as a button). The idx.co.id commands (market-wide foreign flow in shares, the calendar, indices, and `market brokers --source idx`) are CLI and MCP only, because idx.co.id refuses requests from Cloudflare Workers; so, for now, is `ownership institutions`, until MSN is checked from the Worker. Typed into the terminal, any of these, and the CLI-only commands (`watch`, `mcp`, `ownership import`, ...), answer with a notice instead of an error: where the command runs and why, its CLI line with your own arguments, and the nearest command the page has. A line pasted from the CLI, such as `idx stocks quote BBCA` or `idx -o json market overview`, runs as the page's own command. `quote` with one symbol also draws a price chart (1D, 5D, 1M, 6M, YTD, 1Y, 5Y) with volume, the previous close and a crosshair readout. The web `bandar screen` and `bandar broker` read a scan the Worker runs after each close, once IPOT publishes the session's broker lists, over the session's 100 most traded stocks; their warnings say when an older session is shown. The CLI scans live. A Cloudflare Worker refreshes a D1 snapshot of the whole IDX board every minute in trading hours and serves `/api/*` with the exact JSON envelopes the CLI prints; everything else is fetched on demand and cached. The Worker lives in [`web/`](https://github.com/fzn0x/idx/blob/main/web/) and answers on the custom domain only (`workers_dev = false`, Host check in the Worker). The same site serves this README and `docs/` as **https://ihsg.midoriresearch.com/docs/**, every page also as Markdown at its `.md` address (`/docs/mcp.md`), with [`/llms.txt`](https://ihsg.midoriresearch.com/llms.txt) and `/llms-full.txt` for language models. They are built from the repository at deploy time. ## Ownership data pipeline Nobody imports the KSEI register by hand, and nothing outside the repository can write it: 1. [`.github/workflows/ownership.yml`](https://github.com/fzn0x/idx/blob/main/.github/workflows/ownership.yml) runs at 08:17, 14:17 and 20:17 WIB. It fetches the IDX "Data Kepemilikan Saham" page through a browser-impersonating client, downloads any new or re-published "Pemegang Saham di Atas 1%" workbook, imports every month with `idx` to prove it parses, and publishes the files plus `index.json` and parsed JSON parts to the [`ownership-data`](https://github.com/fzn0x/idx/releases/tag/ownership-data) prerelease. idx.co.id blocks cloud networks, GitHub's own runners included, so the job runs on a self-hosted runner (an always-on machine with an ordinary connection; set the repository variable `OWNERSHIP_RUNNER` to `self-hosted`). On GitHub's runners it rebuilds the parts and the snapshot from the files already mirrored, and turns red once the newest register is 45 days old. From the same database it builds a prebuilt SQLite snapshot (`snapshot-v{V}.json` plus a gzip of the file) and publishes it whenever its content changes, the manifest last. 2. The web terminal's Worker reads that release on its minute cron: it checks `index.json` every 30 minutes, verifies each part's SHA-256 and loads one part per minute into D1. A month appears only once all its rows are in and the count matches. The newest six months are kept. The Worker has no write endpoint at all. 3. `idx ownership sync` on your machine installs that snapshot (a few MB, SHA-256 checked, swapped in as one SQLite transaction) and imports any newer month from the raw files; a database holding months the snapshot lacks is kept unless `--force`. 4. `idx ownership import --latest` tries idx.co.id and, when Cloudflare refuses it, downloads the same file from the mirror and checks its SHA-256. `IDX_OWNERSHIP_SOURCE=mirror` skips idx.co.id. ## Development ```sh cargo build cargo test # recorded fixtures, no network cargo clippy --all-targets -- -D warnings ./scripts/smoke.sh # live providers ``` Architecture, conventions and "how to add a provider" live in [AGENTS.md](https://github.com/fzn0x/idx/blob/main/AGENTS.md). Contributions: [CONTRIBUTING.md](https://github.com/fzn0x/idx/blob/main/CONTRIBUTING.md). ## License [MIT](https://github.com/fzn0x/idx/blob/main/LICENSE) --- Source: https://ihsg.midoriresearch.com/docs/agent-guide.md # Using idx from an agent This page is for AI agents and the people who set them up. idx answers questions about stocks listed on the Indonesia Stock Exchange (IDX, the IHSG composite): quotes, history, fundamentals, technicals, dividends, broker activity, the KSEI register of holders above 1 % and news. An agent reaches it two ways: - **MCP.** `idx mcp` serves the commands as typed tools. This suits chat-style agents; [mcp.md](https://ihsg.midoriresearch.com/docs/mcp.md) has the registration for each client. - **The CLI.** For agents that run shell commands: add `-o json` and parse stdout. Both return the same JSON envelope, so what an agent learns from one applies to the other. ## Rules for calling idx - Tickers are IDX codes such as `BBCA` or `TLKM`. The `.JK` suffix is optional; `^JKSE` is the IHSG composite. - Start with `idx stocks brief ` (MCP: `idx_brief`): quote, fundamentals with a verdict, technicals with a signal, returns against IHSG, dividends, top holders and headlines in one call. - With `-o json`, stdout holds exactly one JSON document; diagnostics go to stderr. Parse the JSON rather than the tables. - Check `stale` and `warnings` in the envelope before quoting a number. `stale: true` means a cached copy older than its lifetime was served because the network failed, the source's bot protection refused the request, or `--offline` was set. - A field the source did not publish is `null`. Say the value is unavailable; do not estimate it. - The fundamentals verdict, the technical signal, news sentiment and the bandarmology readings are rule-based heuristics, and each lists the rules that fired. Repeat them as heuristics, not as advice. - Prices from the free sources are delayed as published. - `idx schema ` prints the JSON Schema of a payload; [json-output.md](https://ihsg.midoriresearch.com/docs/json-output.md) documents every field. ## Exit codes | exit | `error.code` | what to do | | --- | --- | --- | | 0 | | success | | 1 | `internal`, `io` | unexpected; report it | | 2 | `usage` | fix the command, a flag or the symbol | | 3 | `network`, `provider`, `blocked` | a source failed or refused; retry later. `blocked`: its bot protection (Cloudflare) refused the request; for idx.co.id's data a challenged request is already retried for 30 s and a cached copy is served instead when there is one, so the error means none was. It usually passes within a minute: retry then, and from another network if it keeps refusing (data centres, VPNs and CI runners are refused more often), or take the alternative the message names (`stocks foreign --source ipot`, `market foreign --source ipot`, `market brokers --source ipot` for IPOT's latest session); for the ownership register, the GitHub mirror | | 4 | `not_found` | the symbol or record does not exist | | 5 | `offline_miss` | `--offline` was set and nothing is cached | | 6 | `config`, `unsupported` | fix the config, or drop `--provider` so the automatic chain answers | Errors are one JSON object on stderr, for example `{"error":{"code":"not_found","message":"..."},"exit_code":4}`. Over MCP the same object comes back as a tool error (`isError: true`). ## Recipes ```sh idx -o json stocks quote BBCA,BBRI,TLKM | jq '.data[] | {symbol, price, change_pct}' idx -o json stocks fundamental BBCA | jq '.data.verdict' idx -o json stocks history TLKM --period 1y --interval 1wk | jq '.data.summary' idx -o json stocks screen -f 'pe<10,div>5,mcap>5T' --sort div idx -o json bandar detect BBRI --period 1m idx -o json ownership ticker BBCA # after the import below ``` The KSEI register of holders above 1 % lives in a local SQLite database. Import it once, and again when a new month is out: ```sh idx ownership import --latest ``` ## Agent-led install A person can hand this section to a coding agent: "Install idx and register its MCP server with yourself by following https://ihsg.midoriresearch.com/docs/agent-guide.md". 1. Do not use `sudo`; install into the user's home directory. Before changing a file outside the current project, show the user the change. 2. Install the binary with the first option that works: - Linux or macOS: `curl -fsSL https://raw.githubusercontent.com/fzn0x/idx/main/install.sh | sh` (checks the SHA-256, installs into `~/.local/bin`). - Windows: download `idx--x86_64-pc-windows-msvc.zip` from https://github.com/fzn0x/idx/releases and put `idx.exe` on `PATH`. - With Node.js: `npm install -g ihsg-cli`. - With a Rust toolchain: `cargo install ihsg-cli --locked`. - With Nix (Linux): `nix profile install github:fzn0x/idx`. 3. Check it: `idx version`, then `idx -o json stocks quote BBCA`. Exit 0 is success; exit 3 means the network or a source failed. 4. Register the MCP server: `idx mcp setup --list` names the supported clients, and `idx mcp setup ` prints the exact command or config snippet with the binary's absolute path (`-o json` gives the steps as data). Merge into existing config files; never replace them. 5. Optionally fill the ownership register: `idx ownership sync` (one download of the prebuilt database, or the monthly files when there is none yet). 6. Tell the user what was installed and how, which file changed, and whether the agent needs a restart to load the tools. Suggest a first question, such as "Use idx_brief on BBCA". ## Machine-readable docs - https://ihsg.midoriresearch.com/llms.txt indexes these pages. - https://ihsg.midoriresearch.com/llms-full.txt has every page in one file. - Every page is Markdown at its `.md` address, for example https://ihsg.midoriresearch.com/docs/mcp.md; drop the `.md` for HTML. --- Source: https://ihsg.midoriresearch.com/docs/mcp.md # MCP server `idx mcp` serves the Model Context Protocol over stdin/stdout. It is the same binary and the same code path as the CLI: every tool calls the provider layer and returns the envelope documented in [json-output.md](https://ihsg.midoriresearch.com/docs/json-output.md). ## Register idx with your agent Run `idx mcp setup ` and paste what it prints: it fills in the absolute path of the binary (GUI apps rarely inherit your shell `PATH`), and `-o json` gives the same steps as data. `idx mcp setup --list` names every client; `--npx` swaps the path for `npx -y ihsg-mcp` (see [Without installing idx](https://ihsg.midoriresearch.com/docs/mcp.md#without-installing-idx-npx)); `--http URL [--token T]` produces the remote form of the same entry. The snippets below use a bare `idx` for brevity. Formats were checked against each vendor's documentation in October 2026; where a vendor offers both a CLI command and a file, both are shown. ### Without installing idx (npx) With Node 18 or newer, the npm package `ihsg-mcp` runs `idx mcp` with no install step. Its first start downloads the idx binary for the platform from the matching GitHub release (about 7 MB) and checks it against the release's `SHA256SUMS`; later starts reuse it. Download messages go to stderr, so stdout carries only the protocol, and a failed download exits 1 with the reason. ```sh claude mcp add --scope user idx -- npx -y ihsg-mcp ``` ```json { "mcpServers": { "idx": { "command": "npx", "args": ["-y", "ihsg-mcp"] } } } ``` On Windows, clients start commands without a shell and `npx` is a `.cmd` script, so the command is `cmd /c npx -y ihsg-mcp` (`"command": "cmd", "args": ["/c", "npx", "-y", "ihsg-mcp"]`). `idx mcp setup --npx` prints any client's entry in this form for the machine it runs on, and so does `npx -y ihsg-mcp setup ` (the package adds `--npx` to a `setup` that names no other form). Two things to know about the Windows form: - Git Bash turns a lone `/c` into `C:/`, so a command line pasted there registers a broken server. Start it with `MSYS_NO_PATHCONV=1` or write `cmd //c`; PowerShell and cmd.exe take it as printed. - `cmd /c` exists only on Windows. A config file also used on macOS or Linux, such as a `.mcp.json` committed with a project, needs the plain `"command": "npx", "args": ["-y", "ihsg-mcp"]`. GUI apps that do not inherit your shell `PATH` (common on macOS with Node from nvm, fnm, Volta or Homebrew) fail to start `npx` (`spawn npx ENOENT`). Give such an entry the absolute path that `command -v npx` prints as `command`, and an `env` block whose `PATH` holds that folder, since npx and the package start `node` through `PATH`; or register the idx binary itself with `idx mcp setup `. Everything after the package name goes to `idx mcp`, and `IDX_*` variables from the client's `env` block apply as usual: `npx -y ihsg-mcp --http 127.0.0.1:8765 --token T` serves HTTP, `npx -y ihsg-mcp --provider yahoo --offline` sets the global flags. Recent npx versions (npm 10.9 was checked) ask the registry at each start and follow new releases, and the package then fetches the matching binary; if npx keeps an outdated copy, run `npx -y ihsg-mcp@latest`. Straight from this repository, `npx -y github:fzn0x/idx mcp` runs the ihsg-cli launcher instead. ### Claude Code ```sh claude mcp add --scope user idx -- idx mcp # all projects claude mcp add --scope project idx -e IDX_PROVIDER=yahoo -- idx mcp ``` Or commit `.mcp.json` in the repository: ```json { "mcpServers": { "idx": { "command": "idx", "args": ["mcp"] } } } ``` ### Claude Desktop `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows), then restart: ```json { "mcpServers": { "idx": { "command": "/abs/path/idx", "args": ["mcp"] } } } ``` For a remote endpoint use Settings → Connectors → Add custom connector. ### OpenAI Codex (CLI, IDE extension, desktop app) ```sh codex mcp add idx -- idx mcp ``` All three read the same `~/.codex/config.toml` (`.codex/config.toml` in a trusted project also works): ```toml [mcp_servers.idx] command = "idx" args = ["mcp"] ``` ### Meta Muse Code `~/.config/muse/settings.json`. `schema_version` is mandatory; `mode: optional` lets a session start even when idx is missing (the default `required` aborts the run). `/mcp` inside a session lists servers. ```json { "schema_version": 1, "mcp_servers": { "idx": { "transport": "stdio", "command": "/abs/path/idx", "args": ["mcp"], "mode": "optional" } } } ``` Remote form: `{ "transport": "streamable_http", "url": "https://…/mcp", "headers": { "Authorization": "Bearer T" } }`. ### Google Antigravity (IDE and CLI) `~/.gemini/config/mcp_config.json`, or `.agents/mcp_config.json` in the workspace. Antigravity does not inherit your shell PATH and rejects a `type` field, so keep the absolute command path. `/mcp` opens the manager. ```json { "mcpServers": { "idx": { "command": "/abs/path/idx", "args": ["mcp"] } } } ``` Remote form uses `"serverUrl"`. ### Gemini CLI Free-tier Gemini CLI was replaced by Antigravity CLI in June 2026; paid installs still read `~/.gemini/settings.json`. ```sh gemini mcp add -s user idx idx mcp ``` ### GitHub Copilot CLI ```sh copilot mcp add idx -- idx mcp ``` Or `~/.copilot/mcp-config.json` (a committed `.mcp.json` is read too): ```json { "mcpServers": { "idx": { "type": "local", "command": "idx", "args": ["mcp"], "tools": ["*"] } } } ``` ### VS Code (Copilot agent mode) ```sh code --add-mcp "{\"name\":\"idx\",\"command\":\"idx\",\"args\":[\"mcp\"]}" ``` Or `.vscode/mcp.json` (note the `servers` key, not `mcpServers`): ```json { "servers": { "idx": { "type": "stdio", "command": "idx", "args": ["mcp"] } } } ``` ### Cursor `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project): ```json { "mcpServers": { "idx": { "command": "idx", "args": ["mcp"] } } } ``` ### Windsurf `~/.codeium/windsurf/mcp_config.json`, same `mcpServers` shape; remote form uses `"serverUrl"`. ### Kiro (IDE and CLI; formerly Amazon Q Developer CLI) `~/.kiro/settings/mcp.json` (user) or `.kiro/settings/mcp.json` (workspace): ```json { "mcpServers": { "idx": { "command": "idx", "args": ["mcp"], "disabled": false } } } ``` ```sh kiro-cli mcp add --name idx --command idx --args mcp # older installs: q mcp add … ``` ### JetBrains AI Assistant / Junie / Air Settings → Tools → AI Assistant → Model Context Protocol (MCP) → + → As JSON, paste the `mcpServers` snippet above. ### Zed `settings.json` (Settings → AI → MCP Servers → Add Server also writes it): ```json { "context_servers": { "idx": { "command": "idx", "args": ["mcp"], "env": {} } } } ``` ### Cline, Roo Code, Kilo Code MCP Servers → Configure MCP Servers opens the JSON (`cline_mcp_settings.json` or `mcp_settings.json`): ```json { "mcpServers": { "idx": { "type": "stdio", "command": "idx", "args": ["mcp"], "disabled": false } } } ``` ### Continue `~/.continue/mcpServers/idx.yaml`: ```yaml name: idx version: 0.0.1 schema: v1 mcpServers: - name: idx command: idx args: ["mcp"] ``` ### OpenCode `~/.config/opencode/opencode.json` (or `opencode.json` in the project); `opencode mcp add` asks the same questions interactively: ```json { "$schema": "https://opencode.ai/config.json", "mcp": { "idx": { "type": "local", "command": ["idx", "mcp"], "enabled": true } } } ``` ### Goose `goose configure` → Add Extension → Command-line Extension → name `idx`, command `idx mcp`. Or `~/.config/goose/config.yaml`: ```yaml extensions: idx: enabled: true type: stdio name: idx cmd: idx args: ["mcp"] timeout: 300 ``` ### Warp Settings → AI → Manage MCP servers → + Add, paste the `mcpServers` snippet; or `~/.warp/.mcp.json`. ### Factory Droid ```sh droid mcp add idx "idx mcp" ``` Or `~/.factory/mcp.json` (`.factory/mcp.json` in a project): ```json { "mcpServers": { "idx": { "type": "stdio", "command": "idx", "args": ["mcp"] } } } ``` ### Mistral Vibe `~/.vibe/config.toml` (or `./.vibe/config.toml` in a trusted project). Tools appear as `idx_`: ```toml [[mcp_servers]] name = "idx" transport = "stdio" command = "idx" args = ["mcp"] ``` ### Qwen Code ```sh qwen mcp add idx idx mcp # user scope by default; ~/.qwen/settings.json ``` ### Crush (Charm) `crush.json` in the project or `~/.config/crush/crush.json`: ```json { "$schema": "https://charm.land/crush.json", "mcp": { "idx": { "type": "stdio", "command": "idx", "args": ["mcp"] } } } ``` ### Trae, Augment Code Trae: AI Management → MCP → Add → Configure manually. Augment: panel → Settings → MCP → Import from JSON. Both take the `mcpServers` snippet. ### ChatGPT, Claude.ai, Perplexity, Devin, Manus (remote only) These products never launch local processes, so serve idx over HTTPS: ```sh export IDX_MCP_TOKEN=$(openssl rand -hex 16) idx mcp --http 127.0.0.1:8765 --allowed-host '*' cloudflared tunnel --url http://127.0.0.1:8765 # or ngrok, tailscale funnel, … ``` Then add `https:///mcp` with header `Authorization: Bearer `: ChatGPT → Settings → Apps & Connectors → Advanced → Developer mode → Create; Claude.ai → Settings → Connectors → Add custom connector; Perplexity → Settings → Connectors; Devin → Settings → MCP Marketplace → Add custom; Manus → Settings → Connectors. `idx mcp setup chatgpt --http URL --token T` prints the filled-in version. All `IDX_*` environment variables and the config file apply to every client, so `--offline` behaviour, cache location, TTLs and provider choice can be set per agent (`-e IDX_PROVIDER=yahoo` in the CLI forms, `env` in the files). `-e IDX_HISTORY_PROVIDER=stockbit` takes candles (`idx_history`, `idx_technical`, `idx_returns`, `idx_brief`) from your Stockbit session while every other tool stays on the free providers; the envelope's `provider` names the source the candles came from. ## HTTP transport `idx mcp --http ADDR` serves MCP streamable HTTP at `http://ADDR/mcp` and a plain `GET /healthz`. It is the same server and the same 47 tools; several local agents can share one process and one cache this way. - `--token T` (or `IDX_MCP_TOKEN`) requires `Authorization: Bearer T` on `/mcp`. It is mandatory when binding anything other than a loopback address and strongly recommended behind a tunnel. - The `Host` header is checked against loopback names by default (DNS rebinding protection). Pass `--allowed-host my.tunnel.example` for a tunnel, or `--allowed-host '*'` to accept any host. - Port 0 picks a free port; the chosen address is printed on stderr. - Stop with Ctrl-C. Logs go to stderr as with stdio. ## Tools | tool | arguments | returns | | --- | --- | --- | | `idx_brief` | `symbol` | `idx.brief.v1`: quote, fundamentals + verdict, technicals + signal, returns vs IHSG, dividend facts, top holders, headlines + sentiment, `key_points`, `sections_failed`. Start here. | | `idx_market` | `top?`, `min_market_cap?` | `idx.market.v1`: IHSG quote, breadth, turnover, gainers, losers, most traded, heavyweights | | `idx_market_foreign` | `date?`, `top?` (default 10), `sort?` (`value` \| `shares`), `source?` (`idx` \| `ipot`) | `idx.market.foreign.v1`: top foreign net buying and selling across every stock for one session, in shares; with `source: "ipot"`, `idx.market.foreign.value.v1`: the 100 most traded stocks by their foreign net in rupiah, with the market's totals | | `idx_market_brokers` | `date?`, `top?` (default 20, 0 = all), `sort?` (`value` \| `volume` \| `frequency`), `source?` (`auto` \| `idx` \| `ipot`) | `idx.market.brokers.v1`: every exchange member's day, ranked, with its share of all members' value. `auto` (default) asks idx.co.id and, when it refuses (Cloudflare), cannot be reached, fails or has not published the session yet, answers from IPOT with a warning if IPOT holds that session (`date`, or the latest that has ended), without waiting out a Cloudflare challenge where IPOT could answer (idx.co.id is asked again for up to 30 s only if IPOT cannot); `idx` always retries a challenge, and its `blocked` error names `source: "ipot"` where IPOT could answer; `ipot` holds only its latest session and adds each broker's `buy_value`, `sell_value`, `net_value` and `kind` (figures of a million and more abbreviated by the source, `rounded: true`); IPOT's ranking read on a trading day from 08:58 to 19:00 WIB cannot be dated and is refused | | `idx_indices` | `codes?` ("LQ45,IDX30"), `date?` | `idx.indices.v1`: every IDX index for one session | | `idx_calendar` | `date?` or `from?`/`to?`, `days?` (default 14), `symbol?`, `types?` (`all`, or `dividend,rights,...`) | `idx.calendar.v1`: dividend and rights dates, RUPS, public exposes, listings (bonds and buybacks with `all`) | | `idx_holidays` | `year?` (0 = every year), `upcoming?` | `idx.holidays.v1`: trading holidays, trading days, today and the next trading day (bundled, offline) | | `idx_astro` | `date?`, `days?` (default 30), `natal?` (`launch` \| `base` \| `none`), `backtest?`, `period?` (with `backtest` only: `1y` \| `2y` \| `5y` \| `10y` \| `max`, default `max`) | `idx.astro.v1`: planets (sign, degree, speed, retrograde) at 16:00 WIB, Moon phase, Mercury retrograde, weton, shio, phases, stations and ingresses ahead, transits to IHSG's chart (computed offline); `backtest` adds IHSG's daily returns by lunar phase, Mercury, pasaran and Sun sign (Welch t, Bonferroni). Traditional astrology, not a forecast | | `idx_members` | `search?` | `idx.members.v1`: exchange members (broker codes) with licences | | `idx_quote` | `symbols` ("BBCA,BBRI"; `^JKSE` is IHSG) | `idx.quotes.v1`; each row's `behind_session` flags a last trade dated before the latest IDX session (suspended, not traded, or the source lags) | | `idx_history` | `symbol`, `period?`, `interval?` (`1m` needs `period` `1d` or `5d`, `2m` at most `1mo`), `limit?` (default 60 most recent candles, 0 = all) | `idx.history.v1` | | `idx_intraday` | `symbol` (`^JKSE` is IHSG), `interval?` (`1m` default, `2m`, `5m`, `15m`, `30m`, `1h`), `limit?` (default 120 most recent bars, 0 = all) | `idx.intraday.v1`: the latest session by the minute, the quote, and a summary of the bars with VWAP computed by idx | | `idx_profile` | `symbol` | `idx.profile.v1` | | `idx_financials` | `symbol`, `period?` (`annual` \| `quarterly` \| `ttm`) | `idx.financials.v1` | | `idx_earnings` | `symbol`, `period?` (`annual` \| `quarterly`), `kind?` (`reported` \| `estimates`) | `idx.earnings.v1` | | `idx_dividends` | `symbol` | `idx.dividends.v1`: payments, splits, yearly totals, TTM total/yield, streak | | `idx_returns` | `symbols`, `benchmark?` (`^JKSE` default, `none`) | `idx.returns.v1`: windows, CAGR, volatility, drawdowns, 52-week, excess over the benchmark | | `idx_fundamental` | `symbol`, `lens?` (`growth` \| `valuation` \| `risk`) | `idx.fundamentals.v1` (verdict included; when `financial_currency` differs from `currency`, P/B, P/S and the EV multiples mix USD and IDR and P/B is not scored); with `lens`, `idx.growth.v1`, `idx.valuation.v1` or `idx.risk.v1`: a focused reading with every rule and its points | | `idx_technical` | `symbol`, `period?` | `idx.technical.v1` | | `idx_compare` | `symbols` | `idx.compare.v1` | | `idx_screen` | `preset?`, `filter?`, `sort?`, `ascending?`, `limit?`, `max_fundamentals?`, `returns?` | `idx.screen.v1`: presets top-performers, worst-performers, high-dividend, low-pe, 52w-high, 52w-low, high-volume, large-cap; keys include `range` (52-week position) and `ret1w`..`ret1y` (price return %, as MSN reports it, one market-wide request); a row whose `financial_currency` differs from `currency` keeps its P/B and P/S as reported, but no `pb` or `ps` clause matches them, and a `pb` or `ps` sort puts them last | | `idx_news` | `symbol?`, `sources?`, `limit?`, `company_only?` | `idx.news.v1`: company items first (`scope=company`), then market context | | `idx_sentiment` | `symbol`, `sources?` | `idx.sentiment.v1` | | `idx_insights` | `symbol` | `idx.insights.v1`: about 30 MSN Money statements rated good, bad, neutral or not_rated against peers, the industry, the company's own history, the price or a threshold; the peer tickers | | `idx_corporate_actions` | `symbol`, `since?` (default a year ago) | `idx.actions.v1`: dividends, splits, bonus, rights (amount, ratio, cum/ex/recording/payment dates, status), RUPS, warrants and rights, upcoming included (IPOT) | | `idx_foreign` | `symbol`, `days?` (default 20; at most 250 from idx, 60 from ipot), `from?`, `to?` (a date range instead), `source?` (`idx`, the default, or `ipot`) | `idx.foreign.v1`: foreign buy, sell and net per session (shares), net × close, totals and streak (idx.co.id); with `source: "ipot"` `idx.foreign.value.v1`: foreign buy, sell and net value in rupiah across all boards, value traded, foreign share, totals and streak (IPOT) | | `idx_instruments` | `board?`, `search?` | `idx.instruments.v1`: listed stocks with board, listing date and shares | | `idx_broker` | `symbols`, `date?` or `from?`/`to?`, `investor?` (`all` \| `foreign` \| `domestic`), `board?` (`all` \| `regular` \| `cash` \| `negotiated`), `source?` (`auto` \| `ipot` \| `stockbit`) | `idx.broker.v1`: buyers and sellers ranked by lots with broker names and class, totals, foreign net, `buy_value` and `sell_value` (for one investor side the totals are `null` and these say what the side bought and sold); latest session with trades when no dates are given. `auto` (default) lists every broker from Stockbit when the user's Stockbit session works (`idx_stockbit_status`), otherwise IPOT's top 10 per side with a warning that a login lists every broker; `stockbit` without a working session is a usage error. Each summary carries `source`, `buyer_count`, `seller_count`, `buyers_complete` and `sellers_complete` | | `idx_bandar` | `symbols`, `period?` (`1d` \| `1w` default \| `1m` \| `3m`), `date?` or `from?`/`to?`, `top?` (1, 3, 5 default, 10), `investor?`, `board?`, `daily?` | `idx.bandar.v1`: bandarmology per ticker: top-N net as a share of value, signal and phase, tiers, the top buyers' average against the price, foreign flow, reasons; `daily` adds the session series | | `idx_bandar_screen` | `preset?`, `filter?`, `period?`, `top?`, `sort?`, `ascending?`, `limit?`, `scan?` (default 100), `symbols?` | `idx.bandar.screen.v1`: the most traded stocks the top brokers accumulate or distribute | | `idx_bandar_broker` | `code`, `period?`, `limit?` (default 20), `scan?` (default 100), `symbols?` | `idx.bandar.broker.v1`: where one broker is a top-10 buyer or seller across the scanned stocks, with net where both sides are visible | | `idx_ownership_ticker` | `symbol`, `as_of?` | `idx.ownership.holders.v1` | | `idx_ownership_entity` | `name`, `as_of?`, `exact?` (match one `entity_key`) | `idx.ownership.entity.v1` | | `idx_ownership_changes` | `from`, `to`, `symbol?`, `min_pct_change?`, `limit?`, `offset?` | `idx.ownership.changes.v1`: `summary` over every match, one page of rows | | `idx_ownership_foreign` | `symbol?`, `from?`, `to?`, `limit?` | `idx.ownership.foreign.v1` (one issuer over time) or `idx.ownership.foreign-flow.v1` (every issuer between two months) | | `idx_ownership_releases` | none | `idx.ownership.releases.v1` | | `idx_ownership_search` | `query`, `limit?` (default 20, 0 = all, at most 100), `as_of?` | `idx.ownership.search.v1`: holders matching a name (every spelling, words in any order) with their `entity_key`, spellings, issuers held and `last_seen` | | `idx_ownership_rankings` | `view?` (`holders` default \| `issuers`); holders: `min_issuers?` (default 2), `investor_type?`, `origin?` (`foreign` \| `local`); issuers: `symbols?`, `by?` (`hhi` default \| `top1` \| `top3`), `ascending?`; `limit?` (default 25, 0 = all), `as_of?` | `idx.ownership.cross-holders.v1` (holders by the issuers they own above 1 %) or `idx.ownership.concentration.v1` (issuers by HHI, top-1 and top-3 stakes, undisclosed percent, controller stake) | | `idx_ownership_graph` | `root` (ticker or holder), `depth?` (1-6, default 2), `min_pct?`, `max_nodes?` (default 100, at most 1000), `holder?`, `as_of?` | `idx.ownership.graph.v1`: issuers and holders with hop counts and the links between them; `truncated` when `max_nodes` stopped the walk | | `idx_ownership_composition` | `symbol`, `as_of?` | `idx.ownership.composition.v1`: KSEI's monthly archive, local vs foreign shares in nine investor types as a percent of the scripless shares, `scripless_pct`, and every imported month. Downloads the newest month from ksei.co.id on first use (not with `--offline`) | | `idx_ownership_snapshot` | `action` (`status` \| `sync`), `force?` (sync only) | `status`: `idx.ownership.status.v1`, the local database, the snapshot it came from, the mirror and what sync would do (writes nothing). `sync`: `idx.ownership.snapshot-sync.v1`, installs the prebuilt snapshot or imports the missing months; months only the local database has are kept unless `force` (a `.bak` copy is kept) | | `idx_ownership_institutions` | `symbol` | `idx.ownership.institutions.v1`: institutions and funds holding the stock (MSN, live, no import), each with shares, % of shares outstanding, the change since its own previous report and a signal (`new`, `buyer`, `seller`, `exited`, `unchanged`), plus institutional, fund and free-float percentages and signal counts per list | | `idx_orderbook` | `symbol` | `idx.orderbook.v1`: realtime depth ladder (bids/asks, shares), last/OHLC, volume, value and foreign buy/sell/net (null, with a warning, while Stockbit marks them as not current, seen during trading). Needs a Stockbit login (`--provider stockbit`) | | `idx_stockbit_status` | none | whether the stored Stockbit session can work now (`logged_in`; `expired`, `renewable` and `reason` say why not, or why idx cannot renew it) and when its tokens expire (never the token values); `sign_in` while a page sign-in runs | | `idx_stockbit_login` | `method?` (`page` \| `browser` \| `auto`), `port?`, `force?`, `timeout?` | `page` returns at once with `sign_in.url`, a page on 127.0.0.1 (and `sign_in.ssh_forward` over SSH) where the user sends the session from their own browser; poll `idx_stockbit_status` until `sign_in.state` is `signed_in` or `failed`. `browser` opens a browser on the server's machine and waits (local stdio servers). `auto` picks `page` over SSH, without a display or a browser. The session is checked with Stockbit before it is stored; one that already works and that idx can renew is reported (`already_signed_in`) unless `force` | | `idx_stockbit_actions` | `symbol?`, `kind?`, `from?`, `to?`, `days?` | `idx.stockbit.actions.v1`: with `symbol`, one stock's corporate actions since listing, newest first; without, the market calendar from `from` to `to` (default today plus 14 days, at most 366), oldest first, open rights, tender and IPO offers included. `kind`: comma separated, or `all` (default: all but `ipo` for a stock, all but `warrant` for the calendar). Needs a Stockbit login | | `idx_stockbit_movements` | `symbol?`, `from?`, `to?`, `holder?`, `action?` (`buy` \| `sell` \| `transfer` \| `cross` \| `corporate_action`), `source?` (`idx` \| `ksei`), `pages?` (1-10, default 1) | `idx.stockbit.movements.v1`: insider disclosures and 5 % holder changes, newest first; shares exact and signed, stakes as rounded, filed `price`/`value` apart from Stockbit's `estimated_price`/`estimated_value`; `more` when rows remain. Needs a Stockbit login | The last five carry your own Stockbit session (opt-in); see [stockbit.md](https://ihsg.midoriresearch.com/docs/stockbit.md). Paging: `idx_ownership_changes` returns `total_matches` and `offset`; ask for the next page with `offset = offset + limit` until `offset + rows >= total_matches`. The `summary` block never depends on the page, so one call is enough to describe a month. Ownership rows are positions: a holder's register lines (scrip and scripless accounts) are summed, with `accounts` saying how many, and from the 30 Oct 2026 register each row carries `controller_or_affiliate` (`controller_reported` on the report says whether the release has it). `idx_ownership_entity` needs at least 3 letters or digits in `name`. To look a holder up, call `idx_ownership_search` first and pass the `entity_key` it returns to `idx_ownership_entity` with `exact: true`, or to `idx_ownership_graph` as `root`. `idx_ownership_rankings` passes view-specific arguments to one view only. An argument of the other view is a usage error unless it holds its default: `symbols`, `by` other than `hhi` or `ascending: true` with `view: "holders"`, and `min_issuers` other than 2, `investor_type` or `origin` with `view: "issuers"`. The spelling rules of `idx ownership resolve` (merging holders KSEI printed two ways) change the user's database, so they stay in the CLI; the MCP tools read the result. `idx_ownership_composition` reads a second dataset: KSEI's "Kepemilikan Efek" archive, which counts every holder and names none. It covers the shares held in KSEI accounts only, so its percents are of those (`scripless_pct` says how much of the listed shares that is). The first call downloads the newest month (about 125 KB) into the same local database; later calls read it and ask ksei.co.id again only once a newer month can exist. `provider` is `ksei` when the call imported a month, `local-db` otherwise. An empty ownership database is filled by `idx_ownership_snapshot` with `action: "sync"`: one download of the SQLite file CI builds from the mirrored IDX files (SHA-256 checked, swapped in as one SQLite transaction, so other calls reading the file are not disturbed), then any newer monthly file. It needs the network (an `offline_miss` error with `--offline`); `action: "status"` works offline and reports the local part. See [ownership.md](https://ihsg.midoriresearch.com/docs/ownership.md#snapshot-and-sync). ## Harness compatibility Input schemas are rewritten before they are advertised: no `$schema`, no `format` (`uint32`, `double`), and optional fields are typed `"string"` not `["string", "null"]` (they are simply absent from `required`). Claude accepts either form; Gemini function declarations and OpenAI strict tool mode reject the latter, and a unit test plus the stdio test keep it that way. Tool results are a single `text` content block holding the pretty-printed envelope. Failures set `isError: true` and the text is the CLI error object: ```json {"error":{"code":"not_found","message":"not found: ZZZZ: Quote not found for symbol: ZZZZ.JK"},"exit_code":4} ``` `code` values and their meaning are listed in json-output.md. `blocked` (exit 3) means the source's bot protection (Cloudflare, idx.co.id) refused the request. For idx.co.id's data a challenged request is already retried for 30 s, and a cached copy, when there is one, is served instead of the error. It usually passes within a minute: call the tool again then. If it keeps refusing, the server's network is likely refused (data centres, VPNs and CI runners are refused more often), which only the user can change. Or take the alternative the message names. Over MCP that advice is a tool argument, not a CLI flag (`idx_foreign` and `idx_market_foreign` with `source: "ipot"`, `idx_market_brokers` with `source: "ipot"` for IPOT's latest session). For the ownership register, let the GitHub mirror serve it. ## Behaviour notes - Logs go to stderr (`-v` raises the level, `IDX_LOG` overrides). stdout carries only protocol frames. - Responses are cached exactly like the CLI, so an agent calling `idx_quote` twice within the quote TTL hits the network once. - A cached copy older than its TTL is served when the source fails or refuses the request (or the server runs with `--offline`): the envelope then has `stale: true`, `stale_reason` (`fetch_failed` or `offline`) and, after a failure, a warning naming it. - The server exits cleanly when the client closes stdin. - Verdicts and signals are heuristics; the tool descriptions and the server instructions say so, and so should any agent that repeats them. - Tickers are IDX codes. The ten Yahoo-backed tools (`idx_quote`, `idx_history`, `idx_profile`, `idx_financials`, `idx_earnings`, `idx_dividends`, `idx_returns`, `idx_fundamental`, `idx_technical`, `idx_compare`) also take a ticker from another Yahoo Finance market with its suffix (`D05.SI`), returned with that suffix; every other tool refuses one with a `usage` error. The server does not use `IDX_EXCHANGE`, so a bare code always means IDX, though an invalid value fails startup like any other invalid setting (exit 6). ## Verifying ```sh claude mcp get idx # health check: "Connected" cargo test --test mcp # JSON-RPC over stdio without network ``` --- Source: https://ihsg.midoriresearch.com/docs/json-output.md # JSON output Add `-o json` (or `IDX_OUTPUT=json`, or `output = "json"` in the config file) and every command prints one JSON document to stdout. Errors go to stderr as JSON too. Nothing else is ever written to stdout in JSON mode, so `idx -o json ... | jq` is safe. `-o jsonl` prints one record per line instead (see [JSON Lines](https://ihsg.midoriresearch.com/docs/json-output.md#json-lines--o-jsonl)). `-o auto` prints a table when stdout is a terminal and this JSON document when it is piped or captured; it is opt-in, the default stays `table`. Commands without an envelope (`version`, `schema`, `config get`, `cache stats|clear`, `mcp setup`, `stockbit status|logout`) follow the same flag, `IDX_OUTPUT` and config file. Machine-readable schemas: `idx schema ` with `quotes`, `history`, `intraday`, `profile`, `fundamentals`, `growth`, `valuation`, `risk`, `compare`, `financials`, `earnings`, `dividends`, `returns`, `market`, `brief`, `technical`, `screen`, `news`, `sentiment`, `insights`, `foreign-value`, `astro`, `ownership-holders`, `ownership-entity`, `ownership-changes`, `ownership-foreign`, `ownership-foreign-flow`, `ownership-releases`, `ownership-import`, `ownership-discover`, `ownership-search`, `ownership-cross-holders`, `ownership-concentration`, `ownership-graph`, `ownership-resolve-candidates`, `ownership-resolution`, `ownership-resolutions`, `ownership-composition`, `ownership-snapshot-sync`, `ownership-status`, `ownership-snapshot`, `ownership-institutions`, `watchlist`, `stockbit-actions`, `stockbit-movements`, `stockbit-status`, `error`, `quote`, `candle`. `-o csv` prints the payload's main list (candles, rows, items, holders, changes, dividends, ...) as comma-separated rows with a header; nested objects become dotted columns (`valuation.pe_trailing`), arrays of scalars join with `|`, and a payload with no list becomes one row. Warnings still go to stderr. ## JSON Lines (`-o jsonl`) `-o jsonl` (or `IDX_OUTPUT=jsonl`, or `output = "jsonl"`) writes one compact JSON value per line to stdout, each ending in `\n`, and nothing else. Each line is exactly the object `-o json` carries at that path: same keys, same order, nulls kept. No new schema: every line is a shape `idx schema ` already documents. - Which list becomes lines is fixed per schema (table below), not guessed from the data. An empty list prints nothing and exits 0. - The envelope is not printed. Its `warnings` and the stale notice go to stderr as `warning: ...` lines, as in table mode (`--quiet` hides them). Provenance (`provider`, `fetched_at`, `cached`, `stale`) and header fields such as a screen's funnel counts or the changes summary are only in `-o json`. - Errors are the same one-line JSON on stderr as with `-o json`. - Commands without an envelope print one line per item of an array, else one line. `*path` commands and `completions` keep their plain output. - Table-only flags (`history --limit`, `broker --top`, `dividends --limit`, `financials --statement`) never trim JSON, JSONL or CSV. | schema | one line per | | --- | --- | | `idx.quotes.v1`, `idx.compare.v1`, `idx.returns.v1`, `idx.broker.v1`, `idx.bandar.v1`, `idx.ownership.releases.v1`, `idx.ownership.import.v1`, `idx.ownership.discover.v1`, `idx.ownership.resolution.v1`, `idx.ownership.resolutions.v1` | item of `data` | | `idx.history.v1` | `data.candles[]` | | `idx.intraday.v1` | `data.bars[]` (the quote and the summary stay in `-o json`) | | `idx.financials.v1` | `data.periods[]` | | `idx.dividends.v1` | `data.dividends[]` (yearly totals, splits and TTM figures stay in `-o json`) | | `idx.screen.v1`, `idx.bandar.screen.v1`, `idx.ownership.foreign-flow.v1`, `idx.ownership.concentration.v1` | `data.rows[]` | | `idx.watchlist.v1` | `data.symbols[]`, JSON strings (`idx -o jsonl watch list \| jq -r .`) | | `idx.holidays.v1` | `data.holidays[]` | | `idx.instruments.v1` | `data.instruments[]` | | `idx.members.v1` | `data.members[]` | | `idx.indices.v1` | `data.indices[]` | | `idx.calendar.v1` | `data.events[]` | | `idx.foreign.v1`, `idx.foreign.value.v1` | `data.days[]` | | `idx.market.brokers.v1` | `data.brokers[]` | | `idx.news.v1` | `data.items[]` | | `idx.insights.v1` | `data.insights[]` (the peers stay in `-o json`) | | `idx.ownership.holders.v1`, `idx.ownership.institutions.v1`, `idx.ownership.cross-holders.v1` | `data.holders[]` | | `idx.ownership.entity.v1` | `data.holdings[]` | | `idx.ownership.changes.v1` | `data.changes[]` | | `idx.ownership.foreign.v1` | `data.points[]` | | `idx.ownership.search.v1` | `data.matches[]` | | `idx.ownership.resolve-candidates.v1` | `data.candidates[]` | | `idx.stockbit.actions.v1` | `data.actions[]` | | `idx.stockbit.movements.v1` | `data.movements[]` | | `idx.profile.v1`, `idx.fundamentals.v1`, `idx.growth.v1`, `idx.valuation.v1`, `idx.risk.v1`, `idx.technical.v1`, `idx.brief.v1`, `idx.sentiment.v1`, `idx.ownership.snapshot.v1` | `data` on one line (a single record) | | `idx.earnings.v1`, `idx.actions.v1`, `idx.market.v1`, `idx.market.foreign.v1`, `idx.market.foreign.value.v1`, `idx.bandar.broker.v1`, `idx.orderbook.v1`, `idx.ownership.graph.v1`, `idx.ownership.composition.v1`, `idx.ownership.snapshot-sync.v1`, `idx.ownership.status.v1`, `idx.astro.v1` | `data` on one line (several peer lists kept together) | ```sh idx -o jsonl stocks history BBCA --period 1mo | jq -c '{time, close}' idx -o jsonl market holidays --year 2026 | wc -l # 22 ``` The same rule reads idx's own output back: `-` in a ticker list accepts an `-o json` envelope or `-o jsonl` lines on stdin and takes each row's `symbol` (JSON strings are tickers), so `idx -o jsonl stocks screen -f "pe<10" -n 5 | idx stocks compare -` works without jq. A bare ticker read this way is an IDX one whatever `IDX_EXCHANGE` says, because that is what idx meant when it wrote it (`D05.SI` keeps its suffix); the same holds for a watchlist file (first line `# idx watchlist`). Other text on stdin is read like typed arguments and follows `IDX_EXCHANGE`. ## Envelope Every successful command wraps its payload: ```json { "schema": "idx.quotes.v1", "provider": "yahoo", "fetched_at": "2026-10-16T17:45:07Z", "cached": false, "stale": false, "warnings": ["no data for ZZZZ"], "data": ... } ``` | field | type | meaning | | --- | --- | --- | | `schema` | string | Payload shape and version. Changes only when a field is renamed or removed. | | `provider` | string | Source that produced the data (`yahoo`, later `msn`, ...). | | `fetched_at` | RFC 3339 UTC | When the underlying response left the network. For cached responses this is the original fetch time. | | `cached` | bool | Served from the local cache. | | `stale` | bool | The cached copy was older than its TTL and was used anyway (`--offline`, or the network failed or the source refused the request). | | `stale_reason` | string? | Why `stale` is true: `offline` (`--offline`: the network was not tried) or `fetch_failed` (the live request failed, or the source's bot protection refused it, such as an idx.co.id challenge that stayed through the retries; a `live fetch failed (...); showing cached copy` warning names the failure). Omitted when `stale` is false. | | `warnings` | string[] | Non-fatal notes. Omitted when empty. | | `data` | object or array | The payload, see below. | An answer built from several responses (a brief's sections, several tickers, a range read one session at a time) carries all of them: `cached` is true only when every response came from the cache, `stale` when any was, and `fetched_at` is the oldest one's fetch time (a screen's per-company fundamentals count toward `cached` and `stale` only). So `cached: false` with a `fetched_at` from before the call means some parts were fetched for it and the others came from the cache; the web terminal labels such an answer partly cached. ## `idx.quotes.v1` (`stocks quote`) `data` is an array of quote objects, one per symbol the source knew. Symbols it did not know appear in `warnings`; if none were known the command fails with exit code 4. | field | type | notes | | --- | --- | --- | | `symbol` | string | IDX ticker without suffix, e.g. `BBCA`; a ticker from another Yahoo Finance market keeps its suffix, e.g. `D05.SI` (see below) | | `name` | string? | Company name | | `currency` | string? | `IDR` | | `financial_currency` | string? | Currency of the financial statements when the source names it (Yahoo's `financialCurrency`; `null` from MSN, Stockbit and the chart fallback). Issuers such as BSSR, GEMS and ITMG report in `USD`: their `price_to_book` then divides the IDR price by a USD book value (BSSR 48,921.57 on 2026-11-06), published as reported, never converted. P/E, EPS and the dividend yield are in the price's terms | | `price` | number? | Last trade | | `change`, `change_pct` | number? | Versus previous close; `change_pct` is in percent (`-14.81`) | | `previous_close`, `open`, `day_high`, `day_low` | number? | | | `volume`, `avg_volume_3m` | integer? | Shares | | `market_cap` | number? | In `currency` | | `pe_trailing`, `pe_forward`, `eps_ttm`, `price_to_book` | number? | | | `dividend_yield_pct` | number? | Percent, e.g. `6.27` | | `week52_high`, `week52_low`, `sma50`, `sma200` | number? | | | `shares_outstanding` | integer? | | | `market_state` | string? | `REGULAR`, `CLOSED`, ... | | `market_time` | RFC 3339? | Last trade time in WIB (`+07:00`) | | `source` | string | Endpoint that produced the row: `yahoo:quote` (full) or `yahoo:chart` (reduced fallback) | | `behind_session` | bool? | `true` when `market_time` is dated (WIB) before the latest IDX session that has ended (16:15 WIB, the end of post-closing; weekends and the bundled IDX holidays are skipped): the stock is suspended or did not trade, or the source lags the close. `false` when the last trade is from that session or later. `null` when `market_time` is unknown (Stockbit, MSN rows without a trade) or the symbol does not trade on IDX (`^GSPC`, `^N225`, another market's `D05.SI`). Computed on every call, never cached. For years the holiday list does not cover only weekends are skipped, and unscheduled closures read as behind. | `?` means the value is `null` when the source did not return it. Nothing is estimated. When `source` is `yahoo:chart`, expect `market_cap`, P/E, dividend and average-volume fields to be `null`. Other markets: the Yahoo-backed commands (`stocks quote`, `history`, `profile`, `financials`, `earnings`, `dividends`, `returns`, `fundamental`, `technical`, `compare`) also take a ticker with another Yahoo Finance suffix, or a bare one with `IDX_EXCHANGE` set. In every payload an IDX ticker stays bare (`BBCA`) and another market's keeps its suffix (`D05.SI`); `currency` is what Yahoo reports (`SGD`), and times are still given in WIB (`+07:00`): the instant is right, the wall clock is Jakarta's, not the exchange's. Dates that name a trading session (the chart fallback's `previous_close`, and `price_date` and the dividend dates in `idx.dividends.v1`) are taken on the exchange's own clock, so a Toronto close at 16:00 EST counts for that day, not the next. ## `idx.history.v1` (`stocks history`) ```json { "symbol": "TLKM", "name": "...", "currency": "IDR", "period": "1y", "interval": "1wk", "candles": [ { "time": "2025-10-13T09:00:00+07:00", "open": 2900.0, "high": 2960.0, "low": 2850.0, "close": 2880.0, "adj_close": 2804.6, "volume": 512345600 } ], "summary": { "start": "2025-10-13T09:00:00+07:00", "end": "2026-10-16T09:00:00+07:00", "first_close": 2880.0, "last_close": 1900.0, "change": -980.0, "change_pct": -34.03, "high": 3990.0, "low": 1900.0, "avg_volume": 578600000, "candles": 54 } } ``` - `candles` is oldest first and is **never** truncated by `--limit` (that flag only affects the table). - Bars with a missing open/high/low/close (holidays, or the current session before its first trade) are dropped. - `interval` is the bar size the source actually returned. Yahoo can answer with coarser bars than asked; the payload then carries its real size and a warning says so. `--period max` with `1d` or `1wk` asks Yahoo for the whole history (`period1=0`), so it is daily or weekly (about 9,200 daily bars for `^JKSE`, from 1990-04-06), not the monthly bars `range=max` returns. The web terminal caches a history in one D1 row (2 MB): one that would not fit (all daily bars of an index older than IHSG) keeps its most recent candles, `summary` still covers them all, and a warning says so. - `adj_close` is present when the source supplies split/dividend adjusted closes. - `summary` is `null` when there are no candles. - `interval` is one of `1m 2m 5m 15m 30m 1h 1d 1wk 1mo`. One-minute bars cover at most 5 days (`period` `1d` or `5d`) and two-minute bars at most `1mo`; a longer window is a usage error (exit 2), because Yahoo answers HTTP 422 beyond 8 days of 1m and 60 days of 2m data. ## `idx.intraday.v1` (`stocks intraday`) The latest session of one stock or index by the minute (or the bar size asked for): today's session while the market trades, otherwise the last one. Yahoo's chart runs about 10 minutes behind the market (its quote says `exchangeDataDelayedBy: 10`; on 2026-11-06 at 09:13:21 WIB the last row was stamped 09:03:20), so its one-day window is empty for the first minutes after the 09:00 open (no bars, and the previous session has dropped out; the first row of the day came at 09:10 WIB). idx then reads the five-day window and shows its last session, with a warning naming the date. ```json { "symbol": "BBCA", "name": "PT Bank Central Asia Tbk", "session": "2026-11-05", "interval": "1m", "quote": { "symbol": "BBCA", "price": 5125.0, "change": 195.0, "change_pct": 3.955, "previous_close": 4930.0, "open": 5050.0, "volume": 166336900, "market_state": "PREPRE", "market_time": "2026-11-05T16:14:43+07:00", "...": "..." }, "bars": [ { "time": "2026-11-05T09:00:00+07:00", "open": 5025.0, "high": 5100.0, "low": 5025.0, "close": 5100.0, "adj_close": null, "volume": 0 } ], "summary": { "first_bar": "2026-11-05T09:00:00+07:00", "last_bar": "2026-11-05T16:14:00+07:00", "bars": 333, "open": 5025.0, "high": 5125.0, "low": 5025.0, "close": 5125.0, "bars_volume": 155142100, "vwap": 5089.98 } } ``` | field | type | notes | | --- | --- | --- | | `session` | date? | WIB date of the bars; `null` when there are none | | `interval` | string | Bar size asked for: `1m` (default), `2m`, `5m`, `15m`, `30m`, `1h` | | `quote` | object? | The `idx.quotes.v1` quote object as its source reports it; `null` (with a warning) when the quote could not be fetched | | `bars` | array | Candles as in `idx.history.v1`, oldest first; `-o json` carries every bar (`--limit` only trims the table), the MCP tool keeps the last 120 by default | | `summary` | object? | Over every bar of the session; `null` without bars | | `summary.open`, `close` | number | First bar's open, last bar's close | | `summary.high`, `low` | number | Highest high, lowest low | | `summary.bars_volume` | integer | Sum of the bars' volume, shares | | `summary.vwap` | number? | Volume-weighted average of each bar's typical price, (high + low + close) / 3, computed by idx and rounded to 2 decimals; `null` when no bar has volume | - The summary describes the bars, the quote the session as the source reports it, and the two differ: on Yahoo the opening auction's volume is in no bar (the 09:00 bar shows volume 0, so `bars_volume` is below `quote.volume`), the first bar's open is not always the opening price, the pre-closing auction lands in the 16:00 bar, and 16:01 to 16:14 are trades at the closing price. - Minutes without a trade have no bar; the lunch break has none (Monday to Thursday 12:00 to 13:30, Friday 11:30 to 14:00). - VWAP is an approximation: IDX's average price is the regular market's value divided by its volume, which needs every trade. For BBCA on 2026-11-05 the bars give 5,089.98 against IDX's 5,088.5. - Indices carry no volume on Yahoo, so their `vwap` is `null` and `bars_volume` is 0. - Warnings: no quote (and why), the five-day retry when the 1-day window is empty (for some minutes after the open, or no trade today), no bars (the stock did not trade, or the source has none outside trading hours), and bars and quote from different dates. - In session, and in multi-day windows after the close, Yahoo ends with a row stamped at the last trade time (e.g. 09:02:16 or 16:14:43); it is a snapshot, not a bar, and is left out, in `idx.history.v1` too. Bars sit on the interval's grid from the window's first bar (09:00), so a row off that grid, or one that repeats the time of the row before it, is the snapshot. - With `--provider stockbit` the bars are Stockbit's realtime one-minute candles for the day (none outside the session). ## `idx.screen.v1` (`stocks screen`) `data.rows[]` carry the quote-level fields (`price`, `change_pct`, `market_cap`, `pe_trailing`, `pe_forward`, `price_to_book`, `dividend_yield_pct`, `eps_ttm`, `avg_volume_3m`, 52-week range) and, when `fundamentals_checked` is true, `sector`, `industry`, `roe_pct`, `roa_pct`, `net_margin_pct`, `operating_margin_pct`, `debt_to_equity`, `revenue_growth_pct`, `earnings_growth_pct`, `peg`, `price_to_sales`. `data.universe`, `quoted`, `matched_quote_level`, `fundamentals_checked`, `fundamentals_capped` and `total_matches` describe the funnel, so a reader can tell a genuine "no match" from a capped fundamentals pass. `rows[].currency` and `rows[].financial_currency` (optional) are the price's currency and the statements' when the source names them: Yahoo's `currency` and `financialCurrency` from the quote, else from the fundamentals, as under `idx.quotes.v1` and `idx.fundamentals.v1`. A row's `price_to_book` and `price_to_sales` are both in `financial_currency` terms: a fundamentals report over statements in another currency fills neither (under `auto`, an MSN report for a company whose quote names USD statements, since MSN names no statement currency). Filters apply to the values as published, with one exception below. `price_to_book` comes from the quote and is negative for a company whose shareholders' equity is below zero, so `pb<1` matches those too; add `pb>0` (`pb>0,pb<1`) to keep only companies trading below a positive book value. A negative `debt_to_equity` means the same, so `de<0.5` matches it too; add `de>0`. The exception: when a row's `financial_currency` differs from its `currency` (issuers such as BSSR, GEMS and ITMG report in USD), its `price_to_book` and `price_to_sales` divide the IDR price by USD figures (BSSR's P/B 48,921.57 on 2026-11-06). The row keeps them as reported, but they are not ratios, so `pb` and `ps` read them as missing: no `pb` or `ps` clause matches the row, low or high, and a `pb` or `ps` sort puts it last, as for a row without the value. Through 0.8.2 they were compared as published, so such a row passed `pb>3` and led `--sort pb`. P/E, EPS and the dividend yield are in the price's terms and filter as before. The table marks such a P/B with `*` and names those companies under it. Added in 0.8.2, all optional: - `data.preset`: the preset name (`top-performers`, `worst-performers`, `high-dividend`, `low-pe`, `52w-high`, `52w-low`, `high-volume`, `large-cap`) or `null`. Presets are idx's own definitions: a clause list plus a sort key and direction (`top-performers` sorts by `ret1y` descending, `low-pe` is `pe>0` ascending, `52w-high` is `range>=95`, `52w-low` `range<=5`, ...; `idx stocks screen --help` lists them). `data.filters` lists every clause applied, the preset's first. - `rows[].range_52w_pct`: where the price sits in the 52-week range, computed by idx from the quote, 0 at the low and 100 at the high (not clamped; `null` without all three figures or when the high equals the low). Filter and sort key `range`. - `rows[].return_1w_pct`, `return_1m_pct`, `return_3m_pct`, `return_6m_pct`, `return_ytd_pct`, `return_1y_pct`: price returns in percent as MSN reports them (keys `ret1w`, `ret1m`, `ret3m`, `ret6m`, `retytd`, `ret1y`). They come from one request for MSN's whole-market screener list, made only when a filter, the sort or `--returns` needs them, and are `null` otherwise or when MSN lists no value. A filter or sort that needs them fails rather than returning an empty screen when the list cannot be fetched. - `data.returns_source`: `msn:screener` when the return windows were joined, else `null`. ## `idx.news.v1` (`stocks news`) and `idx.sentiment.v1` (`stocks sentiment`) News items: `title`, `url`, `source` (`kontan/investasi`, `cnbcindonesia/market`, ...), `published` (WIB), `summary`, `matched_by` (`ticker` | `name` | `all`), `scope` (`company` when the item mentions the requested ticker, else `market`), `sentiment` (-1..+1), `positive_terms`, `negative_terms`. With a symbol, `items` lists company headlines first and then market headlines up to the limit; `company_matches` counts the company headlines before the limit and `company_only` records whether market context was suppressed. The sentiment report (company headlines only) adds `label` (`positive` | `negative` | `neutral` | `no_coverage`), `score`, counts, the covered period, top terms and the strongest `headlines`, plus `method` describing the lexicon rule. MSN Money's crowd sentiment is not part of it: `Finance/SentimentBrowser` returns no data for IDX tickers in any market tried (checked 2026-11-06), so `idx.sentiment.v1` stays headline-based. ## `idx.insights.v1` (`stocks insights`) MSN Money's rule-based statements about one company: ```json { "symbol": "BBCA", "name": "Bank Central Asia Tbk PT", "updated": "2026-11-05T17:51:31Z", "good": 17, "bad": 4, "neutral": 0, "not_rated": 9, "peers": ["BBKP", "BABP", "SUPA", "INPC"], "insights": [ { "category": "Valuation", "name": "P/B ratio", "statement": "BBCA is trading at a P/B multiple (2.10x) above weighted average of its peers (0.56x)", "short_statement": "...", "status": "bad", "criteria": "lower_is_better", "value": 2.09778355810005, "benchmark_value": 0.564114114036551, "compared_with": "peers" } ] } ``` - `insights[]` keeps MSN's order: `Valuation`, `Earnings`, `Growth`, `Performance`, `Health`. The same `name` can appear twice with different benchmarks (peers and industry). - `status`: `good`, `bad`, `neutral` or `not_rated` (MSN's `NA`, usually missing data; an unknown rating is also listed as `not_rated`, with a warning). `criteria`: `lower_is_better` or `higher_is_better`. - `value` and `benchmark_value` are as MSN reports them and their units differ by statement: multiples (P/E 10.2), fractions (growth 0.038), rupiah (revenue). For a statement MSN could not rate, a 0/0 pair is its placeholder for missing data and is reported as `null`. - `compared_with` is derived by idx from MSN's benchmark object: `peers` (the peer group's aggregate), `industry` (the industry aggregate), `own_history` (the company's earlier quarters or years), `price` (analyst target against the current price) or `threshold` (a fixed level, e.g. the analyst rating scale); `null` for a benchmark idx does not recognise. - `peers`: the IDX tickers in MSN's peer group, resolved with one `Finance/Quotes` call; empty with a warning when that lookup fails. - `updated`: when MSN last recomputed the statements (UTC), about once a day. An unknown ticker exits 4; a rejected MSN key exits 3 and names `IDX_MSN_API_KEY`. ## `idx.dividends.v1` (`stocks dividends`) `dividends[]` (`date` = ex-date, `amount` per share as reported, split-adjusted on Yahoo) and `splits[]` (`date`, `numerator`, `denominator`, `ratio`) oldest first, with `price` and `price_date` from the same response. Computed by idx and documented as such: `years[]` (`year`, `payments`, `total`, `change_pct`), `last`, `ttm_total`, `ttm_payments`, `ttm_yield_pct` (= TTM total / price), `consecutive_years`, `as_of`. ## `idx.broker.v1` (`broker`) Array, one summary per requested ticker, from Indo Premier (IPOT) or, with a working Stockbit session, from Stockbit (`--source auto`, the default; see below): `symbol`, `name` (bundled universe), `from` and `to` (the range the source applied, WIB dates), `investor` (`all` | `foreign` | `domestic`), `board` (`all` | `regular` | `cash` | `negotiated`), `buyers[]` and `sellers[]` (each broker's gross buying or selling, ranked by lots, most first: `rank`, `code`, `name` from the bundled IDX member list or `null`, `kind` = `local` | `foreign` | `state_owned` or `null`, `lots` (1 lot = 100 shares), `value` in IDR, `average_price`), and the range totals `total_value`, `total_lots`, `foreign_net_value` (foreign buy minus sell), `average_price` (`null` when nothing traded), `buy_value` and `sell_value` (what the selected investors bought and sold, IDR). Added in place, after `rounded`: `buyer_count` and `seller_count` (brokers on each side as the source counts them, or `null`), `buyers_complete` and `sellers_complete` (`true` when the list holds every broker on its side) and `source` (`ipot` | `stockbit`, the source of this summary; `--source auto` can mix them in one call, and the envelope's `provider` is the first summary's). What IPOT's totals mean depends on the filters (Stockbit's follow the list): - All investors: `total_value`, `total_lots` and `average_price` are the range's; `buy_value` and `sell_value` both equal the value traded. - One side (`investor` `foreign` or `domestic`): the source's totals are that side's buying, so `total_value`, `total_lots` and `average_price` are `null`, `buy_value` is the side's buying as published, and `sell_value` is computed by idx from the foreign net: foreign `buy_value - foreign_net_value`, domestic `buy_value + foreign_net_value`. BBCA on 2026-11-05: foreign investors bought 605.4 B and sold 301.5 B, domestic investors bought 242.8 B, of 848.3 B traded. - A board filter: the source's foreign net covers every board whatever the filter, so `foreign_net_value` is `null`, and so is a side's `sell_value`. IPOT abbreviates figures of a million and more to one decimal (`1.6 M`); those arrive as plain numbers at that precision and the summary carries `rounded: true`. Nothing is netted per broker or estimated. A range without trades has empty lists and a warning. Over a range that starts before the source's latest session and contains it, the source's foreign net counts that session twice (its value, lots and lists are right). When a range reached the last two trading days at the time it was read, `foreign_net_value` is therefore the sum of the last two sessions with trades, each read on its own, and of the rest of the range. That is usually three more requests (four when the range reaches a session still trading), up to six when the latest sessions had no trades (single sessions are read back from the end, at most five, until two had trades), and more only when a part of the range cached while it was current has to be split again; most of them are cached. If those cannot be read it is `null` with a warning. IPOT publishes the top 10 buyers and the top 10 sellers and no count, so its `buyer_count` and `seller_count` are `null` and its lists incomplete, unless a side has fewer than ten rows: then the list is every broker on that side and the count is its length. A page with trades but no lists yet (a session still trading) is incomplete. Under `auto` without a working session a warning says so when a list reached ten rows. From Stockbit (`/marketdetectors/` in gross mode, see [stockbit.md](https://ihsg.midoriresearch.com/docs/stockbit.md#broker-summary)) every broker that bought and every broker that sold is listed and counted; Stockbit ranks by value and idx orders each side by lots, so `rank` means the same as IPOT's (ties keep Stockbit's order). Figures are exact (`rounded: false`); odd lots count as fractions of a lot. `total_value`, `total_lots`, `buy_value` and `sell_value` are sums over the full lists, `null` when a list is cut, and `average_price` is `total_value` over the shares (`total_lots` × 100), computed by idx. For all investors `foreign_net_value` comes from a second read of foreign investors over the same range and board, their buying less their selling (with a board filter it is that board's); for one investor side it is that side's buying less its selling (foreign) or the opposite (domestic). Stockbit is not read for domestic investors or the cash and negotiated boards yet: `auto` takes IPOT there. Stockbit's classes (`Asing`, `Lokal`, `Pemerintah`) can differ from IPOT's. With `-o csv` the rows are one broker per side: `symbol, from, to, investor, board, side (buy|sell), rank, code, name, kind, lots, value, average_price, source, side_count, side_complete` (the last three: the summary's source, and that side's `buyer_count` or `seller_count` and completeness). ## `idx.bandar.v1` (`bandar detect`) Array, one reading per requested ticker, derived from the IPOT broker summary (see `idx.broker.v1`) and the price; the rules are in `src/analysis/bandar.rs`. `symbol`, `name`, `period` (`1d` | `1w` | `1m` | `3m`, or `custom` for explicit dates), `from`, `to`, `investor`, `board`, `top` (N behind the headline: 1, 3, 5 or 10), `signal` (`big_accumulation` | `accumulation` | `neutral` | `distribution` | `big_distribution`: top-N net at +15 %, +5 %, -5 %, -15 % of value), `phase` (`quiet_accumulation` | `markup` | `quiet_distribution` | `markdown` | `neutral`, `null` without a price move), `net_value` (IDR), `net_lots` and `net_pct` (of value) of the top-N tier, `price` (last price of the final session: the quote's when the range ends on the quote's session, otherwise the close), `price_change_pct` (from the close before `from`), `bandar_average` (volume-weighted average of the top-N buyers) and `bandar_gain_pct` (`price` against it), `total_value`, `total_lots`, `foreign_net_value`, `foreign_net_pct`, `tiers[]` (one per N in 1, 3, 5, 10: `top`, `buy_value`, `sell_value`, `net_value`, `buy_lots`, `sell_lots`, `net_lots`, `net_pct`, `buy_share_pct`, `sell_share_pct`, `buy_average`, `sell_average`, `signal`), `buyers[]` and `sellers[]` (the top N, shaped as in `idx.broker.v1`), `daily` (`null` unless `--daily`: `days[]` of `date`, `total_value`, `net_value`, `net_pct`, `signal`, `cumulative_net_value`, `foreign_net_value`, then `accumulation_days`, `distribution_days`, `neutral_days`, `streak` (+3 = three accumulation sessions in a row, -2 = two distribution sessions) and `cumulative_net_value`), `reasons[]` and `rounded`. A range without trades reads `neutral` with a single reason. `foreign_net_value` over several sessions is rebuilt from single sessions as described for `idx.broker.v1`; `bandar screen` reads its ranges the same way, while `bandar broker`, which shows no foreign net, reads only the range pages. With an `investor` filter the brokers are that side's, while `total_value`, `total_lots` and every percentage stay those of the stock's value traded, read from the all-investor summary of the same range and board (one more request per range, and per session with `--daily`). ## `idx.bandar.screen.v1` (`bandar screen`) An object: `period`, `from`, `to`, `top`, `preset` (or `null`), `filters` (every clause, the preset's first), `sort`, `descending`, `scanned` (tickers considered: the most traded by value in the latest session, or the ones given), `checked` (those with trades), `total_matches` and `rows[]`: `symbol`, `name`, `signal`, `phase`, `net_pct`, `net_value`, `net_lots`, `price`, `price_change_pct`, `bandar_average`, `bandar_gain_pct`, `buy_share_pct`, `total_value`, `foreign_net_value`, `foreign_net_pct`, `top_buyers[]` and `top_sellers[]` (broker codes), `rounded`. Filter keys: `acc` (`net_pct`), `net`, `lots`, `fnet`, `fpct`, `gain`, `chg`, `turnover`, `conc` (`buy_share_pct`), `price`, `signal=`, `phase=`. With `-o csv` the rows are the screen rows. ## `idx.bandar.broker.v1` (`bandar broker`) An object: `code`, `name` (bundled member list), `period`, `from`, `to`, `scanned`, `checked` (scanned stocks with trades), `failed`, `bought_value` and `sold_value` (sums of `value` over every row, before `--limit`), then `bought[]` (stocks where the broker is a top-10 buyer) and `sold[]` (top-10 seller), largest value first: `symbol`, `name`, `rank` on that side, `lots`, `value`, `average_price`, `share_pct` (of all value traded in the stock), `opposite_value` (the broker's value on the other side when it is also top 10 there, else `null`) and `net_value` (buy minus sell, in both lists, when both sides are visible). Only the top 10 per side are published, so a broker below tenth place in a stock is absent; nothing is estimated for it. The web terminal answers `bandar screen` and `bandar broker` from the Worker's scan of the last session instead of scanning live: from 16:45 WIB after each close it waits for IPOT to publish the session's broker lists, then reads the 100 most traded stocks. The shapes are the same. When the newest trading session is not the one shown, `warnings` say which one is shown and why (the newer scan is waiting for IPOT, running, ended without IPOT publishing the session or answering, read too few stocks, or starts at 16:45 WIB) and, from two sessions behind, how many sessions behind it is. They also count the shown session's stocks without a broker summary or not published by IPOT and say when the scan reads those again or, when that would be at or after the next session's 16:45 WIB, that they count as failed or not published then. They also say when its stocks were picked from the quote board of another session (a scan that started after the next open). Each stock counts once in `scanned`. `fetched_at` is when the shown session's scan last stored a stock. Before it stored one, it is when the session's record was filed, or when the scan started while it has no record yet. For a session the Worker's earlier code scanned to the end, it is when that scan ended; for one it kept no record of, 16:45 WIB of the session, the earliest its scan could have read it. ## `idx.actions.v1` (`stocks actions`) An object, from IPOT: `symbol`, `name`, `since` (the source's filter; upcoming events are included), `actions[]` newest cum date first (`kind` = `cash_dividend` | `stock_dividend` | `stock_split` | `reverse_split` | `bonus` | `rights_issue` | `warrant` | `other`, `label` as published, `amount` (IDR per share: the dividend, or a rights issue's exercise price; `null` where the source shows 0), `ratio_old` and `ratio_new` (old : new, `null` for cash dividends), `cum_date`, `ex_date`, `recording_date`, `distribution_date`, `end_date` (end of a rights trading period, or the last step of a split or bonus), `status`, `details`), `meetings[]` (RUPS: `date`, `time`, `status`, `venue`, `agenda`) and `warrants_rights[]` (`code`, `kind`, `exercise_price`, `shares`, `listing_date`, `maturity_date`, `status`, `details`). ## `idx.foreign.v1` (`stocks foreign`) An object, from idx.co.id's daily stock summary: `symbol`, `name`, `from`, `to`, `days[]` oldest first (`date`, `close`, `change`, `volume`, `value`, `foreign_buy`, `foreign_sell`, `foreign_net` in shares, and `foreign_net_value_at_close`) and `totals` (`sessions`, `foreign_buy`, `foreign_sell`, `foreign_net`, `foreign_net_value_at_close`, `net_buy_sessions`, `net_sell_sessions`, `streak`: +3 = three net-buy sessions in a row, -2 = two net-sell sessions, 0 when the latest was flat). IDX publishes foreign trading in shares only; `foreign_net_value_at_close` is net shares × close, a rupiah scale for comparing stocks, not the value foreigners traded at. `volume` and `value` exclude non-regular (negotiated) trades, as the source reports them. `--days N` (MCP `days`) gives the latest N sessions; `--from D [--to D]` (MCP `from`, `to`) the sessions of a range among the latest 250. `--source ipot` (MCP `source: "ipot"`) answers with `idx.foreign.value.v1` instead. ## `idx.foreign.value.v1` (`stocks foreign --source ipot`) An object, from Indo Premier's broker summary (`idx schema foreign-value`): `symbol`, `name`, `from` and `to` (first and last session shown), `days[]` oldest first, one per session the stock traded: `date`, `value` (traded by all investors on all boards, IDR), `lots`, `average_price`, `foreign_buy_value` (as published), `foreign_sell_value` (`foreign_buy_value - foreign_net_value`, computed by idx), `foreign_net_value` (as published) and `foreign_share_pct` (`(buy + sell) / (2 × value) × 100`, computed by idx); `totals` (`sessions`, `value`, `foreign_buy_value`, `foreign_sell_value`, `foreign_net_value`, `foreign_share_pct` over all the sessions, `net_buy_sessions`, `net_sell_sessions`, `streak` as in `idx.foreign.v1`) and `rounded`. Every figure is in rupiah across all boards, negotiated trades included, so it is not comparable field by field with `idx.foreign.v1`'s regular-board shares; the two share no field names. Each session costs two single-session requests (all investors and foreign investors; a range's own foreign net is not used, see `idx.broker.v1`), cached, at most 60 sessions. A day without trades is skipped; a traded day on which no foreigner traded has zeros. The source publishes a session after the close: while today trades it is left out with a warning, and so is any day whose page shows trades without broker lists (not published yet; such a page is cached for minutes, not the week). A range with no published session with trades is not found (exit 4), and says so when a session was left out for that reason; when a day failed, that failure is the error instead, since the day may have traded. `--days N` with fewer sessions with trades in the days read gives those, with a warning. It rounds figures of a million and more to one decimal (`1.2 T` is ±4 %), and `foreign_sell_value` carries the error of both its terms. ## `idx.market.foreign.v1` (`market foreign`) An object for one session: `date`, `stocks`, the market sums `foreign_buy`, `foreign_sell`, `foreign_net` (shares) and `foreign_net_value_at_close`, `sort` (`value` | `shares`), and `net_buy[]` / `net_sell[]` (largest first): `symbol`, `name`, `close`, `change`, `change_pct`, `volume`, `value`, `foreign_buy`, `foreign_sell`, `foreign_net`, `foreign_net_value_at_close`. ## `idx.market.foreign.value.v1` (`market foreign --source ipot`) The same question from Indo Premier, in rupiah, in a shape of its own: never mixed with `idx.market.foreign.v1`, whose figures are idx.co.id's shares. An object for one session: `date`, `scanned` (the stocks read: the 100 most traded by value on the latest quotes, the bandar screen's scan, not every stock), `checked` (those with a broker summary that had trades), `market`, `net_buy[]` / `net_sell[]` (largest first) and `rounded`. Each row: `symbol`, `name`, `price`, `change_pct` (from the previous session's close), `value` (traded on all boards, IDR), `foreign_net_value` (the broker summary's `F. NVal`, all boards, as published) and `foreign_net_pct` (of `value`, computed). `market` is IPOT's market statistics for the session: `value` (its `TOTAL`: regular, negotiated and cash), `foreign_buy_value`, `foreign_sell_value` and `foreign_net_value`, as published; IPOT does not say which boards its foreign figures cover. `provider` is `ipot`. The session is the latest one IPOT has published broker summaries for (the bandar screen's `1d`); a `--date` before it is a usage error and a later one `not_found`, and `--sort shares` (idx.co.id's net shares) a usage error. The market statistics page carries no date and is dated as IPOT's broker ranking is (see `idx.market.brokers.v1` below), with its own copy of BBCA's statistics page: when it cannot be dated, or is another session's than the stocks', `market` is `null` and a warning says why, so on a trading day from 08:58 to 19:00 WIB it is `null` unless a copy read outside those hours can answer. Figures of a million and more are abbreviated, to one decimal on the broker summaries and two on the market statistics (`rounded: true`). The web terminal's `market foreign` (`/api/market/foreign`) answers with this shape from the nightly bandar scan's `1d` rows in D1 (the session's 100 most traded stocks) and IPOT's market statistics, read and dated as its broker ranking is there. idx.co.id refuses the site's Cloudflare Worker, so `source=idx` and `sort=shares` are `usage` errors; the scan's warnings say which session is shown when it is not the latest. ## `idx.market.brokers.v1` (`market brokers`) An object for one session: `date`, `members` (with trades), `total_volume`, `total_value`, `total_frequency`, `sort` (`value` | `volume` | `frequency`), `rounded` and `brokers[]`: `rank`, `code`, `name` (as the source publishes it), `volume`, `value`, `frequency`, `value_share_pct`, then `buy_value`, `sell_value`, `net_value` (bought minus sold) and `kind` (`local` | `foreign` | `state_owned`), which only IPOT publishes: `null` from idx.co.id. A member's buying and selling count together, so `total_value` is twice the market's value. `provider` says which source answered: `idx`, idx.co.id's daily broker summary (any session), or `ipot`, IPOT's ranking of its latest session (`--source ipot`, MCP `source: "ipot"`). `auto`, the default, asks idx.co.id first; when idx.co.id refuses (Cloudflare), cannot be reached, fails, offline has no copy, or has not published the session yet, IPOT answers with a warning saying why, but only if it holds the session asked for: the `--date`, or with none the latest session that has ended. Otherwise idx.co.id's answer stands, with what IPOT holds or why it could not be read. IPOT could answer for no `--date` or the latest session that has ended, outside a trading day's 08:58 to 19:00 WIB. There `auto` does not wait out a Cloudflare challenge: idx.co.id is asked once, without the 30 s of retries, and IPOT answers at once (a cached copy of idx.co.id's summary still answers first, marked stale); only if IPOT cannot stand in after all is idx.co.id asked again with the retries. `--source idx` (MCP `source: "idx"`) always retries, and its `blocked` error names `--source ipot` (MCP `source: "ipot"`) where IPOT could answer. A session idx.co.id has not published yet (its summary read on its own day has no rows) is named whatever the source: with no `--date` the session before comes with a warning, and a `--date` of it is `not_found`. IPOT takes no date: a `--date` before its session, or after today, is a usage error; a later one up to today, or a day without a session, is `not_found`; and under `auto` idx.co.id's error stands. It abbreviates figures of a million and more to two decimals (`2.79 T`, so `rounded: true`) and publishes `net_value` with its own two decimals, so it can differ from `buy_value - sell_value`. Members without trades are left out, as idx.co.id leaves them out; a broker IPOT lists apart under another member's code (`JB`, `[CC] BJB Sekuritas Jawa Barat`) is counted in that member, as idx.co.id counts it, with a warning. IPOT's ranking page carries no date: the session comes from a second page, the `Today` row of BBCA's statistics, read with the ranking (within a minute). When the two pages roll over to a new session has not been measured, so a ranking read on a trading day from 08:58 to 19:00 WIB cannot be dated: in those hours the source answers a `provider` error (a copy read then is `offline_miss` when served offline, and `network`, naming the failure, when served because IPOT could not be read), under `auto` idx.co.id's error stands, and IPOT is not asked at all unless a copy read outside those hours is cached. Until the rollover is measured, both pages are assumed to have rolled over by 19:00 WIB. A date page that names an older session than the latest that had ended when the pages were read is then refused, unless the pair is the very one accepted for that session before. A ranking that has not rolled over by 19:00 while its date page has is caught only when this cache remembers the previous session's ranking, from an earlier IPOT read: the same figures under a new date are refused. The web terminal's `brokers` (`/api/market/brokers`) answers from IPOT only, with this shape and the `--source ipot` rules and messages: idx.co.id refuses the site's Cloudflare Worker, so `source=idx` is a `usage` error there, and `source=auto` reads IPOT with a warning saying so. The Worker keeps the two pages as one pair and replaces it only when both were read again, so the copy that stands in for a failed read (`stale: true`) is always a pair read together. ## `idx.indices.v1` (`market indices`) `date` and `indices[]` in the source's order (IHSG first): `code` (`COMPOSITE` is IHSG), `previous`, `high`, `low`, `close`, `change`, `change_pct` (computed), `constituents`, `volume`, `value`, `frequency`, `market_cap`. ## `idx.calendar.v1` (`market calendar`) `from`, `to`, `symbol` (filter or `null`), `categories` (shown), `total` (entries in the range before the category filter) and `events[]` oldest first: `date`, `code` (a ticker, or an issuer such as `DJPU` for government bonds), `category` (`dividend` | `rights` | `bonus` | `split` | `warrant` | `rups` | `public_expose` | `buyback` | `listing` | `bond` | `other`), `milestone` (`cum`, `ex`, `recording`, `payment`, `distribution`, `listing`, `trading_start`, `trading_end`, `plan`, `notice`, `result`, `material`, `coupon`, `principal`, `maturity`, `report`, or `null`), `description` as published, `location`, `meeting_at` (RUPS date and time, WIB), `expose_date`, `source_type` and `source_step` (the source's own fields) and `id`. Category and milestone are classified by idx from the wording; entries the source lists twice are shown once. ## `idx.holidays.v1` (`market holidays`) `year` (or `null` for every year), `years_covered`, `trading_days` (weekdays minus holidays in `year`), `today`, `today_is_trading_day`, `next_trading_day` and `holidays[]` (`date`, `weekday`, `name`, `name_en`). Bundled; `provider` is `bundled`. ## `idx.astro.v1` (`market astro`) ```json { "date": "2026-11-06", "at": "2026-11-06T16:00:00+07:00", "bodies": [ { "body": "sun", "longitude": 223.96, "sign": "scorpio", "degree": 13.96, "speed": 1.003, "retrograde": false }, { "body": "mercury", "longitude": 219.92, "sign": "scorpio", "degree": 9.92, "speed": -1.22, "retrograde": true } ], "moon": { "phase": "waning_crescent", "elongation": 325.98, "illumination": 0.087, "age_days": 26.7, "previous_new_moon": "2026-10-10T22:50:00+07:00", "next_new_moon": "2026-11-09T14:02:00+07:00", "next_full_moon": "2026-11-24T21:54:00+07:00" }, "mercury": { "retrograde": true, "retrograde_since": "2026-10-24T14:17:00+07:00", "retrograde_until": "2026-11-13T22:56:00+07:00", "next_retrograde": { "from": "2027-02-10T00:33:00+07:00", "until": "2027-03-03T19:41:00+07:00" } }, "weton": { "day": "Jumat", "day_en": "Friday", "pasaran": "Pahing", "neptu_day": 6, "neptu_pasaran": 9, "neptu": 15 }, "shio": { "animal": "horse", "animal_id": "Kuda", "element": "fire", "year_start": "2026-02-17" }, "upcoming": { "from": "2026-11-06T00:00:00+07:00", "to": "2026-12-06T00:00:00+07:00", "events": [ { "at": "2026-11-09T14:02:00+07:00", "kind": "new_moon", "body": "moon", "sign": "scorpio" } ] }, "natal": { "chart": "launch", "date": "1983-04-01", "at": "1983-04-01T12:00:00+07:00", "bodies": [ ... ], "transits": [ { "transit": "saturn", "aspect": "trine", "natal": "uranus", "orb": 0.03, "applying": false } ] }, "backtest": null, "method": "Apparent geocentric positions in the tropical zodiac ...", "disclaimer": "Traditional astrology, not a forecast or investment advice: ..." } ``` - Computed on the machine (no network) for 16:00 WIB on `date`, the IDX close; `provider` is `computed` and `cached` is `false`. With `--backtest` the envelope carries the `^JKSE` history's provenance instead (`provider` `yahoo`, `cached`, `stale`, `fetched_at`, warnings). - `bodies` lists the Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune and Pluto in that order: apparent geocentric ecliptic longitude of date (tropical zodiac) to 2 decimals, the `sign` and `degree` of that rounded longitude, `speed` in degrees per day (3 decimals, negative while retrograde) and `retrograde` (always `false` for the Sun and Moon). It is the only top-level list, so `-o csv market astro` prints it. - `moon.phase` is one of eight 45° sectors of the Moon-Sun elongation centred on the exact phases (`new_moon`, `waxing_crescent`, `first_quarter`, `waxing_gibbous`, `full_moon`, `waning_gibbous`, `last_quarter`, `waning_crescent`), so `full_moon` spans about 3.7 days; the exact instants are in `upcoming.events`. `illumination` is the lit fraction 0-1 (3 decimals), `age_days` the days since the previous new moon. - `mercury`: `retrograde_since` and `retrograde_until` are the stations around the current retrograde (`null` when direct); `next_retrograde` the next period starting after `at`. - `weton`: the civil date's day and pasaran (Legi, Pahing, Pon, Wage, Kliwon) with their neptu. Javanese days start at sunset; idx uses the civil date. - `shio`: the Chinese year holding the date (`year_start` is its Imlek, a China Standard Time date); `null` before Imlek 1924 and after 2050. - `upcoming.events` (`kind`: `new_moon`, `first_quarter`, `full_moon`, `last_quarter`, `station_retrograde`, `station_direct`, `ingress`) from 00:00 WIB on `date` for `--days` days (1-366; the web terminal takes up to 90), oldest first, rounded to the minute. `sign` is the sign the body is in, or the one it enters. The Moon's own sign changes are not listed. - `natal` (`null` with `--natal none`): IHSG's chart at 12:00 WIB on `1983-04-01` (`launch`, the default) or `1982-08-10` (`base`), dates from secondary sources. `bodies` adds `approximate` (`true` for the Moon only: the time of day is unknown). `transits` lists the aspects (`conjunction`, `sextile`, `square`, `trine`, `opposition`) of the Sun and Mercury to Pluto at `at` to the natal Sun and Mercury to Pluto within 2°, closest first; `applying` means the orb shrinks over the next day. - `backtest` (`null` unless asked): `symbol` (`^JKSE`), `period` (`1y`, `2y`, `5y`, `10y`, `max`; the web terminal stops at `10y`), `from` and `to` (session dates of the first and last return), `returns`, `mean_pct`, `median_pct` (4 decimals), `win_rate_pct` (2 decimals), `tests`, `alpha` (0.05), `bonferroni_alpha` (`alpha / tests`, 6 decimals), `significant` (groups below it), `factors[]` and `method`. Each factor (`moon_phase`, `mercury`, `pasaran`, `sun_sign`) lists its `groups[]`: `group`, `n`, `mean_pct`, `median_pct`, `stdev_pct` (4 decimals; `null` when `n` is 0, `stdev_pct` when `n` < 2), `win_rate_pct`, `t_stat` (Welch's t against all other sessions, 3 decimals) and `p_value` (two-sided, 6 decimals), `null` with fewer than two sessions on either side, and `significant`. Returns are close to close, one close per WIB date; a session that was still trading when the history was fetched (on its date, before 17:00 WIB) is left out with a warning, however much later the cached copy is read. A history coarser than daily is a provider error; fewer than 31 closes is `not_found`. - `method` and `disclaimer` are fixed text. There is no interpretive text in v1; meanings, if added, will be a new optional field. ## `idx.instruments.v1` (`stocks list`) and `idx.members.v1` (`market members`) `idx.instruments.v1`: `total`, `board` and `search` (filters or `null`) and `instruments[]` (`symbol`, `name`, `listing_date`, `shares`, `board`). `idx.members.v1`: `total`, `search` and `members[]` (`code`, `name`, `licenses[]`). When idx.co.id cannot be reached and nothing is cached, the bundled lists stand in (`provider: bundled`, a warning, and `null` for what they lack). ## `idx.returns.v1` (`stocks returns`) Array, one `Performance` per requested ticker: `as_of`, `price`, `start`, `candles_used`, `returns` (`1w 1m 3m 6m ytd 1y 3y 5y`, percent, `null` when the history is too short), `cagr_3y_pct`, `cagr_5y_pct`, `volatility_1y_pct` (annualised stdev of daily log returns), `max_drawdown_1y` and `max_drawdown_all` (`pct`, `peak_date`, `trough_date`, `recovered`), `best_day_1y`, `worst_day_1y`, `high_52w`, `low_52w`, `pct_from_52w_high`, `pct_from_52w_low`, `days_since_52w_high`, and `benchmark` (`symbol`, `name`, `returns`, `excess` in percentage points) or `null`. Everything is computed from five years of daily closes; dividends are excluded. ## `idx.market.v1` (`market overview`) `as_of`, `market_state`, `behind_session` (`true` when even the newest quote, `as_of`, is dated before the latest IDX session that has ended; same rule as the quote field), `index` (a `MarketRow` for `^JKSE`), `universe`, `quoted`, `advancing`, `declining`, `unchanged`, `turnover`, `liquid_min_market_cap`, and the lists `gainers`, `losers`, `most_active`, `heavyweights` of `MarketRow` (`symbol`, `name`, `price`, `change`, `change_pct`, `volume`, `value`, `market_cap`). The web terminal serves the same shape plus `index_tape`, `refreshed_at` and `market_open` (false on weekends and IDX holidays). Its quote, market and screen answers come from a board a cron refreshes every minute in trading hours and every 15 minutes outside them; a board older than that allows (10 or 35 minutes) is served with `stale: true`, `stale_reason: "fetch_failed"` and a warning giving its time. ## `idx.brief.v1` (`stocks brief`) `symbol`, `name`, `generated_at`, `key_points[]` (plain-language lines), then one section per topic, each either `null` or the dedicated command's object: `quote` (one `Quote`, with `behind_session`; a key point names the last trade date when it is `true`), `fundamentals` (`idx.fundamentals.v1` data), `technical`, `returns` (one `Performance`), `dividends` (`ttm_total`, `ttm_payments`, `ttm_yield_pct`, `consecutive_years`, `last`, `payments_on_record`), `ownership` (`as_of`, `listed_pct`, `foreign_pct`, `holders_listed`, `top_holders[]` with the same fields as `ownership ticker`), `news` (`company_matches`, `sentiment`, `score`, `headlines[]`), and `sections_failed[]` naming the `null` sections, with the reason in the envelope's `warnings`. ## `idx.ownership.*.v1` (`ownership ...`) `ticker` (`idx.ownership.holders.v1`) and `entity` (`idx.ownership.entity.v1`) list `Holding` rows: one holder's position in one issuer, the holder's register lines (one per SID, one per scrip account without an SID) summed. Since 0.8.2 each row adds `accounts` (lines summed; `null` for months imported by idx 0.8.1 or older, which kept one line) and `controller_or_affiliate` (`CONTROLLER`, `AFFILIATE OF THE CONTROLLER` or `null`, from the register as of 30 Oct 2026); both reports add `controller_reported` (whether the release has that column). `changes` rows add `accounts_from`, `accounts_to` and `controller_or_affiliate` (later release, earlier one for exits). `releases` rows keep `rows` (register lines) and add `positions`, `other_date_rows` and `controller_column` (`null` for months imported by idx 0.8.1 or older). Notes about the months read (imported by an old idx, rows dated otherwise) come in the envelope's `warnings`. The versions stay `v1`: every change is an added field, and the values now match the documented "one entity's position". The analytics read the same positions of one release (`as_of`; percentages on the 0-100 scale, rounded to 4 decimals at the end): - `search` (`idx.ownership.search.v1`): `query`, `as_of`, `total_matches`, `matches[]` with `entity_key` (pass it to `entity --exact` or `graph`), `holder`, `matched` (`exact`, `spelling`, `prefix`, `contains`, `words`), `spellings[]` (every spelling recorded, any month), `issuers` and `total_pct` in `as_of` (0 when absent that month), `investor_type`, `local_foreign` (largest position; `null` when absent) and `last_seen`. - `cross-holders` (`idx.ownership.cross-holders.v1`): the filters applied (`min_issuers`, `investor_type`, `origin`), `total_matches` and `holders[]` with `issuers`, `total_pct` (a breadth measure, not a share of anything), `max_pct` and `positions[]` (`symbol`, `issuer_name`, `shares_total`, `pct`). - `concentration` (`idx.ownership.concentration.v1`): `sort` (`hhi`, `top1`, `top3`), `ascending`, `total_matches`, `controller_reported` and `rows[]` with `holders_listed`, `top_holder`, `top1_pct`, `top3_pct`, `listed_pct`, `undisclosed_pct`, `hhi` (sum of the listed holders' squared stakes, 0-10,000) and `controller_pct` (`null` when the release has no controller column). - `graph` (`idx.ownership.graph.v1`): `root` (`ticker:` or `holder:`), `depth`, `min_pct`, `max_nodes`, `truncated`, `issuers[]` and `holders[]` with their `hops`, then `links[]` (`from`, `to`, `holder`, `symbol`, `shares_total`, `pct`, `hops`). `links` comes last, so `-o csv` prints the links. - `resolve candidates` (`idx.ownership.resolve-candidates.v1`): `keep` and `merge` (`entity_key`, `holder`, `issuers`, `last_seen`), `reasons` (`renamed`, `similar_name`), `renamed[]` (`from`, `to`, `symbols`) and `listed_together[]` (`as_of`, `symbol`). - `resolve merge|map|undo` (`idx.ownership.resolution.v1`): a list of `action`, `dry_run`, `resolution` (`id`, `kind`, `subject`, `holder`, `entity_key`, `combined[]`, `created_at`), `spellings_moved`, `lines_moved`, `positions_moved`, `positions_combined` and `releases[]`. `resolve list` (`idx.ownership.resolutions.v1`) lists the stored `resolution` objects. `discover` (`idx.ownership.discover.v1`) entries add `family` (`above1`, `above5`, `investor-type`, `classification` or `composition`) and `importable` (`true` for `above1` and `composition`); `origin` can now also be `ksei.co.id`. The envelope's `provider` names where the entries came from: `idx` (idx.co.id), `github-mirror` (the mirror, when idx.co.id refused), `ksei` (ksei.co.id only, as with `--family composition`), or `idx+ksei` and `github-mirror+ksei` when the families were listed from both. `import` (`idx.ownership.import.v1`) reports add `family` (`above1` or `composition`) and `other_securities` (composition only: rows of other security types left out). For a composition month `release.rows` and `release.issuers` count the stocks stored, `release.entities` is 0 and `positions`, `other_date_rows` and `controller_column` are `null`. Both stay `v1` (added fields). ## `idx.ownership.composition.v1` (`ownership composition`) KSEI's monthly "Kepemilikan Efek" archive for one stock (see [ownership.md](https://ihsg.midoriresearch.com/docs/ownership.md#ksei-composition-kepemilikan-efek)). `provider` is `ksei` when the call downloaded the month, `local-db` otherwise. With `-o csv` the rows are the nine investor types, whatever the number of imported months: `symbol, as_of, code, investor_type, local_shares, foreign_shares, local_pct, foreign_pct`. ```json { "symbol": "BBCA", "as_of": "2026-10-30", "price": 4980.0, "shares_listed": 123275050000, "shares_scripless": 52453691120, "scripless_pct": 42.5501, "local_shares": 17194269088, "foreign_shares": 35259422032, "local_pct": 32.7799, "foreign_pct": 67.2201, "by_type": [ {"code": "ID", "investor_type": "INDIVIDUAL", "local_shares": 12030567470, "foreign_shares": 324800480, "local_pct": 22.9356, "foreign_pct": 0.6192} ], "history": [ {"as_of": "2026-09-30", "local_pct": 31.1106, "foreign_pct": 68.8894, "scripless_pct": 42.5501, "foreign_change_pp": null}, {"as_of": "2026-10-30", "local_pct": 32.7799, "foreign_pct": 67.2201, "scripless_pct": 42.5501, "foreign_change_pp": -1.6693} ], "source": "https://www.ksei.co.id/storage/Download/BalanceposEfek20261030.zip" } ``` `shares_scripless` is the local total plus the foreign total: the shares held in KSEI accounts. Shares still held as certificates are outside the archive. Every `*_pct` but `scripless_pct` is a percent of `shares_scripless` (4 decimals, `null` when it is 0); `scripless_pct` is `shares_scripless` over `shares_listed`. `by_type` always lists the nine types in KSEI's order (`IS`, `CP`, `PF`, `IB`, `ID`, `MF`, `SC`, `FD`, `OT`). `history` is every imported month for the stock, oldest first; `foreign_change_pp` is the change from the previous imported month. `price` is `null` when KSEI left it blank. ## `idx.ownership.snapshot-sync.v1` (`ownership sync`) and `idx.ownership.status.v1` (`ownership status`) `sync` (provider `github-mirror`): `action` (`installed`, `updated`, `imported` or `up_to_date`), `database`, `snapshot` (the mirror's snapshot for this idx's schema: `file`, `generated_at`, `db_schema_version`, `oldest`, `newest`, `releases`, `bytes`; `null` when the mirror has none), the months `installed` from it, `imported` from the monthly files (missing or re-published months), `kept` (only in the local database, kept without `--force`) and `dropped` (replaced with `--force`), `backup` (the `.bak` path or `null`), and `releases` / `newest` after the sync. The name is not `idx.ownership.sync.v1` because the web terminal's `/api/ownership/status` already answers with that one (the state of its D1 loader). `status` (provider `local-db`): `database`, `exists`, `bytes`, `db_schema_version`, `supported_schema_version`, `releases`, `oldest`, `newest`, `installed_snapshot` (from the file's `snapshot_info` table, `null` for a database built by local imports), `remote` (`mirror`, `index_generated_at`, `releases`, `newest`, `snapshot`; `null` offline, with `IDX_OWNERSHIP_MIRROR=off` or when the mirror cannot be read), `missing`, `outdated` (months held as the installed snapshot carried them while `index.json` lists a re-published file; sync imports them again), `local_only` and `sync` (`up_to_date`, `install`, `update`, `import` or `null` without `remote`). Status never creates the database file. `idx.ownership.snapshot.v1` is the manifest CI publishes as `snapshot-v{V}.json` next to the snapshot file (and the `data` of the hidden `ownership build-snapshot -o json`); see [ownership.md](https://ihsg.midoriresearch.com/docs/ownership.md#snapshot-and-sync). ## `idx.watchlist.v1` (`watch add|rm|list|clear`) `path` and `symbols[]`. `idx watch` itself returns `idx.quotes.v1` and `idx watch returns` returns `idx.returns.v1`. ## `idx.profile.v1` (`stocks profile`) `data` is one object: `symbol`, `name`, `sector`, `industry`, `summary`, `employees`, `founded_year`, `website`, `city`, `country`, `exchange`, `currency`, `market_cap`, `shares_outstanding`, `directors[]` (`name`, `title`; populated by MSN, empty from Yahoo) and `source`. ## `idx.fundamentals.v1` (`stocks fundamental`) `data` is one object grouped by theme. Percent fields end in `_pct` and are already percentages (`21.8` = 21.8 %). Ratios are plain multiples (`debt_to_equity: 0.6` = 60 % of equity). ```json { "symbol": "BBCA", "name": "PT Bank Central Asia Tbk", "currency": "IDR", "financial_currency": "IDR", "sector": "Financial Services", "industry": "Banks - Regional", "price": 5175.0, "beta": -0.017, "week52_high": 8750.0, "week52_low": 4820.0, "valuation": { "market_cap": 6.357e14, "enterprise_value": 6.099e14, "pe_trailing": 12.87, "pe_forward": 10.73, "peg": 1.52, "price_to_book": 2.35, "price_to_sales": 5.82, "ev_to_ebitda": null, "ev_to_revenue": 5.58 }, "profitability": { "roe_pct": 21.82, "roa_pct": 3.67, "gross_margin_pct": null, "operating_margin_pct": 66.75, "net_margin_pct": 53.12 }, "health": { "debt_to_equity": null, "current_ratio": null, "quick_ratio": null, "total_debt": 5.2e13, "total_cash": 7.8e13 }, "growth": { "revenue_growth_pct": 2.5, "earnings_growth_pct": 0.4, "revenue_cagr_5y_pct": 8.63, "eps_growth_3y_pct": 12.21 }, "dividend": { "yield_pct": 6.27, "payout_ratio_pct": 75.42, "ex_dividend_date": "2026-08-31" }, "per_share": { "eps_ttm": 402.07, "eps_forward": 482.48, "book_value": 2201.5, "revenue": 888.2 }, "analyst": { "recommendation": "strong_buy", "target_mean": 8197.5, "target_high": 10100.0, "target_low": 5900.0, "analysts": 24 }, "fiscal_year_end": "2025-12-31", "most_recent_quarter": "2026-06-30", "verdict": { "label": "fair", "score": 2, "reasons": ["trailing P/E 12.9 is moderate (10 to 15)", "..."], "inputs_available": 7 }, "sources": ["yahoo:quoteSummary", "msn:equities", "msn:quotes"] } ``` - `auto` asks Yahoo first and fills remaining `null`s from MSN; `sources` lists who contributed. Values are never averaged across sources. - Yahoo reports gross and EBITDA margins as `0` for banks; that is published as `null` because it means "not applicable", not 0 %. - MSN writes `±999.9999` in a ratio field (forward P/E, PEG, P/B, P/S, debt/equity) where it cannot compute one, notably debt/equity when shareholders' equity is negative; that is published as `null`. - `currency` is the price's currency. `financial_currency` is the currency of the financial statements when the source names it (Yahoo `financialCurrency`; MSN does not, so it is `null` under `--provider msn`). Coal, oil and gas, and airline issuers such as ITMG, MEDC and GIAA report in USD: their `price_to_book`, `price_to_sales`, `ev_to_ebitda` and `ev_to_revenue` then divide the IDR price by USD figures (ITMG P/B 14103 on 2026-11-06), and `per_share.book_value`, `per_share.revenue`, `health.total_debt` and `health.total_cash` are in USD. idx publishes them as reported, never converted; the verdict does not score P/B then, and the tables mark those multiples with `*`. Under `auto` none of these eight fields is filled from MSN, which names no statement currency, so each of them is Yahoo's or `null`. P/E, EPS and the dividend yield are consistent. - `beta` is the source's own figure. Yahoo's does not track IHSG for IDX shares (BBCA 0.066 on 2026-11-06, against 0.95 from a year of daily closes against `^JKSE`); `stocks risk` computes beta against IHSG itself. - `verdict.label` is `undervalued` | `fair` | `overvalued` | `insufficient_data` (fewer than three rule inputs). Every rule that fired is in `reasons`. It is a heuristic, not advice. - A negative `per_share.book_value` (or a negative `price_to_book` or `debt_to_equity`) means shareholders' equity is below zero. The verdict then scores one adverse rule (-2, "negative shareholders' equity ...") and sets the P/B, ROE and debt/equity rules aside; those figures still count in `inputs_available`. An ROE above 15 % next to a net loss (negative net margin or EPS) is not rewarded either. A P/B that mixes currencies is neither scored nor counted, and no reason is listed for it. ## Lenses: `idx.growth.v1`, `idx.valuation.v1`, `idx.risk.v1` (`stocks growth|valuation|risk`) Three focused readings next to the blended verdict, also returned by `stocks fundamental SYMBOL --lens growth|valuation|risk` and by MCP `idx_fundamental` with `lens`. Each `data` carries the inputs it read (the source's values as reported, `null` when absent), the figures idx computed from them, a `reading` and `sources`: ```json "reading": { "label": "moderate", "score": 2, "inputs_available": 6, "rules": [ { "rule": "liquidity", "points": 1, "reason": "current ratio 0.79 is under 1" }, { "rule": "volatility", "points": 1, "reason": "volatility 49.2% a year is 1.68x IHSG's 29.3% (over 1.5x)" } ], "notes": [] } ``` (TLKM's risk reading on the recorded fixtures, two of its six rules shown. A rule that could not run lands in `notes`, for example `"leverage and liquidity: not scored for a financial company"` for a bank.) - `rules[]` lists every rule that ran, with its `points` (0 still ran and counts) and a `reason` carrying the figure and the threshold; `score` is their sum and `inputs_available` their number. `notes[]` names every rule that could not run and why (not reported, a financial company, statements in another currency, negative equity, fewer than 3 analysts, fewer than three fiscal years, benchmark unavailable, a price that barely moved). Fewer than three rules give `insufficient_data`. - Rule ids are stable; the thresholds are heuristics, not advice. The valuation lens can disagree with the verdict of `stocks fundamental`, which also weighs quality and growth. - A lens that needs several inputs fetches them concurrently. A missing one is named in `warnings` and the reading runs with fewer rules; the call fails only when the essential inputs all fail (growth: fundamentals and statements; risk: fundamentals and the stock's candles). It fails with `not_found` when the fundamentals or the stock's candles say the ticker is unknown; a statements "not found" (a listed ticker can have none) does not count, so the fundamentals' own error (a provider failure, an offline cache miss) is the one reported. ### `idx.valuation.v1`: points favour cheapness `symbol`, `name`, `currency`, `financial_currency`, `financial_company`, `negative_equity`, `price`, `pe_trailing`, `pe_forward`, `peg`, `price_to_book`, `price_to_sales`, `ev_to_ebitda`, `ev_to_revenue` (shown, not scored), `dividend_yield_pct`, `payout_ratio_pct`, `target_mean`, `analysts`, `target_upside_pct` (computed: target over price, minus one), `reading`, `sources`. Label: `cheap` (score 3 or more), `expensive` (-3 or less), `fair`, `insufficient_data`. | rule | points | | --- | --- | | `pe_trailing` | under 10: +2; 10 to 15: +1; 15 to 25: 0; over 25: -1; over 35: -2; not positive: 0 | | `pe_forward` | only without a trailing P/E, above 0: under 10: +1; over 25: -1; else 0 | | `peg` | above 0: under 1: +1; over 2: -1; else 0 | | `price_to_book` | under 1: +1; over 4: -1; else 0. Not scored with negative equity or mixed currencies | | `price_to_sales` | under 1: +1; over 5: -1; else 0. Not for a financial company or mixed currencies | | `ev_to_ebitda` | above 0: under 6: +1; over 15: -1; else 0. Not for a financial company or mixed currencies | | `dividend_yield` | 4 % or more: +1, but 0 when the payout exceeds 100 %; under 4 %: 0 | | `analyst_target` | 3 or more analysts: upside over +20 %: +1; under -10 %: -1; else 0 | ### `idx.growth.v1`: points favour growth `symbol`, `name`, `statements_currency`, `most_recent_quarter`, `revenue_growth_pct` and `earnings_growth_pct` (latest quarter against the same quarter a year earlier, as reported), `revenue_cagr_5y_pct` and `eps_growth_3y_pct` (MSN's, when it contributed), `eps_ttm`, `eps_forward`, `eps_forward_growth_pct` (computed, both EPS above zero), `annual[]` (one per fiscal year, oldest first: `end_date`, `revenue`, `net_income`, `revenue_change_pct`, `net_income_change_pct`, the changes computed against the year before), `revenue_cagr_pct` and `net_income_cagr_pct` (computed from the oldest to the newest fiscal year with a value, at least three such years, both ends above zero), `reading`, `sources`. Label: `growing` (score 2 or more), `shrinking` (-2 or less), `mixed` (points both ways), `stable`, `insufficient_data`. | rule | points | | --- | --- | | `revenue_growth`, `earnings_growth` | latest quarter over +10 %: +1; below 0 %: -1; else 0 (a figure beyond ±100 % is marked "from a small or negative base") | | `eps_forward_growth` | forward EPS against trailing over +10 %: +1; below -10 %: -1; else 0 | | `revenue_cagr` | idx's CAGR over three or more fiscal years: over +10 %: +1; below 0 %: -1; else 0 | | `revenue_cagr_5y` | only with fewer than three fiscal years: MSN's 5-year revenue rate, as reported, same bands | | `revenue_consistency` | revenue grew every year: +1; in fewer than half the years: -1; else 0 (three or more fiscal years) | | `net_income_cagr` | idx's CAGR over three or more fiscal years: over +10 %: +1; below 0 %: -1; else 0. A profit turning into a loss: -1; a loss turning into a profit: +1 | | `eps_growth_3y` | only with fewer than three fiscal years: MSN's 3-year EPS growth, as reported, same bands | ### `idx.risk.v1`: points add risk `symbol`, `name`, `currency`, `financial_company`, `negative_equity`, `debt_to_equity`, `current_ratio`, `quick_ratio`, `net_margin_pct`, `eps_ttm`, `price` (`null` without daily candles for the stock), `reading`, `sources`. `price` is computed by idx from a year of daily closes on the dates the stock and IHSG both traded (Yahoo's `^JKSE` bar often lags the shares by a session): `benchmark` (`^JKSE`), `from`, `to`, `observations` (matched daily returns), `zero_returns` (how many of them left the stock's close unchanged; `null` without IHSG), `beta` and `correlation` (from simple daily returns, 3 dp, `null` under 120 observations), `volatility_pct` and `benchmark_volatility_pct` (annualised standard deviation of daily log returns), `max_drawdown` (`pct`, `peak_date`, `trough_date`, `recovered`; `null` when the price never fell) and `benchmark_max_drawdown_pct`. Without IHSG the price rules become notes and `price` carries the stock's own volatility and drawdown over its own dates. Label: `high` (score 3 or more), `low` (-2 or less), `moderate`, `insufficient_data`. | rule | points | | --- | --- | | `negative_equity` | +2 (any sector); the leverage rule is then skipped | | `leverage` | debt/equity over 2x: +1; under 0.5x: -1; else 0. Not for a financial company | | `liquidity` | current ratio under 1: +1; 1.5 or more: -1; else 0. Not for a financial company | | `losses` | negative net margin or EPS over the trailing twelve months: +1; else 0 | | `beta` | against IHSG, 120 or more daily returns: over 1.3: +1; under 0.7: -1; else 0 | | `volatility` | the stock's over IHSG's: over 1.5x: +1; under 0.8x: -1; else 0 | | `drawdown` | the stock's largest fall minus IHSG's: 15 points or more deeper: +1; 15 points or more shallower: -1; else 0 | | `illiquidity` | more than half of 20 or more matched daily returns are zero: +1, and `beta`, `volatility` and `drawdown` are not scored | Price risk is relative to IHSG so that a market-wide fall (IHSG fell 46 % peak to trough in the year to 2026-11-04) does not make every stock look risky. A price that does not move would look calm on all three price rules (beta 0, no volatility, no fall), yet a suspended share, one stuck at the floor price or one that barely trades is hard to sell: when the close is unchanged on more than half the matched sessions, the `illiquidity` rule scores instead and the three become a note (their figures stay in `price`). Below that line thin trading still biases a daily beta toward 0 and understates volatility; `zero_returns`, `observations` and `correlation` show how much they rest on. ## `idx.compare.v1` (`stocks compare`) `data` is an array of `idx.fundamentals.v1` objects in the order requested. Symbols that failed are listed in `warnings`. ## `idx.financials.v1` (`stocks financials`) ```json { "symbol": "BBCA", "currency": "IDR", "period": "annual", "periods": [ { "end_date": "2025-12-31", "period": "annual", "income": { "total_revenue": 1.14e14, "gross_profit": null, "operating_income": null, "ebitda": null, "ebit": null, "interest_income": 9.8e13, "interest_expense": 1.0e13, "net_interest_income": 8.8e13, "pretax_income": 7.1e13, "tax_provision": 1.4e13, "net_income": 5.75e13, "net_income_common": 5.75e13, "eps_basic": 467.0, "eps_diluted": 467.0 }, "balance": { "total_assets": 1.59e15, "total_liabilities": 1.31e15, "stockholders_equity": 2.81e14, "cash_and_equivalents": 9.1e13, "total_debt": 2.4e12, "net_debt": null, "shares_outstanding": 1.23e11 }, "cash_flow": { "operating": 7.75e13, "investing": -3.4e13, "financing": -4.2e13, "capital_expenditure": -2.45e12, "free_cash_flow": 7.5e13, "dividends_paid": -3.76e13 } } ], "source": "yahoo:timeseries" } ``` - `period` is `annual` (about four fiscal years), `quarterly` (about five quarters) or `ttm` (trailing twelve months; `balance` fields are `null`). - `periods` is oldest first. Amounts are in `currency` units as reported; outflows are negative. - Banks have no EBITDA, gross profit or operating income series at the source, so those are `null`. - Every period carries all three statements. `--statement income,cash` narrows the table only: JSON and CSV keep the three, since a blanked statement would read as data the source does not have. ## `idx.earnings.v1` (`stocks earnings`) `data`: `estimates[]`, `quarters[]` (`period` like `3Q2025`, `end_date`, `reported_date`, `eps_actual`, `eps_estimate`, `surprise_pct`), `yearly[]` and `quarterly[]` (`period`, `revenue`, `earnings`, `profit_margin_pct`), `next` (`date`, `date_is_estimate`, `eps_avg`/`eps_low`/`eps_high`, `eps_growth_pct`, `revenue_avg`/`revenue_low`/`revenue_high`, `analysts`), `ex_dividend_date`, `dividend_date`, `source`, `scope`. - `estimates[]` is the analyst consensus for this and next quarter and fiscal year, in source order: `period` (`0q`, `+1q`, `0y`, `+1y`), `period_type` (`quarterly` or `annual`), `end_date`, `eps_avg`/`eps_low`/`eps_high`, `eps_year_ago`, `eps_growth_pct` (percent, versus a year earlier), `eps_analysts`, `revenue_avg`/`revenue_low`/`revenue_high`, `revenue_year_ago`, `revenue_growth_pct`, `revenue_analysts`. An optional addition to `idx.earnings.v1`: envelopes from older versions lack it. A period with no figure and no analyst is left out, so a ticker no analyst covers has an empty list. - Yahoo pads figures it has not loaded as zeros; idx reports those as `null` (a quarter's `revenue`, `earnings` and `profit_margin_pct`, a consensus amount in `estimates[]` or `next`), never as `0`. Analyst counts keep their zero. `profit_margin_pct` is set only when both revenue and earnings are. - `scope` is `null` unless rows were filtered out (`stocks earnings --annual --quarterly --reported --estimates`, MCP `period` and `kind`). Then it is `{ "annual", "quarterly", "reported", "estimates" }`, each `true` when those rows were kept, and an empty list or a `null` `next` comes from the filter, not the source. Giving neither flag of a pair keeps both. `quarters[]` and `quarterly[]` are reported quarterly rows, `yearly[]` reported annual rows, `estimates[]` follow their `period_type`, and `next` counts as a quarterly estimate. The filter applies to JSON and CSV alike. ## `idx.technical.v1` (`stocks technical`) Computed locally from daily candles (`provider` names where the candles came from). `data`: `as_of`, `period`, `candles_used`, `price`, `change_pct_1d|1w|1m|3m`, `high_52w`, `low_52w`, `moving_averages` (`sma20|50|200`, `ema20|50|200`), `rsi14`, `macd` (`line`, `signal`, `histogram`), `bollinger` (`upper`, `middle`, `lower`, `percent_b`, `bandwidth_pct`), `atr14`, `volume` (`last`, `avg20`, `ratio`), `levels` (`support[]`, `resistance[]`, nearest first), `trend` (`uptrend` | `downtrend` | `sideways` | `unknown`) and `signal` (`label` `buy` | `sell` | `neutral`, `score`, `reasons[]`). Indicators that need more history than was available are `null`. ## `idx.orderbook.v1` (`stockbit orderbook`) Realtime via the Stockbit provider (your own session; see [stockbit.md](https://ihsg.midoriresearch.com/docs/stockbit.md)). `provider` is `stockbit`. `data`: `symbol`, `name`, `last`, `change`, `change_pct`, `previous_close`, `open`, `day_high`, `day_low`, `volume` (shares), `value` (IDR), `bids[]` and `asks[]` (each a level `{price, shares, orders}`, best price first), and the session foreign flow `foreign_buy`, `foreign_sell`, `foreign_net` (IDR). Stockbit flags these three with `has_foreign_bs`; while it is not `true` (seen during trading, when the fields still hold the previous session's figures) they are `null` and `warnings` says why. Sizes are shares (one IDX lot is 100 shares). A level the book does not show, or a field the source omits, is absent/`null`. ## `idx.ownership.institutions.v1` (`ownership institutions`) From MSN Money's ownership data (`provider` is `msn`), live, no import. `data`: `symbol`, `name`, `institutions_pct`, `top10_institutions_pct`, `funds_pct` (percent of shares outstanding, as MSN reports them), `float_pct` (free float, percent of shares outstanding), `oldest_report`, `newest_report`, `flow` (`institutions` and `funds`, each `new`, `buyers`, `sellers`, `exited`, `unchanged`, `net_shares_change`) and `holders[]` (institutions first, then funds): `kind` (`institution` | `fund`), `rank`, `name`, `shares`, `shares_change` (since that holder's previous report), `pct_outstanding`, `pct_outstanding_change` (percentage points), `pct_portfolio` (funds only, percent of the fund), `report_date` (each holder's own) and `signal` (`new` | `buyer` | `seller` | `exited` | `unchanged`, computed by idx; `null` when `shares` or `shares_change` is missing). Funds overlap their managers: never add the two lists. A stock MSN lists no holders for has empty `holders` and a warning. `-o csv` prints one row per holder; with no holders it prints the holder header alone, so files for several symbols share one layout. See [ownership.md](https://ihsg.midoriresearch.com/docs/ownership.md#institutional-and-fund-holders-msn). ## `idx.stockbit.actions.v1` (`stockbit actions`, `stockbit calendar`) From the user's own Stockbit session; `provider` is `stockbit`. `data`: `symbol` (the stock asked for; `null` for the calendar), `name` (from the bundled issuer list), `from` / `to` (the range idx applied; `null` when open), `kinds` (each one request), `total` (rows Stockbit returned for these kinds before the range), and `actions[]`, newest first for a stock and oldest first for the calendar. Each action: `symbol` (a structured warrant's own code for those), `name` (IPOs, tender offers), `kind` (`dividend`, `stock_dividend`, `bonus`, `rights`, `warrant`, `split`, `reverse_split`, `rups`, `public_expose`, `ipo`, `tender_offer`), `date` (the cum date for entitlements, the ex date without one, else the event's first day), `cum_date`, `ex_date`, `recording_date` (for a RUPS, the date that decides who may attend), `distribution_date` (payment; a tender offer's settlement), `dividend_per_share` and `currency` (ISO code), `ratio_old` / `ratio_new` (as published), `factor` (Stockbit's price adjustment factor), `price` (IDR per share: rights or warrant exercise, tender offer, final IPO price), `trading_start` / `trading_end` (rights and warrants), `exercise_start` / `exercise_end` (warrants), `offer_start` / `offer_end` (IPO offering, tender offer), `listing_date`, `shares` and `shares_pct` (offered or sought), `time` (RUPS or expose time as published, WIB), `venue`, `note`, `id` (Stockbit's row id). Placeholders (empty strings, zeros, `0001-01-01`) are `null`. A kind that fails is a `warnings` entry. ## `idx.stockbit.movements.v1` (`stockbit movements`) From the user's own Stockbit session; `provider` is `stockbit`. `data`: the filters asked for (`symbol`, `from`, `to`, `holder_id`, `action` as `buy`, `sell`, `transfer`, `cross` or `corporate_action`, `source` as `idx` or `ksei`; `null` when not given), `pages_read`, `more` (rows remain past the pages read, or a later page failed, which `warnings` names), and `movements[]`, newest first: `date`, `symbol`, `holder`, `holder_id` (the source's id for the holder, the same on all of that holder's rows), `roles` (`director`, `commissioner`, or another role lowercased), `foreign` (`true`, `false`, `null`), `action` (the source's label, lowercased: `buy`, `sell`, `transfer`, `cross`, `right_issue`, `private_placement`, `tender_offer`, ...), `source` (`idx` insider disclosure, `ksei` holder at 5 % and above), `shares_from`, `shares_to`, `shares_change` (exact, signed), `pct_from`, `pct_to`, `pct_change` (percent of the company as the source rounds it), `price` and `value` (IDR, as filed; `null` when the source only estimates), `estimated_price` and `estimated_value` (the source's estimate when no price was filed; `null` otherwise), `purpose` (as filed), `broker` and `broker_group` (seen on KSEI rows). ## `stockbit status` (`idx schema stockbit-status`) A bare object, not an envelope, and never a token value (the MCP tool `idx_stockbit_status` returns the same): `logged_in` (`true` when the stored session can work now: its refresh token has not expired, or its access token has not; each token's own `exp` decides, one without an `exp` counts as unexpired and one that is not a JWT as unusable), `renewable` (`true` when idx can renew the access token because the refresh token has not expired; while `logged_in` is `true` and `renewable` is `false`, the session stops working when the access token expires), `expired` (`true` when a session is stored but its tokens have expired), `reason` (why the session cannot work or cannot be renewed, and that `idx stockbit login` renews it; `null` while it works and renews), `access` and `refresh` (each `present`, `expires_at` in Unix seconds and `expires_in_secs`, negative once expired; both `null` when the token is absent, not a JWT or without an `exp`), and `session_file` (the absolute path). Nothing here asks Stockbit: a token it revoked, or another tool spent, still counts until its `exp`, or until this process sees Stockbit refuse it (an `idx mcp` server remembers a refresh token Stockbit refused: `renewable` turns `false`, `logged_in` follows the access token alone and `reason` says so). `sign_in` is MCP only: the reply of `idx_stockbit_login`, and in `idx_stockbit_status` the latest page sign-in that tool started in the same server; it is absent otherwise (the CLI never sets it). Its fields: `state` (`waiting`, `signed_in`, `failed` or `already_signed_in`), `method` (`page`, `browser` or `none`), `url` and `port` (the page on 127.0.0.1 while `waiting`), `ssh_forward` (an `ssh -N -L` command when the server is reached over SSH), `expires_in_secs` (until the page stops listening), `already_open` (an earlier page was still open and is returned), `warning` (the latest note, such as why Stockbit refused a pasted session) and `error` (why it `failed`). ## Errors Written to stderr, exit code non-zero. With `-o json`, `-o jsonl` or `-o auto` when piped (from the flag, `IDX_OUTPUT` or the config file, the flag first) an error is one JSON line; otherwise it is `error: ...` text, with a `hint:` line where one helps: ```json {"error":{"code":"not_found","message":"not found: no quotes for ZZZZ"},"exit_code":4} ``` | exit | `code` | when | | --- | --- | --- | | 1 | `internal`, `io` | unexpected failure | | 2 | `usage` | bad flags or symbol | | 3 | `network`, `provider`, `blocked` | network failure or the source returned an error; `blocked`: the source's bot protection (Cloudflare) refused the request with a challenge or a block page. For idx.co.id's data a challenged request is retried for 30 s first (`market brokers` under `auto`, where IPOT could answer, asks idx.co.id once and lets IPOT stand in, and retries only if IPOT cannot), and a cached copy, when there is one, is served marked stale instead of the error. A challenge usually passes within a minute: retry then, and from another network if it keeps refusing. A block page can last longer: retry later, or from another network if it persists. Data centres, VPNs and CI runners are refused more often. The message names any other source for the same question (`--source ipot` for a stock's foreign flow and for the latest session's foreign net of the most traded stocks, `market brokers --source ipot` for the member ranking of IPOT's latest session when that is the session asked for); for the ownership register, the GitHub mirror | | 4 | `not_found` | symbol or record does not exist | | 5 | `offline_miss` | `--offline` and nothing cached | | 6 | `config`, `unsupported` | bad config file or env value; or the selected provider cannot serve this command (`--provider auto` can) | ## Compatibility promise - Adding an optional field to a payload does not bump the `schema` version. - Renaming, removing or changing the type/unit of a field does, and the old version is kept for at least one minor release behind a flag. - Numbers are plain JSON numbers (no strings), timestamps are RFC 3339. --- Source: https://ihsg.midoriresearch.com/docs/configuration.md # Configuration Precedence, highest first: **command-line flags → `IDX_*` environment variables → config file → built-in defaults**. ## Config file | platform | path | | --- | --- | | Linux, macOS | `$XDG_CONFIG_HOME/idx/config.toml`, else `~/.config/idx/config.toml` | | Windows | `%APPDATA%\idx\config.toml` | Override the location with `--config PATH` or `IDX_CONFIG=PATH`. ```sh idx config init # write a commented template idx config set provider yahoo idx config set history_provider stockbit # candles from your Stockbit session idx config set cache.quote_ttl_secs 120 idx config get # effective settings after all layers idx config get provider # one key, as stored in the file idx config path ``` ```toml provider = "auto" # auto | yahoo | msn | stockbit history_provider = "auto" # auto | yahoo | stockbit: where candles come from output = "table" # auto | table | json | jsonl | csv color = "auto" # auto | always | never timeout_secs = 15 exchange = "JK" # Yahoo suffix for bare tickers in the Yahoo-backed commands [cache] dir = "/custom/cache/dir" quote_ttl_secs = 60 history_ttl_secs = 3600 [stockbit] # session = "/path/to/stockbit-session.json" # `idx stockbit login` writes it ``` ## Environment variables | variable | flag | meaning | | --- | --- | --- | | `IDX_PROVIDER` | `--provider` | `auto`, `yahoo`, `msn`, `stockbit` | | `IDX_HISTORY_PROVIDER` | `--history-provider` | `auto` (default), `yahoo`, `stockbit`: where candles come from, see below | | `IDX_OUTPUT` | `-o, --output` | `table` (default), `json`, `jsonl`, `csv`, or `auto`: a table when stdout is a terminal, `json` when it is piped or captured | | `IDX_QUIET` | `-q, --quiet` | `1`/`true`/`yes`/`on`: stderr carries errors only (no warnings, notes or loading line); ignored when `-v` is given. No config key | | `IDX_EXCHANGE` | | Yahoo Finance suffix a bare ticker gets in `stocks quote`, `history`, `profile`, `financials`, `earnings`, `dividends`, `returns`, `fundamental`, `technical`, `compare` and `watch`: `JK` (default, IDX), `SI`, `HK`, ... One to four letters, an optional leading dot, any case; `IDX` means `JK`. Config key `exchange` | | `IDX_COLOR` | | `auto`, `always`, `never` | | `NO_COLOR` | `--no-color` | any non-empty value disables colour | | `IDX_PROGRESS` | | `auto` (default): a loading line on stderr while a command waits on the network, naming the hosts it waits on; only when stderr is a terminal and without `-v`. `never` turns it off, `always` shows it where stderr does not report a terminal (mintty, some IDE consoles) | | `IDX_TIMEOUT_SECS` | `--timeout` | per-request timeout | | `IDX_CACHE_DIR` | `--cache-dir` | cache location | | `IDX_OFFLINE` | `--offline` | `1`/`true`: never touch the network | | `IDX_NO_CACHE` | `--no-cache` | `1`/`true`: ignore cached responses | | `IDX_QUOTE_TTL_SECS` | | cache lifetime for quotes | | `IDX_HISTORY_TTL_SECS` | | cache lifetime for history (intraday bars are capped at 120 s) | | `IDX_CONFIG` | `--config` | config file path | | `IDX_LOG` | `-v` | tracing filter, e.g. `idx=debug` | | `IDX_HOLIDAYS` | | CSV (`date,name,name_en`) replacing the bundled trading-holiday list (`data/holidays.csv`) | | `IDX_STOCKBIT_SESSION` | | Stockbit session file (`idx stockbit login` writes it); default beside the config file. See [stockbit.md](https://ihsg.midoriresearch.com/docs/stockbit.md) | | `IDX_STOCKBIT_REFRESH_TOKEN` | | Seeds an unattended Stockbit session (the refresh token); the session file wins once it exists | | `IDX_STOCKBIT_ACCESS_TOKEN` | | Optional access token to seed alongside the refresh token | | `IDX_BROWSER` | | Browser executable `idx stockbit login` launches (default: the first Chrome/Edge/Brave found) | ## Output on a terminal and in a pipe `table` stays the default whether stdout is a terminal or not, so existing `idx ... > file` and `| grep` pipelines keep reading tables. Opt in to `output = "auto"` (or `IDX_OUTPUT=auto`) for a table on a terminal and the JSON envelope whenever stdout is piped or captured, which suits agents that forget `-o json`. `idx config get` shows the setting next to what it resolves to (`output_setting` and `output` in its JSON). Errors and the commands that need no data source (`version`, `schema`, `config`, `mcp setup`) resolve the format the same way, flag first, then `IDX_OUTPUT`, then the config file; a layer that does not parse is skipped there, so a broken setting still gets a readable error. ## Other markets (`exchange`) Yahoo Finance lists IDX shares under `.JK`, which idx adds for you. The Yahoo-backed commands also take a ticker from another Yahoo market with its suffix (`idx stocks history D05.SI`), or bare once `exchange` says which market (`idx config set exchange SI`, then `idx stocks history D05`). `.JK` always means IDX, and every other command (brief, screen, news, broker, bandar, foreign, actions, market, ownership, stockbit) reads IDX tickers only and refuses `D05.SI` with exit 2. MSN and Stockbit cover IDX only, so with `--provider auto` Yahoo answers for other markets. Saved watchlist entries keep their suffix (`D05.SI`), so changing `exchange` later does not change them. For the same reason idx's own output piped back through `-` (`-o json`, `-o jsonl`, a watchlist file) reads a bare code as IDX; only typed text on stdin follows `exchange`. MCP tools take explicit suffixes but do not use `IDX_EXCHANGE`: a bare code is always IDX there. An invalid value still stops `idx mcp` from starting, as any invalid setting does. ## History provider `history_provider` decides where candles come from, separately from `provider`. Every command that reads candles follows it: `stocks history`, `technical`, `returns`, `brief`, `bandar` price moves, `watch returns` and the MCP tools behind them. | `provider` | `history_provider` | candles from | | --- | --- | --- | | `auto`, `yahoo` | `auto`, `yahoo` | Yahoo (unchanged) | | `msn` | `auto`, `yahoo` | Yahoo: MSN has no price history | | `stockbit` | `auto` | Stockbit where its bars are the ones asked for (daily over `1mo` to `1y`, hourly over `5d`, weekly over `5y`, and same-day intraday), Yahoo otherwise; Yahoo also when Stockbit fails (no session, expired token, network) | | `stockbit` | `yahoo` | Yahoo | | any | `stockbit` | Stockbit, no fallback (needs `idx stockbit login`, exit 2 without it) | Stockbit picks the bar size from the window, so under `provider = "stockbit"` a `2y` or `5y` daily request would come back weekly and feed weekly returns into daily analytics; `auto` sends those to Yahoo and says so in a warning. A fallback after a Stockbit failure is a warning too (`stockbit history failed (...); candles from yahoo`), and the JSON envelope's `provider` names the source the candles came from. `history_provider = "stockbit"` with the default `provider = "auto"` keeps the free providers for quotes and fundamentals and takes only candles from Stockbit; a request it does not serve as asked then carries a warning naming the bars and span it returned (`--period 1mo --interval 1h` comes back as one week of hourly bars). ## Cache | platform | path | | --- | --- | | Linux, macOS | `$XDG_CACHE_HOME/idx`, else `~/.cache/idx` | | Windows | `%LOCALAPPDATA%\idx\cache` | Each response is stored as one JSON file named by the SHA-256 of its cache key. Freshness is judged against the TTL in effect when you *read*, so changing a TTL applies immediately. - `--offline`: serve whatever is cached, fresh or stale; exit 5 on a miss. - `--no-cache`: always fetch; the result is still written for later. - `--offline` with an entry older than its TTL: the entry is served with `stale: true` and `stale_reason: "offline"`. - Network failure with a stale entry present: the stale entry is served with `stale: true`, `stale_reason: "fetch_failed"` and a warning naming the failure (`live fetch failed (...); showing cached copy`). Table mode prints the warnings and `warning: showing stale cached data from WIB` to stderr. - idx.co.id's bot protection (Cloudflare) refusing a request counts as such a failure. A challenged request is asked again after 5, 10 and 15 s (four tries, at most 30 s of waiting per request; the loading line shows `www.idx.co.id retry 1`); one met by a block page is not. A refusal is never cached. For a minute after a challenge lasts through every pause, the next idx.co.id requests (from any command or MCP call on the same cache, `--no-cache` aside) are asked once, so a command that makes several meets the refusal or its cached copies at once instead of waiting 30 s for each. `market brokers` under `--source auto` asks idx.co.id once, without the pauses, where IPOT could answer for the session asked for, and lets IPOT stand in; only if IPOT cannot is idx.co.id asked again with every pause. An empty copy of a day's stock, broker or index summary does not stand in for a failed fetch, as it may be from before the summary was out: the error stands and names the copy. So does a copy of a stock's history without a session in the `stocks foreign --from/--to` range. With nothing cached the error is `blocked` (exit 3) and says what to do; under `--no-cache` it says the cache was skipped and that `--offline` serves a copy if there is one. - idx.co.id's day summaries (stock, broker, index) are kept for 30 days once the session is over, and for 15 minutes on the day itself. An empty one cached on or before its own day, before the summary was out, keeps the 15 minutes on later days too, so it is asked again instead of passing for a day without trading. - Network error text and warnings never include a URL's query string, and cache keys never hold a session token, so the Yahoo crumb and the MSN key stay out of every message; `-vv` debug logging still prints full request URLs. - News feeds: a feed that times out or refuses the connection is not asked again for five minutes. Until then its cached copy is served with a warning naming the failure, or the warning alone when nothing is cached, so one silent feed does not make every call wait out `--timeout`. `--no-cache` asks anyway. ```sh idx cache stats idx cache clear idx cache path ``` The Yahoo session (anonymous cookie + crumb) is cached for six hours under the same directory. `idx cache clear` removes it too. --- Source: https://ihsg.midoriresearch.com/docs/ownership.md # Ownership data Since March 2026 KSEI compiles, and IDX publishes monthly, the list of every shareholder holding **more than 1 %** of a listed company ("Pemegang Saham di Atas 1%"). `idx ownership` imports those files into a local SQLite database and answers questions from it offline. One command reads another source: `idx ownership institutions` asks MSN Money live for the institutions and funds that hold a stock (see [the last section](https://ihsg.midoriresearch.com/docs/ownership.md#institutional-and-fund-holders-msn)). KSEI also publishes, on its own site, a monthly composition of every security's holders by investor type, local and foreign ("Kepemilikan Efek"). It names no holders; idx keeps it in separate tables and reads it with `idx ownership composition` (see [KSEI composition](https://ihsg.midoriresearch.com/docs/ownership.md#ksei-composition-kepemilikan-efek)). ## Source files | period | format | where | | --- | --- | --- | | 30 Oct 2026 onwards | XLSX, 13 columns (`CONTROLLER_OR_AFFILIATE` after `INVESTOR_CLASSIFICATION`) | idx.co.id, "Data Kepemilikan Saham" page, `Media/.../*-satu-persen.xlsx` | | June to September 2026 | XLSX, 12 columns | same page | | February to May 2026 | PDF announcement | idx.co.id announcements ("Pemegang Saham di atas 1% (KSEI)") | Columns: `DATE, SHARE_CODE, ISSUER_NAME, INVESTOR_NAME, INVESTOR_CLASSIFICATION` (`INVESTOR_TYPE` in the first releases), `LOCAL_FOREIGN, NATIONALITY, DOMICILE, HOLDINGS_SCRIPLESS, HOLDINGS_SCRIP, TOTAL_HOLDING_SHARES, PERCENTAGE`, and from the register as of 30 Oct 2026 `CONTROLLER_OR_AFFILIATE` (`CONTROLLER`, `AFFILIATE OF THE CONTROLLER` or blank) after `INVESTOR_CLASSIFICATION`. Columns are found by name, so both layouts import; the "di Atas 5 %" and investor-type workbooks on the same page lack required columns and are rejected with a clear message. `idx ownership discover --family` lists them all: | `--family` | title on the page | file | importable | | --- | --- | --- | --- | | `above1` (default) | Pemegang Saham di Atas 1% per 31 Agustus 2026 | idx.co.id `Media/.../peng-YYYY-MM-NNNNN-satu-persen.xlsx` | yes | | `above5` | Pemegang Saham di Atas 5% per 23 September 2026 (a daily register: holders above 5 %, per account, with the day's change) | idx.co.id `peng-YYYY-MM-DD-NNNNN-lima-persen.xlsx` | no, listed only | | `investor-type` | Data Kepemilikan Saham Perusahaan Tercatat per Tipe Investor per 31 Agustus 2026 | idx.co.id `peng-YYYY-MM-NNNNN-tipe.xlsx` | no, listed only | | `classification` | Data Kepemilikan Saham Perusahaan Tercatat Berdasarkan Klasifikasi Investor per 31 Agustus 2026 | idx.co.id `peng-YYYY-MM-NNNNN-klasifikasi.xlsx` | no, listed only | | `composition` | Kepemilikan Efek 30 Oktober 2026 | ksei.co.id `storage/Download/BalanceposEfekYYYYMMDD.zip` | yes | | `all` | every family above | | | ```sh idx ownership discover # the above-1 % register, every month listed idx ownership discover --family all --limit 2 # every family, newest two each idx ownership discover --family composition --limit 24 # two years of KSEI archives ``` `--limit` counts per family (0: all). Without it every idx.co.id file is listed, as before, and one ksei.co.id page (eight months). Each entry carries `family` and `importable` in JSON. A family whose source fails becomes a warning while the others answer; the command fails only when every family failed (with the JSON code `blocked` when Cloudflare refused idx.co.id). Each row carries its own `DATE`. A file is filed under its most common date (`as_of`); rows with another date are kept but left out of that release's holdings (see Database). ## Getting the files idx.co.id is behind Cloudflare bot management, so a plain HTTP client gets a challenge page. `ownership import --latest|--all` and `ownership discover` try, in order: 1. a browser-shaped request (Chrome user agent and headers); 2. a `curl-impersonate` binary on `PATH` (`curl_chrome131`, `curl_chrome124`, `curl_chrome116`, `curl-impersonate-chrome`, `curl-impersonate`; override with `IDX_CURL_IMPERSONATE=/path/to/binary`); 3. the GitHub mirror (below): the same files, downloaded from the repository's `ownership-data` release and checked against the SHA-256 in its `index.json`. A warning says when the mirror was used. `IDX_OWNERSHIP_MIRROR=` points at another mirror, `off` disables it; `IDX_OWNERSHIP_SOURCE=mirror` skips idx.co.id, `idx` never uses the mirror. `--file ` and `--url ` import one file directly; `--url` also accepts `file://` paths. `idx ownership discover` lists what is available, where it comes from and which months are already imported. When Cloudflare refuses idx.co.id, with a challenge ("Just a moment...", response header `cf-mitigated: challenge`) or a block page ("Attention Required! | Cloudflare"), and no curl-impersonate binary gets through, the error has the JSON code `blocked` (exit 3, as any network failure) and names the kind of refusal and the ways out: retry later or from another network, the mirror, curl-impersonate, or a download in a browser plus `import --file`. `--latest`, `--all` and `discover` still fall back to the mirror, and the warning says which refusal it was. Any other failure (an outage, a 404) keeps its HTTP status and is not called Cloudflare. ## The mirror `.github/workflows/ownership.yml` runs `scripts/ownership_mirror.py` at 08:17, 14:17 and 20:17 WIB. idx.co.id blocks cloud networks: GitHub-hosted runners get a Cloudflare challenge even with a browser TLS fingerprint, and requests from Cloudflare Workers are refused. The job therefore needs a runner on an ordinary (home or office) connection: 1. Repository → Settings → Actions → Runners → New self-hosted runner, and follow the three commands on any always-on machine with Git, `gh` and Python 3.10+ (Windows, macOS or Linux). Install it as a service. 2. Repository → Settings → Secrets and variables → Actions → Variables: `OWNERSHIP_RUNNER` = `self-hosted`. Without that variable the job runs on `ubuntu-latest`, cannot reach idx.co.id, and only rebuilds the parts and the snapshot from the files already mirrored (so a newer idx parser still reaches the web terminal and `idx ownership sync`); it fails once the newest register is 45 days old, so a stale mirror shows up as a red run. Each run of the script: 1. fetches the IDX page with [curl_cffi](https://github.com/lexiforest/curl_cffi) (browser TLS fingerprint), saves it, and parses it with `idx ownership discover` (one parser for CLI and CI); when that fails it carries on with the months already mirrored, and fails the run if the index lists a month whose file is missing from the release (it would otherwise drop that month); 2. reuses files already mirrored, downloads new or re-published ones (a new IDX URL for the same month counts as re-published) and checks they are real XLSX/PDF files; 3. imports every month, oldest first, into a fresh database with `idx ownership import --file`; any parse failure fails the run, so a broken file never reaches the mirror; 4. exports each month as JSON parts of 1,000 rows (one row per holder and issuer, the holder's accounts summed; fields as in `idx ownership ticker -o json` plus `accounts` and `controller_or_affiliate`) and writes `index.json`. `rows` is the number of exported rows (positions), which the Worker checks; `lines` is the register lines behind them: ```json { "schema": "idx.ownership.mirror.v1", "generated_at": "2026-11-07T01:17:40Z", "source_page": "https://www.idx.co.id/id/perusahaan-tercatat/data-kepemilikan-saham/", "releases": [ { "as_of": "2026-09-30", "title": "Pemegang Saham di Atas 1% per 30 September 2026", "format": "xlsx", "source_url": "https://www.idx.co.id/Media/.../peng-2026-09-00024-satu-persen.xlsx", "file": "ksei-above1-2026-09-30.xlsx", "sha256": "804b3bb7…", "bytes": 563730, "rows": 7151, "issuers": 961, "entities": 5145, "lines": 7154, "positions": 7151, "other_date_rows": 0, "controller_column": false, "parts": [ { "file": "ksei-above1-2026-09-30.part01.json", "sha256": "…", "rows": 1000 } ] } ] } ``` 5. builds the prebuilt snapshot of the same database with the hidden `idx ownership build-snapshot`: `ownership-v{V}-{newest}-{sha12}.sqlite.gz` and `snapshot-v{V}.json` (see Snapshot and sync). It is published when its content digest differs from the published manifest's, also when idx.co.id was unreachable, so GitHub-hosted runners keep it current after a parser or schema change; the script refuses a file above 100 MiB; 6. uploads only what changed: raw files and parts, then `index.json`, then the snapshot file; it checks GitHub's SHA-256 digest of the uploaded file and only then uploads the manifest. It deletes assets nothing references any more, keeping the manifests of the three newest schema versions, the file each names and, for one more run, the file the replaced manifest named. The release is a prerelease so it never becomes the repository's "latest" release. The web terminal's Worker (`web/src/ownership-sync.js`) loads the parts into D1 on its minute cron, newest month first, one part per minute, within a daily rows-written budget (`OWN_DAILY_WRITE_BUDGET`, default 60,000 per UTC day, inside D1's free tier of 100,000). It keeps `OWN_KEEP_RELEASES` months (default 6). A month is published only after its row count matches the index; a re-published IDX file (new SHA-256) is reloaded, and so is a month whose parts changed in an index written by idx 0.8.2 or later (`parts_key`), so every kept month reloads once after the mirror is rebuilt with summed accounts. A reload first deletes the copy D1 holds, which costs as many writes as loading it (about 21,600 each way for one month) and hides the month until the new copy is in, so the budget charges deletes too and a reload starts only when both fit in what is left of the day. With the default budget that is one reload a day; months not reloaded yet stay readable as their older copy, with a warning. `/api/ownership/status` shows progress. There is no upload endpoint: the only input is the repository's own release. Running the workflow by hand: Actions → "ownership data" → Run workflow (tick "dry run" to parse without uploading). From any machine idx.co.id accepts, with `gh` logged in: `pip install curl_cffi && python scripts/ownership_mirror.py --idx target/debug/idx --repo fzn0x/idx` (add `--dry-run` to change nothing). ## KSEI composition (Kepemilikan Efek) KSEI lists one archive per month at (eight months per page, newest first; `?page=N` for older ones), a few days after the month's last trading day: `BalanceposEfek.zip`, about 125 KB, the date being that last trading day. The ZIP holds one pipe-separated text file, `Balancepos.txt`, CRLF line endings: ```text Date|Code|Type|Sec. Num|Price|Local IS|Local CP|Local PF|Local IB|Local ID|Local MF|Local SC|Local FD|Local OT|Total|Foreign IS|Foreign CP|Foreign PF|Foreign IB|Foreign ID|Foreign MF|Foreign SC|Foreign FD|Foreign OT|Total 30-OCT-2026|BBCA|EQUITY|123275050000|4980|2310613490|752250260|302459300|517672400|12030567470|1072720030|119977978|72753960|15254200|17194269088|666444425|575351467|4718402998|2382605437|324800480|20393323810|457281188|320342605|5420869622|35259422032 ``` One row per security: shares listed (`Sec. Num`), the price, and the shares held by local and by foreign holders in nine investor types (IS insurance, CP corporate, PF pension fund, IB financial institution, ID individual, MF mutual fund, SC securities company, FD foundation, OT others), each side with its total. idx stores the `EQUITY` rows (1,005 on 30 Oct 2026) and counts the other types (bonds, sukuk, warrants, rights, funds, crowdfunding) without storing them. The header must match exactly; another layout is refused, not guessed at. **Scripless only.** The buckets cover shares held in KSEI accounts. Shares still held as certificates (scrip) are outside the archive: BBCA's rows add up to 42.55 % of its listed shares on 30 Oct 2026, because its controlling holder's 54.94 % stake is scrip. Percentages are therefore taken over the scripless total, and `scripless_pct` (scripless shares over listed shares) is always shown next to them. Never read the composition as a percent of listed shares. ```sh idx ownership composition BBCA # newest month, nine types, local vs foreign idx ownership composition TLKM --as-of 2026-09-30 idx ownership import --latest --family composition # newest archive not imported yet idx ownership import --all --family composition # every listed month (up to 10 pages) idx ownership import --file BalanceposEfek20261030.zip # or the TXT inside idx ownership import --url https://www.ksei.co.id/storage/Download/BalanceposEfek20261030.zip ``` `ownership composition` downloads the newest month on first use, and later asks ksei.co.id again only once a newer month can exist (after the end of the month following the newest imported one; the listing is cached 12 h). `--as-of` downloads a listed month that is missing. `--offline` never downloads. The envelope's `provider` is `ksei` when the call imported, `local-db` otherwise, and a warning names what was imported. The report gives the stock's local and foreign shares and percents, the nine types in KSEI's order, and every imported month with the foreign percent's change in percentage points (`idx.ownership.composition.v1`). `-o csv` writes one row per investor type (`symbol, as_of, code, investor_type, local_shares, foreign_shares, local_pct, foreign_pct`) however many months are imported; the history is in the table and the JSON. Composition imports never touch the register: `releases`, `ticker`, `entity`, `changes` and `foreign` read exactly what they read before, and a composition month and a register month can share a date. In an import report a composition month has `family: "composition"`, `rows` and `issuers` = the stocks stored, `entities` 0, `other_securities` the rows of other types, and `positions`, `other_date_rows`, `controller_column` null. Archives are read in memory, never unpacked to disk. A ZIP is refused when an entry's path leaves the archive (absolute, a drive, `..`), when it holds more than 16 entries, or when its text file unpacks to more than 64 MiB; a download stops at 32 MiB. Rows with a bad cell (a date, a count with separators, a non-positive `Sec. Num`) are skipped with a warning naming the line; a row whose types do not add up to KSEI's total keeps the total, with a warning. ksei.co.id has no bot protection (nginx and S3, any user agent). The web terminal does not carry the composition yet: a Worker has not been checked against ksei.co.id, so the tool is CLI and MCP only. ## Snapshot and sync `idx ownership sync` fills or refreshes the local database with one download: a prebuilt SQLite file CI builds from the mirrored IDX files, instead of downloading and parsing every month. ```sh idx ownership sync # install the snapshot, then any newer month idx ownership sync --force # replace a database holding months the snapshot lacks idx ownership status # local database, installed snapshot, mirror, what sync would do idx --offline ownership status # the local part only ``` The mirror release carries, per database schema version `V` (`PRAGMA user_version`, 4 today): | asset | content | | --- | --- | | `snapshot-v{V}.json` | the manifest, schema `idx.ownership.snapshot.v1`, uploaded last | | `ownership-v{V}-{newest as_of}-{sha12}.sqlite.gz` | the database, vacuumed and gzipped; `sha12` is the start of its SHA-256, so a published file never changes | ```json { "schema": "idx.ownership.snapshot.v1", "generated_at": "2026-11-06T03:10:55Z", "built_by": "idx 0.8.2", "db_schema_version": 4, "file": "ownership-v4-2026-09-30-d4b3c324d27f.sqlite.gz", "compression": "gzip", "bytes": 6736237, "sha256": "d4b3c324d27f…", "sqlite_bytes": 20283392, "sqlite_sha256": "…", "content_sha256": "…", "tables": { "aliases": 6293, "composition": 0, "composition_releases": 0, "entities": 5740, "holding_lines": 57505, "holdings": 57451, "releases": 8, "resolutions": 0 }, "releases": [ { "as_of": "2026-09-30", "format": "xlsx", "sha256": "804b3bb7…", "source_url": "https://www.idx.co.id/Media/yqjhhsee/peng-2026-09-00024-satu-persen.xlsx", "rows": 7154, "holdings": 7151, "issuers": 961, "entities": 5145 } ] } ``` `sha256` and `bytes` describe the `.sqlite.gz`, `sqlite_sha256` and `sqlite_bytes` the decompressed file, `releases[].sha256` the IDX file each month came from (the same as in `index.json`), `rows` its register lines and `holdings` the positions stored. `content_sha256` is a digest of every table with import times left out: two builds of the same files by the same parser give the same digest, so CI republishes only when the data, the parser or the schema changed. For the 8 months from February to September 2026 the file is 20.3 MB, 6.7 MB gzipped (the register lines and the summed positions are both stored). What `sync` does, first match wins: | local database | action | | --- | --- | | written by a newer idx (schema above `V`) | refused (exit 6) unless `--force` | | the mirror has no `snapshot-v{V}.json`, or one of a kind this idx cannot read (a later manifest schema or compression; a warning says so) | import the monthly files it lacks (`imported`) | | missing or without releases | install the snapshot (`installed`) | | has months neither the snapshot nor `index.json` has, a month re-imported from another file since the install, or rows in a table the snapshot leaves empty | keep it, import the months it lacks (`imported` or `up_to_date`, the months in `kept`); `--force` replaces it (`updated`, the months in `dropped`) | | installed from this very snapshot and still holding its months | nothing (`up_to_date`) | | anything else: the snapshot has more months, a re-published file, a parser fix or a new schema, or the database was built by local imports | replace it with the snapshot (`updated`) | After installing, `sync` imports any month `index.json` lists that the snapshot does not have yet (a snapshot built before the newest file, or an older idx installing a frozen older version), from the raw files with their SHA-256 checked. It also imports again a month the database holds exactly as its snapshot carried it while `index.json` lists another file for it (IDX re-published the month after that snapshot was built); `status` lists those in `outdated`. A month imported locally from another file is never touched. Files are compared by the SHA-256 of the IDX file, never the SQLite files themselves: idx rewrites a database's header when it opens it. Safety: - Nothing is written before every check passed: the manifest (schema, version, file name, sizes, hashes, at most 1 MiB), the download (at most `bytes`, then size and SHA-256), the decompressed file (size, SHA-256, `PRAGMA quick_check`, schema version, page size, rows per table, each release's date, file hash, lines and positions, and the recorded content digest). Its schema must be exactly this idx's tables and indexes plus `snapshot_info`: a view, a trigger or any other object is refused, so nothing in a download runs in the database later. A failed check is a provider error (exit 3) and leaves the database untouched. - The download URL is always the mirror's base plus the manifest's `file`, a bare asset name: a manifest cannot point anywhere else. - Replacing a database first copies it to `.bak` (overwriting an older one), then copies the snapshot in with SQLite's online backup API: one write transaction into the live file. Other idx processes reading it (an `idx mcp` call) keep their view until they finish; nothing is renamed or deleted under them. - One sync of a database runs at a time: parallel calls of one `idx mcp` wait for each other, and other idx processes wait on an exclusive SQLite lock on `.sync.lock`, an empty file left in place. A sync that waited finds the database up to date instead of installing it twice. - The installed file keeps the snapshot's `snapshot_info` table (generated_at, built_by, content digest, the months and file hashes it held), which is how `sync` and `status` recognise it later. Queries ignore it. `--offline` makes `sync` fail with exit 5 and `status` report the local part (exit 0, `remote: null`, a warning). `IDX_OWNERSHIP_MIRROR=` points both at another mirror (`file://` works), `off` disables them (`sync` exit 6). `IDX_OWNERSHIP_SOURCE` does not apply: sync is a mirror operation. In MCP the same is one tool, `idx_ownership_snapshot`, with `action: "sync"` or `"status"` and `force`. When `V` changes, the manifests of the three newest versions and their files stay on the release: an idx that knows an older schema keeps installing its own (older) snapshot and imports the newer months from the raw files. Building one by hand (what the mirror script does, from any database whose months were all imported by idx 0.8.2 or later): ```sh IDX_OWNERSHIP_DB=build/ownership/ownership.sqlite \ idx ownership build-snapshot --out build/snap --sources build/ownership/out/index.json ``` `build-snapshot` is hidden from `--help`. It never changes the database it reads: one of another schema version is refused, not migrated. `--sources` records each month's idx.co.id URL from the mirror's `index.json` (when the file's SHA-256 matches) and `--generated-at` sets the build time (default now), which also replaces every `imported_at`. ## Database Default path: `%LOCALAPPDATA%\idx\ownership.sqlite` on Windows, `$XDG_DATA_HOME/idx/ownership.sqlite` or `~/.local/share/idx/ownership.sqlite` elsewhere. Override with `IDX_OWNERSHIP_DB` or `ownership.db` in the config. Tables (schema version 4, kept in `PRAGMA user_version`; version 3 adds `resolutions`, version 4 the two composition tables): - `releases` one row per imported month (`as_of` unique; re-importing a month replaces it), with the source path/URL and SHA-256 of the file, the register lines stored (`row_count`), the `positions` built from them, the `other_date_rows` left out and whether the file had `CONTROLLER_OR_AFFILIATE` (`controller_column`). - `entities` one row per normalised holder: upper-cased alphanumerics, single spaces, legal-form tokens `PT`, `TBK`, `PERSERO` removed. `PT ALAMTRI RESOURCES INDONESIA TBK` and `Alamtri Resources Indonesia, Tbk.` share one entity. - `aliases` every raw spelling seen, pointing at its entity. - `holding_lines` every register line as printed, keyed by (release, sheet row), with its own `report_date`, entity, investor type, controller flag, local/foreign, nationality, domicile, scripless/scrip/total shares and percent. Nothing is dropped here. - `holdings` the positions every query reads: one row per (release, symbol, entity), built from the release's lines dated its `as_of`. A holder's lines are summed (shares and percent; `accounts` counts them); the holder's spelling is the largest line's, and type, controller flag, local/foreign, nationality and domicile come from the largest line that has a value. - `resolutions` your spelling rules from `idx ownership resolve` (version 3): `merge` (one holder key folded into another) or `map` (one spelling moved to a holder), the holder it now belongs to, the issuer-months combined with `--fold` and when it was made. Every import applies them. - `composition_releases` (version 4) one row per imported KSEI composition month (`as_of` unique; re-importing replaces it), with the format (`zip` or `txt`), source, SHA-256, the stocks stored and the other securities left out. - `composition` one row per (month, stock): shares listed, price, the nine local and nine foreign investor-type counts and both totals, as printed. - `snapshot_info` (only in a database installed by `idx ownership sync`): key/value rows naming the snapshot it came from. Queries never read it. Why one holder has several lines: KSEI computes holdings per SID, and per account for scrip held without an SID (the register's own disclaimer). A holder typically appears once for its scripless C-BEST position and once for a scrip (eBAE) account, often spelled differently and without the L/A flag or investor type on the scrip line. idx 0.8.1 and older kept only the last of those lines; for example KREN's KRESNA PRIMA INVEST showed 4.01 % instead of 9.44 % + 4.01 % = 13.45 % on 30 Sep 2026. Percentages are rounded to four decimals on import to remove binary noise in the source (`1.1499999999999999` becomes `1.15`). Numbers stored as text are read per column: a share count may use `.`, `,` or spaces as thousands separators and an all-zero decimal tail (`2.500.000,00`); in a percentage a single `.` or `,` is the decimal mark (`7,19` is 7.19, `1,000` is 1.0). A percentage outside 0 to 100 or a fractional share count skips the row with a warning naming the cell. Nothing else is altered. Upgrades: opening a database migrates it in place (one transaction; a file written by a newer idx is refused). Months imported by idx 0.8.1 or older stay readable but have no lines: their `positions` are blank, `accounts` is `null`, and every query that reads them warns until they are re-imported with `idx ownership import --all --force` (or `--file`). ## Queries ```sh idx ownership ticker BBCA # holders above 1 %, newest month idx ownership ticker BBCA --as-of 2026-07-31 idx ownership entity "Dwimuria Investama" # every position of one holder idx ownership changes --from 2026-07-31 --to 2026-08-31 --symbol BBCA idx ownership changes --from 2026-02-27 --to 2026-08-31 --min-change 1 --limit 0 idx ownership changes --from 2026-07-31 --to 2026-08-31 --limit 100 --offset 100 # page 2 idx ownership foreign BBCA # foreign vs local share, month by month idx ownership foreign # every issuer's foreign change, newest two months idx ownership releases ``` `ticker` and `entity` list positions: a holder on several register lines (scrip and scripless accounts) is one row with `accounts` > 1 (an `Accts` column in the table). From the 30 Oct 2026 register each row also carries `controller_or_affiliate`, and `controller_reported` says whether the release has that column (a `Control` column in the table). `changes` compares two imported months per (symbol, entity), each side's accounts summed: `new` holders (absent before: crossed 1 % or newly listed), `exited` holders (absent after) and `changed` positions, sorted by absolute percentage-point move. Rows carry `local_foreign` (`L`/`A`), `accounts_from`, `accounts_to` and `controller_or_affiliate`. A holder with the same shares on both sides is not a change when its percent did not move, or, when a side has several lines, moved by no more than their rounding (each line is printed to 0.01 pp, so `n` summed lines can drift by 0.005 × `n` pp: scrip converted to scripless). With one line on each side any move counts, since the issued share count changed. The `summary` block is computed over every match before `--limit`/`--offset` paging: counts of new, exited, increased and decreased holders, net percentage points split by foreign/local and by KSEI investor type, per-issuer flow (with the foreign part), and the top rises and falls. For a single symbol the net figures are exact; across the register they are a flow indicator. `foreign` sums the `L`/`A` flag KSEI prints on each listed holder. With a symbol it gives one issuer's disclosed foreign percent, holder counts and month-over-month change per release; without one it ranks every issuer by the change in disclosed foreign stake between two releases. Sums cover holders above 1 % only, so this is the *disclosed* foreign stake, not total foreign ownership. JSON schemas: `idx schema ownership-holders|ownership-entity|ownership-changes| ownership-foreign|ownership-foreign-flow|ownership-releases|ownership-import| ownership-discover|ownership-search|ownership-cross-holders| ownership-concentration|ownership-graph|ownership-resolve-candidates| ownership-resolution|ownership-resolutions|ownership-composition| ownership-snapshot-sync|ownership-status|ownership-snapshot| ownership-institutions`. ## Finding a holder ```sh idx ownership search salim # case-insensitive, any spelling idx ownership search "salim anthoni" # words in any order idx ownership search "uob kay hian" --limit 5 idx ownership entity --exact "ANTHONI SALIM" # one holder's positions, by key ``` `search` matches the normalised name (`entity_key`) and every spelling KSEI printed in any imported month. Each match says how it matched: `exact` (the query normalises to the key), `spelling` (one of the printed spellings, case and spacing ignored), `prefix`, `contains`, or `words` (every query word starts a word of the key or a spelling, in any order). Exact and spelling matches come first, then holders listed in the selected release (`--as-of`, default newest) before those that left the register, then prefix, contains and word matches, then the holders with more issuers. A query needs 2 letters or digits. `entity --exact` takes the key and lists that one holder, where plain `entity` lists every holder whose key contains the name. `entity` reads one release (newest by default), so for a holder that left the register pass its `last_seen` as `--as-of`; the line under the search table prints that command for the first match. ## Holders across issuers ```sh idx ownership cross-holders # holders above 1 % in at least 2 issuers idx ownership cross-holders --type individual # people only idx ownership cross-holders --origin foreign --min-issuers 5 ``` A holder's accounts in one issuer are summed first, so an issuer counts once. `total_pct` adds stakes in different companies: it measures breadth and is not a share of anything. `--type` is a case-insensitive substring of KSEI's investor type (`mutual fund` matches `MUTUAL FUNDS`), `--origin` the `A`/`L` flag; a holder without a type or flag never passes those filters. Custodian and nominee accounts (securities companies, private banks) lead the ranking because the register names the account holder, not the beneficial owner: on 30 Sep 2026 UOB KAY HIAN PRIVATE LIMITED is listed in 64 issuers. A holder printed two ways counts as two until you merge them (below). ## Concentration ```sh idx ownership concentration # most concentrated first, by HHI idx ownership concentration --ascending # most widely held first idx ownership concentration --by top1 --limit 50 idx ownership concentration BBCA BBRI TLKM ``` Definition, per issuer and release: - Holders: those listed above 1 % for the issuer, each holder's accounts summed into one position. - `hhi` = the sum of the squared percentages of those holders, on the 0-100 scale, so it runs from 0 to 10,000 (10,000 means one holder owns everything). Nothing is rounded before the last step. - `top1_pct` and `top_holder` (ties by name), `top3_pct` (the three largest, fewer when fewer are listed), `listed_pct` (their sum), `holders_listed`. - `undisclosed_pct` = max(0, 100 - `listed_pct`): what the register does not show. It is left out of the HHI (treated as fully dispersed), so the HHI is a lower bound of the full figure. It is not IDX's free float, a narrower regulatory figure. - `controller_pct`: the stakes flagged CONTROLLER or AFFILIATE OF THE CONTROLLER, from the 30 Oct 2026 register on (`null` for earlier releases). The reference CLI 0xrsydn/idx-cli uses the same definition over register rows; idx sums a holder's accounts first (two accounts of 9.44 % and 4.01 % add 180.9 points, not 105.2), keeps the decimals (BBCA scores 3,021.91 rather than 3,021 on 30 Sep 2026) and calls the remainder `undisclosed_pct`. No upper bound is published: KSEI lists accounts, so a holder below 1 % per account is not guaranteed below 1 % in total. ## Network graph ```sh idx ownership graph BBCA # holders of BBCA and their other holdings idx ownership graph "Anthoni Salim" --depth 3 idx ownership graph AADI --min-pct 5 # major stakes only idx ownership graph BBRI --dot | dot -Tsvg -o bbri.svg ``` The register is a bipartite graph: holders on one side, issuers on the other, a link for each holding above 1 %. The walk starts at a ticker (or, when ROOT is not a ticker with holders or `--holder` is given, at the holder whose key ROOT normalises to, else at the best `search` match, with a warning naming it) and goes breadth-first for `--depth` hops (1-6, default 2): from a ticker, hop 1 is its holders and hop 2 their other issuers. Within a hop the strongest links come first, so `--max-nodes` (default 100, at most 1000) keeps the important part; reaching it sets `truncated` and warns. Links are every holding between two reached nodes. Node ids are `ticker:` and `holder:`, the same in every database. `--dot` prints a deterministic Graphviz digraph (holders as ellipses, issuers as boxes, the percent on each edge, the root in bold) and wins over `-o`; `-o csv` prints the links. Sizes on the 30 Sep 2026 register: BBCA at depth 2 has 13 nodes; DRS LO KHENG HONG has 106 at depth 2 and 1,774 at depth 4; the custodian UOB KAY HIAN reaches 452 at depth 2 (44 with `--min-pct 5`). ## Spelling changes and merges KSEI sometimes prints one holder differently from month to month, and `changes` then shows an exit plus a new holder. Between 31 Aug and 30 Sep 2026, SURONO SUBEKTI became DRS SURONO SUBEKTI (18 issuers), UOB KAY HIAN PTE LTD became UOB KAY HIAN PRIVATE LIMITED (54 issuers), and in all 232 of the 1,451 change rows idx 0.8.1 reported were one name leaving an issuer and another entering it with exactly the same share count. ```sh idx ownership resolve candidates # suggestions with their evidence idx ownership resolve merge "DRS SURONO SUBEKTI" "SURONO SUBEKTI" --dry-run idx ownership resolve merge "DRS SURONO SUBEKTI" "SURONO SUBEKTI" idx ownership resolve map "IR. T. PERMADI RACHMAT" "T PERMADI RACHMAT" idx ownership resolve list idx ownership resolve undo 1 ``` `candidates` (also `unresolved`) pairs holders on two kinds of evidence: `renamed`, the same share count left one name and reached the other in the same issuer between two consecutive imported months; and `similar_name`, the same words once legal forms (`PT`, `TBK`, `LTD`, `PTE`, `LIMITED`, `CORPORATION`, ...) and titles (`DRS`, `DR`, `IR`, `H`, `HJ`, ...) are dropped, in any order (names of one word are left out). Pairs listed in one issuer in one month are marked `listed_together`. These are suggestions, and both kinds have false positives: an identical share count is also what a real block sale looks like (MANDOM CORPORATION to KALON MIDCO HOLDINGS CO LTD in TCID), and holders listed together are often different people (CAUDLE GAVIN ARNOLD and GAVIN ARNOLD CAUDLE in PALM). `merge KEEP MERGE...` moves every spelling and register line of each MERGE holder to KEEP, in every imported month, and every later import follows (a new spelling whose key was merged away joins KEEP too). `map SPELLING HOLDER` moves one printed spelling (`--new` makes HOLDER a new holder). Holders are named by key or display name, exactly; `search` shows both and every spelling. When both are listed in the same issuer in one month the command stops unless you pass `--fold`, which adds the stakes up. `--dry-run` reports the counts and writes nothing. `undo ID` deletes a rule and moves back what it moved; months with register lines are rebuilt from them, so even a fold splits again exactly. A merge of a holder that had been merged into earlier re-points those rules too, so undo reverts exactly one rule. The rules live in the ownership database (`resolutions`), so they survive re-imports (`import --all --force` re-applies them) but not a new database file. Months imported by idx 0.8.1 or older have no register lines: a rule edits their positions in place, a fold there cannot be split again, and a warning asks to re-import them. The rules are local to the CLI and the MCP server reading this file; the GitHub mirror and the web terminal keep the automatic grouping. ## Caveats - Holders below 1 % are not published, so `listed_pct` is below 100 %. - Entity grouping is heuristic: it merges spelling variants, not legally distinct vehicles with similar names. The raw spelling is kept on every line. Two different holders whose names normalise alike in one ticker are summed into one position (they used to replace each other). Titles (DRS, IR) and word order are not normalised; `resolve` merges those by hand. - The register names account holders: custodians and nominees (securities companies, private banks) hold for clients, so cross-holder rankings and graphs show them as hubs. - The web terminal's `ownership search` covers the selected month only (its spellings), and it has no `resolve`: your rules stay in your database. - Attributes of a summed position come from its largest line that has them: a scrip line without an L/A flag counts with its holder's flagged line in `ownership foreign`. - Rows dated other than the release's `as_of` are kept but never summed into it; the import, `releases` (`other_date_rows`) and a not-found message say so. None of the 2026 IDX files mixes dates. - `CONTROLLER_OR_AFFILIATE` comes from the issuer's Form E009 for the previous month, forwarded by IDX to KSEI, so it can lag a month. - PDF imports depend on the text layer of the announcement; numbers are checked but a reformatted PDF may need the parser updated. ## Institutional and fund holders (MSN) ```sh idx ownership institutions BBCA idx -o json ownership institutions TLKM | jq '.data.flow' idx -o csv ownership institutions BBRI > holders.csv ``` The KSEI register stops at 1 %, so most foreign funds never appear in it. `ownership institutions` fills that gap from MSN Money: the `ownershipSummary` block of the same `Finance/Equities` response that `stocks profile` and `stocks fundamental` read through the MSN provider (one request, cached with the fundamentals TTL, 6 h by default, served stale with `--offline`). Nothing is imported or stored in the database. Each report carries: - `institutions_pct`, `top10_institutions_pct`, `funds_pct`: percent of shares outstanding held by all institutions, the ten largest, and funds, as MSN reports them (not sums of the listed rows). - `float_pct`: the free float as a percent of shares outstanding, as MSN reports it (BBCA 38.2 %, consistent with Yahoo's institutions against institutions-of-float ratio). - `holders[]`: up to 15 institutions (`kind: institution`) and 20 funds (`kind: fund`), each in MSN's rank order, with `shares`, `shares_change` (bought or sold since that holder's previous report), `pct_outstanding`, `pct_outstanding_change` (percentage points of the company), `pct_portfolio` (funds only: the stock's weight in the fund, in percent), `report_date` and `signal`. - `flow.institutions` and `flow.funds`: counts of each signal and the sum of `shares_change` per list. `signal` is computed by idx from `shares` and `shares_change`: `exited` when the holding is 0 after a sale, `new` when the change equals the whole holding, otherwise `buyer`, `seller` or `unchanged` by the sign of the change. It is `null` when either number is missing. Read it with care: - Every holder reports on its own date (BBCA: from 2025-12-31 to 2026-10-31 on 2026-11-06), so a change is "since that holder's last filing", not over one common period. `oldest_report` and `newest_report` give the span; exited holders are often the oldest rows. - Funds are part of their managers' institutional positions (Fidelity the manager is an institution, Fidelity's funds are funds): never add the two lists or their percentages. - Not used on purpose: MSN's `marketValue` (a holder-level total, identical for one manager across stocks), its `change` (the share change at the report-date price, in an unstated currency that looks like USD; neither is the value of this position in rupiah) and `insiderOwnershipPercent` (1.21 % for BBCA, whose controlling holder has 54.94 % in the KSEI register). - Coverage: on 2026-11-06, 420 of the 887 IDX stocks MSN lists had institutions, 381 had the block with empty lists and 86 had none. A stock without holders gives an empty report with a warning (exit 0); a ticker MSN does not list on IDX exits 4; `--provider yahoo` exits 6 (the default `auto` reaches MSN). - Bing's `hedgefunddataprovider` API, which some tools use for holders, carries US 13F filings only: it answers `{"securityOwnerships":[]}` for every IDX stock, so idx does not use it. The web terminal does not run this command yet: MSN has not been checked from the site's Cloudflare Worker. Schema: `idx schema ownership-institutions` (`idx.ownership.institutions.v1`). --- Source: https://ihsg.midoriresearch.com/docs/stockbit.md # Stockbit realtime (opt-in) `idx` reads IDX data from free, keyless public endpoints. **Stockbit is the one exception**: an opt-in provider that serves realtime quotes, history and a live order book from [Stockbit](https://stockbit.com)'s own backend, carrying **your own Stockbit account session**. It is never in the provider `auto` chain: quotes and history use it only when you ask for it with `--provider stockbit`. The same session also reads what only Stockbit has in one place: every corporate action of a stock since its listing, the market's corporate-action calendar, and the insider and 5 % holder movements filed with IDX and KSEI (`idx stockbit actions`, `idx stockbit calendar`, `idx stockbit movements`). And `idx broker` lists every broker that bought and sold a stock from it, by itself once you are logged in (see **Broker summary**). > This uses an unofficial, undocumented API with your personal account. It may > conflict with Stockbit's Terms of Use, and automated access could get an > account flagged. Use your own account, at your own risk, at a human-ish > request rate. `idx` is not affiliated with or endorsed by Stockbit. ## Log in once ```sh idx stockbit login ``` idx never asks for your Stockbit password. You sign in on stockbit.com itself, and idx takes only what that sign-in leaves behind: the `credentialStorage` cookie, which holds the session's two tokens. Whichever way it arrives, idx first stores it (`0600`), then checks it with one request to Stockbit; when Stockbit refuses it, the previous session is put back (or none, if there was none) and nothing changes. When the session already stored still works and idx can renew it, `login` says so and stops; `--force` signs in anyway. **In a browser idx opens** (the default on a desktop): idx launches a Chromium-family browser (Chrome, Edge, Brave; `--browser` or `IDX_BROWSER` chooses one) on Stockbit's sign-in page, with a profile only idx uses (`stockbit-chrome-profile` beside the session file; `--profile` moves it). You sign in there, with whatever Stockbit asks (password, OTP, captcha). idx reads nothing but cookies over the Chrome DevTools Protocol: it intercepts no request, so the password never passes through idx. Once Stockbit accepts the session, idx closes Stockbit's tab, deletes the cookie from its profile (so a later run cannot pick up a pair idx has since rotated) and closes the browser. The profile keeps Stockbit's other cookies, so the next sign-in may skip a step. Google sign-in refuses browsers driven this way ("This browser or app may not be secure"); use the local page instead. **Through a local page** (`--page`; also chosen by itself over SSH, without a display, or without a Chromium-family browser): ```sh idx stockbit login --page # prints http://127.0.0.1:7878/login/ idx stockbit login --page --port 7878 # pin the port, e.g. for an SSH forward ``` idx serves a page on 127.0.0.1 only, at a path with a random secret, for the browser you already use (any browser, Firefox included). Over SSH it also prints the forward to run on the machine with the browser, for example `ssh -N -L 7878:127.0.0.1:7878 you@server`; then open the URL there. On the page: 1. Open a **private window** and sign in at stockbit.com (Google sign-in works here). idx then holds a session of its own while your everyday browser keeps its own. 2. Drag **Send to idx** to the bookmarks bar, then click that bookmark on the stockbit.com tab. It runs only on stockbit.com, reads `credentialStorage` there and opens the page with the value in the address fragment, which the browser never sends to any server. A bookmark from an earlier run carries an old secret: drag it again. 3. Or paste the cookie into the page (DevTools > Application > Cookies, or the clipboard button). The page posts only that value to idx, which checks it with Stockbit and answers on the page. Close the private window afterwards **without signing out**. The page answers only on its secret path, only to a loopback `Host`, and takes a POST only from a loopback origin; it keeps no copy, sets `no-store`, `no-referrer` and a CSP that allows nothing outside the page, accepts one session and stops (or after `--timeout`, 300 seconds by default). Port 7878 is the default; when it is taken idx picks a free one, `--port` pins it and `--port 0` takes any. **By pasting the cookie**, from any browser: ```sh idx stockbit login --manual # paste it into this terminal pbpaste | ssh you@host idx stockbit login # or pipe it; a pipe on stdin means paste ``` Sign in at stockbit.com (a private window keeps your everyday browser's session apart, see **One refresher per session**), open DevTools (F12) > Application > Cookies > `https://stockbit.com` and copy the value of `credentialStorage`. A bare refresh token works too. Close that window afterwards without signing out: signing out there may end the session idx now holds. The session is stored locally (see **Where the session lives**) and refreshed for you from then on. Check it any time: ```sh idx stockbit status # logged in? when do the tokens expire? idx stockbit logout # delete the stored session ``` `status` reads the stored tokens' own expiry and sends nothing. The access token lasts about a day and the refresh token about a week. idx refreshes only when a request needs it (the access token has expired, expires within the hour, or Stockbit refused it), and each refresh replaces both tokens, so a request made while the access token is still good renews nothing. The refresh token therefore runs out about a week after the last refresh, which can be up to a day short of a week after the last use; using idx at least every six days keeps the session working. Once the refresh token has run out too, `status` says logged in `no`, with a note that the session expired and that `idx stockbit login` renews it (`"logged_in": false, "expired": true` in JSON). While only the access token can still be used (its refresh token expired, or none is stored), logged in is `yes` with a note that the session stops working when that token expires (`"renewable": false`). A session Stockbit ended early (signed out elsewhere, or refreshed by another tool) still reads as logged in until a request finds out: that request fails naming `idx stockbit login`, and the process that sent it (a running `idx mcp` server, say) then reports that Stockbit refused the refresh token; `idx stockbit status`, a process of its own, cannot tell. ## Use it Once logged in, point any quote/history command at the provider: ```sh idx --provider stockbit stocks quote BBCA BBRI # realtime, not delayed idx --provider stockbit stocks history BBCA --period 1mo idx --provider stockbit stocks intraday BBCA # today's one-minute bars, realtime idx config set provider stockbit # make it the default idx config set history_provider stockbit # or: candles only, free providers for the rest ``` `history_provider` (`--history-provider`, `IDX_HISTORY_PROVIDER`) picks where candles come from apart from `provider`. With `history_provider = "stockbit"` the free providers keep serving quotes, fundamentals, financials and the rest while history, technicals, returns and the brief take Stockbit's candles. Under `provider = "stockbit"` the default `history_provider = "auto"` keeps on Stockbit only the requests whose bars Stockbit returns as asked (daily over `1mo` to `1y`, hourly over `5d`, weekly over `5y`, same-day intraday) and sends the rest (`2y` or `5y` daily, `1y` weekly, any monthly bars) to Yahoo with a note; when the session fails it falls back to Yahoo with a warning. `history_provider = "stockbit"` keeps every window on Stockbit. See [configuration.md](https://ihsg.midoriresearch.com/docs/configuration.md#history-provider). And read the live order book (Stockbit-only — the free sources have no depth): ```sh idx stockbit orderbook BBCA # depth ladder + session foreign flow idx stockbit orderbook BBRI --depth 5 idx -o json stockbit orderbook TLKM | jq '.data.bids[0]' ``` Sizes in the order book are **shares** (one IDX lot is 100 shares); values are IDR. History candle volumes are converted from Stockbit's lots to shares so they match the other providers. Stockbit chooses the candle granularity and span from the window (`1d` is one-minute; `5d`, and intraday intervals over any longer period, hourly over one week; `1mo` to `1y` daily; `2y` weekly over three years; `5y` and longer weekly over five years), and a warning says so whenever those bars are not the `--interval` asked for or that span is not the `--period` (`--period 1y --interval 1wk` gets daily bars and a warning; `--period 1mo --interval 1h` gets one week of hourly bars and a warning, since the candles are not trimmed to the period; `--period 5d --interval 1h` gets the hourly bars it asked for and none). Foreign flow in the order book (`foreign_buy`, `foreign_sell`, `foreign_net`) comes with a flag, `has_foreign_bs`. While it is `false` the figures are not the current session's: a BBCA book recorded in session 2 on 2026-11-03 carried a foreign net of -116.1B, which is IPOT's figure for 2026-11-02 (2026-11-03 itself closed at -28.8B). So idx keeps the three figures only while Stockbit marks them current (`true`, as in idx's own books recorded after the close) and otherwise leaves them empty, with a warning that says why (on stderr in a table, in `warnings` in JSON). A missing or non-boolean flag counts as not current. The `false` book came from another account (recorded by 0xrsydn/idx-cli), and its `orderbook_active_feature_mobile` differs too (`ORDER_BOOK_FEATURE_UNSPECIFIED`, where idx's books say `ORDER_BOOK_FEATURE_FOREIGN_BS`), so it is still open whether the flag follows the session clock or an account feature. idx reads only the flag, so either way the figures show only when Stockbit marks them current. `stocks intraday` takes its bars where candles come from, so with `--provider stockbit` or `history_provider = "stockbit"` it shows today's realtime one-minute bars from your session during trading hours; the default providers serve Yahoo's delayed bars without a login. When the day's window is still empty, idx retries over five days only with a source whose five-day window has the bars asked for: under `--provider stockbit` that is Yahoo (the last session's bars, with a warning naming its date), while with `history_provider = "stockbit"` there are no bars until Stockbit has some (Stockbit's five-day window is hourly; the quote still answers). The Stockbit provider covers `quote`, `history` and `orderbook`. The other commands (fundamentals, financials, news, …) stay on the free providers; run them without `--provider stockbit`, or keep `provider` on `auto` and set only `history_provider = "stockbit"`. ## Corporate actions and calendar ```sh idx stockbit actions BBCA # every kind but ipo, newest first idx stockbit actions TLKM --kind dividend idx stockbit actions BBRI --kind split,bonus,rights --from 2015-01-01 idx stockbit calendar # the next 14 days, oldest first idx stockbit calendar --kind dividend --days 30 idx stockbit calendar --kind ipo,tender --from 2026-01-01 --to 2026-12-31 ``` One structured row per action, with the dates and terms and no announcement text (schema `idx.stockbit.actions.v1`). Kinds: `dividend`, `stock_dividend`, `bonus`, `rights`, `warrant`, `split`, `reverse_split`, `rups` (alias `meeting`), `public_expose` (`expose`), `ipo`, `tender_offer` (`tender`), or `all`. Each kind is one request, a second apart, cached for 6 hours; a kind that fails leaves a warning and the others still show. The pause holds across every command and MCP tool call on the same session in one idx process, so parallel tool calls queue a second apart too. - A row's `date` is its cum date for dividends, bonuses, rights and splits (the ex date when there is none), else the event's first day: a warrant's first trading day, the meeting or expose day, an IPO's listing day, a tender offer's opening day. - `stockbit actions` reads a stock's whole history: BBCA's cash dividends go back to 2009, its splits to 2004. `--from`/`--to` narrow it. The default is every kind but `ipo`. - `stockbit calendar` reads the market-wide lists and keeps the rows dated in the range (at most 366 days; default today and the next 14), plus rights trading windows, tender offers and IPO offerings still open in it. The default is every kind but `warrant`: most market-wide warrant rows are structured warrants, listed under their own codes (`ASIIZPCN7A`), dozens a month. - As recorded on 2026-11-03, the market-wide lists reach about a year back and as far ahead as announced, except RUPS and public exposes, whose market lists started on the recording day. The stock dividend and reverse split lists were empty that day; their rows are read by the field names the other entitlement rows use. - `ratio_old : ratio_new` is as published (1 : 5 for a five-for-one split, 655 held : 104 rights). `price` is the rights or warrant exercise price, the tender offer price, or the final IPO price, IDR per share. Anything Stockbit leaves empty or zero is `null`. Without a login, `idx stocks actions` (IPOT, with the announcement texts) and `idx market calendar` (the exchange's own calendar) cover similar ground. ## Insider and 5 % holder movements ```sh idx stockbit movements BBCA # one stock, newest first idx stockbit movements # the whole market idx stockbit movements --source ksei --action sell idx stockbit movements --from 2026-10-01 --to 2026-10-31 --pages 5 idx stockbit movements --holder 1000004481 # one holder, by the HOLDER ID column ``` Reported changes in a holder's stake (schema `idx.stockbit.movements.v1`), from two kinds of filing that Stockbit shows in one feed: - `idx`: insider disclosures to IDX. Directors, commissioners and other insiders, any size, with the trade price the insider filed. - `ksei`: KSEI's daily changes of holders at 5 % and above. No price is filed; Stockbit shows an estimate, which idx keeps in `estimated_price` and `estimated_value` (shown as `~21` in the table), never in `price` or `value`. `shares_from`, `shares_to` and `shares_change` are exact and signed; `pct_from`, `pct_to` and `pct_change` are percent of the company as Stockbit rounds them (an insider's 0.0003 % stays 0.0003). A rights issue the holder did not take up shows as `shares_change: 0` with a negative `pct_change`. `--action` filters `buy`, `sell`, `transfer`, `cross` (crossings between accounts: repos, pledges) or `corporate-action` (rights issues, private placements, tender offers and the like); each row's own `action` is Stockbit's label, lowercased. `--holder` takes the digits of a row's `holder_id`, the same on every row of that holder. Stockbit serves 50 rows a page; `--pages` reads up to 10, one request each, a second apart, cached for an hour. `more` says rows remain. The feed gains rows at the top as filings arrive, so a row repeated across pages is kept once, a page cached before the page above it was read is read again live (rows that moved down past the page break would otherwise be on neither page; when it cannot be read again, a warning says rows may be missing), and a later page that fails keeps the rows already read (with a warning). These are filings, not signals: a share plan reads as many insiders buying at one price on one day (BBCA's directors on 2026-03-25 at 6,982), crossings name securities firms as holders, and rows appear days after the change. The monthly register of holders above 1 % (`idx ownership`, no login) is the other view. ## Broker summary ```sh idx broker BBCA # every broker, once logged in idx broker BBCA --source stockbit -n 0 # Stockbit or nothing, every row in the table idx broker BBRI --from 2026-11-02 --to 2026-11-06 --investor foreign idx broker BBCA --source ipot # IPOT's top 10, no login ``` `idx broker` reads Stockbit's broker summary by itself once a working session is stored (what `idx stockbit status` calls logged in): `--source auto`, the default, takes Stockbit then and IPOT otherwise, and `--source stockbit` fails, naming `idx stockbit login`, when no session works. The schema stays IPOT's, `idx.broker.v1`: - idx asks for gross figures: each broker's own buying on the buy side and its own selling on the sell side, as IPOT publishes them. Stockbit's net mode lists each broker once, on the side where it was net; idx does not use it and refuses a reply in those figures. - Every broker is listed. BBCA had 59 buyers and 67 sellers on 2026-11-06, and 92 a side over the year to that day. `buyer_count` and `seller_count` are Stockbit's counts; `buyers_complete` and `sellers_complete` say whether each list holds them all. A cut list keeps its totals `null`, and the table says `Top buyers (5 of 59)`. - Stockbit ranks a side by value; idx orders it by lots, as IPOT ranks, so `rank` means the same. Ordered so, Stockbit's first ten were IPOT's top 10 for 2026-11-06 and for 2026-11-02 to 2026-11-06: the same brokers in the same order, with the same lots and values at IPOT's precision. Classes are Stockbit's own labels (`Asing`, `Lokal`, `Pemerintah`), which can differ from IPOT's: HD is foreign on Stockbit and local on IPOT. - Figures are exact (`rounded: false`), and odd lots count as fractions of a lot (YU bought 108,898.5 lots that day). Totals are sums over the full lists: both sides came to 1,035,652,066,452 IDR. Stockbit's own totals are the buy side's at about eight significant digits and are not used. - The foreign net comes from a second read, of foreign investors over the same range and board: their buying less their selling, 218,988,301,100 IDR for BBCA on 2026-11-06 (IPOT's `F. NVal`: 219.0 B). With `--board regular` it is the regular board's, which IPOT does not publish. A day without trades needs no second read; when the second read fails the net is `null` with a warning. - A range is one read, aggregated by Stockbit, so no session is rebuilt one by one as IPOT's foreign net over a range is. - Domestic investors and the cash and negotiated boards have not been recorded yet, so idx does not ask Stockbit for them: `auto` takes IPOT, with a note, and `--source stockbit` is a usage error. - Under `auto`, a ticker Stockbit fails for (its CDN refusing this network, a session Stockbit ended) is read from IPOT with a warning; after a failure no other read could get past, the rest of the call is read from IPOT too. Each summary's `source` says which answered. - Reads are paced a second apart like the other Stockbit lists: one or two per ticker, plus one per weekday without trades while looking for the latest session. A range that ended before today is cached for a week, one that reaches today for five minutes. Without a working session, or with `--source ipot`, the summary is IPOT's top 10 as before; under `auto` a note then says a login lists every broker, when a list reached IPOT's ten rows. ## MCP The MCP server exposes five Stockbit tools alongside the rest: - `idx_stockbit_status`: can the stored session work now (`logged_in`; `expired`, `renewable` and `reason` say why not, or why idx cannot renew it), and when do the tokens expire (never the token values). Call it before the others. - `idx_orderbook`: the live depth ladder and foreign flow for one ticker. - `idx_stockbit_actions`: one stock's corporate actions (`symbol`), or the market calendar without one; `kind`, `from`, `to`, `days` as on the CLI. - `idx_stockbit_movements`: insider and 5 % holder movements; `symbol`, `from`, `to`, `holder`, `action`, `source`, `pages` as on the CLI. - `idx_stockbit_login`: starts a sign-in. `method: page` returns at once with a URL on 127.0.0.1 (and the `ssh -N -L` forward when the server is reached over SSH) for the user to open in their own browser, as with `--page`; the agent then polls `idx_stockbit_status`, whose `sign_in` shows `waiting`, `signed_in` or `failed`. `method: browser` opens a browser **on the machine running the server** and waits, which only suits a local stdio server the user sits at. A session that already works, and that idx can renew, is reported without opening anything (`force` signs in anyway). The tool never asks for, and agents should never ask the user for, a password, a cookie or a token in the chat. Prefer telling the user to run `idx stockbit login` in a terminal themselves. `idx_broker` reads the same session: its `source` (`auto`, `ipot`, `stockbit`) works as `--source` does on the CLI, so an agent gets every broker once the user is logged in. To serve realtime quotes/history over MCP, run the server with the Stockbit provider: `idx --provider stockbit mcp`. For Stockbit candles with the free providers for everything else, register it with `-e IDX_HISTORY_PROVIDER=stockbit` instead. ## Where the session lives The session file holds the rotating tokens and is written with `0600` permissions on Unix. By default it sits beside the *default* config file (e.g. `%APPDATA%\idx\stockbit-session.json` on Windows, `~/.config/idx/stockbit-session.json` on Linux), also when `--config` points elsewhere. Move it with: - `stockbit.session` in `config.toml`; - `IDX_STOCKBIT_SESSION=/path/to/session.json`. For an unattended deployment (a server, the web worker) seed the tokens through the environment instead of logging in interactively: - `IDX_STOCKBIT_REFRESH_TOKEN` — the refresh token (required); - `IDX_STOCKBIT_ACCESS_TOKEN` — an access token to start with (optional). The file always wins over the env seeds; the seeds only fill an empty session. ## One refresher per session Stockbit's `POST /login/refresh` **rotates** the refresh token: every refresh mints a new one and retires the token it was given. So a single session must have exactly one process refreshing it. If you point two tools (say `idx` and another bot) at the *same* Stockbit session, they will retire each other's token and both get logged out. Give each its own `idx stockbit login` / account, or let one own the refresh and feed the other its current token. Inside idx, refreshes are serialised so that one session does have one refresher: a process keeps one session store per session file (the CLI handler, the `--provider stockbit` provider, every MCP tool call and every MCP HTTP session share it; an MCP server takes it once when it starts and holds it until it stops), and a refresh also takes a lock file beside the session (`stockbit-session.lock`) so a CLI command and a running MCP server do not refresh at the same moment. Inside both locks the file is read again: a pair another holder just stored is used, not refreshed a second time. A lock older than a minute counts as left behind by a crashed process and is taken over. A refresh token Stockbit refuses is not presented again by the same process; `idx stockbit login` is the way back. ## What `idx` talks to All on `https://exodus.stockbit.com`, with `Authorization: Bearer ` and browser-shaped `origin`/`referer` headers: | endpoint | used for | | --- | --- | | `GET /company-price-feed/v2/orderbook/companies/{SYM}` | quote (OHLC, volume, value, foreign flow only while `has_foreign_bs` is true) and the full depth ladder | | `GET /charts/{SYM}/daily?timeframe=..&chart_type=PRICE_CHART_TYPE_CANDLE` | OHLCV candles | | `GET /corpaction/{type}[?symbol={SYM}]` | corporate actions, one kind per request: `dividend`, `stock_dividend`, `bonus`, `rightissue`, `warrant`, `stocksplit`, `reversesplit`, `rups`, `pubex`, `ipo`, `tenderoffer` | | `GET /insider/company/majorholder?page=N[&symbol=][&date_start=][&date_end=][&insider=][&action_type=][&source_type=]` | insider and 5 % holder movements, 50 rows a page | | `GET /marketdetectors/{SYM}?transaction_type=TRANSACTION_TYPE_GROSS&market_board=MARKET_BOARD_ALL\|_REGULER&investor_type=INVESTOR_TYPE_ALL\|_FOREIGN&from=..&to=..&limit=1000` | the broker summary: every broker that bought and sold over the range (`idx broker`) | | `GET /company-price-feed/market-time/session` | checks a session before `idx stockbit login` stores it (never from the cache) | | `POST /login/refresh` | rotate the session (bearer = refresh token, empty body) | The same API places orders and moves cash, so idx keeps an allowlist of the read-only paths above and refuses any other URL before a token is read. A 401, or a 403 with a JSON body, means Stockbit refused the access token: idx refreshes once and retries once. A 403 with an HTML body is Stockbit's CDN (CloudFront) refusing this client or network; a refresh cannot change that, so idx reports it and does not retry. The same goes for `POST /login/refresh`: only a 401, or a 403 with a JSON body, counts as a refused refresh token there; an HTML 403 is reported as the block, and the token stays usable once it lifts. Indices use Stockbit's own code (`IHSG`, `LQ45`), mapped from idx's caret form (`^JKSE`, `^JKLQ45`). The token is never written to a log, an error message or a cache key.