feat: Stockbit login for the order book, live quotes and daily history

Opt-in, with the user's own Stockbit session from a pasted
credentialStorage cookie (or IDX_STOCKBIT_REFRESH_TOKEN):

- idx stockbit login/status/logout; session file 0600, tokens never shown
- rotating refresh under a cross-process lock; one refresh+retry on 401
- stocks orderbook: depth with lots and orders, OHLC, value, foreign flow
- --provider stockbit quotes from the session API when logged in
- --history-provider stockbit, and auto under the stockbit provider for
  5d-1y daily windows
- MCP get_orderbook and stockbit_status (17 tools)

Endpoints, units and recorded responses from fzn0x/idx (MIT).
This commit is contained in:
hermes 2026-10-23 08:35:57 +00:00
commit b5ae9b98de
61 changed files with 2882 additions and 129 deletions

View file

@ -30,6 +30,8 @@ directories = "5"
chrono.workspace = true
thiserror = "2"
fastrand = "2"
# Decodes a Stockbit session JWT's expiry (`exp`); never verifies or signs.
base64 = "0.22"
rusqlite = { workspace = true, optional = true }
quick-xml = { version = "0.37", optional = true }
sha2 = { workspace = true, optional = true }

View file

@ -11,6 +11,7 @@ use crate::api::history_provider;
use crate::api::types::{Fundamentals, Interval, Period};
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::config::HistoryProviderKind;
use crate::error::IdxError;
use crate::market::HistorySource;
use bandar::{BandarReport, analyze_bandar, check_top};
@ -110,8 +111,16 @@ impl Analysis<'_> {
Ok(BandarScreen { reports, failures })
}
/// Where `technical` reads its year of daily history from, for an
/// optional `--history-provider` override.
pub fn technical_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
self.idx
.market()
.history_source(mode, &Period::OneYear, &Interval::Day)
}
/// Technical analysis over one year of daily history from `source`
/// (see `Market::history_source`).
/// (see `technical_source`).
pub fn technical(
&self,
symbol: &str,
@ -119,9 +128,8 @@ impl Analysis<'_> {
policy: CachePolicy,
) -> Result<Fetched<TechnicalReport>, IdxError> {
let idx = self.idx;
let (provider_kind, provider) =
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
let bucket = format!("{}-technical", provider_kind.as_str());
let provider = history_provider(source.provider, idx.config.verbose);
let bucket = format!("{}-technical", source.provider.as_str());
let resolved = idx.resolve(symbol)?;
cached_fetch(
&idx.cache,
@ -130,7 +138,7 @@ impl Analysis<'_> {
key: &resolved,
ttl_secs: idx.config.quote_ttl,
subject: &resolved,
source: provider_kind.as_str(),
source: source.provider.as_str(),
},
policy,
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),

View file

@ -1,10 +1,11 @@
//! Fixture-backed provider for tests, enabled by the `testing` feature and
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
use super::types::{Bar, Fundamentals, Interval, Intraday, Period, Quote};
use super::stockbit::exodus;
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
use super::{
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, QuoteProvider,
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
OrderBookProvider, QuoteProvider, TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
};
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
use crate::config::ProviderKind;
@ -135,22 +136,31 @@ impl HistoryProvider for MockProvider {
}
}
/// Stockbit symbol pages from `tests/fixtures/stockbit_symbol_<code>.html`;
/// a code without a fixture is Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS`
/// (e.g. `403`) answers that HTTP status instead, and `IDX_MOCK_ERROR` fails
/// as for the other providers.
/// Stockbit from fixtures. Without a session (none stored or seeded through
/// `IDX_STOCKBIT_REFRESH_TOKEN`): the public page,
/// `tests/fixtures/stockbit_symbol_<code>.html`. With one: the session API,
/// `tests/fixtures/stockbit_exodus_orderbook_<code>.json` and
/// `stockbit_exodus_chart_candle_<code>_<window>.json`, with the market state
/// from `stockbit_exodus_market_session.json`. A code without a fixture is
/// Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` (e.g. `403`) answers that HTTP
/// status instead, and `IDX_MOCK_ERROR` fails as for the other providers.
pub struct MockStockbitProvider;
impl MockStockbitProvider {
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
fn failure(&self, symbol: &str) -> Option<IdxError> {
if std::env::var("IDX_MOCK_ERROR").is_ok() {
return Err(IdxError::ProviderUnavailable);
return Some(IdxError::ProviderUnavailable);
}
if let Ok(status) = std::env::var("IDX_MOCK_STOCKBIT_STATUS") {
return Err(stockbit::status_error(
status.parse().unwrap_or(500),
symbol,
));
let status = std::env::var("IDX_MOCK_STOCKBIT_STATUS").ok()?;
Some(stockbit::status_error(
status.parse().unwrap_or(500),
symbol,
))
}
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
if let Some(err) = self.failure(symbol) {
return Err(err);
}
let code = stockbit::stockbit_code(symbol)?;
let path = format!(
@ -162,10 +172,32 @@ impl MockStockbitProvider {
Err(_) => Err(stockbit::status_error(404, symbol)),
}
}
/// A session API fixture, or the error a request would meet.
fn exodus(&self, symbol: &str, name: &str) -> Result<String, IdxError> {
if !stockbit::has_session() {
return Err(IdxError::AuthError(stockbit::auth::NO_SESSION.to_string()));
}
if let Some(err) = self.failure(symbol) {
return Err(err);
}
std::fs::read_to_string(format!("tests/fixtures/stockbit_exodus_{name}"))
.map_err(|_| IdxError::SymbolNotFound(symbol.to_string()))
}
fn book(&self, symbol: &str) -> Result<OrderBook, IdxError> {
let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase();
let body = self.exodus(symbol, &format!("orderbook_{code}.json"))?;
let state = self.exodus(symbol, "market_session.json")?;
exodus::parse_orderbook(symbol, &body, exodus::parse_market_status(&state))
}
}
impl QuoteProvider for MockStockbitProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
if stockbit::has_session() {
return self.book(symbol).map(|book| exodus::quote_from_book(&book));
}
let mut quote = self.snapshot(symbol)?.quote;
// Quotes are judged against the clock (behind the session, a week
// old); a fixed trade time would make their output age.
@ -186,6 +218,27 @@ impl IntradayProvider for MockStockbitProvider {
}
}
impl OrderBookProvider for MockStockbitProvider {
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
self.book(symbol)
}
}
impl HistoryProvider for MockStockbitProvider {
fn history(
&self,
symbol: &str,
period: &Period,
interval: &Interval,
) -> Result<Vec<Bar>, IdxError> {
let window = exodus::history_window(period, interval)
.ok_or_else(|| exodus::unsupported_window(period, interval))?;
let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase();
let body = self.exodus(symbol, &format!("chart_candle_{code}_{window}.json"))?;
exodus::daily_for_period(&body, period)
}
}
/// Broker summaries from `tests/fixtures/ipot_brokersummary_<code>_<from>
/// [_<to>][_<investor>_<board>].html`; a query without a fixture reads as an
/// unknown code.

