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

@ -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