//! 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::(&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 { 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 { 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, 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, 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 { 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 { static CACHE: Mutex)>> = 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 { 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 { 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 { 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) -> Result { 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 { 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 { serde_json::from_str::(body) .ok()? .get("error_type")? .as_str() .map(str::to_string) } fn message(body: &str) -> Option { serde_json::from_str::(body) .ok()? .get("message")? .as_str() .map(str::to_string) } fn data(body: &str, what: &str) -> Result { 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 { value.and_then(number).map(|n| n.round() as i64) } fn count(value: Option<&Value>) -> Option { 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, ) -> Result { 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 { 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 { 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, 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, 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, period: &Period) -> Vec { 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| { 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, "Request blocked."); 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); } }