View file

@ -14,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind};
use crate::error::IdxError;
use types::{
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
Intraday, NewsItem, Period, Quote, SentimentData,
Intraday, NewsItem, OrderBook, Period, Quote, SentimentData,
};
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
@ -72,6 +72,12 @@ pub trait IntradayProvider {
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError>;
}
/// The order book of one IDX stock: needs the user's Stockbit session.
pub trait OrderBookProvider {
/// `symbol` is resolved, e.g. `BBCA.JK`.
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError>;
}
pub trait FundamentalsProvider {
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
}
@ -242,9 +248,12 @@ fn mock_market(_provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
#[cfg(feature = "testing")]
fn mock_history(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> {
Some(Box::new(
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
))
match provider {
ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)),
_ => Some(Box::new(
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
)),
}
}
#[cfg(not(feature = "testing"))]
@ -285,7 +294,7 @@ fn build_selected_provider(
news: Some(Box::new(msn::MsnProvider::new(verbose))),
screener: Some(Box::new(msn::MsnProvider::new(verbose))),
},
// Quotes only; history falls back to Yahoo (see `history_source`).
// Quotes only; history needs a session (see `history_source`).
ProviderKind::Stockbit => SelectedProvider {
kind: ProviderKind::Stockbit,
market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())),
@ -328,6 +337,14 @@ pub fn intraday_provider() -> Box<dyn IntradayProvider> {
Box::new(stockbit::StockbitProvider::new())
}
pub fn orderbook_provider() -> Box<dyn OrderBookProvider> {
#[cfg(feature = "testing")]
if mock::enabled() {
return Box::new(mock::MockStockbitProvider);
}
Box::new(stockbit::StockbitProvider::new())
}
/// Today in WIB, the date IDX trades on.
pub(crate) fn today_wib() -> chrono::NaiveDate {
#[cfg(feature = "testing")]
@ -338,40 +355,44 @@ pub(crate) fn today_wib() -> chrono::NaiveDate {
}
/// The provider that serves history for `provider` under `history_mode`.
pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind {
/// `auto` uses Stockbit when it is the selected provider, the user has a
/// session and the window is one of its daily charts; otherwise Yahoo, whose
/// candles are full OHLCV for any window.
pub fn history_source(
provider: ProviderKind,
history_mode: HistoryProviderKind,
period: &Period,
interval: &Interval,
stockbit_session: bool,
) -> ProviderKind {
match history_mode {
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
HistoryProviderKind::Msn => ProviderKind::Msn,
HistoryProviderKind::Auto => match provider {
ProviderKind::Yahoo | ProviderKind::Msn | ProviderKind::Stockbit => ProviderKind::Yahoo,
},
HistoryProviderKind::Stockbit => ProviderKind::Stockbit,
HistoryProviderKind::Auto => {
if provider == ProviderKind::Stockbit
&& stockbit_session
&& stockbit::exodus::history_window(period, interval).is_some()
{
ProviderKind::Stockbit
} else {
ProviderKind::Yahoo
}
}
}
}
/// Resolves a history provider based on the selected market data provider and
/// history provider strategy.
///
/// `history_mode=auto` keeps using Yahoo for IDX history because Yahoo provides
/// full OHLCV candles. Explicit `msn` opts into MSN's price-only chart feed.
pub fn history_provider(
provider: ProviderKind,
history_mode: HistoryProviderKind,
verbose: bool,
) -> Result<(ProviderKind, Box<dyn HistoryProvider>), IdxError> {
let resolved = history_source(provider, history_mode);
/// The history provider for a source from `history_source`.
pub fn history_provider(source: ProviderKind, verbose: bool) -> Box<dyn HistoryProvider> {
if mock_enabled()
&& let Some(mock) = mock_history(resolved, verbose)
&& let Some(mock) = mock_history(source, verbose)
{
return Ok((resolved, mock));
return mock;
}
match resolved {
ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))),
ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))),
ProviderKind::Stockbit => Err(IdxError::Unsupported(
"Stockbit serves no price history; use --history-provider yahoo or msn".to_string(),
)),
match source {
ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)),
ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)),
ProviderKind::Stockbit => Box::new(stockbit::StockbitProvider::new()),
}
}
@ -472,4 +493,72 @@ mod tests {
"unsupported: screen: command requires --provider msn"
);
}
#[test]
fn auto_history_uses_stockbit_only_with_a_session_and_a_daily_window() {
use super::history_source;
use crate::api::types::{Interval, Period};
use crate::config::HistoryProviderKind::{Auto, Stockbit, Yahoo};
let source = |provider, mode, period, interval, session| {
history_source(provider, mode, &period, &interval, session)
};
let year = (Period::OneYear, Interval::Day);
assert_eq!(
source(
ProviderKind::Stockbit,
Auto,
year.0.clone(),
year.1.clone(),
true
),
ProviderKind::Stockbit
);
// No session, a window Stockbit charts weekly, or another provider.
assert_eq!(
source(
ProviderKind::Stockbit,
Auto,
year.0.clone(),
year.1.clone(),
false
),
ProviderKind::Yahoo
);
assert_eq!(
source(
ProviderKind::Stockbit,
Auto,
Period::FiveYears,
Interval::Day,
true
),
ProviderKind::Yahoo
);
assert_eq!(
source(
ProviderKind::Msn,
Auto,
year.0.clone(),
year.1.clone(),
true
),
ProviderKind::Yahoo
);
// An explicit choice wins either way.
assert_eq!(
source(
ProviderKind::Msn,
Stockbit,
year.0.clone(),
year.1.clone(),
false
),
ProviderKind::Stockbit
);
assert_eq!(
source(ProviderKind::Stockbit, Yahoo, year.0, year.1, true),
ProviderKind::Yahoo
);
}
}

