mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 01:35:36 +00:00
Merge pull request #73 from 0xrsydn/feat/stockbit
feat: login-free Stockbit intraday and quote provider
This commit is contained in:
commit
be9d17bb80
157 changed files with 14699 additions and 481 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
287
docs/STOCKBIT.md
Normal 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue