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 - 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 minute) and `--provider stockbit` quotes with the best bid/offer while the
market is open, delayed at least 10 minutes. With your own account 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 quotes and Stockbit daily history. Personal use under Stockbit's terms; see
the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/) the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
- Local file cache with TTL, and `--offline` mode with stale-cache fallback - 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 - Order book depth without a login: no free source exists (IDX sells delayed
depth to vendors); `stocks orderbook` needs the user's Stockbit login. depth to vendors); `stocks orderbook` needs the user's Stockbit login.
- Stockbit login follow-ups: intraday OHLCV from the session's `1d` chart; - Stockbit login follow-ups: intraday OHLCV from the session's `1d` chart;
observing the session API's open-market state name (assumed confirming session 1 is `STATE_NAME_SESSION_1` (session 2, `BREAK` and
`STATE_NAME_MARKET_OPEN`; `STATE_NAME_MARKET_CLOSED` seen 2026-10-29); an `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 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 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 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 /// 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 { fn market_words(state: &str) -> String {
state state
.strip_prefix("STATE_NAME_MARKET_") .strip_prefix("STATE_NAME_MARKET_")
.or_else(|| state.strip_prefix("STATE_NAME_"))
.unwrap_or(state) .unwrap_or(state)
.replace('_', " ") .replace('_', " ")
.to_lowercase() .to_lowercase()
@ -326,7 +327,9 @@ pub fn print_orderbook(book: &OrderBook, no_color: bool) -> Result<(), IdxError>
); );
} }
match book.market_status.as_deref() { 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!( Some(state) => println!(
"market {}: the book as the market left it", "market {}: the book as the market left it",
terminal_safe(&market_words(state)) 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. //! user's session (see `auth`). Opt-in: used only when a session is stored.
//! //!
//! - `GET /company-price-feed/v2/orderbook/companies/{CODE}`: last price, //! - `GET /company-price-feed/v2/orderbook/companies/{CODE}`: last price,
//! OHLC, volume (shares), value, frequency, foreign buy/sell/net (IDR) and //! OHLC, volume (shares), value, frequency, foreign buy/sell/net (IDR, used
//! the depth ladder `bid[]`/`offer[]` of `{price, que_num, volume}` (shares). //! 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, //! - `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`: //! - `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 //! daily candles, `formatted_date` `YYYY-MM-DD`, string OHLC, `value` the
//! close and `volume` in lots. //! close and `volume` in lots.
@ -29,10 +32,19 @@ use crate::error::IdxError;
const BASE_URL: &str = "https://exodus.stockbit.com"; const BASE_URL: &str = "https://exodus.stockbit.com";
const REFRESH_PATH: &str = "/login/refresh"; const REFRESH_PATH: &str = "/login/refresh";
/// The state in which the book is live. Assumed from Stockbit's naming /// The prefix of the states in which the regular market trades:
/// (only `STATE_NAME_MARKET_CLOSED` has been recorded); any other state hides /// `STATE_NAME_SESSION_2` was recorded on 2026-11-03 from 13:30; session 1 is
/// bid/offer in quotes, as on the public page. /// assumed to follow the same naming. Every other state (`STATE_NAME_BREAK`,
pub const MARKET_OPEN: &str = "STATE_NAME_MARKET_OPEN"; /// `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. /// The market's state changes a few times a day; one read serves this long.
const MARKET_STATE_TTL: Duration = Duration::from_secs(30); const MARKET_STATE_TTL: Duration = Duration::from_secs(30);
@ -293,6 +305,12 @@ pub fn parse_orderbook(
} else { } else {
None 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 { Ok(OrderBook {
symbol: resolved.to_string(), symbol: resolved.to_string(),
name: d.get("name").and_then(Value::as_str).map(str::to_string), name: d.get("name").and_then(Value::as_str).map(str::to_string),
@ -306,9 +324,9 @@ pub fn parse_orderbook(
volume, volume,
value: count(d.get("value")), value: count(d.get("value")),
frequency: count(d.get("frequency")), frequency: count(d.get("frequency")),
foreign_buy: whole(d.get("fbuy")), foreign_buy: foreign("fbuy"),
foreign_sell: whole(d.get("fsell")), foreign_sell: foreign("fsell"),
foreign_net: whole(d.get("fnet")), foreign_net: foreign("fnet"),
bids: levels("bid"), bids: levels("bid"),
offers: levels("offer"), offers: levels("offer"),
market_status, market_status,
@ -318,7 +336,7 @@ pub fn parse_orderbook(
/// A quote from the order book; bid/offer only while the market is open. /// A quote from the order book; bid/offer only while the market is open.
pub fn quote_from_book(book: &OrderBook) -> Quote { 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(); let best = |levels: &[BookLevel]| open.then(|| levels.first().map(|l| l.price)).flatten();
Quote { Quote {
symbol: book.symbol.clone(), symbol: book.symbol.clone(),
@ -473,21 +491,59 @@ mod tests {
#[test] #[test]
fn quote_shows_the_best_levels_only_while_the_market_is_open() { 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 trading = parse_market_status(&fixture("market_session_trading.json"));
let closed = quote_from_book(&book); let book = |state: Option<String>| {
assert_eq!((closed.bid, closed.offer), (None, None)); parse_orderbook("BBCA.JK", &fixture("orderbook_bbca_in_session.json"), state).unwrap()
book.market_status = Some(MARKET_OPEN.to_string()); };
let open = quote_from_book(&book); let open = quote_from_book(&book(trading));
assert_eq!((open.bid, open.offer), (None, Some(5175))); assert_eq!((open.bid, open.offer), (Some(4850), Some(4860)));
assert_eq!(open.price, 5175); 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] #[test]
fn market_status_is_read_verbatim() { 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!( assert_eq!(
parse_market_status(&fixture("market_session.json")).as_deref(), (live.foreign_buy, live.foreign_sell, live.foreign_net),
Some("STATE_NAME_MARKET_CLOSED") (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] #[test]

View file

@ -126,7 +126,8 @@ pub struct OrderBook {
pub value: Option<u64>, pub value: Option<u64>,
/// Trades in the session. /// Trades in the session.
pub frequency: Option<u64>, 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_buy: Option<i64>,
pub foreign_sell: Option<i64>, pub foreign_sell: Option<i64>,
pub foreign_net: Option<i64>, pub foreign_net: Option<i64>,
@ -134,7 +135,8 @@ pub struct OrderBook {
pub bids: Vec<BookLevel>, pub bids: Vec<BookLevel>,
/// Best price first. /// Best price first.
pub offers: Vec<BookLevel>, 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>, pub market_status: Option<String>,
/// Stockbit's listing status when it is not active (e.g. suspended). /// Stockbit's listing status when it is not active (e.g. suspended).
pub session_note: Option<String>, 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( #[tool(
description = "Order book of one IDX stock from the user's own Stockbit session: \ 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, \ `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 \ volume (shares), value and frequency, and foreign buy/sell/net in IDR (null while Stockbit \
Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left \ marks them not current, as during trading; get_foreign_flow has them per session). \
it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL \ `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." the user can open."
)] )]
async fn get_orderbook(&self, params: Parameters<OrderBookParams>) -> ToolResult { async fn get_orderbook(&self, params: Parameters<OrderBookParams>) -> ToolResult {

View file

@ -332,7 +332,7 @@
"name": "get_investor_holdings" "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": { "inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {

View file

@ -269,13 +269,17 @@ WIB as the fallback.
its recordings are `tests/fixtures/stockbit_exodus_*`): its recordings are `tests/fixtures/stockbit_exodus_*`):
`/company-price-feed/v2/orderbook/companies/{CODE}` gives the depth ladder `/company-price-feed/v2/orderbook/companies/{CODE}` gives the depth ladder
(`volume` in shares, `que_num` orders), OHLC, value, frequency and foreign (`volume` in shares, `que_num` orders), OHLC, value, frequency and foreign
buy/sell/net; `/company-price-feed/market-time/session` the market state buy/sell/net (kept only when `has_foreign_bs` is true: on 2026-11-03 in
(`STATE_NAME_MARKET_CLOSED`, cached 30 s per process); `/charts/{CODE}/daily` 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 daily candles for `1m`/`3m`/`1y` windows (volume in lots, returned in
shares). Requests share the public page's cross-process pacing. shares). Requests share the public page's cross-process pacing.
- With a session, `--provider stockbit` quotes come from the order book (bid - 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 and offer only in a `STATE_NAME_SESSION_<n>` state; session 2 was observed
in session), `stocks orderbook` works, and `auto` history under the stockbit 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`, 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 `3m` or `1y`), Yahoo otherwise. Without one, quotes use the public page and
the order book is `AUTHERROR`. 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 | | `--provider stockbit` quotes | public page, delayed | Stockbit's API, live |
| Bid and offer in quotes | best price, while open | best price, while open | | 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 history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows |
| `stocks intraday` | public page | public page | | `stocks intraday` | public page | public page |
The order book is what Stockbit's own app shows. Outside trading it is the 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 table and shares in JSON. `--history-provider stockbit` asks for Stockbit's
candles explicitly; windows it charts only weekly or hourly (`2y`, `5y`, candles explicitly; windows it charts only weekly or hourly (`2y`, `5y`,
`1wk`) answer `UNSUPPORTED`. `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 With your own Stockbit session (`idx stockbit login`), `orderbook` shows
every bid and offer level with lots and queued orders, the session's OHLC, 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). `AUTHERROR`. See [Stockbit](/cli/stockbit/#with-your-stockbit-login).
## Company data ## Company data

View file

@ -94,7 +94,7 @@ Every IDX holding (above 1%) of one investor, matched by name.
## `get_orderbook` ## `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 | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |