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

feat: login-free Stockbit intraday and quote provider
This commit is contained in:
dogfiles 2026-11-05 18:03:58 +07:00 • committed by GitHub
commit be9d17bb80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
157 changed files with 14699 additions and 481 deletions

View file

@ -55,6 +55,9 @@ command parses arguments, makes one idx-core call, and renders the result.
(`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries).
- **IDX (idx.co.id)** — the daily trading summary behind `stocks flow
--source idx` (`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow).
- **Stockbit (public page)** — `stocks intraday` whichever provider is
selected (`IntradayProvider`), and quotes under the opt-in
`--provider stockbit`. See [Stockbit](#stockbit).
### MSN Endpoints
- `https://assets.msn.com/service/`: quotes, charts, equities, earnings,
@ -79,7 +82,7 @@ WIB as the fallback.
`behind_session` when `as_of` is before the latest weekday whose session has
ended (16:15 WIB). It is computed on return, not cached; MSN can lag the close
by hours, and on an exchange holiday every quote reads as behind. Only `.JK`
quotes and `--region id` screens are marked; other exchanges keep other hours.
quotes, `--region id` screens and `stocks intraday` are marked; other exchanges keep other hours.
Table output notes behind quotes, and calls those over seven days old not
current.
- `change`, `change_pct`, and `volume` are nullable when the provider omits them;
@ -151,6 +154,163 @@ WIB as the fallback.
range fails on one. `foreign_net_value` is net shares at the session's
average price (value over volume), an estimate.
### Stockbit
- `GET https://stockbit.com/symbol/{CODE}` needs no login. Next.js
server-renders the quote and today's chart into the page's `__NEXT_DATA__`
JSON; `api/stockbit/parse.rs` reads it purely. `props.pageProps.company`
is the quote (numbers mostly as strings, an index price as a float string),
`orderbook.{bid,offer}.price` the best bid and offer, `market_hour.status`
the market's state, and `status` `STATUS_ACTIVE|SUSPENDED|DELISTED`.
`chart.prices[]` holds one price per minute in WIB with no offset, after a
synthetic first row at the previous close; per-minute OHLC and volume are
empty. An unknown code is HTTP 404 with the 404 page.
- Stockbit's JSON API (`exodus.stockbit.com`) answers 401 without a user's
bearer token; it is used only with the user's own session (below).
- Bid and offer are kept only while `market_hour.status` is `open`, so a
closing book never reads as a live one. Observed 2026-10-23: `open` during
trading (bid/offer set), `break` from 11:30 on a Friday, `close` after
hours; any other value fails closed. In session the chart gained a point
every minute and the last trade was within a minute of the clock, although
Stockbit's terms call its data delayed at least 10 minutes. Order-book
sizes are not shown; they are shares (BBCA's best bid on 2026-10-23 was
1,357,500 on the page and 13,575 lots in the session API's book). `as_of`
comes from `date`/`time`
only when the stock traded (a suspended stock carries today's date).
`session_note` repeats what the page says (suspended, delisted) or "no
trades this session"; it is never inferred.
- CloudFront in front of the page answers a static 403 to some user agents
(`curl/`, `axios/`, `Go-http-client/`, empty) and does not fingerprint TLS,
so a plain ureq request with `idx-cli/<version> (+repo)` passes. A 403 is
`UPSTREAMBLOCKED` and is not retried. Requests are at least 1 s apart
across the user's idx processes (the last request's time sits in
`<cache>/stockbit.pace` under a file lock, so a shell loop of `idx` calls is
paced too), and snapshots are cached for 5 s (at most `quote_ttl`), which
coalesces an agent's quote-then-intraday burst across MCP calls.
- Stockbit's terms call its data delayed at least 10 minutes and for
personal use; frontends say so. Fixtures (`tests/fixtures/stockbit_*`) are
pages recorded 2026-10-22 after the close, cut to the fields read.
### Stockbit session (opt-in login)
- `idx stockbit login` stores the user's session from a pasted
`credentialStorage` cookie (or a bare refresh token) in
`stockbit-session.json`, written atomically with mode 0600, in
`auth::session_dir()`: the config directory, or on macOS the other default
location when the session or profile is there, because a shell with
`XDG_CONFIG_HOME=~/.config` and an app started from the Dock resolve to
different places (a custom `XDG_CONFIG_HOME` stands alone, which keeps
isolated runs off the user's session);
`IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` fill fields the
file lacks. Stockbit publishes no OAuth client a third party can register,
and its sign-in is a first-party flow guarded by reCAPTCHA, so idx cannot
ask for a token itself; every way in ends with the cookie's two tokens.
- What the cookie is (read from Stockbit's web bundle, 2026-10-29): the web
app's persisted credential store, written by script, host-only on
`stockbit.com`, for 30 days, as percent-encoded
`{"state":{"access","refresh","user"},"version":0}`. Every sign-in
(password, Google or Apple, a new device's one-time code, recovery) and the
app's own `POST /login/refresh` go through one `setCredentials`, so the
cookie always holds the newest pair. A signed-out page writes the store
without tokens, so a cookie counts only when `auth::session_from_paste`
finds tokens in it. The app refreshes by itself when it loads with a stale
access token, which is why idx never leaves a session in a browser it does
not own.
- `api/stockbit/signin.rs` is the one sign-in flow; `idx stockbit login` waits
on it in the foreground, and idx-mcp's `stockbit_login` runs it on a thread
(`start_background`) that `stockbit_status` reports on (`sign_in`: waiting,
signed_in or failed, with the error's code). The thread's warnings go into
`sign_in.warning` through `runtime::on_warning`, since nobody reads
idx-mcp's stderr, and the error that ends a browser wait names a refusal
it hit. A thread does not outlive
`main`, so idx-mcp calls `signin::shutdown` when stdin closes or on SIGTERM
or Ctrl-C, which closes a waiting browser (SIGKILL cannot). `Method::Auto` uses the browser
unless the shell is over SSH or has no display, and falls back to the page
only when the browser cannot start (none found, or it exits at launch); a
sign-in that started and then timed out or was closed is an error. Both
ways end in `signin::accept`, which is `stockbit::login`: store, check with
one request, restore the previous session if refused. A stored session that
still works means nothing to sign in to: both frontends verify it with one
request first, which is the common case, because every refresh extends the
session.
- The browser way (`api/stockbit/browser.rs`): Chrome, Chromium, Edge, Brave
or Helium on `stockbit.com/login`, with a profile of its own at
`<session dir>/stockbit-browser` (mode 0700) and
`--remote-debugging-port=0`. A profile belongs to one browser (its cookies
use that browser's key, and an older Chromium may refuse a newer profile),
so `<profile>/idx-browser` records the executable and the next run reuses
it; `IDX_STOCKBIT_BROWSER` overrides that (and is the only browser the mock
provider will start). The
browser writes the port and WebSocket path to `<profile>/DevToolsActivePort`;
idx connects and polls `Storage.getCookies`. On a session `accept` takes, it
opens a blank tab, closes every other one (no Stockbit script may write the
cookie back), deletes `credentialStorage` by the domain and path the browser
reported, reads the cookies again to confirm it is gone, and asks the
browser to close. Stockbit's device cookies stay, so a later
sign-in needs no new one-time code. The window waits 15 minutes; closing it
or the browser exiting ends the wait at once. The password, one-time codes
and reCAPTCHA stay in the browser. Google refused its own sign-in in this
window on 2026-10-31 ("This browser or app may not be secure"), most likely
because of the debugging flag, so password sign-in is the supported way
here and Google users go through the page.
- The page way (`api/stockbit/loopback.rs`), for `--no-browser`, SSH, no
display or no browser: a page on 127.0.0.1 (7878 when free, so an `ssh -L`
forward stays the same) with a bookmarklet that reads the cookie in the
user's own browser and opens the page with it in the URL fragment, or a
clipboard button; a per-run secret guards the path, the page is `no-store`
with a strict CSP, and the server stops on success or after five minutes.
Piped stdin keeps the paste path. Sign-in progress goes to stderr (as JSON
lines with `-o json`), so stdout holds only the result.
- `api/stockbit/auth.rs` reads expiries from the JWTs (`exp`, unverified)
and refreshes an access token within an hour of expiry with
`POST /login/refresh` (bearer = refresh token), which rotates both tokens.
Because a refresh token is spent once, refreshes run under an exclusive
lock on `stockbit-session.lock` and re-read the file first, so concurrent
idx processes never spend the same token. A data request answered 401 (or
a JSON 403) refreshes once and retries once; a second refusal is
`AUTHERROR`. Tokens never reach output, errors, logs or cache keys
(`Session`'s `Debug` redacts them).
- `api/stockbit/exodus.rs` (endpoints and units mapped by fzn0x/idx, MIT;
its recordings are `tests/fixtures/stockbit_exodus_*`):
`/company-price-feed/v2/orderbook/companies/{CODE}` gives the depth ladder
(`volume` in shares, `que_num` orders), OHLC, value, frequency and foreign
buy/sell/net (kept only when `has_foreign_bs` is true: on 2026-11-03 in
session it was false and the figures were the previous session's);
`/company-price-feed/market-time/session` the market state of the `regular`
board (`STATE_NAME_SESSION_2` while trading, `STATE_NAME_BREAK`,
`STATE_NAME_MARKET_CLOSED`; cached 30 s per process; the `fca` board's own
state is not read); `/charts/{CODE}/daily`
daily candles for `1m`/`3m`/`1y` windows (volume in lots, returned in
shares). Requests share the public page's cross-process pacing.
- With a session, `--provider stockbit` quotes come from the order book (bid
and offer only in a `STATE_NAME_SESSION_<n>` state; session 2 was observed
on 2026-11-03, session 1 is assumed to match), `stocks orderbook` works, and `auto` history under the stockbit
provider uses Stockbit's daily windows (`5d` to `1y`, trimmed from `1m`,
`3m` or `1y`), Yahoo otherwise. Without one, quotes use the public page and
the order book is `AUTHERROR`.
- Corporate actions (`corporate/`, `api/stockbit/corpaction.rs`):
`/corpaction/{type}` per kind (`dividend`, `stock_dividend`, `bonus`,
`rightissue`, `warrant`, `stocksplit`, `reversesplit`, `rups`, `pubex`,
`ipo`, `tenderoffer`), with `?symbol=` for one stock's history or without
for the market (about a year back and what is announced ahead). Each type
has its own field prefix and the list key is not always the path's, so
entitlements are read by field suffix (`_cumdate`, `_exdate`, ...). One
request per kind, cached 6 hours under `stockbit-corpaction`
(`<kind>:<SYMBOL.JK>` or `<kind>:market`); `stocks actions` /
`get_corporate_actions` sort newest first, `stocks calendar` /
`get_corporate_calendar` keep what `CalendarRange::includes`: a
`CorporateAction::date` (the cum date for entitlements) in the range, or a
rights, tender or IPO `period` that overlaps it (not a warrant's, which
runs for years), sorted oldest first. Endpoint notes and the wider
design: `docs/STOCKBIT.md`.
- Shareholder movements (`movements`, `api/stockbit/movement.rs`) read
`/insider/company/majorholder`: IDX insider disclosures and KSEI 5% holder
changes, 50 rows a page, filtered by Stockbit (`symbol`, `date_start`,
`date_end`, `insider` (holder id), `action_type`, `source_type`).
`ownership movements` and `get_shareholder_movements` read up to 10 pages
as one answer, cached an hour under `stockbit-movements`, keyed by every
filter and the page count. If a page after the first fails, the rows read
before it come back with `incomplete` set and nothing is cached. The KSEI
register in SQLite is not involved.
### Capability Gating
```
MarketDataProvider = QuoteProvider + FundamentalsProvider
@ -158,11 +318,20 @@ HistoryProvider = separate trait, not all providers implement
Factory functions:
default_provider(kind) → Box<dyn MarketDataProvider>
history_provider(kind, mode, verbose) → Result<(ProviderKind, Box<dyn HistoryProvider>)>
history_provider(source, verbose) → Box<dyn HistoryProvider> (source from history_source)
broker_provider() → Box<dyn BrokerSummaryProvider>
trading_summary_provider() → Box<dyn TradingSummaryProvider>
intraday_provider() → Box<dyn IntradayProvider>
orderbook_provider() → Box<dyn OrderBookProvider>
corporate_action_provider() → Box<dyn CorporateActionProvider>
shareholder_movement_provider() → Box<dyn ShareholderMovementProvider>
```
`ProviderKind::Stockbit` serves quotes (and, with a session, daily history):
fundamentals are `UNSUPPORTED`, the MSN-only capabilities say "requires
--provider msn", and `auto` history uses Yahoo unless a session covers the
window.
## Ownership Module (SQLite-backed)
Unlike the `stocks` module (live-fetch), ownership is **import-then-query**:
@ -240,7 +409,9 @@ CLI flags > environment variables > config file > defaults
bidirectional overrides replaced before it reaches a terminal table.
- Exit codes by class: `0` success, `2` `INVALIDINPUT`/`CONFIGERROR` and clap usage
errors, `3` `SYMBOLNOTFOUND`/`NOMARKETDATA`/`CACHEMISS`, `4` `HTTP`/`RATELIMITED`/
`PROVIDERUNAVAILABLE`/`OFFLINE`/`AUTHERROR`, `5` `UNSUPPORTED`, `1` everything else
`PROVIDERUNAVAILABLE`/`OFFLINE`/`AUTHERROR`/`UPSTREAMBLOCKED`, `5` `UNSUPPORTED`,
`1` everything else. `UPSTREAMBLOCKED` is an upstream refusing this client
outright (a CDN 403 page); retrying at once does not help.
- A totally failed `stocks compare` emits one structured error in JSON mode; per-symbol warnings are retained for partial success, not printed ahead of a total-failure error.
## CLI Output and Offline Cache

View file

@ -93,7 +93,9 @@ Key decisions:
- **`Fetched<T>` envelope** for live data: `{ data, source, fetched_at, stale }`,
so agents and API clients can see freshness. idx-core still emits the
stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI
stderr stays identical.
stderr stays identical. idx-mcp also catches a call's warnings with
`runtime::on_warning` (the call runs on one blocking thread) and returns
them as `warnings`, since an agent never reads the server's stderr.
- **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` /
`--no-cache` flag handling now spread through `cli/stocks.rs`.
- **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads
@ -184,7 +186,7 @@ is another 20-30% smaller than pretty JSON.
| 1 | other failure | `IO`, `DATABASEERROR`, `PARSEERROR`, ... |
| 2 | invalid usage or input | clap errors, `INVALIDINPUT`, `CONFIGERROR` |
| 3 | not found or no data | `SYMBOLNOTFOUND`, `NOMARKETDATA`, `CACHEMISS` |
| 4 | upstream unavailable, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE`, `AUTHERROR` |
| 4 | upstream unavailable or blocked, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE`, `AUTHERROR`, `UPSTREAMBLOCKED` |
| 5 | unsupported | `UNSUPPORTED` |
- JSON keeps today's top-level shape: no `{data, meta}` wrapper, so `jq` paths

View file

@ -42,12 +42,12 @@ contents. The native macOS installer job runs separately to exercise BSD tools.
## Live drift check
Fixtures cannot tell when MSN, Yahoo, or IDX change their responses.
Fixtures cannot tell when MSN, Yahoo, IPOT, IDX, or Stockbit change their responses.
`scripts/live-smoke.sh` runs shipped commands against the real upstreams and
validates each JSON output shape. It is opt-in and never part of the gate.
```bash
nix develop --command scripts/live-smoke.sh # stocks + ownership
nix develop --command scripts/live-smoke.sh # stocks + stockbit + ownership
nix develop --command scripts/live-smoke.sh --group stocks --symbol BBRI
nix develop --command scripts/live-smoke.sh --bin ./result/bin/idx
nix develop --command scripts/live-smoke.sh --compare ./old/bin/idx # A/B against an older build
@ -58,10 +58,24 @@ scripts/live-smoke.sh --dry-run
`flow` and `flow --source idx` on the new binary only, so an older `--compare` build that predates
them is fine), plus `compare BUMI,ADRO,AIMS`,
whose MSN key ratios contain non-finite values.
- `stockbit`: `intraday` for the ticker and `^JKSE`, and
`--provider stockbit stocks quote`, on the new binary only. A run outside the
session sees `market_status: close` and no bid/offer. These run without a
Stockbit login: each side has its own config, and seeding it with a real
refresh token would rotate (and so retire) that token. When
`idx stockbit status` reports a session, `stocks orderbook`, `stocks actions`,
`stocks calendar` and `ownership movements` (market-wide, one page) also run, on the new binary with your real config
directory (the session file is shared under its lock, never copied; cache
and data stay isolated). Check the rest by hand:
`idx --provider stockbit stocks quote BBCA` and
`idx --provider stockbit stocks history BBCA --period 1y`.
- `ownership`: `discover`, then imports the newest supported report into a temp
DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many
datacenter IPs, including GitHub-hosted runners; run it from a network IDX accepts.
A case whose upstream refuses us (`UPSTREAMBLOCKED`, an HTTP 403) prints
`BLOCKED (HTTP 403)` and counts as a failure, never as a pass.
`--compare <old-idx>` runs every case with both binaries back to back, each
with its own config and cache, and fails on a different exit code, any added,
missing, or retyped JSON key, or changed values for `profile`, `financials`,

287
docs/STOCKBIT.md Normal file
View file

@ -0,0 +1,287 @@
# Stockbit Session Features: Research and Design
What the user's Stockbit session (`idx stockbit login`, see the Stockbit
sections of `docs/ARCHITECTURE.md`) can serve beyond the order book, live
quotes and daily history, and how idx should add it. Endpoints were found in
Stockbit's web bundle (`stockbit.com/_next/static/chunks/*.js`, the API client
module) and probed with a real session on Tue 2026-11-03 during trading hours,
one GET at a time at least 1 s apart. Nothing here ships yet unless marked.
## Rules for every session feature
1. **Read-only by construction.** The session client (`api/stockbit/exodus.rs`)
issues GET only on an allowlist of path prefixes (`READ_PATHS`), plus
`POST /login/refresh`.
The bundle also holds trading (orders, day trade, PIN, cash withdrawal,
portfolio), chat, posting and account endpoints; idx never calls them, and
the allowlist makes a stray call fail before it leaves the process.
2. **One client.** Every endpoint goes through `Exodus::get`: rotating refresh
under the file lock, one retry after a 401 / JSON 403, the shared 1 s
pacing, `AUTHERROR` / `UPSTREAMBLOCKED` / `RATELIMITED` mapping, and no
token in logs, errors or cache keys.
3. **Stockbit-only commands use Stockbit whatever `--provider` says**, like
`stocks intraday`; without a session they answer `AUTHERROR` (exit code as
today). All endpoints below answer `401` without a session.
4. **Pure parsers, typed output.** Stockbit sends numbers as display strings
(`"193,206"`, `"+0.0001"`, `"1.03B"`), dates as `"25 Mar 26"` or
`YYYY-MM-DD`, and placeholders (`""`, `0`, `"0001-01-01 00:00:00"`). Parsers
turn them into integers, decimals, `NaiveDate` and `None`, and are tested
against recorded fixtures (`tests/fixtures/stockbit_exodus_*`).
5. **Units checked live** per endpoint before release (chart volume is lots,
the order book is shares).
6. **Cache per endpoint**: book and quote 5 s, market state 30 s, movements
1 h, corporate actions 6 h.
7. **Terms**: Stockbit's data is for personal, non-commercial use; the user
docs (`web/src/content/docs/cli/stockbit.md`) say so for every feature.
## Endpoint inventory
All on `https://exodus.stockbit.com`, bearer access token, HTTP 200 on
2026-11-03 unless noted.
| Endpoint | Serves | Plan |
| --- | --- | --- |
| `/company-price-feed/v2/orderbook/companies/{SYM}` | quote, OHLC, foreign flow, depth | shipped (#74) |
| `/company-price-feed/market-time/session` | market state | shipped (#74) |
| `/charts/{SYM}/daily?timeframe=1m\|3m\|1y` | daily candles | shipped (#74) |
| `/charts/{SYM}/daily?timeframe=1w` | hourly candles, 5 sessions | P2 |
| `/corpaction/{type}[?symbol=SYM]` | corporate actions and calendar | shipped: `stocks actions`, `stocks calendar` |
| `/insider/company/majorholder` | insider / major-holder movements | **P1** |
| `/insider/shareholding/composition/companies/{SYM}` | holder composition by period | P2 |
| `/marketdetectors/{SYM}` | broker summary, Stockbit's bandar detector | P2 |
| `/analyst-ratings/{SYM}/consensus`, `/analyst-ratings/{SYM}` | estimates, ratings | P3 |
| `/order-trade/running-trade`, `/order-trade/trade-book` | trade tape | P3 |
| `/emitten-metadata/shareholders/{SYM}/chart` | composition chart | not probed: needs a second token from `POST /emitten-metadata/shareholders/token` |
| `/keystats/ratio/v1/{SYM}`, `/findata-view/company/financial` | ratios, statements | skip for now (MSN covers them) |
### Market state (`market-time/session`)
`data.detail` has two boards, `regular` and `fca` (full call auction), each
with `session`, `state_name`, `state_start_time`, `state_end_time`,
`time_left`, `is_last_session`, `is_end_of_day`. Observed:
| Time (WIB) | `regular` | `fca` |
| --- | --- | --- |
| Sat 2026-10-18 20:15 | `STATE_NAME_MARKET_CLOSED`, session 0, end of day | same |
| Tue 2026-11-03 12:35 | `STATE_NAME_BREAK`, session 1, 12:00:00-13:29:59 | `STATE_NAME_BREAK`, session 3, 12:00:00-13:59:59 |
| Tue 2026-11-03 13:30 | `STATE_NAME_SESSION_2`, session 2, 13:30:00-15:49:59 | still `STATE_NAME_BREAK` |
| Tue 2026-11-03 14:00 | `STATE_NAME_SESSION_2` | `STATE_NAME_SESSION_4` |
The order book and session quotes treat any numbered `STATE_NAME_SESSION_<n>`
as trading (#74; the assumed `STATE_NAME_MARKET_OPEN` never appeared). idx
reads `regular` only, so FCA-board stocks can show the regular board's state
around the break.
In session the order book's `fbuy`/`fsell`/`fnet` were the previous
session's (equal to IPOT's 2026-11-02 net for BBCA, BBRI, TLKM) with
`has_foreign_bs: false`; idx keeps them only when the flag is true (#74).
Other order-book fields not used yet: `total_bid_offer` (queued lots and
orders on each side), `market_data` (volume and value by board: regular,
negotiated, cash), `iepiev` (indicative equilibrium price and volume, for the
auctions), `corp_action.active`.
### Charts
- `timeframe=1d`, `PRICE_CHART_TYPE_CANDLE`: empty `prices` during the break.
- `timeframe=1d`, `PRICE_CHART_TYPE_LINE`: a rolling ~350 minutes of
per-minute closes across two sessions, no OHLC or volume
(`xaxisopt: "intraday"`). Adds nothing over the public `stocks intraday`.
- `timeframe=1w`, candle: hourly bars for 5 sessions, `interval_in_minutes:
60`, volume in lots. The newest bar is stamped with its last update
(`11:59:00`), not the hour. BBCA's three bars on 2026-11-03 summed to
101.2M shares against 105.1M in the order book (pre-open auction or the
negotiated market, unconfirmed).
Hourly history needs a datetime bar (`Ohlc.date` is a `NaiveDate`) and an
hourly `Interval`, across Yahoo, MSN, output, goldens and the MCP schema; that
is its own change.
## P1: corporate actions (shipped)
Shipped as `idx stocks actions`, `idx stocks calendar` (not a top-level
`calendar`: it sits beside `stocks screen`) and the MCP tools
`get_corporate_actions`, `get_corporate_calendar`; the notes below are the
endpoint as recorded. Before, idx had none (only the `high-dividend` screen and Yahoo's dividend
yield). They explain price gaps in history (splits, bonuses, rights) and give
a calendar of cum dates.
### Endpoint
`GET /corpaction/{type}?symbol=SYM` gives one stock's history (BBCA: 37 cash dividends, cum dates 2009-06-09 to 2026-08-28); `GET /corpaction/{type}` without `symbol` is the
market-wide list, newest cum/event date first, upcoming events included. The
bundle passes `limit` (default 30); `rightissue` honoured it loosely (28 rows
for `limit=5`), `warrant` ignored it (1329 rows for `limit=3`), so idx filters
by date itself.
| `type` | Response key | Field prefix | Fields that matter |
| --- | --- | --- | --- |
| `dividend` | `dividend` | `dividend_` | `cumdate`, `exdate`, `recdate`, `paydate`, `value` (per share), `currency` |
| `stock_dividend` | `stock_dividend` | — | empty on 2026-11-03; record a row before parsing |
| `bonus` | `bonus` | `sahambonus_`, **`stocksplit_`** | `ratio` (`"6 : 4"`), `stocksplit_{cumdate,exdate,recdate,paymentdate,old,new,factor}` |
| `rightissue` | `rightissue` | `rightissue_` | `cumdate`, `exdate`, `recdate`, `old`, `new`, `ratio`, `price`, `factor`, `trading_start`, `trading_end` |
| `warrant` | `warrant` | `wrant_` | `trading_from`, `trading_end`, `exc_from`, `exc_end`, `exc_price` |
| `stocksplit` | `stocksplit` | `stocksplit_` | `cumdate`, `exdate`, `recdate`, `old`, `new`, `ratio`, `factor` |
| `reversesplit` | **`stock_reverse`** | — | empty on 2026-11-03; record a row before parsing |
| `rups` | `rups` | `rups_` | `date`, `time`, `venue`, `eligible_date` |
| `pubex` | `pubex` | `puexp_` | `date`, `time`, `venue` |
| `ipo` | `ipo` | `ipo_` | `listing_date`, `company_name`, `ipo_price.{minimum,maximum,final}`, `ipo_data_detail` (placeholders common) |
| `tenderoffer` | **`tender`** | `tender_` | `start`, `end`, `paydate`, `price`, `shares`, `percentage` |
| `economic` | — | — | macro calendar, not a corporate action; out of scope |
Examples from 2026-11-03: PPGL bonus 6:4 ex 2026-11-03; PRDA split 1:5 cum
2026-12-17; MGLV rights 100:15 at 8,880 cum 2026-11-04; BMAS rights 655:104 at
350 cum 2026-11-23, rights trading 2026-11-27 to 12-03; AUTO tender offer at
3,600 until 2026-11-11.
### Types
As shipped (`corporate/types.rs`), one flat record with a calendar `date` and
optional per-kind fields; JSON leaves absent ones out:
```rust
pub struct CorporateAction {
pub symbol: String, // BBCA.JK
pub kind: ActionKind, // JSON: dividend, stock-dividend, bonus, rights, warrant,
// split, reverse-split, meeting, expose, ipo, tender
pub date: NaiveDate, // cum date for entitlements (ex date without one),
// else the event's first day
pub cum_date: Option<NaiveDate>,
pub ex_date: Option<NaiveDate>,
pub record_date: Option<NaiveDate>,
pub pay_date: Option<NaiveDate>,
pub cash_per_share: Option<f64>, // dividends; IDR to the sen, else up to 6 decimals
pub currency: Option<String>, // dividends not paid in IDR
pub ratio: Option<Ratio>, // old : new
pub factor: Option<f64>, // Stockbit's price adjustment
pub price: Option<i64>, // rights, warrant, tender or IPO price (IDR)
pub period: Option<DateRange>, // rights or warrant trading, tender, IPO offer
pub exercise: Option<DateRange>, // warrant exercise
pub shares: Option<u64>, // offered (IPO) or sought (tender)
pub percentage_bps: Option<i64>, // tender
pub time: Option<String>, // meeting or expose
pub venue: Option<String>,
pub name: Option<String>, // IPO or tender issuer
}
```
One parser per type maps its prefix into this record; unknown types and empty
lists are not errors. A row whose dates are all placeholders is dropped.
### Surface
- `idx stocks actions SYM [--kind dividend,rights,...]`: one stock's
history, newest first; every kind but `ipo` by default.
- `idx stocks calendar [--kind ...] [--from DATE] [--to DATE]`: market-wide,
default today to +30 days, oldest first by `date`; rights trading, tender
offers and IPO offers still open in the range are included too.
- MCP: `get_corporate_actions {symbol, kinds?}`,
`get_corporate_calendar {kinds?, from?, to?}`.
- Later: `stocks history` notes ex-dates of splits, bonuses and rights in the
window, so a gap is not read as a crash.
## P1: shareholder movements
### Endpoint
Rows shipped as `idx ownership movements` and `get_shareholder_movements`;
the summary score is next.
`GET /insider/company/majorholder?symbol=&insider=&date_start=&date_end=&action_type=&source_type=&page=`;
all parameters optional, all filtered by Stockbit (checked 2026-11-03).
`date_start` / `date_end` are YYYY-MM-DD; `insider` is the holder id, which is
the row's `id` (not unique per row); `action_type` takes `ACTION_TYPE_BUY`,
`_SELL`, `_TRANSFER`, `_CROSS` or `_CORPACTION` (the app's filter list);
`source_type` takes `SOURCE_TYPE_IDX` or `_KSEI`. `page` counts from 1. 50 rows
a page, `data.is_more` for the next page; a busy day has more than 50 rows.
Without `symbol` it is market-wide, newest first: on 2026-11-03 the newest rows
were from 2026-11-02. BBCA's 50 rows span 2024-03-22 to 2026-03-25.
Each row (`data.movement[]`): `name`, `symbol`, `date` (`"25 Mar 26"`),
`previous` / `current` / `changes` `{value: shares as "193,206", percentage}`,
`action_type` (`ACTION_TYPE_BUY`, `_SELL`, `_TRANSFER`, `_CROSS`,
`_RIGHT_ISSUE`, `_PRIVATE_PLACEMENT`, `_TENDER_OFFER`; the bundle also names
`_BONUS_STOCK`, `_DIVIDEND_STOCK`, `_DIVIDEND_CASH`), `nationality`
(`NATIONALITY_TYPE_LOCAL|FOREIGN`), `badges` (`SHAREHOLDER_BADGE_DIREKTUR`,
`..._KOMISARIS`), `price.raw.value`, `value.raw.value` (IDR),
`is_estimated_price`, `purpose` (free text such as "Pembelian", "Investasi"),
`data_source.type` (`SOURCE_TYPE_IDX` for insider disclosures,
`SOURCE_TYPE_KSEI` for 5% holder changes), `broker_detail` (empty so far).
KSEI rows carry an estimated price (`is_estimated_price`: true); IDX rows
mostly the trade price, though crossings can be estimated too. `CROSS` rows
name securities firms as the holder. A rights issue can change the stake
with no change in shares (dilution).
### Relation to `ownership`
The KSEI register idx imports is a monthly snapshot of holders above 1%. This
feed is the individual changes, days after the trade, including insiders far
below 1%. It joins the ownership entities by name (the same resolution
`ownership resolve` maintains), but needs no SQLite import: it is live.
### Signal quality
Discretionary insider buying, above all by several insiders together, is the
form of this data with predictive power in the literature; selling much less.
The raw feed is noisy:
- Program-like rows: BBCA's 2026-03-25 rows are every director and
commissioner "buying" at the same 6,982, which reads as a share plan, not
open-market conviction. Flag many holders on one date at one price.
- `TRANSFER` rows and estimated prices (`is_estimated_price`).
- The reporting lag: part of the move can be priced before the row appears.
So the command shows the rows faithfully, and a separate summary scores them:
net value by symbol, the number of distinct buyers (cluster), value against
average daily value, holders crossing 5% / 10%, and the program flag. Calling
it alpha waits for an event study (feed history back to 2024 per symbol,
Yahoo prices, forward 5/20/60-session returns against IHSG).
### Surface
- `idx ownership movements [SYM] [--from DATE] [--to DATE] [--holder ID]
[--action buy|sell|transfer|cross|corporate-action] [--source idx|ksei]
[--pages N]`: rows, newest first (shipped). `--holder` takes the holder id
rather than a name: Stockbit filters by id, and a name search would only see
the pages already read.
- MCP: `get_shareholder_movements {symbol?, from?, to?, holder_id?, action?,
source?, pages?}` (shipped).
- Next: `idx ownership movements --summary [--from DATE]`, the per-symbol
score.
## P2 and P3
- **Composition by period** (`/insider/shareholding/composition/companies/{SYM}`,
optional `period_start`, `period_end`, `view`): per `report_date` (latest
2026-10-30 on 2026-11-03), total shares and each holder's shares,
percentage and previous shares. Fresher than waiting on the IDX register;
could feed `ownership changes` as a source.
- **Market detector** (`/marketdetectors/{SYM}`): `bandar_detector` with
`accdist` labels per top-N, broker counts, average price. A second source
for `stocks broker` / `bandar` beside IPOT (`--source ipot|stockbit`), as
`flow` has `--source ipot|idx`; IPOT answered 502 on 2026-10-31.
- **Hourly candles** (`timeframe=1w`), see "Charts".
- **Analyst consensus**: revenue and operating profit actuals and estimates
by year; ratings per broker.
- **Trade tape**: running trade and trade book; large and only meaningful in
session; wait for `--watch`.
## Order of work
1. Close #74: the open-state name in trading hours, fixtures for the observed
states (done 2026-11-03).
2. Corporate actions (parsers, `stocks actions`, `stocks calendar`, MCP,
goldens) (done).
3. Shareholder movements, rows first (done), then `--summary`.
4. Event study for the movement score; P2 by demand.
## Open questions
- The `regular` state in session 1 (assumed `STATE_NAME_SESSION_1`), the
pre-open (08:45-09:00) and the closing auction (15:50-16:00).
- When `has_foreign_bs` turns true, and whether the figures are then the
day's.
- `stock_dividend` and `reversesplit` row shapes (both empty on 2026-11-03).
- Whether `/corpaction/{type}` takes a date range (the bundle sends only
`limit` and `symbol`).
- The reporting lag between a movement's `date` and its appearance in the
feed (sample the market-wide first page daily).