idx-cli/docs/STOCKBIT.md
0xrsydn ae0ca0c109 feat: shareholder movements from the Stockbit session (ownership movements)
`idx ownership movements [SYMBOL]` and the MCP tool
`get_shareholder_movements` list reported changes in insiders' and major
holders' stakes, newest first, from Stockbit's
/insider/company/majorholder: IDX insider disclosures (any size, with the
trade price) and KSEI changes of holders at 5% and above (Stockbit's
estimated price). Stockbit filters by date range, holder id, action and
source; each page is 50 rows and one request, up to 10 pages, cached an
hour per page under every filter.

Rows keep exact signed share counts and the stake in basis points as
Stockbit rounds it; unknown action labels are kept as `other` rather than
dropped. JSON is {movements, more}; JSONL is one movement per line. The
command lives under `ownership` but needs no import, and `ownership`
commands now take --offline / --no-cache for it.

The per-symbol summary score is the next PR; docs/STOCKBIT.md records the
endpoint's parameters and action types as checked on 2026-11-03.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
2026-11-03 16:02:26 +07:00

271 lines
15 KiB
Markdown

# 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 on an allowlist of path prefixes, 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
```rust
pub struct CorporateAction {
pub symbol: String, // BBCA.JK
pub kind: ActionKind, // Dividend, StockDividend, Bonus, Rights, Warrant,
// Split, ReverseSplit, Meeting, PublicExpose, Ipo, TenderOffer
pub cum_date: Option<NaiveDate>,
pub ex_date: Option<NaiveDate>,
pub record_date: Option<NaiveDate>,
pub pay_date: Option<NaiveDate>,
pub event_date: Option<NaiveDate>, // meeting, expose, listing, tender end
pub detail: ActionDetail, // kind-specific: amount + currency; old:new ratio
// and factor; price; trading/exercise windows; venue
}
```
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`.
- MCP: `get_corporate_actions {symbol, kinds?, since?}`,
`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).