idx-cli/crates/idx-core/src/api/stockbit/exodus.rs

708 lines
26 KiB
Rust

//! Stockbit's own market-data API, `https://exodus.stockbit.com`, with the
//! 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, 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`: `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.
//! - `POST /login/refresh` with the refresh token as bearer rotates the pair.
//!
//! A 401 (or a JSON 403) spends one refresh and retries once. Requests share
//! the public page's pacing. Endpoints and units were mapped by fzn0x/idx
//! (MIT); `tests/fixtures/stockbit_exodus_*` are its recordings.
use std::sync::Mutex;
use std::time::{Duration, Instant};
use chrono::{Months, NaiveDate};
use serde_json::Value;
use super::auth::SessionStore;
use super::parse::number;
use super::{USER_AGENT, pace, stockbit_code};
use super::{corpaction, movement};
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
use crate::corporate::types::{ActionKind, CorporateAction};
use crate::error::IdxError;
use crate::movements::types::{MovementPage, MovementQuery};
const BASE_URL: &str = "https://exodus.stockbit.com";
const REFRESH_PATH: &str = "/login/refresh";
/// The only paths idx GETs from the session API. The same API places orders,
/// withdraws cash and changes the PIN; a path outside these fails before any
/// request (or token refresh) leaves the process. A feature that reads a new
/// endpoint adds its prefix here.
const READ_PATHS: &[&str] = &[
"/company-price-feed/",
"/charts/",
"/corpaction/",
"/insider/company/majorholder?",
];
/// 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);
pub(crate) struct Exodus {
agent: ureq::Agent,
store: SessionStore,
}
struct Reply {
status: u16,
body: String,
}
impl Reply {
/// The server refused the access token: 401, or a 403 with a JSON body
/// (a CDN block is an HTML page).
fn rejected(&self) -> bool {
self.status == 401
|| (self.status == 403 && serde_json::from_str::<Value>(&self.body).is_ok())
}
}
impl Exodus {
pub(crate) fn new(store: SessionStore) -> Self {
let agent = ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_recv_body(Some(Duration::from_secs(10)))
.timeout_global(Some(Duration::from_secs(20)))
.http_status_as_error(false)
.build()
.into();
Self { agent, store }
}
pub(crate) fn store(&self) -> &SessionStore {
&self.store
}
/// One authorised request (the market's state), refreshing the session
/// when needed: proves Stockbit accepts it.
pub(crate) fn verify(&self) -> Result<(), IdxError> {
self.get("/company-price-feed/market-time/session", "market session")
.map(|_| ())
}
pub(crate) fn orderbook(&self, resolved: &str) -> Result<OrderBook, IdxError> {
let code = stockbit_code(resolved)?;
let body = self.get(
&format!("/company-price-feed/v2/orderbook/companies/{code}"),
resolved,
)?;
parse_orderbook(resolved, &body, self.market_status())
}
pub(crate) fn quote(&self, resolved: &str) -> Result<Quote, IdxError> {
self.orderbook(resolved).map(|book| quote_from_book(&book))
}
/// Daily candles covering `period`, oldest first.
pub(crate) fn daily(
&self,
resolved: &str,
period: &Period,
interval: &Interval,
) -> Result<Vec<Ohlc>, IdxError> {
let window =
history_window(period, interval).ok_or_else(|| unsupported_window(period, interval))?;
let code = stockbit_code(resolved)?;
let body = self.get(
&format!("/charts/{code}/daily?timeframe={window}&chart_type=PRICE_CHART_TYPE_CANDLE"),
resolved,
)?;
daily_for_period(&body, period)
}
/// Corporate actions of `kind`: one stock's (`resolved`) or the market's.
pub(crate) fn corporate_actions(
&self,
kind: ActionKind,
resolved: Option<&str>,
) -> Result<Vec<CorporateAction>, IdxError> {
let path = format!("/corpaction/{}", corpaction::path_type(kind));
let (path, subject) = match resolved {
Some(resolved) => (
format!("{path}?symbol={}", stockbit_code(resolved)?),
resolved.to_string(),
),
None => (path, "corporate actions".to_string()),
};
let body = self.get(&path, &subject)?;
corpaction::parse_corporate_actions(kind, &body)
}
/// Page `page` of shareholder movements: one stock's (`query.symbol`,
/// resolved) or the market's.
pub(crate) fn shareholder_movements(
&self,
query: &MovementQuery,
page: u32,
) -> Result<MovementPage, IdxError> {
let code = query.symbol.as_deref().map(stockbit_code).transpose()?;
let subject = query
.symbol
.clone()
.unwrap_or_else(|| "shareholder movements".to_string());
let body = self.get(
&movement::request_path(query, code.as_deref(), page),
&subject,
)?;
movement::parse_movements(&body)
}
/// The market's state, read at most every `MARKET_STATE_TTL` per process.
/// Unknown on any failure, which hides bid/offer.
fn market_status(&self) -> Option<String> {
static CACHE: Mutex<Option<(Instant, Option<String>)>> = Mutex::new(None);
let mut cached = CACHE.lock().unwrap_or_else(|p| p.into_inner());
if let Some((at, state)) = cached.as_ref()
&& at.elapsed() < MARKET_STATE_TTL
{
return state.clone();
}
let state = self
.get("/company-price-feed/market-time/session", "market session")
.ok()
.and_then(|body| parse_market_status(&body));
*cached = Some((Instant::now(), state.clone()));
state
}
fn get(&self, path: &str, subject: &str) -> Result<String, IdxError> {
if !is_read_path(path) {
return Err(IdxError::Unsupported(format!(
"idx does not call {path} on Stockbit's session API"
)));
}
let refresher = |refresh: &str| self.refresh(refresh);
let token = self.store.access(&refresher, unix_now())?;
let mut reply = self.send(path, &token)?;
if reply.rejected() {
let token = self.store.after_rejection(&token, &refresher)?;
reply = self.send(path, &token)?;
if reply.rejected() {
return Err(IdxError::AuthError(
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
));
}
}
classify(reply, subject)
}
fn send(&self, path: &str, token: &str) -> Result<Reply, IdxError> {
pace();
let response = self
.agent
.get(format!("{BASE_URL}{path}"))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
.header("Origin", "https://stockbit.com")
.header("Referer", "https://stockbit.com/")
.header("Authorization", format!("Bearer {token}"))
.call()
.map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?;
read_reply(response)
}
/// `POST /login/refresh`; the response body carries the new pair.
fn refresh(&self, refresh: &str) -> Result<String, IdxError> {
pace();
let response = self
.agent
.post(format!("{BASE_URL}{REFRESH_PATH}"))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Origin", "https://stockbit.com")
.header("Referer", "https://stockbit.com/")
.header("Authorization", format!("Bearer {refresh}"))
.send("{}")
.map_err(|e| IdxError::Http(format!("stockbit refresh: {e}")))?;
let reply = read_reply(response)?;
match reply.status {
200..=299 => Ok(reply.body),
401 | 403 => Err(IdxError::AuthError(
"Stockbit rejected the refresh token (expired or revoked); run `idx stockbit \
login` again"
.to_string(),
)),
status => Err(IdxError::Http(format!("stockbit refresh: HTTP {status}"))),
}
}
}
fn read_reply(response: ureq::http::Response<ureq::Body>) -> Result<Reply, IdxError> {
let status = response.status().as_u16();
let body = response
.into_body()
.read_to_string()
.map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?;
Ok(Reply { status, body })
}
fn unix_now() -> i64 {
chrono::Utc::now().timestamp()
}
/// A non-rejected reply as data or the error its status means.
fn classify(reply: Reply, subject: &str) -> Result<String, IdxError> {
match reply.status {
200..=299 => Ok(reply.body),
404 if error_type(&reply.body).as_deref() == Some("NOT_FOUND") => {
Err(IdxError::SymbolNotFound(subject.to_string()))
}
429 => Err(IdxError::RateLimited),
403 => Err(blocked()),
status => Err(IdxError::Http(format!(
"stockbit api: HTTP {status}{}",
message(&reply.body)
.map(|m| format!(": {m}"))
.unwrap_or_default()
))),
}
}
fn error_type(body: &str) -> Option<String> {
serde_json::from_str::<Value>(body)
.ok()?
.get("error_type")?
.as_str()
.map(str::to_string)
}
fn message(body: &str) -> Option<String> {
serde_json::from_str::<Value>(body)
.ok()?
.get("message")?
.as_str()
.map(str::to_string)
}
fn data(body: &str, what: &str) -> Result<Value, IdxError> {
let mut value: Value = serde_json::from_str(body)
.map_err(|e| IdxError::ParseError(format!("stockbit {what}: {e}")))?;
match value.get_mut("data") {
Some(data) => Ok(data.take()),
None => Err(IdxError::ParseError(format!("stockbit {what}: no data"))),
}
}
fn whole(value: Option<&Value>) -> Option<i64> {
value.and_then(number).map(|n| n.round() as i64)
}
fn count(value: Option<&Value>) -> Option<u64> {
value
.and_then(number)
.filter(|n| *n >= 0.0)
.map(|n| n.round() as u64)
}
/// The order book and session figures; `market_status` comes from the
/// market-time endpoint.
pub fn parse_orderbook(
resolved: &str,
body: &str,
market_status: Option<String>,
) -> Result<OrderBook, IdxError> {
let d = data(body, "order book")?;
let price = whole(d.get("lastprice").or_else(|| d.get("close")))
.ok_or_else(|| IdxError::ParseError("stockbit order book: no last price".to_string()))?;
let levels = |key: &str| -> Vec<BookLevel> {
d.get(key)
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|row| {
Some(BookLevel {
price: whole(row.get("price")).filter(|p| *p > 0)?,
shares: count(row.get("volume")).unwrap_or(0),
orders: count(row.get("que_num")).unwrap_or(0),
})
})
.collect()
})
.unwrap_or_default()
};
let listing = d.get("status").and_then(Value::as_str).unwrap_or_default();
let volume = count(d.get("volume"));
let session_note = if !listing.is_empty() && !listing.eq_ignore_ascii_case("active") {
Some(format!(
"Stockbit lists the stock as {}",
listing.to_ascii_lowercase()
))
} else if volume == Some(0) {
Some("no trades this session".to_string())
} 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),
price,
change: whole(d.get("change")),
change_pct: d.get("percentage_change").and_then(number),
prev_close: whole(d.get("previous")),
open: whole(d.get("open")),
high: whole(d.get("high")),
low: whole(d.get("low")),
volume,
value: count(d.get("value")),
frequency: count(d.get("frequency")),
foreign_buy: foreign("fbuy"),
foreign_sell: foreign("fsell"),
foreign_net: foreign("fnet"),
bids: levels("bid"),
offers: levels("offer"),
market_status,
session_note,
})
}
/// A quote from the order book; bid/offer only while the market is open.
/// Its `market_status` is the public page's vocabulary, `open` or `close`,
/// so a quote reads the same with or without a login; the order book keeps
/// Stockbit's state verbatim.
pub fn quote_from_book(book: &OrderBook) -> Quote {
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(),
price: book.price,
change: book.change,
change_pct: book.change_pct,
volume: book.volume,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close: book.prev_close,
avg_volume: None,
as_of: None,
behind_session: false,
bid: best(&book.bids),
offer: best(&book.offers),
market_status: book
.market_status
.as_ref()
.map(|_| if open { "open" } else { "close" }.to_string()),
session_note: book.session_note.clone(),
}
}
pub fn parse_market_status(body: &str) -> Option<String> {
data(body, "market session")
.ok()?
.pointer("/detail/regular/state_name")?
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
}
/// The chart window holding `period` of daily candles: Stockbit picks the
/// bar size from the window (`1w` is hourly, `3y`/`5y` weekly), so only the
/// daily windows qualify.
pub fn history_window(period: &Period, interval: &Interval) -> Option<&'static str> {
match (interval, period) {
(Interval::Day, Period::FiveDays | Period::OneMonth) => Some("1m"),
(Interval::Day, Period::ThreeMonths) => Some("3m"),
(Interval::Day, Period::SixMonths | Period::OneYear) => Some("1y"),
_ => None,
}
}
pub fn unsupported_window(period: &Period, interval: &Interval) -> IdxError {
IdxError::Unsupported(format!(
"Stockbit history covers 5d to 1y of daily candles, not {} at {}; use \
--history-provider yahoo",
period.as_str(),
interval.as_str()
))
}
/// The candles of `period` from a daily chart response.
pub fn daily_for_period(body: &str, period: &Period) -> Result<Vec<Ohlc>, IdxError> {
Ok(trim_to_period(parse_daily(body)?, period))
}
/// Daily candles, oldest first. Volume arrives in lots and is returned in
/// shares, as from the other providers.
pub fn parse_daily(body: &str) -> Result<Vec<Ohlc>, IdxError> {
let d = data(body, "chart")?;
let rows = d
.get("prices")
.and_then(Value::as_array)
.ok_or_else(|| IdxError::ParseError("stockbit chart: no prices".to_string()))?;
let mut bars = Vec::with_capacity(rows.len());
for row in rows {
let Some(close) = whole(row.get("value")) else {
continue;
};
let Some(date) = row
.get("formatted_date")
.and_then(Value::as_str)
.and_then(|d| NaiveDate::parse_from_str(d.trim(), "%Y-%m-%d").ok())
else {
return Err(IdxError::ParseError(format!(
"stockbit chart: row without a daily date: {row}"
)));
};
bars.push(Ohlc {
date,
open: whole(row.get("open")).unwrap_or(close),
high: whole(row.get("high")).unwrap_or(close),
low: whole(row.get("low")).unwrap_or(close),
close,
volume: count(row.get("volume")).unwrap_or(0).saturating_mul(100),
});
}
bars.sort_by_key(|b| b.date);
Ok(bars)
}
/// The candles within `period` of the latest one (5 sessions for `5d`).
fn trim_to_period(mut bars: Vec<Ohlc>, period: &Period) -> Vec<Ohlc> {
let Some(last) = bars.last().map(|b| b.date) else {
return bars;
};
let months = match period {
Period::FiveDays => {
let start = bars.len().saturating_sub(5);
return bars.split_off(start);
}
Period::OneMonth => 1,
Period::ThreeMonths => 3,
Period::SixMonths => 6,
_ => 12,
};
match last.checked_sub_months(Months::new(months)) {
Some(start) => bars.into_iter().filter(|b| b.date > start).collect(),
None => bars,
}
}
/// A 403 from the CDN in front of the session API: a block, not a refused
/// session, so nothing is retried.
pub(crate) fn blocked() -> IdxError {
IdxError::UpstreamBlocked(
"exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \
retry a block. Try again later or from another network"
.to_string(),
)
}
/// Whether `path` is one idx reads; `..` never climbs out of a prefix.
fn is_read_path(path: &str) -> bool {
READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..")
}
#[cfg(test)]
mod tests {
use super::*;
/// Only the endpoints idx reads pass; trading and account paths, and a
/// path that climbs out of a prefix, never leave the process.
#[test]
fn only_read_paths_are_called() {
for path in [
"/company-price-feed/market-time/session",
"/company-price-feed/v2/orderbook/companies/BBCA",
"/charts/BBCA/daily?timeframe=1y&chart_type=PRICE_CHART_TYPE_CANDLE",
"/corpaction/dividend?symbol=BBCA",
"/insider/company/majorholder?page=1&symbol=BBCA",
] {
assert!(is_read_path(path), "{path}");
}
// Paths another module builds come from its own builder, so the
// allowlist cannot drift from them.
let query = MovementQuery::default();
for path in [
movement::request_path(&query, None, 1),
movement::request_path(&query, Some("BBCA"), 3),
] {
assert!(is_read_path(&path), "{path}");
}
for path in [
"/order/buy",
"/login/refresh",
"/corpaction/../order/buy",
"/insider/company/majorholder/../../order",
"/charts",
"",
] {
assert!(!is_read_path(path), "{path}");
}
}
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!(
"{}/tests/fixtures/stockbit_exodus_{name}",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
}
#[test]
fn order_book_carries_depth_in_shares_and_foreign_flow() {
let book = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
assert_eq!(
(book.price, book.prev_close, book.change),
(5175, Some(6075), Some(-900))
);
assert_eq!(
(book.open, book.high, book.low),
(Some(5825), Some(5825), Some(5175))
);
assert_eq!(book.foreign_net, Some(-2_329_493_087_500));
// BBCA closed at its lower limit: no bids, offers best price first.
assert!(book.bids.is_empty());
assert_eq!(
book.offers[0],
BookLevel {
price: 5175,
shares: 264_462_500,
orders: 3284
}
);
assert!(book.offers.windows(2).all(|w| w[0].price < w[1].price));
assert_eq!(book.session_note, None);
}
#[test]
fn quote_shows_the_best_levels_only_while_the_market_is_open() {
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);
assert_eq!(open.market_status.as_deref(), Some("open"));
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!(shut.market_status.as_deref(), Some("close"), "{state}");
}
let unknown = quote_from_book(&book(None));
assert_eq!((unknown.bid, unknown.market_status), (None, 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!(
(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]
fn errors_map_to_their_codes() {
let reply = |status, body: &str| Reply {
status,
body: body.to_string(),
};
assert!(matches!(
classify(reply(404, &fixture("orderbook_zzzz.json")), "ZZZZ.JK"),
Err(IdxError::SymbolNotFound(s)) if s == "ZZZZ.JK"
));
assert!(reply(401, &fixture("unauthorized.json")).rejected());
assert!(reply(403, &fixture("unauthorized.json")).rejected());
let blocked = reply(403, "<HTML>Request blocked.</HTML>");
assert!(!blocked.rejected());
assert!(matches!(
classify(blocked, "BBCA.JK"),
Err(IdxError::UpstreamBlocked(_))
));
assert!(matches!(
classify(reply(429, ""), "x"),
Err(IdxError::RateLimited)
));
}
#[test]
fn daily_candles_convert_lots_to_shares_and_trim_to_the_period() {
let year = parse_daily(&fixture("chart_candle_bbca_1y.json")).unwrap();
assert_eq!(year.len(), 240);
assert!(year.windows(2).all(|w| w[0].date < w[1].date));
let month = parse_daily(&fixture("chart_candle_bbca_1m.json")).unwrap();
let first = &month[0];
assert_eq!(first.date.to_string(), "2026-09-16");
assert_eq!(
(first.open, first.high, first.low, first.close),
(6350, 6425, 6325, 6325)
);
assert_eq!(first.volume, 72_157_400);
let six = trim_to_period(year.clone(), &Period::SixMonths);
assert_eq!(six.first().unwrap().date.to_string(), "2026-04-17");
assert_eq!(trim_to_period(month, &Period::FiveDays).len(), 5);
}
#[test]
fn only_daily_windows_map_to_a_chart() {
assert_eq!(history_window(&Period::OneYear, &Interval::Day), Some("1y"));
assert_eq!(history_window(&Period::TwoYears, &Interval::Day), None);
assert_eq!(history_window(&Period::OneYear, &Interval::Week), None);
assert_eq!(history_window(&Period::OneDay, &Interval::Day), None);
}
}