View file

@ -0,0 +1,607 @@
//! The user's own Stockbit session: two bearer JWTs, where they are kept, and
//! how they are refreshed. Opt-in; nothing here runs without a session.
//!
//! - The **access** token (about a day) authorises every
//! `exodus.stockbit.com` request.
//! - The **refresh** token (about a week) is spent on `POST /login/refresh`,
//! which answers a new access token *and a new refresh token*, retiring the
//! one presented. So exactly one party may refresh a session: two idx
//! processes refresh under a file lock and re-read what the other wrote, and
//! a browser sharing the session gets logged out once idx refreshes.
//!
//! The session lives in `<config dir>/stockbit-session.json` (mode 0600).
//! `IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` seed fields the
//! file lacks, for servers. Tokens never reach logs, errors or cache keys.
//! The token shapes follow fzn0x/idx (MIT).
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::IdxError;
pub const ENV_REFRESH: &str = "IDX_STOCKBIT_REFRESH_TOKEN";
pub const ENV_ACCESS: &str = "IDX_STOCKBIT_ACCESS_TOKEN";
const SESSION_FILE: &str = "stockbit-session.json";
const LOCK_FILE: &str = "stockbit-session.lock";
/// Refresh this long before the access token expires, so no request races
/// the expiry.
const REFRESH_BUFFER_SECS: i64 = 60 * 60;
pub(crate) const NO_SESSION: &str =
"no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)";
/// The persisted token pair. Expiries are read from the tokens themselves.
#[derive(Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Session {
#[serde(default)]
pub access: String,
#[serde(default)]
pub refresh: String,
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Session")
.field("access", &(!self.access.is_empty()).then_some("<redacted>"))
.field(
"refresh",
&(!self.refresh.is_empty()).then_some("<redacted>"),
)
.finish()
}
}
impl Session {
pub fn is_empty(&self) -> bool {
self.access.is_empty() && self.refresh.is_empty()
}
}
/// A token's presence and expiry, never its value.
#[derive(Debug, Clone, Serialize)]
pub struct TokenInfo {
pub present: bool,
/// When the token expires; `null` when absent or not a JWT.
pub expires_at: Option<DateTime<Utc>>,
/// Seconds until expiry, negative once expired.
pub expires_in_secs: Option<i64>,
}
impl TokenInfo {
fn of(token: &str, now: i64) -> Self {
let present = !token.is_empty();
let exp = present.then(|| token_expiry(token)).flatten();
Self {
present,
expires_at: exp.and_then(|e| DateTime::from_timestamp(e, 0)),
expires_in_secs: exp.map(|e| e - now),
}
}
}
/// What `idx stockbit status` and the MCP `stockbit_status` tool report.
#[derive(Debug, Clone, Serialize)]
pub struct SessionStatus {
/// A refresh or access token is stored or seeded (live or not).
pub logged_in: bool,
pub access: TokenInfo,
pub refresh: TokenInfo,
pub session_file: String,
}
/// Reads, writes and refreshes the session.
#[derive(Debug, Clone)]
pub struct SessionStore {
dir: PathBuf,
seed: Session,
}
impl SessionStore {
/// The session beside `config.toml`, seeded from the environment.
pub fn open() -> Result<Self, IdxError> {
let config = crate::config::config_path()?;
let dir = config
.parent()
.ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))?;
let env = |name| {
std::env::var(name)
.map(|v| normalize_token(&v))
.unwrap_or_default()
};
Ok(Self::at(
dir.to_path_buf(),
Session {
access: env(ENV_ACCESS),
refresh: env(ENV_REFRESH),
},
))
}
pub fn at(dir: PathBuf, seed: Session) -> Self {
Self { dir, seed }
}
pub fn path(&self) -> PathBuf {
self.dir.join(SESSION_FILE)
}
/// The file's tokens, each missing one taken from the seed.
pub fn load(&self) -> Session {
let mut session = std::fs::read_to_string(self.path())
.ok()
.and_then(|raw| serde_json::from_str::<Session>(&raw).ok())
.unwrap_or_default();
session.access = normalize_token(&session.access);
session.refresh = normalize_token(&session.refresh);
if session.access.is_empty() {
session.access.clone_from(&self.seed.access);
}
if session.refresh.is_empty() {
session.refresh.clone_from(&self.seed.refresh);
}
session
}
pub fn has_session(&self) -> bool {
!self.load().is_empty()
}
/// Writes the session atomically, readable by the owner only.
pub fn save(&self, session: &Session) -> Result<(), IdxError> {
let io = |e: std::io::Error| IdxError::Io(format!("stockbit session: {e}"));
std::fs::create_dir_all(&self.dir).map_err(io)?;
let tmp = self.dir.join(format!("{SESSION_FILE}.tmp"));
let body = serde_json::to_vec_pretty(session)
.map_err(|e| IdxError::Io(format!("stockbit session: {e}")))?;
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let mut file = options.open(&tmp).map_err(io)?;
std::io::Write::write_all(&mut file, &body).map_err(io)?;
file.sync_all().map_err(io)?;
drop(file);
std::fs::rename(&tmp, self.path()).map_err(io)
}
/// Deletes the stored session; false when there was none. Seeds from the
/// environment are not affected.
pub fn clear(&self) -> Result<bool, IdxError> {
match std::fs::remove_file(self.path()) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(IdxError::Io(format!("stockbit session: {e}"))),
}
}
pub fn status(&self, now: i64) -> SessionStatus {
let session = self.load();
SessionStatus {
logged_in: !session.is_empty(),
access: TokenInfo::of(&session.access, now),
refresh: TokenInfo::of(&session.refresh, now),
session_file: self.path().display().to_string(),
}
}
/// An access token good for at least `REFRESH_BUFFER_SECS`, refreshing
/// with `refresher` (refresh token in, response body out) when it is not.
pub fn access(
&self,
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
now: i64,
) -> Result<String, IdxError> {
let current = self.load();
if current.is_empty() {
return Err(IdxError::AuthError(NO_SESSION.to_string()));
}
if fresh(&current.access, now) {
return Ok(current.access);
}
self.locked(|| {
// Another process may have refreshed while this one waited.
let session = self.load();
if fresh(&session.access, now) {
return Ok(session.access);
}
match self.rotate(&session, refresher) {
Ok(access) => Ok(access),
// A failed refresh must not block a token that still works.
Err(_) if token_expiry(&session.access).is_some_and(|exp| now < exp) => {
Ok(session.access)
}
Err(e) => Err(e),
}
})
}
/// A new access token after the server rejected `rejected`, unless
/// another process already replaced it.
pub fn after_rejection(
&self,
rejected: &str,
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
) -> Result<String, IdxError> {
self.locked(|| {
let session = self.load();
if !session.access.is_empty() && session.access != rejected {
return Ok(session.access);
}
self.rotate(&session, refresher)
})
}
/// Spends the refresh token and stores the new pair.
fn rotate(
&self,
session: &Session,
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
) -> Result<String, IdxError> {
if session.refresh.is_empty() {
return Err(IdxError::AuthError(
"the Stockbit session has no refresh token; run `idx stockbit login`".to_string(),
));
}
let body = refresher(&session.refresh)?;
let next = parse_refresh(&body, session)?;
self.save(&next)?;
Ok(next.access)
}
/// Runs `f` holding the session lock, so only one process spends the
/// rotating refresh token at a time.
fn locked<T>(&self, f: impl FnOnce() -> Result<T, IdxError>) -> Result<T, IdxError> {
let io = |e: std::io::Error| IdxError::Io(format!("stockbit session lock: {e}"));
std::fs::create_dir_all(&self.dir).map_err(io)?;
let lock = lock_file(&self.dir.join(LOCK_FILE)).map_err(io)?;
lock.lock().map_err(io)?;
let result = f();
drop(lock);
result
}
}
fn lock_file(path: &Path) -> std::io::Result<File> {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
}
fn fresh(access: &str, now: i64) -> bool {
token_expiry(access).is_some_and(|exp| now < exp - REFRESH_BUFFER_SECS)
}
/// Strips whitespace and a leading `Bearer `.
pub fn normalize_token(raw: &str) -> String {
let t = raw.trim();
t.strip_prefix("Bearer ")
.or_else(|| t.strip_prefix("bearer "))
.unwrap_or(t)
.trim()
.to_string()
}
/// A JWT's `exp` (Unix seconds), read without verifying the signature: idx
/// only carries these tokens.
pub fn token_expiry(token: &str) -> Option<i64> {
let payload = token.split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload.trim_end_matches('='))
.ok()?;
serde_json::from_slice::<Value>(&bytes)
.ok()?
.get("exp")?
.as_i64()
}
/// Three dot-separated base64url parts with a JSON header (`eyJ`).
fn looks_like_jwt(value: &str) -> bool {
let mut parts = value.split('.');
matches!(parts.next(), Some(h) if h.starts_with("eyJ"))
&& parts.next().is_some_and(|p| !p.is_empty())
&& parts.next().is_some_and(|p| !p.is_empty())
&& parts.next().is_none()
}
/// The JWT under key `slot` anywhere in `value`: a bare string or an object
/// with `token`. Stockbit's envelopes have moved between versions.
fn find_token(value: &Value, slot: &str) -> Option<String> {
match value {
Value::Object(map) => {
let direct = map.get(slot).and_then(|hit| {
hit.as_str()
.or_else(|| hit.get("token").and_then(Value::as_str))
.filter(|t| looks_like_jwt(t))
.map(str::to_string)
});
direct.or_else(|| map.values().find_map(|child| find_token(child, slot)))
}
Value::Array(items) => items.iter().find_map(|child| find_token(child, slot)),
_ => None,
}
}
/// The new pair from a `/login/refresh` response; a response that rotates
/// only the access token keeps the presented refresh token.
pub fn parse_refresh(body: &str, previous: &Session) -> Result<Session, IdxError> {
let value: Value = serde_json::from_str(body)
.map_err(|e| IdxError::ParseError(format!("stockbit refresh response: {e}")))?;
let access = find_token(&value, "access").ok_or_else(|| {
IdxError::ParseError("stockbit refresh response carried no access token".to_string())
})?;
Ok(Session {
access,
refresh: find_token(&value, "refresh").unwrap_or_else(|| previous.refresh.clone()),
})
}
/// A session from what a user pastes: the `credentialStorage` cookie of
/// stockbit.com (percent-encoded JSON), that JSON decoded, or a bare refresh
/// token.
pub fn session_from_paste(raw: &str) -> Option<Session> {
let raw = raw.trim();
let decoded = percent_decode(raw);
for candidate in [decoded.as_str(), raw] {
if let Ok(value) = serde_json::from_str::<Value>(candidate) {
let session = Session {
access: find_token(&value, "access").unwrap_or_default(),
refresh: find_token(&value, "refresh").unwrap_or_default(),
};
if !session.is_empty() {
return Some(session);
}
}
}
let token = normalize_token(raw);
looks_like_jwt(&token).then(|| Session {
access: String::new(),
refresh: token,
})
}
/// `%XX` escapes decoded; anything else kept as is.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& let Some(hex) = s.get(i + 1..i + 3)
&& let Ok(byte) = u8::from_str_radix(hex, 16)
{
out.push(byte);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
}
/// An unsigned JWT expiring at `exp`.
fn jwt(exp: i64, tag: &str) -> String {
let enc = |v: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v);
format!(
"{}.{}.{}",
enc(r#"{"alg":"none"}"#),
enc(&format!(r#"{{"exp":{exp},"tag":"{tag}"}}"#)),
enc("sig")
)
}
fn refresh_body(access: &str, refresh: &str) -> String {
format!(
r#"{{"data":{{"access":{{"token":"{access}"}},"refresh":{{"token":"{refresh}"}}}}}}"#
)
}
struct TempDir(PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir()
.join(format!("idx-stockbit-auth-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Self(dir)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
const NOW: i64 = 1_800_000_000;
#[test]
fn reads_the_pair_from_a_refresh_response_and_the_cookie() {
let pair = parse_refresh(
&fixture("stockbit_exodus_login_refresh_constructed.json"),
&Session::default(),
)
.unwrap();
assert_eq!(token_expiry(&pair.access), Some(1_792_385_008));
assert!(looks_like_jwt(&pair.refresh));
let pasted = session_from_paste(&fixture(
"stockbit_exodus_credential_storage_cookie_constructed.txt",
))
.unwrap();
assert!(looks_like_jwt(&pasted.access) && looks_like_jwt(&pasted.refresh));
let bare = jwt(NOW, "r");
assert_eq!(
session_from_paste(&format!(" Bearer {bare}\n")),
Some(Session {
access: String::new(),
refresh: bare
})
);
assert_eq!(session_from_paste("not a token"), None);
}
#[test]
fn a_refresh_without_a_new_refresh_token_keeps_the_old_one() {
let previous = Session {
access: String::new(),
refresh: jwt(NOW, "old"),
};
let access = jwt(NOW + 86_400, "a");
let next =
parse_refresh(&format!(r#"{{"data":{{"access":"{access}"}}}}"#), &previous).unwrap();
assert_eq!(
next,
Session {
access,
refresh: previous.refresh
}
);
}
#[test]
fn a_fresh_access_token_is_used_without_refreshing() {
let dir = TempDir::new("fresh");
let store = SessionStore::at(dir.0.clone(), Session::default());
let access = jwt(NOW + 2 * REFRESH_BUFFER_SECS, "a");
store
.save(&Session {
access: access.clone(),
refresh: jwt(NOW + 7 * 86_400, "r"),
})
.unwrap();
let refresher = |_: &str| -> Result<String, IdxError> { panic!("must not refresh") };
assert_eq!(store.access(&refresher, NOW).unwrap(), access);
}
#[test]
fn a_stale_access_token_rotates_both_tokens_and_persists_them() {
let dir = TempDir::new("rotate");
let store = SessionStore::at(dir.0.clone(), Session::default());
let old_refresh = jwt(NOW + 7 * 86_400, "r1");
store
.save(&Session {
access: jwt(NOW + 60, "a1"),
refresh: old_refresh.clone(),
})
.unwrap();
let (new_access, new_refresh) = (jwt(NOW + 86_400, "a2"), jwt(NOW + 8 * 86_400, "r2"));
let calls = Cell::new(0);
let refresher = |presented: &str| {
calls.set(calls.get() + 1);
assert_eq!(presented, old_refresh);
Ok(refresh_body(&new_access, &new_refresh))
};
assert_eq!(store.access(&refresher, NOW).unwrap(), new_access);
assert_eq!(calls.get(), 1);
// The rotated pair is what the next process reads.
assert_eq!(
store.load(),
Session {
access: new_access,
refresh: new_refresh
}
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(store.path())
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn a_rejected_token_already_replaced_by_another_process_is_not_refreshed_again() {
let dir = TempDir::new("rejected");
let store = SessionStore::at(dir.0.clone(), Session::default());
let replaced = jwt(NOW + 86_400, "theirs");
store
.save(&Session {
access: replaced.clone(),
refresh: jwt(NOW + 86_400, "r"),
})
.unwrap();
let refresher = |_: &str| -> Result<String, IdxError> { panic!("must not refresh") };
assert_eq!(
store
.after_rejection(&jwt(NOW, "mine"), &refresher)
.unwrap(),
replaced
);
}
#[test]
fn no_session_is_an_auth_error_and_the_file_beats_the_seed() {
let dir = TempDir::new("seed");
let empty = SessionStore::at(dir.0.clone(), Session::default());
let refresher = |_: &str| -> Result<String, IdxError> { unreachable!() };
assert!(matches!(
empty.access(&refresher, NOW),
Err(IdxError::AuthError(_))
));
let seeded = SessionStore::at(
dir.0.clone(),
Session {
access: String::new(),
refresh: jwt(NOW, "env"),
},
);
assert!(seeded.has_session());
let stored = jwt(NOW, "file");
seeded
.save(&Session {
access: String::new(),
refresh: stored.clone(),
})
.unwrap();
assert_eq!(seeded.load().refresh, stored);
}
#[test]
fn status_and_debug_never_show_a_token() {
let dir = TempDir::new("status");
let token = jwt(NOW + 100, "secret");
let store = SessionStore::at(
dir.0.clone(),
Session {
access: token.clone(),
refresh: String::new(),
},
);
let status = store.status(NOW);
assert!(status.logged_in);
assert_eq!(status.access.expires_in_secs, Some(100));
assert!(!status.refresh.present);
let shown = format!(
"{} {:?}",
serde_json::to_string(&status).unwrap(),
store.load()
);
assert!(!shown.contains(&token), "{shown}");
}
}

View file

@ -0,0 +1,543 @@
//! 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) 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`).
//! - `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 crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
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 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)
}
/// 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> {
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(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(),
)),
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
};
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: whole(d.get("fbuy")),
foreign_sell: whole(d.get("fsell")),
foreign_net: whole(d.get("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.
pub fn quote_from_book(book: &OrderBook) -> Quote {
let open = book.market_status.as_deref() == Some(MARKET_OPEN);
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.clone(),
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,
}
}
#[cfg(test)]
mod tests {
use super::*;
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 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);
}
#[test]
fn market_status_is_read_verbatim() {
assert_eq!(
parse_market_status(&fixture("market_session.json")).as_deref(),
Some("STATE_NAME_MARKET_CLOSED")
);
}
#[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);
}
}

View file

@ -15,6 +15,8 @@
//! - Stockbit's terms describe its market data as delayed at least 10
//! minutes and for personal use; frontends say so.
pub mod auth;
pub mod exodus;
mod parse;
use std::fs::{File, OpenOptions};
@ -24,9 +26,13 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub use parse::{parse_symbol_page, status_error, stockbit_code};
use super::types::{Fundamentals, Intraday, Quote};
use super::{FundamentalsProvider, IntradayProvider, QuoteProvider};
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
use super::{
FundamentalsProvider, HistoryProvider, IntradayProvider, OrderBookProvider, QuoteProvider,
};
use crate::error::IdxError;
use auth::SessionStore;
use exodus::Exodus;
pub const SOURCE: &str = "stockbit";
/// Identifies idx-cli and where to reach its maintainers.
@ -45,8 +51,13 @@ pub(crate) const CACHE_TTL_SECS: u64 = 5;
const MIN_INTERVAL: Duration = Duration::from_secs(1);
const BASE_URL: &str = "https://stockbit.com/symbol/";
/// Quotes come from the user's session when one is stored (live, from
/// `exodus`), else from the public page. The order book and history need the
/// session; intraday always reads the public page.
pub struct StockbitProvider {
agent: ureq::Agent,
/// `None` when the config directory cannot be resolved.
exodus: Option<Exodus>,
}
impl StockbitProvider {
@ -59,7 +70,20 @@ impl StockbitProvider {
.timeout_global(Some(Duration::from_secs(20)))
.build()
.into();
Self { agent }
Self {
agent,
exodus: SessionStore::open().ok().map(Exodus::new),
}
}
/// The session API when the user has a session.
fn session(&self) -> Option<&Exodus> {
self.exodus.as_ref().filter(|e| e.store().has_session())
}
fn require_session(&self) -> Result<&Exodus, IdxError> {
self.session()
.ok_or_else(|| IdxError::AuthError(auth::NO_SESSION.to_string()))
}
/// The quote and today's series for a resolved symbol (`BBCA.JK`).
@ -99,7 +123,7 @@ impl Default for StockbitProvider {
/// `idx` calls, a CLI beside idx-mcp) share the time of the last request in
/// `<cache>/stockbit.pace` under an exclusive file lock. When that file is
/// unusable, pacing is per process.
fn pace() {
pub(crate) fn pace() {
static LAST: Mutex<Option<Instant>> = Mutex::new(None);
let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(prev) = *last {
@ -151,7 +175,10 @@ fn now_ms() -> u128 {
impl QuoteProvider for StockbitProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
self.snapshot(symbol).map(|day| day.quote)
match self.session() {
Some(exodus) => exodus.quote(symbol),
None => self.snapshot(symbol).map(|day| day.quote),
}
}
}
@ -167,12 +194,71 @@ impl IntradayProvider for StockbitProvider {
}
}
impl OrderBookProvider for StockbitProvider {
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
self.require_session()?.orderbook(symbol)
}
}
impl HistoryProvider for StockbitProvider {
fn history(
&self,
symbol: &str,
period: &Period,
interval: &Interval,
) -> Result<Vec<Bar>, IdxError> {
self.require_session()?.daily(symbol, period, interval)
}
}
/// Whether the user has a Stockbit session stored or seeded.
pub fn has_session() -> bool {
SessionStore::open().is_ok_and(|store| store.has_session())
}
pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError {
IdxError::Unsupported(format!(
"{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo"
))
}
/// Stores a pasted session (stockbit.com's `credentialStorage` cookie or a
/// refresh token) and checks it with one request, which refreshes it when the
/// access token is missing or stale. A session Stockbit refuses is removed.
pub fn login(pasted: &str) -> Result<auth::SessionStatus, IdxError> {
let session = auth::session_from_paste(pasted).ok_or_else(|| {
IdxError::InvalidInput(
"not a Stockbit session: paste the value of stockbit.com's `credentialStorage` \
cookie, or a refresh token"
.to_string(),
)
})?;
let store = SessionStore::open()?;
store.save(&session)?;
#[cfg(feature = "testing")]
let check = !crate::api::mock::enabled();
#[cfg(not(feature = "testing"))]
let check = true;
if check && let Err(err) = Exodus::new(store.clone()).verify() {
let _ = store.clear();
return Err(err);
}
Ok(store.status(chrono::Utc::now().timestamp()))
}
/// The stored session's state, without a request and without any token.
pub fn session_status() -> Result<auth::SessionStatus, IdxError> {
Ok(SessionStore::open()?.status(chrono::Utc::now().timestamp()))
}
/// Deletes the stored session: whether there was one, and the state after
/// (still logged in when `IDX_STOCKBIT_*` seeds one).
pub fn logout() -> Result<(bool, auth::SessionStatus), IdxError> {
let store = SessionStore::open()?;
let removed = store.clear()?;
Ok((removed, store.status(chrono::Utc::now().timestamp())))
}
#[cfg(test)]
mod tests {
use super::USER_AGENT;

View file

@ -271,7 +271,7 @@ fn wib(local: NaiveDateTime) -> Option<DateTime<FixedOffset>> {
/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`,
/// `"NA"` and empty strings are absent.
fn number(value: &Value) -> Option<f64> {
pub(super) fn number(value: &Value) -> Option<f64> {
let n = match value {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.trim().parse::<f64>().ok()?,

View file

@ -94,6 +94,52 @@ pub struct Intraday {
pub points: Vec<IntradayPoint>,
}
/// One price level of an order book.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BookLevel {
/// Limit price in IDR (whole Rupiah).
pub price: i64,
/// Size resting at this price, in shares (1 lot = 100 shares).
pub shares: u64,
/// Orders queued at this price.
pub orders: u64,
}
/// The order book of one IDX stock from the user's Stockbit session, with
/// the session's figures and foreign flow. Outside trading it is the book as
/// the market left it; `market_status` says which.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderBook {
pub symbol: String,
pub name: Option<String>,
/// Last price in IDR (whole Rupiah).
pub price: i64,
pub change: Option<i64>,
pub change_pct: Option<f64>,
pub prev_close: Option<i64>,
pub open: Option<i64>,
pub high: Option<i64>,
pub low: Option<i64>,
/// Session volume in shares.
pub volume: Option<u64>,
/// Session value traded in IDR.
pub value: Option<u64>,
/// Trades in the session.
pub frequency: Option<u64>,
/// Foreign buy, sell and net value over the session, IDR.
pub foreign_buy: Option<i64>,
pub foreign_sell: Option<i64>,
pub foreign_net: Option<i64>,
/// Best price first.
pub bids: Vec<BookLevel>,
/// Best price first.
pub offers: Vec<BookLevel>,
/// Stockbit's session state, verbatim (e.g. `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>,
}
/// OHLC candle data normalized from Yahoo Finance chart indicators.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ohlc {

View file

@ -31,6 +31,8 @@ pub enum HistoryProviderKind {
Auto,
Yahoo,
Msn,
// Needs the user's Stockbit session (`idx stockbit login`).
Stockbit,
}
impl ProviderKind {
@ -63,6 +65,7 @@ impl HistoryProviderKind {
Self::Auto => "auto",
Self::Yahoo => "yahoo",
Self::Msn => "msn",
Self::Stockbit => "stockbit",
}
}
@ -73,9 +76,11 @@ impl HistoryProviderKind {
Ok(Self::Yahoo)
} else if value.eq_ignore_ascii_case("msn") {
Ok(Self::Msn)
} else if value.eq_ignore_ascii_case("stockbit") {
Ok(Self::Stockbit)
} else {
Err(IdxError::ConfigError(format!(
"invalid history provider '{value}' (expected auto, yahoo, or msn)"
"invalid history provider '{value}' (expected auto, yahoo, msn, or stockbit)"
)))
}
}

View file

@ -6,8 +6,10 @@ use std::str::FromStr;
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
use crate::api::types::{Interval, Intraday, Ohlc, Period, Quote};
use crate::api::{history_provider, history_source, idx_offset, intraday_provider, stockbit};
use crate::api::types::{Interval, Intraday, Ohlc, OrderBook, Period, Quote};
use crate::api::{
history_provider, history_source, idx_offset, intraday_provider, orderbook_provider, stockbit,
};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::config::{HistoryProviderKind, ProviderKind};
use crate::error::IdxError;
@ -91,11 +93,20 @@ impl Market<'_> {
}
/// Resolves the history source for an optional `--history-provider`
/// override, without fetching.
pub fn history_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
/// override and the window asked for, without fetching.
pub fn history_source(
&self,
mode: Option<HistoryProviderKind>,
period: &Period,
interval: &Interval,
) -> HistorySource {
let mode = mode.unwrap_or(self.idx.config.history_provider);
let selected = self.idx.provider.kind();
let provider = history_source(selected, mode);
// Only `auto` under the stockbit provider looks for a session.
let session = mode == HistoryProviderKind::Auto
&& selected == ProviderKind::Stockbit
&& stockbit::has_session();
let provider = history_source(selected, mode, period, interval, session);
HistorySource {
provider,
mode,
@ -112,9 +123,8 @@ impl Market<'_> {
policy: CachePolicy,
) -> Result<Fetched<Vec<Ohlc>>, IdxError> {
let idx = self.idx;
let (provider_kind, provider) =
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
let bucket = format!("{}-history", provider_kind.as_str());
let provider = history_provider(source.provider, idx.config.verbose);
let bucket = format!("{}-history", source.provider.as_str());
let resolved = idx.resolve(symbol)?;
let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str());
cached_fetch(
@ -124,7 +134,7 @@ impl Market<'_> {
key: &key,
ttl_secs: idx.config.quote_ttl,
subject: &resolved,
source: provider_kind.as_str(),
source: source.provider.as_str(),
},
policy,
|| provider.history(&resolved, period, interval),
@ -132,6 +142,31 @@ impl Market<'_> {
)
}
/// The order book of one IDX stock from the user's Stockbit session,
/// whichever market provider is selected.
pub fn orderbook(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<OrderBook>, IdxError> {
let idx = self.idx;
let resolved = idx.resolve(symbol)?;
let provider = orderbook_provider();
cached_fetch(
&idx.cache,
CacheSpec {
bucket: "stockbit-orderbook",
key: &resolved,
ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
subject: &resolved,
source: stockbit::SOURCE,
},
policy,
|| provider.orderbook(&resolved),
Ok,
)
}
/// Screener results, ranked locally and truncated to `query.limit`.
pub fn screen(
&self,

View file

@ -0,0 +1 @@
{"data":{"cagr":"", "change":-1225, "drawdown":"", "markingpoint":"", "percentage":"-19.14", "prices":[{"date":"1789491600000", "formatted_date":"2026-09-16", "xlabel":"", "value":"6325", "percentage":"-1.17", "change":-75, "open":"6350", "high":"6425", "low":"6325", "volume":"721574"}, {"date":"1789578000000", "formatted_date":"2026-09-17", "xlabel":"", "value":"6350", "percentage":"-0.78", "change":-50, "open":"6400", "high":"6400", "low":"6325", "volume":"618120"}, {"date":"1789664400000", "formatted_date":"2026-09-18", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6325", "high":"6350", "low":"6225", "volume":"1767537"}, {"date":"1789923600000", "formatted_date":"2026-09-21", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6300", "high":"6300", "low":"6225", "volume":"699141"}, {"date":"1790010000000", "formatted_date":"2026-09-22", "xlabel":"", "value":"6200", "percentage":"-3.12", "change":-200, "open":"6225", "high":"6250", "low":"6175", "volume":"932112"}, {"date":"1790096400000", "formatted_date":"2026-09-23", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6200", "high":"6300", "low":"6200", "volume":"968261"}, {"date":"1790182800000", "formatted_date":"2026-09-24", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6250", "high":"6275", "low":"6225", "volume":"756486"}, {"date":"1790269200000", "formatted_date":"2026-09-25", "xlabel":"", "value":"6250", "percentage":"-2.34", "change":-150, "open":"6225", "high":"6275", "low":"6200", "volume":"894474"}, {"date":"1790528400000", "formatted_date":"2026-09-28", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6225", "high":"6250", "low":"6175", "volume":"1121076"}, {"date":"1790614800000", "formatted_date":"2026-09-29", "xlabel":"", "value":"6150", "percentage":"-3.91", "change":-250, "open":"6150", "high":"6225", "low":"6125", "volume":"1497834"}, {"date":"1790701200000", "formatted_date":"2026-09-30", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6100", "high":"6150", "low":"6050", "volume":"2213357"}, {"date":"1790787600000", "formatted_date":"2026-10-01", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6050", "high":"6150", "low":"6000", "volume":"1180449"}, {"date":"1790874000000", "formatted_date":"2026-10-02", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6000", "high":"6150", "low":"5975", "volume":"1319203"}, {"date":"1791133200000", "formatted_date":"2026-10-05", "xlabel":"", "value":"6175", "percentage":"-3.52", "change":-225, "open":"6100", "high":"6175", "low":"6075", "volume":"899057"}, {"date":"1791219600000", "formatted_date":"2026-10-06", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6175", "high":"6200", "low":"6100", "volume":"1521734"}, {"date":"1791306000000", "formatted_date":"2026-10-07", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6075", "high":"6100", "low":"5975", "volume":"1626908"}, {"date":"1791392400000", "formatted_date":"2026-10-08", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6100", "high":"6150", "low":"6000", "volume":"1131403"}, {"date":"1791478800000", "formatted_date":"2026-10-09", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6025", "high":"6075", "low":"5975", "volume":"870986"}, {"date":"1791738000000", "formatted_date":"2026-10-12", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"6050", "high":"6100", "low":"5950", "volume":"979651"}, {"date":"1791824400000", "formatted_date":"2026-10-13", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"5925", "high":"5950", "low":"5850", "volume":"1227169"}, {"date":"1791910800000", "formatted_date":"2026-10-14", "xlabel":"", "value":"6125", "percentage":"-4.30", "change":-275, "open":"6000", "high":"6125", "low":"5950", "volume":"1363031"}, {"date":"1791997200000", "formatted_date":"2026-10-15", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6075", "high":"6125", "low":"6025", "volume":"1347632"}, {"date":"1792083600000", "formatted_date":"2026-10-16", "xlabel":"", "value":"5175", "percentage":"-19.14", "change":-1225, "open":"5825", "high":"5825", "low":"5175", "volume":"12410982"}], "timeframe":"1m", "xaxisopt":"", "previous":6400, "line_weight":2, "previous_timeframe_price":{"date":"", "formatted_date":"", "xlabel":"", "value":"", "percentage":"", "change":0, "open":"", "high":"", "low":"", "volume":""}, "chart_type":"PRICE_CHART_TYPE_CANDLE", "interval_in_minutes":0, "allowed_chart_type":["PRICE_CHART_TYPE_LINE", "PRICE_CHART_TYPE_CANDLE"], "max_candles":24}, "message":"Successfully retrieved company daily chart"}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
%7B%22state%22%3A%7B%22access%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-19T04%3A43%3A28Z%22%7D%2C%22refresh%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-25T04%3A43%3A28Z%22%7D%2C%22user%22%3A%7B%22username%22%3A%22demo%22%7D%7D%2C%22version%22%3A0%7D

View file

@ -0,0 +1,13 @@
{
"message": "Successfully refresh token",
"data": {
"access": {
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU",
"expired_at": "2026-10-19T04:43:28Z"
},
"refresh": {
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU",
"expired_at": "2026-10-25T04:43:28Z"
}
}
}

View file

@ -0,0 +1 @@
{"message":"Successfully get session info data", "data":{"datetime":"2026-10-18 20:15:06", "detail":{"fca":{"session":0, "state_name":"STATE_NAME_MARKET_CLOSED", "is_last_session":false, "is_end_of_day":true, "state_start_time":"", "state_end_time":"", "time_left":{"raw":44994, "formatted":"12 jam 29 menit 54 detik"}, "suspend_info":""}, "regular":{"session":0, "state_name":"STATE_NAME_MARKET_CLOSED", "is_last_session":false, "is_end_of_day":true, "state_start_time":"", "state_end_time":"", "time_left":{"raw":44994, "formatted":"12 jam 29 menit 54 detik"}, "suspend_info":""}}}}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"data":{"average":0, "bid":[], "change":-678.67, "close":5185.105, "country":"ID", "domestic":"59.01", "down":"110", "exchange":"IDX", "fbuy":9176233289300, "fnet":-3397459968400, "foreign":"40.99", "frequency":2130259, "fsell":12573693257700, "high":5404.508, "id":"IHSG-0", "lastprice":5185.105, "low":5185.105, "offer":[], "open":5393.924, "percentage_change":-11.57, "previous":5863.78, "status":"Active", "symbol":"IHSG", "symbol_2":"IHSG", "symbol_3":"IHSG", "tradable":false, "unchanged":"1118", "up":"265", "value":26532049500300, "volume":66022512700, "corp_action":{"active":false, "icon":"https://assets.stockbit.com/images/corp_action_event_icon.svg", "text":"Perusahaan Memiliki Corporate Action"}, "notation":[], "uma":false, "has_foreign_bs":true, "iepiev":{"symbol":"", "status":"STATUS_UNSPECIFIED", "iep":{"raw":0, "formatted":""}, "iev":{"raw":0, "formatted":""}, "time_left_seconds":0, "best_bid_offer":{"bid":{"price":{"raw":0, "formatted":"0"}, "quantity":{"raw":0, "formatted":"0"}, "change_percentage":""}, "offer":{"price":{"raw":0, "formatted":"0"}, "quantity":{"raw":0, "formatted":"0"}, "change_percentage":""}, "time_left_seconds":11921}, "title":"", "iep_changes":{"price":{"raw":0, "formatted":""}, "percentage":{"raw":0, "formatted":""}}}, "market_data":[{"label":"All Market", "frequency":{"raw":"2133195", "formatted":"2.13 M"}, "volume":{"raw":"68613461946", "formatted":"68.6 B"}, "value":{"raw":"32013813258468", "formatted":"32 T"}}, {"label":"Regular", "frequency":{"raw":"2130259", "formatted":"2.13 M"}, "volume":{"raw":"66022512700", "formatted":"66 B"}, "value":{"raw":"26532049500300", "formatted":"26.5 T"}}, {"label":"Nego", "frequency":{"raw":"511", "formatted":"511"}, "volume":{"raw":"2524183446", "formatted":"2.52 B"}, "value":{"raw":"5475654641968", "formatted":"5.48 T"}}, {"label":"Cash", "frequency":{"raw":"2425", "formatted":"2,425"}, "volume":{"raw":"66765800", "formatted":"66.8 M"}, "value":{"raw":"6109116200", "formatted":"6.11 B"}}], "name":"Index Harga Saham Gabungan", "icon_url":"https://assets.stockbit.com/logos/companies/IHSG.png?version=1784250340348189864", "ara":{"value":"0", "visible":false}, "arb":{"value":"0", "visible":false}, "company_type":"Index", "total_bid_offer":{"bid":{"freq":"0", "lot":"0", "raw_lot":"0", "raw_freq":"0"}, "offer":{"freq":"0", "lot":"0", "raw_lot":"0", "raw_freq":"0"}, "bid_percent":0}, "next_ara":{"value":"", "visible":false}, "next_arb":{"value":"", "visible":false}, "autoreject_time_left_in_sec":0, "auto_reject_estimation":[], "orderbook_active_feature_mobile":"ORDER_BOOK_FEATURE_FOREIGN_BS"}, "message":"Successfully get company orderbook"}

View file

@ -0,0 +1 @@
{"message":"Company not found","error_type":"NOT_FOUND"}

View file

@ -0,0 +1 @@
{"error_type":"UNAUTHORIZED","message":"Unauthorized"}