fix(stockbit): bid/offer while trading; drop stale foreign flow

Observed in session on 2026-11-03:

- The regular board trades as STATE_NAME_SESSION_2, not the assumed
  STATE_NAME_MARKET_OPEN, so session quotes hid bid/offer all day. Any
  numbered STATE_NAME_SESSION_<n> now counts as trading; the lunch break
  is STATE_NAME_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 and TLKM) and
  has_foreign_bs was false. Foreign figures are kept only when that flag
  is true.

Fixtures for the break, the trading state and an in-session book; the
table names the session ("market open (session 2)").

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-03 14:02:03 +07:00
commit 32f476bae7
14 changed files with 113 additions and 39 deletions

View file

@ -96,7 +96,7 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31
- Stockbit, opt-in: without a login, `stocks intraday` (today's price by the
minute) and `--provider stockbit` quotes with the best bid/offer while the
market is open, delayed at least 10 minutes. With your own account
(`idx stockbit login`): `stocks orderbook` depth and foreign flow, live
(`idx stockbit login`): `stocks orderbook` depth, live
quotes and Stockbit daily history. Personal use under Stockbit's terms; see
the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
- Local file cache with TTL, and `--offline` mode with stale-cache fallback

View file

@ -15,8 +15,9 @@ history lives in git.
- Order book depth without a login: no free source exists (IDX sells delayed
depth to vendors); `stocks orderbook` needs the user's Stockbit login.
- Stockbit login follow-ups: intraday OHLCV from the session's `1d` chart;
observing the session API's open-market state name (assumed
`STATE_NAME_MARKET_OPEN`; `STATE_NAME_MARKET_CLOSED` seen 2026-10-29); an
confirming session 1 is `STATE_NAME_SESSION_1` (session 2, `BREAK` and
`MARKET_CLOSED` were recorded on 2026-11-03), and when `has_foreign_bs`
turns on with the day's foreign figures; an
opt-in daily refresh so a session outlives the refresh token's week, once
it is known whether Stockbit caps a session's total life; and the sign-in
response (`/login/v6/*`, read over DevTools' `Fetch`) as a second signal

View file

@ -272,10 +272,11 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> {
}
/// Stockbit's market state for people: `STATE_NAME_MARKET_CLOSED` reads
/// `closed`.
/// `closed`, `STATE_NAME_BREAK` `break`, `STATE_NAME_SESSION_2` `session 2`.
fn market_words(state: &str) -> String {
state
.strip_prefix("STATE_NAME_MARKET_")
.or_else(|| state.strip_prefix("STATE_NAME_"))
.unwrap_or(state)
.replace('_', " ")
.to_lowercase()
@ -326,7 +327,9 @@ pub fn print_orderbook(book: &OrderBook, no_color: bool) -> Result<(), IdxError>
);
}
match book.market_status.as_deref() {
Some(idx_core::api::stockbit::exodus::MARKET_OPEN) => println!("market open"),
Some(state) if idx_core::api::stockbit::exodus::is_trading(state) => {
println!("market open ({})", terminal_safe(&market_words(state)))
}
Some(state) => println!(
"market {}: the book as the market left it",
terminal_safe(&market_words(state))

View file

@ -2,10 +2,13 @@
//! user's session (see `auth`). Opt-in: used only when a session is stored.
//!
//! - `GET /company-price-feed/v2/orderbook/companies/{CODE}`: last price,
//! OHLC, volume (shares), value, frequency, foreign buy/sell/net (IDR) and
//! the depth ladder `bid[]`/`offer[]` of `{price, que_num, volume}` (shares).
//! OHLC, volume (shares), value, frequency, foreign buy/sell/net (IDR, used
//! only when `has_foreign_bs` is true) and the depth ladder
//! `bid[]`/`offer[]` of `{price, que_num, volume}` (shares).
//! - `GET /company-price-feed/market-time/session`: the market's state,
//! `data.detail.regular.state_name` (e.g. `STATE_NAME_MARKET_CLOSED`).
//! `data.detail.regular.state_name`: `STATE_NAME_SESSION_2` while trading,
//! `STATE_NAME_BREAK`, `STATE_NAME_MARKET_CLOSED` (recorded). The `fca`
//! (full call auction) board beside it keeps its own state and is not read.
//! - `GET /charts/{CODE}/daily?timeframe=1m|3m|1y&chart_type=PRICE_CHART_TYPE_CANDLE`:
//! daily candles, `formatted_date` `YYYY-MM-DD`, string OHLC, `value` the
//! close and `volume` in lots.
@ -29,10 +32,19 @@ use crate::error::IdxError;
const BASE_URL: &str = "https://exodus.stockbit.com";
const REFRESH_PATH: &str = "/login/refresh";
/// The state in which the book is live. Assumed from Stockbit's naming
/// (only `STATE_NAME_MARKET_CLOSED` has been recorded); any other state hides
/// bid/offer in quotes, as on the public page.
pub const MARKET_OPEN: &str = "STATE_NAME_MARKET_OPEN";
/// The prefix of the states in which the regular market trades:
/// `STATE_NAME_SESSION_2` was recorded on 2026-11-03 from 13:30; session 1 is
/// assumed to follow the same naming. Every other state (`STATE_NAME_BREAK`,
/// `STATE_NAME_MARKET_CLOSED`, auctions) hides bid/offer in quotes, as on the
/// public page.
const TRADING_PREFIX: &str = "STATE_NAME_SESSION_";
/// Whether Stockbit's market state is a trading session.
pub fn is_trading(state: &str) -> bool {
state
.strip_prefix(TRADING_PREFIX)
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
/// The market's state changes a few times a day; one read serves this long.
const MARKET_STATE_TTL: Duration = Duration::from_secs(30);
@ -293,6 +305,12 @@ pub fn parse_orderbook(
} else {
None
};
// Stockbit flags whether `fbuy`/`fsell`/`fnet` are current. While the
// flag was off (2026-11-03, in session) they were the previous session's
// figures (equal to IPOT's for 2026-11-02 for BBCA, BBRI and TLKM), so
// they are dropped rather than shown beside today's prices.
let foreign_current = d.get("has_foreign_bs").and_then(Value::as_bool) == Some(true);
let foreign = |key: &str| foreign_current.then(|| whole(d.get(key))).flatten();
Ok(OrderBook {
symbol: resolved.to_string(),
name: d.get("name").and_then(Value::as_str).map(str::to_string),
@ -306,9 +324,9 @@ pub fn parse_orderbook(
volume,
value: count(d.get("value")),
frequency: count(d.get("frequency")),
foreign_buy: whole(d.get("fbuy")),
foreign_sell: whole(d.get("fsell")),
foreign_net: whole(d.get("fnet")),
foreign_buy: foreign("fbuy"),
foreign_sell: foreign("fsell"),
foreign_net: foreign("fnet"),
bids: levels("bid"),
offers: levels("offer"),
market_status,
@ -318,7 +336,7 @@ pub fn parse_orderbook(
/// A quote from the order book; bid/offer only while the market is open.
pub fn quote_from_book(book: &OrderBook) -> Quote {
let open = book.market_status.as_deref() == Some(MARKET_OPEN);
let open = book.market_status.as_deref().is_some_and(is_trading);
let best = |levels: &[BookLevel]| open.then(|| levels.first().map(|l| l.price)).flatten();
Quote {
symbol: book.symbol.clone(),
@ -473,21 +491,59 @@ mod tests {
#[test]
fn quote_shows_the_best_levels_only_while_the_market_is_open() {
let mut book = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
let closed = quote_from_book(&book);
assert_eq!((closed.bid, closed.offer), (None, None));
book.market_status = Some(MARKET_OPEN.to_string());
let open = quote_from_book(&book);
assert_eq!((open.bid, open.offer), (None, Some(5175)));
assert_eq!(open.price, 5175);
let trading = parse_market_status(&fixture("market_session_trading.json"));
let book = |state: Option<String>| {
parse_orderbook("BBCA.JK", &fixture("orderbook_bbca_in_session.json"), state).unwrap()
};
let open = quote_from_book(&book(trading));
assert_eq!((open.bid, open.offer), (Some(4850), Some(4860)));
assert_eq!(open.price, 4860);
for state in ["market_session_break.json", "market_session.json"] {
let shut = quote_from_book(&book(parse_market_status(&fixture(state))));
assert_eq!((shut.bid, shut.offer), (None, None), "{state}");
}
assert_eq!(quote_from_book(&book(None)).bid, None);
}
#[test]
fn market_status_is_read_verbatim() {
for (name, state) in [
("market_session.json", "STATE_NAME_MARKET_CLOSED"),
("market_session_break.json", "STATE_NAME_BREAK"),
("market_session_trading.json", "STATE_NAME_SESSION_2"),
] {
assert_eq!(parse_market_status(&fixture(name)).as_deref(), Some(state));
}
}
#[test]
fn only_numbered_sessions_are_trading() {
assert!(is_trading("STATE_NAME_SESSION_1"));
assert!(is_trading("STATE_NAME_SESSION_2"));
for state in [
"STATE_NAME_BREAK",
"STATE_NAME_MARKET_CLOSED",
"STATE_NAME_MARKET_OPEN",
"STATE_NAME_SESSION_",
"STATE_NAME_SESSION_PRE_OPENING",
"",
] {
assert!(!is_trading(state), "{state}");
}
}
#[test]
fn foreign_flow_only_when_stockbit_marks_it_current() {
// In session (has_foreign_bs false) the figures were 2026-11-02's.
let live =
parse_orderbook("BBCA.JK", &fixture("orderbook_bbca_in_session.json"), None).unwrap();
assert_eq!(
parse_market_status(&fixture("market_session.json")).as_deref(),
Some("STATE_NAME_MARKET_CLOSED")
(live.foreign_buy, live.foreign_sell, live.foreign_net),
(None, None, None)
);
assert_eq!(live.volume, Some(134_744_100));
let done = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
assert_eq!(done.foreign_buy, Some(1_616_461_977_500));
}
#[test]

View file

@ -126,7 +126,8 @@ pub struct OrderBook {
pub value: Option<u64>,
/// Trades in the session.
pub frequency: Option<u64>,
/// Foreign buy, sell and net value over the session, IDR.
/// Foreign buy, sell and net value over the session, IDR; `None` while
/// Stockbit marks them not current (in session they lag a day).
pub foreign_buy: Option<i64>,
pub foreign_sell: Option<i64>,
pub foreign_net: Option<i64>,
@ -134,7 +135,8 @@ pub struct OrderBook {
pub bids: Vec<BookLevel>,
/// Best price first.
pub offers: Vec<BookLevel>,
/// Stockbit's session state, verbatim (e.g. `STATE_NAME_MARKET_CLOSED`).
/// Stockbit's session state, verbatim (e.g. `STATE_NAME_SESSION_2`,
/// `STATE_NAME_BREAK`, `STATE_NAME_MARKET_CLOSED`).
pub market_status: Option<String>,
/// Stockbit's listing status when it is not active (e.g. suspended).
pub session_note: Option<String>,

View file

@ -0,0 +1 @@
{"message":"Successfully get session info data","data":{"datetime":"2026-11-03 12:35:59","detail":{"fca":{"session":3,"state_name":"STATE_NAME_BREAK","is_last_session":false,"is_end_of_day":false,"state_start_time":"12:00:00","state_end_time":"13:59:59","time_left":{"raw":5040,"formatted":"1 jam 24 menit 0 detik"},"suspend_info":""},"regular":{"session":1,"state_name":"STATE_NAME_BREAK","is_last_session":false,"is_end_of_day":false,"state_start_time":"12:00:00","state_end_time":"13:29:59","time_left":{"raw":3240,"formatted":"0 jam 54 menit 0 detik"},"suspend_info":""}}}}

View file

@ -0,0 +1 @@
{"message":"Successfully get session info data","data":{"datetime":"2026-11-03 13:30:03","detail":{"fca":{"session":3,"state_name":"STATE_NAME_BREAK","is_last_session":false,"is_end_of_day":false,"state_start_time":"12:00:00","state_end_time":"13:59:59","time_left":{"raw":1796,"formatted":"0 jam 29 menit 56 detik"},"suspend_info":""},"regular":{"session":2,"state_name":"STATE_NAME_SESSION_2","is_last_session":true,"is_end_of_day":false,"state_start_time":"13:30:00","state_end_time":"15:49:59","time_left":{"raw":9896,"formatted":"2 jam 44 menit 56 detik"},"suspend_info":""}}}}

File diff suppressed because one or more lines are too long

View file

@ -165,9 +165,10 @@ per-minute volume. Empty `points` means the stock did not trade this session (se
#[tool(
description = "Order book of one IDX stock from the user's own Stockbit session: \
`bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, \
volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is \
Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left \
it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL \
volume (shares), value and frequency, and foreign buy/sell/net in IDR (null while Stockbit \
marks them not current, as during trading; get_foreign_flow has them per session). \
`market_status` is Stockbit's state, verbatim: STATE_NAME_SESSION_1 or _2 is trading; in any \
other state the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL \
the user can open."
)]
async fn get_orderbook(&self, params: Parameters<OrderBookParams>) -> ToolResult {

View file

@ -332,7 +332,7 @@
"name": "get_investor_holdings"
},
{
"description": "Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL the user can open.",
"description": "Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR (null while Stockbit marks them not current, as during trading; get_foreign_flow has them per session). `market_status` is Stockbit's state, verbatim: STATE_NAME_SESSION_1 or _2 is trading; in any other state the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL the user can open.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {

View file

@ -269,13 +269,17 @@ WIB as the fallback.
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; `/company-price-feed/market-time/session` the market state
(`STATE_NAME_MARKET_CLOSED`, cached 30 s per process); `/charts/{CODE}/daily`
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 `STATE_NAME_MARKET_OPEN`, a name assumed until observed
in session), `stocks orderbook` works, and `auto` history under the stockbit
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`.

View file

@ -195,12 +195,15 @@ What the login adds:
| --- | --- | --- |
| `--provider stockbit` quotes | public page, delayed | Stockbit's API, live |
| Bid and offer in quotes | best price, while open | best price, while open |
| `stocks orderbook` | `AUTHERROR` | every level with lots and queued orders, OHLC, value, trades, foreign buy/sell/net |
| `stocks orderbook` | `AUTHERROR` | every level with lots and queued orders, OHLC, value, trades; foreign buy/sell/net once Stockbit marks them current |
| `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows |
| `stocks intraday` | public page | public page |
The order book is what Stockbit's own app shows. Outside trading it is the
book as the market left it, and the output says so. Sizes are lots in the
book as the market left it, and the output says so. Foreign buy, sell and net
appear only when Stockbit marks them current: during trading its figures are
the previous session's, so idx leaves them out (`stocks flow` has foreign
flow session by session). Sizes are lots in the
table and shares in JSON. `--history-provider stockbit` asks for Stockbit's
candles explicitly; windows it charts only weekly or hourly (`2y`, `5y`,
`1wk`) answer `UNSUPPORTED`.

View file

@ -42,7 +42,8 @@ idx stocks orderbook BBRI --depth 5
With your own Stockbit session (`idx stockbit login`), `orderbook` shows
every bid and offer level with lots and queued orders, the session's OHLC,
value and trades, and foreign buy, sell and net. Without one it answers
value and trades, and foreign buy, sell and net once Stockbit marks them
current (not during trading, when they lag a day). Without one it answers
`AUTHERROR`. See [Stockbit](/cli/stockbit/#with-your-stockbit-login).
## Company data

View file

@ -94,7 +94,7 @@ Every IDX holding (above 1%) of one investor, matched by name.
## `get_orderbook`
Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL the user can open.
Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR (null while Stockbit marks them not current, as during trading; get_foreign_flow has them per session). `market_status` is Stockbit's state, verbatim: STATE_NAME_SESSION_1 or _2 is trading; in any other state the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL the user can open.
| Input | Type | Required | Description |
| --- | --- | --- | --- |