mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
ca4d3d09c2
commit
32f476bae7
14 changed files with 113 additions and 39 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
1
crates/idx-core/tests/fixtures/stockbit_exodus_market_session_break.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_market_session_break.json
vendored
Normal 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":""}}}}
|
||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_market_session_trading.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_market_session_trading.json
vendored
Normal 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":""}}}}
|
||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca_in_session.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca_in_session.json
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| --- | --- | --- | --- |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue