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

1
Cargo.lock generated
View file

@ -979,6 +979,7 @@ dependencies = [
name = "idx-core"
version = "0.4.0"
dependencies = [
"base64",
"chrono",
"clap",
"directories",

View file

@ -91,12 +91,14 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31
- Analysis: `technical`, `growth`, `valuation`, `risk`, and a combined
`fundamental` report; multi-symbol `compare`; MSN stock `screen`
- Providers: MSN is the default for quotes, fundamentals, and screening;
Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo` and
`IDX_HISTORY_PROVIDER=auto|yahoo|msn`
- Stockbit, opt-in and without a login: `stocks intraday` (today's price by
the minute) and `--provider stockbit` quotes with the best bid/offer while
the market is open. Delayed at least 10 minutes, for personal use under
Stockbit's terms; see the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo|stockbit`
and `IDX_HISTORY_PROVIDER=auto|yahoo|msn|stockbit`
- Stockbit, opt-in: without a login, `stocks intraday` (today's price by the
minute) and `--provider stockbit` quotes with the best bid/offer while the
market is open, delayed at least 10 minutes. With your own account
(`idx stockbit login`): `stocks orderbook` depth and foreign flow, live
quotes and Stockbit daily history. Personal use under Stockbit's terms; see
the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
- Local file cache with TTL, and `--offline` mode with stale-cache fallback
### Ownership (KSEI shareholders above 1%)
@ -163,8 +165,8 @@ change it to `auto` with `idx config set general.output auto`.
## MCP server
`idx-mcp` serves the same data to MCP clients over stdio, as 15 tools:
`get_quotes`, `get_intraday`, `get_price_history`, `screen_stocks`, `get_company`,
`idx-mcp` serves the same data to MCP clients over stdio, as 17 tools:
`get_quotes`, `get_intraday`, `get_orderbook`, `stockbit_status`, `get_price_history`, `screen_stocks`, `get_company`,
`analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`,
`get_foreign_flow`, `get_shareholders`, `get_investor_holdings`,
`search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as

View file

@ -12,11 +12,12 @@ history lives in git.
- `screen presets` / `screen run <PRESET>`: built-in screener presets
(graduate `screen` out of `stocks` only if these grow into a richer workflow)
- Multi-period financial statements (today `financials` is single-period)
- Order book depth: no free, login-free source exists (IDX sells delayed depth
to vendors). Planned as the Stockbit login follow-on: opt-in with the
user's own account, the depth ladder, intraday OHLCV and token refresh from
`exodus.stockbit.com`; fzn0x/idx (MIT) maps the endpoints. The public page
already gives the best bid/offer (L1) while the market is open.
- Order book depth without a login: no free source exists (IDX sells delayed
depth to vendors); `stocks orderbook` needs the user's Stockbit login.
- Stockbit login follow-ups: a browser capture for `idx stockbit login`
(Chrome DevTools, as fzn0x/idx does) instead of pasting the cookie,
intraday OHLCV from the session's `1d` chart, and observing the session
API's open-market state name (assumed `STATE_NAME_MARKET_OPEN`).
- Observe Stockbit's `market_hour.status` in the pre-open (08:45-09:00) and
the closing auction (15:50-16:00) before `--watch` or alerts build on it;
`open`, `break` and `close` are known, and the bid/offer gate hides the book

View file

@ -2,6 +2,7 @@ pub mod cache;
pub mod config;
#[cfg(feature = "ownership")]
pub mod ownership;
pub mod stockbit;
pub mod stocks;
use clap::{Parser, Subcommand, ValueEnum};
@ -69,6 +70,8 @@ pub enum Commands {
#[cfg(feature = "ownership")]
#[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")]
Ownership(ownership::OwnershipCmd),
#[command(about = "Your Stockbit session (opt-in: order book, live quotes)")]
Stockbit(stockbit::StockbitCmd),
#[command(about = "Generate shell completions")]
Completions { shell: Shell },
#[command(about = "Show idx-cli version")]

View file

@ -0,0 +1,136 @@
use std::io::{IsTerminal, Read};
use clap::{Args, Subcommand};
use crate::output::text::terminal_safe;
use crate::output::{OutputFormat, json};
use idx_core::api::stockbit::{self, auth::SessionStatus, auth::TokenInfo};
use idx_core::error::IdxError;
use idx_core::runtime;
#[derive(Debug, Args)]
#[command(
about = "Your Stockbit session (opt-in: order book, live quotes)",
long_about = "Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` \
quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, \
stored in the config directory with owner-only permissions, and never logs them. Stockbit \
rotates the refresh token on every refresh, so a browser sharing the session is logged out \
once idx refreshes: log in through a separate browser profile for idx. Stockbit's terms \
allow personal, non-commercial use.",
after_help = "Examples:\n idx stockbit login # paste the credentialStorage cookie\n pbpaste | idx stockbit login\n idx stockbit status\n idx stockbit logout"
)]
pub struct StockbitCmd {
#[command(subcommand)]
pub command: StockbitSubcommand,
}
#[derive(Debug, Subcommand)]
pub enum StockbitSubcommand {
#[command(
about = "Store a session pasted on stdin and check it with Stockbit",
long_about = "Reads, on stdin, the value of stockbit.com's `credentialStorage` cookie \
(in a logged-in browser: DevTools, Application, Cookies, https://stockbit.com) or a refresh \
token, stores it, and checks it with one request. Servers can set \
IDX_STOCKBIT_REFRESH_TOKEN instead."
)]
Login,
#[command(about = "Show whether a session is stored and when its tokens expire")]
Status,
#[command(about = "Delete the stored session")]
Logout,
}
pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> {
match &cmd.command {
StockbitSubcommand::Login => {
let status = stockbit::login(&read_paste()?)?;
if format.is_json() {
return json::print_json(&status);
}
println!("Stored and checked your Stockbit session.");
print_status(&status);
}
StockbitSubcommand::Status => {
let status = stockbit::session_status()?;
if format.is_json() {
return json::print_json(&status);
}
print_status(&status);
}
StockbitSubcommand::Logout => {
let (removed, status) = stockbit::logout()?;
if status.logged_in {
runtime::warn(
"IDX_STOCKBIT_REFRESH_TOKEN or IDX_STOCKBIT_ACCESS_TOKEN still provides a session",
);
}
if format.is_json() {
return json::print_json(&serde_json::json!({
"removed": removed,
"logged_in": status.logged_in,
"session_file": status.session_file,
}));
}
if removed {
println!("Removed the stored Stockbit session.");
} else {
println!("No Stockbit session was stored.");
}
}
}
Ok(())
}
/// The pasted session: one line from a terminal, else all of stdin.
fn read_paste() -> Result<String, IdxError> {
let stdin = std::io::stdin();
let mut raw = String::new();
if stdin.is_terminal() {
eprintln!(
"Paste the value of stockbit.com's `credentialStorage` cookie (DevTools > \
Application > Cookies) or a refresh token, then press Enter:"
);
stdin
.read_line(&mut raw)
.map_err(|e| IdxError::Io(e.to_string()))?;
} else {
stdin
.lock()
.read_to_string(&mut raw)
.map_err(|e| IdxError::Io(e.to_string()))?;
}
if raw.trim().is_empty() {
return Err(IdxError::InvalidInput(
"nothing pasted: pipe or paste the credentialStorage cookie value or a refresh token"
.to_string(),
));
}
Ok(raw)
}
fn print_status(status: &SessionStatus) {
println!(
"logged in: {}",
if status.logged_in { "yes" } else { "no" }
);
println!("access token: {}", token_line(&status.access));
println!("refresh token: {}", token_line(&status.refresh));
println!("session file: {}", terminal_safe(&status.session_file));
if !status.logged_in {
println!("Run `idx stockbit login` to add one.");
}
}
fn token_line(token: &TokenInfo) -> String {
match (token.present, token.expires_at, token.expires_in_secs) {
(false, _, _) => "none".to_string(),
(true, Some(at), Some(secs)) if secs > 0 => format!(
"expires {} UTC (in {}h {}m)",
at.format("%Y-%m-%d %H:%M"),
secs / 3600,
secs % 3600 / 60
),
(true, Some(at), _) => format!("expired {} UTC", at.format("%Y-%m-%d %H:%M")),
(true, None, _) => "present (expiry unknown)".to_string(),
}
}

View file

@ -6,8 +6,8 @@ use crate::config::{HistoryProviderKind, IdxConfig};
use crate::output::{
render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
render_foreign_flow, render_fundamental, render_growth, render_history, render_insights,
render_intraday, render_news, render_profile, render_quotes, render_risk, render_screener,
render_sentiment, render_technical, render_valuation,
render_intraday, render_news, render_orderbook, render_profile, render_quotes, render_risk,
render_screener, render_sentiment, render_technical, render_valuation,
};
use idx_core::analysis::BandarQuery;
use idx_core::analysis::bandar::DEFAULT_TOP;
@ -23,6 +23,8 @@ use idx_core::{CachePolicy, Idx, runtime};
/// separated by whitespace or commas.
/// Most symbols one `bandar` screen fetches, one request each.
const MAX_BANDAR_SYMBOLS: usize = 100;
/// Most price levels shown per side of an order book.
const MAX_BOOK_DEPTH: usize = 50;
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
let mut symbols = Vec::new();
@ -146,6 +148,21 @@ Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45).",
/// Single ticker symbol (e.g. BBCA).
symbol: String,
},
#[command(
about = "Order book depth and foreign flow (Stockbit login)",
long_about = "The order book of one IDX stock from your own Stockbit session (`idx stockbit \
login`): bid and offer levels with lots and queued orders, the session's OHLC, volume, value \
and foreign buy, sell and net. Outside trading it is the book as the market left it, and \
the output says so.",
after_help = "Examples:\n idx stocks orderbook BBCA\n idx stocks orderbook BBRI --depth 5\n idx -o json stocks orderbook TLKM | jq '.offers[0]'"
)]
Orderbook {
/// Single ticker symbol (e.g. BBCA).
symbol: String,
/// Price levels per side (1-50).
#[arg(long, default_value_t = 10)]
depth: usize,
},
#[command(
about = "Get historical OHLC data",
after_help = "Examples:\n idx stocks history BBCA --period 3mo\n idx stocks history BBCA --period 1y --interval 1wk"
@ -332,13 +349,26 @@ pub fn handle(
let day = idx.market().intraday(symbol, policy)?;
render_intraday(&day.data, format, no_color)
}
StocksSubcommand::Orderbook { symbol, depth } => {
if !(1..=MAX_BOOK_DEPTH).contains(depth) {
return Err(IdxError::InvalidInput(format!(
"--depth must be 1 to {MAX_BOOK_DEPTH} (got {depth})"
)));
}
let mut book = idx.market().orderbook(symbol, policy)?.data;
book.bids.truncate(*depth);
book.offers.truncate(*depth);
render_orderbook(&book, format, no_color)
}
StocksSubcommand::History {
symbol,
period,
interval,
history_provider,
} => {
let source = idx.market().history_source(*history_provider);
let source = idx
.market()
.history_source(*history_provider, period, interval);
announce_history_fallback(&idx, source, config);
let history = idx
.market()
@ -350,7 +380,7 @@ pub fn handle(
symbol,
history_provider,
} => {
let source = idx.market().history_source(*history_provider);
let source = idx.analysis().technical_source(*history_provider);
announce_history_fallback(&idx, source, config);
let report = idx.analysis().technical(symbol, source, policy)?;
render_technical(&report.data, format, no_color)

View file

@ -73,6 +73,12 @@ fn run() -> Result<(), IdxError> {
return Err(err);
}
}
Commands::Stockbit(cmd) => {
if let Err(err) = cli::stockbit::handle(cmd, &config.output) {
emit_error(&err, &config.output);
return Err(err);
}
}
#[cfg(feature = "ownership")]
Commands::Ownership(cmd) => {
if let Err(err) = cli::ownership::handle(&cmd.command, &config) {

View file

@ -15,7 +15,7 @@ use idx_core::analysis::fundamental::{
use idx_core::analysis::report::TechnicalReport;
use idx_core::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc,
Quote, SentimentData,
OrderBook, Quote, SentimentData,
};
use idx_core::broker::types::BrokerSummary;
use idx_core::config::ProviderKind;
@ -92,6 +92,17 @@ pub fn render_intraday(
}
}
pub fn render_orderbook(
book: &OrderBook,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_orderbook(book, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(book),
}
}
pub fn render_broker_summary(
summary: &BrokerSummary,
format: &OutputFormat,

View file

@ -11,8 +11,8 @@ use idx_core::analysis::fundamental::{
use idx_core::analysis::report::TechnicalReport;
use idx_core::analysis::signals::Signal;
use idx_core::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, Intraday,
NewsItem, Ohlc, Quote, SentimentData,
BookLevel, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData,
Intraday, NewsItem, Ohlc, OrderBook, Quote, SentimentData,
};
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
use idx_core::error::IdxError;
@ -128,6 +128,13 @@ fn pct_cell(change_pct: Option<f64>, no_color: bool) -> Cell {
/// Printed under every Stockbit table: what the data is and is not.
const STOCKBIT_SOURCE_NOTE: &str = "Source: Stockbit's public page, delayed at least 10 \
minutes (Stockbit's terms); bid/offer only while the market is open.";
/// Under `--provider stockbit` quotes, which may come from either source.
const STOCKBIT_QUOTE_NOTE: &str = "Source: Stockbit, your session when logged in (`idx \
stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); \
bid/offer only while the market is open.";
/// Under the order book, which comes from the user's own session.
const STOCKBIT_SESSION_NOTE: &str =
"Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).";
fn opt_idr(value: Option<i64>) -> String {
value.map(format_idr).unwrap_or_else(|| "-".to_string())
@ -160,7 +167,12 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx
cell(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())),
cell(opt_idr(q.bid)),
cell(opt_idr(q.offer)),
cell(q.market_status.as_deref().unwrap_or("-")),
cell(
q.market_status
.as_deref()
.map(market_words)
.unwrap_or_else(|| "-".to_string()),
),
cell(q.as_of.map(wib_minute).unwrap_or_else(|| "-".to_string())),
]);
}
@ -177,7 +189,7 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx
for note in stale_quote_notes(quotes, chrono::Utc::now()) {
println!("{}", terminal_safe(&note));
}
println!("{STOCKBIT_SOURCE_NOTE}");
println!("{STOCKBIT_QUOTE_NOTE}");
Ok(())
}
@ -259,6 +271,105 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> {
Ok(())
}
/// Stockbit's market state for people: `STATE_NAME_MARKET_CLOSED` reads
/// `closed`.
fn market_words(state: &str) -> String {
state
.strip_prefix("STATE_NAME_MARKET_")
.unwrap_or(state)
.replace('_', " ")
.to_lowercase()
}
pub fn print_orderbook(book: &OrderBook, no_color: bool) -> Result<(), IdxError> {
let change = match (book.change, book.change_pct) {
(Some(c), Some(p)) => format!(" {c:+} ({p:+.2}%)"),
(Some(c), None) => format!(" {c:+}"),
_ => String::new(),
};
let change = match book.change {
_ if no_color || change.is_empty() => change,
Some(c) if c >= 0 => change.green(),
_ => change.red(),
};
let name = book
.name
.as_deref()
.map(|n| format!(" {}", terminal_safe(n)))
.unwrap_or_default();
println!(
"{}{change}",
format!(
"{}{name} {}",
terminal_safe(&book.symbol),
format_idr(book.price)
)
.bold()
);
let count = |v: Option<u64>| v.map(format_u64).unwrap_or_else(|| "-".to_string());
println!(
"prev close {} · open {} · high {} · low {} · volume {} · value {} · {} trades",
opt_idr(book.prev_close),
opt_idr(book.open),
opt_idr(book.high),
opt_idr(book.low),
count(book.volume),
count(book.value),
count(book.frequency)
);
if book.foreign_buy.is_some() || book.foreign_sell.is_some() {
println!(
"foreign buy {} · sell {} · net {}",
opt_idr(book.foreign_buy),
opt_idr(book.foreign_sell),
opt_idr(book.foreign_net)
);
}
match book.market_status.as_deref() {
Some(idx_core::api::stockbit::exodus::MARKET_OPEN) => println!("market open"),
Some(state) => println!(
"market {}: the book as the market left it",
terminal_safe(&market_words(state))
),
None => println!("market state unknown: the book may not be live"),
}
if let Some(note) = &book.session_note {
println!("Note: {}", terminal_safe(note));
}
if book.bids.is_empty() && book.offers.is_empty() {
println!("No bids or offers.");
} else {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["ORDERS", "LOTS", "BID", "OFFER", "LOTS", "ORDERS"]);
let side = |level: Option<&BookLevel>| match level {
Some(l) => [
format_u64(l.orders),
format_u64(l.shares / 100),
format_idr(l.price),
],
None => ["".to_string(), "".to_string(), "".to_string()],
};
for i in 0..book.bids.len().max(book.offers.len()) {
let [bid_orders, bid_lots, bid] = side(book.bids.get(i));
let [offer_orders, offer_lots, offer] = side(book.offers.get(i));
table.add_row(vec![
cell(bid_orders),
cell(bid_lots),
cell(bid),
cell(offer),
cell(offer_lots),
cell(offer_orders),
]);
}
println!("{table}");
}
println!("{STOCKBIT_SESSION_NOTE}");
Ok(())
}
/// Whole Rupiah for stocks, two decimals for an index level.
fn format_price(price: f64) -> String {
let cents = (price * 100.0).round() as i64;

View file

@ -2137,6 +2137,66 @@ fn provider_flag_overrides_env() {
assert_eq!(quotes[0]["market_cap"], Value::Null);
}
#[test]
fn stockbit_login_stores_a_private_session_and_logout_removes_it() {
let root = test_env_dir("stockbit-login");
let cookie = fs::read_to_string(fixture_path(
"stockbit_exodus_credential_storage_cookie_constructed.txt",
))
.expect("cookie fixture");
let session = root.join("config/idx/stockbit-session.json");
let status = run_success_json(
bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stockbit", "login"])
.write_stdin(cookie.clone()),
);
assert_eq!(status["logged_in"], true);
assert_eq!(status["refresh"]["present"], true);
let stored = fs::read_to_string(&session).expect("session file");
assert!(stored.contains("\"refresh\""));
// Status shows expiries, never a token.
assert!(!serde_json::to_string(&status).unwrap().contains("eyJ"));
#[cfg(unix)]
assert_eq!(
fs::metadata(&session).unwrap().permissions().mode() & 0o777,
0o600
);
let book = run_success_json(
bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stocks", "orderbook", "BBCA"]),
);
assert_eq!(book["offers"][0]["shares"], 264_462_500);
let out = run_success_json(
bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stockbit", "logout"]),
);
assert_eq!(out["removed"], true);
assert!(!session.exists());
run_error_json(
bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
// The book fetched above is still cached for a few seconds.
.args(["-o", "json", "--no-cache", "stocks", "orderbook", "BBCA"]),
"AUTHERROR",
);
// Anything that is not a session is refused and stores nothing.
run_error_json(
bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stockbit", "login"])
.write_stdin("not a token"),
"INVALIDINPUT",
);
assert!(!session.exists());
}
#[test]
fn invalid_quote_ttl_env_returns_non_zero() {
test_bin("invalid-quote-ttl")

View file

@ -263,6 +263,28 @@ args = ["stocks", "intraday", "MABA"]
name = "stockbit-quote"
args = ["--provider", "stockbit", "stocks", "quote", "BBCA,MABA"]
# With a session: IDX_STOCKBIT_REFRESH_TOKEN seeds an unsigned test token that
# expires in 2100; the mock serves the recorded exodus responses.
[[case]]
name = "stockbit-session-orderbook"
args = ["stocks", "orderbook", "BBCA", "--depth", "3"]
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
[[case]]
name = "stockbit-session-quote"
args = ["--provider", "stockbit", "stocks", "quote", "BBCA"]
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
[[case]]
name = "stockbit-session-history"
args = ["--provider", "stockbit", "stocks", "history", "BBCA", "--period", "1mo"]
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
[[case]]
name = "stockbit-status-logged-out"
args = ["stockbit", "status"]
# --- stocks: broker summaries (IPOT) and foreign flow (IDX) --------------------
[[case]]
@ -366,6 +388,15 @@ args = ["stocks", "intraday", "ZZZZ"]
name = "error-stockbit-fundamental-unsupported"
args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"]
[[case]]
name = "error-orderbook-no-session"
args = ["stocks", "orderbook", "BBCA"]
[[case]]
name = "error-stockbit-history-window"
args = ["stocks", "history", "BBCA", "--period", "5y", "--history-provider", "stockbit"]
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
# --- ownership --------------------------------------------------------------------
[[case]]

View file

@ -33,6 +33,9 @@ _idx() {
idx,ownership)
cmd="idx__ownership"
;;
idx,stockbit)
cmd="idx__stockbit"
;;
idx,stocks)
cmd="idx__stocks"
;;
@ -102,6 +105,9 @@ _idx() {
idx__help,ownership)
cmd="idx__help__ownership"
;;
idx__help,stockbit)
cmd="idx__help__stockbit"
;;
idx__help,stocks)
cmd="idx__help__stocks"
;;
@ -174,6 +180,15 @@ _idx() {
idx__help__ownership__resolve,unresolved)
cmd="idx__help__ownership__resolve__unresolved"
;;
idx__help__stockbit,login)
cmd="idx__help__stockbit__login"
;;
idx__help__stockbit,logout)
cmd="idx__help__stockbit__logout"
;;
idx__help__stockbit,status)
cmd="idx__help__stockbit__status"
;;
idx__help__stocks,bandar)
cmd="idx__help__stocks__bandar"
;;
@ -210,6 +225,9 @@ _idx() {
idx__help__stocks,news)
cmd="idx__help__stocks__news"
;;
idx__help__stocks,orderbook)
cmd="idx__help__stocks__orderbook"
;;
idx__help__stocks,profile)
cmd="idx__help__stocks__profile"
;;
@ -348,6 +366,30 @@ _idx() {
idx__ownership__resolve__help,unresolved)
cmd="idx__ownership__resolve__help__unresolved"
;;
idx__stockbit,help)
cmd="idx__stockbit__help"
;;
idx__stockbit,login)
cmd="idx__stockbit__login"
;;
idx__stockbit,logout)
cmd="idx__stockbit__logout"
;;
idx__stockbit,status)
cmd="idx__stockbit__status"
;;
idx__stockbit__help,help)
cmd="idx__stockbit__help__help"
;;
idx__stockbit__help,login)
cmd="idx__stockbit__help__login"
;;
idx__stockbit__help,logout)
cmd="idx__stockbit__help__logout"
;;
idx__stockbit__help,status)
cmd="idx__stockbit__help__status"
;;
idx__stocks,bandar)
cmd="idx__stocks__bandar"
;;
@ -387,6 +429,9 @@ _idx() {
idx__stocks,news)
cmd="idx__stocks__news"
;;
idx__stocks,orderbook)
cmd="idx__stocks__orderbook"
;;
idx__stocks,profile)
cmd="idx__stocks__profile"
;;
@ -447,6 +492,9 @@ _idx() {
idx__stocks__help,news)
cmd="idx__stocks__help__news"
;;
idx__stocks__help,orderbook)
cmd="idx__stocks__help__orderbook"
;;
idx__stocks__help,profile)
cmd="idx__stocks__help__profile"
;;
@ -475,7 +523,7 @@ _idx() {
case "${cmd}" in
idx)
opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership completions version help"
opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership stockbit completions version help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -875,7 +923,7 @@ _idx() {
return 0
;;
idx__help)
opts="stocks config cache ownership completions version help"
opts="stocks config cache ownership stockbit completions version help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -1266,8 +1314,64 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__help__stockbit)
opts="login status logout"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__help__stockbit__login)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__help__stockbit__logout)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__help__stockbit__status)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__help__stocks)
opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare"
opts="quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -1448,6 +1552,20 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__help__stocks__orderbook)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__help__stocks__profile)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
@ -2392,8 +2510,182 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stockbit)
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help login status logout help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--output)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
-o)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stockbit__help)
opts="login status logout help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stockbit__help__help)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stockbit__help__login)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stockbit__help__logout)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stockbit__help__status)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stockbit__login)
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--output)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
-o)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stockbit__logout)
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--output)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
-o)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stockbit__status)
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--output)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
-o)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stocks)
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2691,7 +2983,7 @@ _idx() {
return 0
;;
idx__stocks__help)
opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
opts="quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2886,6 +3178,20 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stocks__help__orderbook)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stocks__help__profile)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
@ -3000,7 +3306,7 @@ _idx() {
return 0
;;
--history-provider)
COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}"))
COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}"))
return 0
;;
--output)
@ -3104,6 +3410,36 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stocks__orderbook)
opts="-o -q -v -h --depth --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--depth)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--output)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
-o)
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stocks__profile)
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
@ -3254,7 +3590,7 @@ _idx() {
fi
case "${prev}" in
--history-provider)
COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}"))
COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}"))
return 0
;;
--output)

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
{"code":"AUTHERROR","error":true,"message":"auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)"}

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
{"code":"UNSUPPORTED","error":true,"message":"unsupported: Stockbit history covers 5d to 1y of daily candles, not 5y at 1d; use --history-provider yahoo"}

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
Error: unsupported: Stockbit history covers 5d to 1y of daily candles, not 5y at 1d; use --history-provider yahoo

View file

@ -10,6 +10,7 @@ Commands:
config Manage configuration
cache Manage local cache
ownership Ownership intelligence (bootstrap with `ownership sync`)
stockbit Your Stockbit session (opt-in: order book, live quotes)
completions Generate shell completions
version Show idx-cli version
help Print this message or the help of the given subcommand(s)

View file

@ -7,6 +7,7 @@ Usage: idx stocks [OPTIONS] <COMMAND>
Commands:
quote Get real-time stock quotes
intraday Today's session price by the minute (Stockbit)
orderbook Order book depth and foreign flow (Stockbit login)
history Get historical OHLC data
technical Run technical analysis on a stock
growth Run growth analysis on a stock

View file

@ -8,5 +8,5 @@ exit: 0
│ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │
└─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘
Note: MABA.JK: Stockbit lists the stock as suspended
Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"date":"2026-09-17","open":6400,"high":6400,"low":6325,"close":6350,"volume":61812000},{"date":"2026-09-18","open":6325,"high":6350,"low":6225,"close":6300,"volume":176753700},{"date":"2026-09-21","open":6300,"high":6300,"low":6225,"close":6225,"volume":69914100},{"date":"2026-09-22","open":6225,"high":6250,"low":6175,"close":6200,"volume":93211200},{"date":"2026-09-23","open":6200,"high":6300,"low":6200,"close":6300,"volume":96826100},{"date":"2026-09-24","open":6250,"high":6275,"low":6225,"close":6225,"volume":75648600},{"date":"2026-09-25","open":6225,"high":6275,"low":6200,"close":6250,"volume":89447400},{"date":"2026-09-28","open":6225,"high":6250,"low":6175,"close":6225,"volume":112107600},{"date":"2026-09-29","open":6150,"high":6225,"low":6125,"close":6150,"volume":149783400},{"date":"2026-09-30","open":6100,"high":6150,"low":6050,"close":6075,"volume":221335700},{"date":"2026-10-01","open":6050,"high":6150,"low":6000,"close":6000,"volume":118044900},{"date":"2026-10-02","open":6000,"high":6150,"low":5975,"close":6100,"volume":131920300},{"date":"2026-10-05","open":6100,"high":6175,"low":6075,"close":6175,"volume":89905700},{"date":"2026-10-06","open":6175,"high":6200,"low":6100,"close":6100,"volume":152173400},{"date":"2026-10-07","open":6075,"high":6100,"low":5975,"close":6050,"volume":162690800},{"date":"2026-10-08","open":6100,"high":6150,"low":6000,"close":6000,"volume":113140300},{"date":"2026-10-09","open":6025,"high":6075,"low":5975,"close":6050,"volume":87098600},{"date":"2026-10-12","open":6050,"high":6100,"low":5950,"close":5950,"volume":97965100},{"date":"2026-10-13","open":5925,"high":5950,"low":5850,"close":5950,"volume":122716900},{"date":"2026-10-14","open":6000,"high":6125,"low":5950,"close":6125,"volume":136303100},{"date":"2026-10-15","open":6075,"high":6125,"low":6025,"close":6075,"volume":134763200},{"date":"2026-10-16","open":5825,"high":5825,"low":5175,"close":5175,"volume":1241098200}]
--- stderr

View file

@ -0,0 +1,51 @@
exit: 0
--- stdout
History for BBCA.JK
┌────────────┬───────┬───────┬───────┬───────┬───────────────┐
│ DATE ┆ OPEN ┆ HIGH ┆ LOW ┆ CLOSE ┆ VOLUME │
╞════════════╪═══════╪═══════╪═══════╪═══════╪═══════════════╡
│ 2026-09-17 ┆ 6,400 ┆ 6,400 ┆ 6,325 ┆ 6,350 ┆ 61,812,000 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-18 ┆ 6,325 ┆ 6,350 ┆ 6,225 ┆ 6,300 ┆ 176,753,700 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-21 ┆ 6,300 ┆ 6,300 ┆ 6,225 ┆ 6,225 ┆ 69,914,100 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-22 ┆ 6,225 ┆ 6,250 ┆ 6,175 ┆ 6,200 ┆ 93,211,200 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-23 ┆ 6,200 ┆ 6,300 ┆ 6,200 ┆ 6,300 ┆ 96,826,100 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-24 ┆ 6,250 ┆ 6,275 ┆ 6,225 ┆ 6,225 ┆ 75,648,600 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-25 ┆ 6,225 ┆ 6,275 ┆ 6,200 ┆ 6,250 ┆ 89,447,400 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-28 ┆ 6,225 ┆ 6,250 ┆ 6,175 ┆ 6,225 ┆ 112,107,600 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-29 ┆ 6,150 ┆ 6,225 ┆ 6,125 ┆ 6,150 ┆ 149,783,400 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-09-30 ┆ 6,100 ┆ 6,150 ┆ 6,050 ┆ 6,075 ┆ 221,335,700 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-01 ┆ 6,050 ┆ 6,150 ┆ 6,000 ┆ 6,000 ┆ 118,044,900 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-02 ┆ 6,000 ┆ 6,150 ┆ 5,975 ┆ 6,100 ┆ 131,920,300 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-05 ┆ 6,100 ┆ 6,175 ┆ 6,075 ┆ 6,175 ┆ 89,905,700 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-06 ┆ 6,175 ┆ 6,200 ┆ 6,100 ┆ 6,100 ┆ 152,173,400 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-07 ┆ 6,075 ┆ 6,100 ┆ 5,975 ┆ 6,050 ┆ 162,690,800 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-08 ┆ 6,100 ┆ 6,150 ┆ 6,000 ┆ 6,000 ┆ 113,140,300 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-09 ┆ 6,025 ┆ 6,075 ┆ 5,975 ┆ 6,050 ┆ 87,098,600 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-12 ┆ 6,050 ┆ 6,100 ┆ 5,950 ┆ 5,950 ┆ 97,965,100 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-13 ┆ 5,925 ┆ 5,950 ┆ 5,850 ┆ 5,950 ┆ 122,716,900 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-14 ┆ 6,000 ┆ 6,125 ┆ 5,950 ┆ 6,125 ┆ 136,303,100 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-15 ┆ 6,075 ┆ 6,125 ┆ 6,025 ┆ 6,075 ┆ 134,763,200 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-16 ┆ 5,825 ┆ 5,825 ┆ 5,175 ┆ 5,175 ┆ 1,241,098,200 │
└────────────┴───────┴───────┴───────┴───────┴───────────────┘
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"symbol":"BBCA.JK","name":"Bank Central Asia Tbk.","price":5175,"change":-900,"change_pct":-14.81,"prev_close":6075,"open":5825,"high":5825,"low":5175,"volume":1241098200,"value":6474934315000,"frequency":185072,"foreign_buy":1616461977500,"foreign_sell":3945955065000,"foreign_net":-2329493087500,"bids":[],"offers":[{"price":5175,"shares":264462500,"orders":3284},{"price":5200,"shares":10053400,"orders":337},{"price":5225,"shares":3711400,"orders":396}],"market_status":"STATE_NAME_MARKET_CLOSED","session_note":null}
--- stderr

View file

@ -0,0 +1,17 @@
exit: 0
--- stdout
BBCA.JK Bank Central Asia Tbk. 5,175 -900 (-14.81%)
prev close 6,075 · open 5,825 · high 5,825 · low 5,175 · volume 1,241,098,200 · value 6,474,934,315,000 · 185,072 trades
foreign buy 1,616,461,977,500 · sell 3,945,955,065,000 · net -2,329,493,087,500
market closed: the book as the market left it
┌────────┬──────┬─────┬───────┬───────────┬────────┐
│ ORDERS ┆ LOTS ┆ BID ┆ OFFER ┆ LOTS ┆ ORDERS │
╞════════╪══════╪═════╪═══════╪═══════════╪════════╡
│ ┆ ┆ ┆ 5,175 ┆ 2,644,625 ┆ 3,284 │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ ┆ ┆ ┆ 5,200 ┆ 100,534 ┆ 337 │
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ ┆ ┆ ┆ 5,225 ┆ 37,114 ┆ 396 │
└────────┴──────┴─────┴───────┴───────────┴────────┘
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"symbol":"BBCA.JK","price":5175,"change":-900,"change_pct":-14.81,"volume":1241098200,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":6075,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"STATE_NAME_MARKET_CLOSED"}]
--- stderr

View file

@ -0,0 +1,9 @@
exit: 0
--- stdout
┌─────────┬───────┬──────┬─────────┬───────────────┬─────┬───────┬────────┬───────┐
│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ BID ┆ OFFER ┆ MARKET ┆ AS OF │
╞═════════╪═══════╪══════╪═════════╪═══════════════╪═════╪═══════╪════════╪═══════╡
│ BBCA.JK ┆ 5,175 ┆ -900 ┆ -14.81% ┆ 1,241,098,200 ┆ - ┆ - ┆ closed ┆ - │
└─────────┴───────┴──────┴─────────┴───────────────┴─────┴───────┴────────┴───────┘
Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
--- stderr

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
{"logged_in":false,"access":{"present":false,"expires_at":null,"expires_in_secs":null},"refresh":{"present":false,"expires_at":null,"expires_in_secs":null},"session_file":"<ROOT>/config/idx/stockbit-session.json"}
--- stderr

View file

@ -0,0 +1,8 @@
exit: 0
--- stdout
logged in: no
access token: none
refresh token: none
session file: <ROOT>/config/idx/stockbit-session.json
Run `idx stockbit login` to add one.
--- stderr

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(
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(
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,
},
}
}
/// 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);
if mock_enabled()
&& let Some(mock) = mock_history(resolved, verbose)
HistoryProviderKind::Stockbit => ProviderKind::Stockbit,
HistoryProviderKind::Auto => {
if provider == ProviderKind::Stockbit
&& stockbit_session
&& stockbit::exodus::history_window(period, interval).is_some()
{
return Ok((resolved, mock));
ProviderKind::Stockbit
} else {
ProviderKind::Yahoo
}
}
}
}
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(),
)),
/// 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(source, verbose)
{
return mock;
}
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"}

View file

@ -38,6 +38,15 @@ pub struct IntradayParams {
pub tail: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct OrderBookParams {
/// IDX ticker, e.g. "BBCA".
pub symbol: String,
/// Price levels per side (default 10, at most 50).
#[serde(default)]
pub depth: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ScreenParams {
/// Screen to run (default "top-performers").

View file

@ -26,8 +26,8 @@ use tokio::sync::Semaphore;
use crate::params::{
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, ScreenParams,
SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, OrderBookParams,
ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
};
const SYNC_TOOL: &str = "ownership_sync";
@ -45,6 +45,8 @@ const MAX_CHANGES_LIMIT: usize = 300;
const CHANGES_TOP_TICKERS: usize = 10;
// A session has about 340 minutes, so this is the whole day.
const MAX_INTRADAY_POINTS: usize = 400;
const BOOK_DEPTH: usize = 10;
const MAX_BOOK_DEPTH: usize = 50;
/// Tool calls running at once; the rest wait their turn.
static CALL_SLOTS: Semaphore = Semaphore::const_new(4);
@ -158,6 +160,34 @@ per-minute volume. Empty `points` means the stock did not trade this session (se
.await
}
#[tool(
description = "Order book of one IDX stock from the user's own Stockbit session: \
`bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, \
volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is \
Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left \
it. AUTHERROR means there is no session: call stockbit_status, and ask the user to run \
`idx stockbit login` in a terminal."
)]
async fn get_orderbook(&self, params: Parameters<OrderBookParams>) -> ToolResult {
let OrderBookParams { symbol, depth } = params.0;
self.live(move |idx| {
let depth = bounded_limit("depth", depth, BOOK_DEPTH, MAX_BOOK_DEPTH)?;
let mut book = idx.market().orderbook(&symbol, CachePolicy::Default)?;
book.data.bids.truncate(depth);
book.data.offers.truncate(depth);
Ok(book)
})
.await
}
#[tool(description = "Whether a Stockbit session is stored (or seeded by \
IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, \
get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the \
user adds one with `idx stockbit login` in a terminal.")]
async fn stockbit_status(&self) -> ToolResult {
blocking(idx_core::api::stockbit::session_status).await
}
#[tool(description = "OHLCV price history for one IDX ticker.")]
async fn get_price_history(&self, params: Parameters<HistoryParams>) -> ToolResult {
let HistoryParams {
@ -168,7 +198,7 @@ per-minute volume. Empty `points` means the stock did not trade this session (se
self.live(move |idx| {
let period = period.as_deref().unwrap_or("3mo").parse()?;
let interval = interval.as_deref().unwrap_or("1d").parse()?;
let source = idx.market().history_source(None);
let source = idx.market().history_source(None, &period, &interval);
idx.market()
.history(&symbol, &period, &interval, source, CachePolicy::Default)
})
@ -365,7 +395,7 @@ are listed under `errors`; the call fails only if every analysis fails."
let name = kind.as_str();
match kind {
AnalysisKind::Technical => {
let source = idx.market().history_source(None);
let source = analysis.technical_source(None);
report.add(name, analysis.technical(&symbol, source, policy))
}
AnalysisKind::Growth => report.add(name, analysis.growth(&symbol, policy)),

View file

@ -331,6 +331,33 @@
},
"name": "get_investor_holdings"
},
{
"description": "Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, and ask the user to run `idx stockbit login` in a terminal.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"depth": {
"default": null,
"description": "Price levels per side (default 10, at most 50).",
"format": "uint",
"minimum": 0,
"type": [
"integer",
"null"
]
},
"symbol": {
"description": "IDX ticker, e.g. \"BBCA\".",
"type": "string"
}
},
"required": [
"symbol"
],
"type": "object"
},
"name": "get_orderbook"
},
{
"description": "Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last.",
"inputSchema": {
@ -556,5 +583,13 @@
"type": "object"
},
"name": "search_investors"
},
{
"description": "Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with `idx stockbit login` in a terminal.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "stockbit_status"
}
]

View file

@ -9,7 +9,7 @@ use idx_core::config::CoreConfig;
use idx_core::investor::{ImportSource, Investor};
use serde_json::{Value, json};
const TOOLS: [&str; 15] = [
const TOOLS: [&str; 17] = [
"analyze_bandar",
"analyze_stock",
"compare_stocks",
@ -18,6 +18,7 @@ const TOOLS: [&str; 15] = [
"get_foreign_flow",
"get_intraday",
"get_investor_holdings",
"get_orderbook",
"get_ownership_changes",
"get_price_history",
"get_quotes",
@ -25,8 +26,15 @@ const TOOLS: [&str; 15] = [
"ownership_status",
"screen_stocks",
"search_investors",
"stockbit_status",
];
/// An unsigned test token expiring in 2100, as a seeded Stockbit session.
const TEST_SESSION: (&str, &str) = (
"IDX_STOCKBIT_REFRESH_TOKEN",
"eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln",
);
struct Client {
child: Child,
stdin: ChildStdin,
@ -362,6 +370,39 @@ fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() {
);
}
#[test]
fn the_order_book_needs_a_session_and_status_never_shows_tokens() {
let mut client = Client::start(&[], false);
let status = client.ok("stockbit_status", json!({}));
assert_eq!(status["logged_in"], false);
let (is_error, body) = client.call("get_orderbook", json!({"symbol": "BBCA"}));
assert!(is_error, "{body}");
assert_eq!(body["code"], "AUTHERROR");
let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]);
let status = client.ok("stockbit_status", json!({}));
assert_eq!(status["logged_in"], true);
assert!(!status.to_string().contains(TEST_SESSION.1));
let book = client.ok("get_orderbook", json!({"symbol": "BBCA", "depth": 2}));
assert_fetched(&book);
assert_eq!(book["source"], "stockbit");
let offers = book["data"]["offers"].as_array().unwrap();
assert_eq!(offers.len(), 2);
assert_eq!(
offers[0],
json!({"price": 5175, "shares": 264_462_500, "orders": 3284})
);
assert_eq!(book["data"]["foreign_net"], -2_329_493_087_500_i64);
assert_eq!(book["data"]["market_status"], "STATE_NAME_MARKET_CLOSED");
for depth in [0, 51] {
let (is_error, body) =
client.call("get_orderbook", json!({"symbol": "BBCA", "depth": depth}));
assert!(is_error, "{body}");
assert_eq!(body["code"], "INVALIDINPUT");
}
}
#[test]
fn errors_carry_cli_codes() {
let mut client = Client::start(&[], false);

View file

@ -164,8 +164,8 @@ WIB as the fallback.
`chart.prices[]` holds one price per minute in WIB with no offset, after a
synthetic first row at the previous close; per-minute OHLC and volume are
empty. An unknown code is HTTP 404 with the 404 page.
- Stockbit's JSON API (`exodus.stockbit.com`: chart, order book) answers 401
without a user's bearer token; it is not used.
- Stockbit's JSON API (`exodus.stockbit.com`) answers 401 without a user's
bearer token; it is used only with the user's own session (below).
- Bid and offer are kept only while `market_hour.status` is `open`, so a
closing book never reads as a live one. Observed 2026-10-23: `open` during
trading (bid/offer set), `break` from 11:30 on a Friday, `close` after
@ -189,6 +189,35 @@ WIB as the fallback.
personal use; frontends say so. Fixtures (`tests/fixtures/stockbit_*`) are
pages recorded 2026-10-22 after the close, cut to the fields read.
### Stockbit session (opt-in login)
- `idx stockbit login` stores the user's session from a pasted
`credentialStorage` cookie (or a bare refresh token) in
`<config dir>/stockbit-session.json`, written atomically with mode 0600;
`IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` fill fields the
file lacks. `api/stockbit/auth.rs` reads expiries from the JWTs (`exp`,
unverified) and refreshes an access token within an hour of expiry with
`POST /login/refresh` (bearer = refresh token), which rotates both tokens.
Because a refresh token is spent once, refreshes run under an exclusive
lock on `stockbit-session.lock` and re-read the file first, so concurrent
idx processes never spend the same token. A data request answered 401 (or
a JSON 403) refreshes once and retries once; a second refusal is
`AUTHERROR`. Tokens never reach output, errors, logs or cache keys
(`Session`'s `Debug` redacts them).
- `api/stockbit/exodus.rs` (endpoints and units mapped by fzn0x/idx, MIT;
its recordings are `tests/fixtures/stockbit_exodus_*`):
`/company-price-feed/v2/orderbook/companies/{CODE}` gives the depth ladder
(`volume` in shares, `que_num` orders), OHLC, value, frequency and foreign
buy/sell/net; `/company-price-feed/market-time/session` the market state
(`STATE_NAME_MARKET_CLOSED`, cached 30 s per process); `/charts/{CODE}/daily`
daily candles for `1m`/`3m`/`1y` windows (volume in lots, returned in
shares). Requests share the public page's cross-process pacing.
- With a session, `--provider stockbit` quotes come from the order book (bid
and offer only in `STATE_NAME_MARKET_OPEN`, a name assumed until observed
in session), `stocks orderbook` works, and `auto` history under the stockbit
provider uses Stockbit's daily windows (`5d` to `1y`, trimmed from `1m`,
`3m` or `1y`), Yahoo otherwise. Without one, quotes use the public page and
the order book is `AUTHERROR`.
### Capability Gating
```
MarketDataProvider = QuoteProvider + FundamentalsProvider
@ -196,15 +225,17 @@ HistoryProvider = separate trait, not all providers implement
Factory functions:
default_provider(kind) → Box<dyn MarketDataProvider>
history_provider(kind, mode, verbose) → Result<(ProviderKind, Box<dyn HistoryProvider>)>
history_provider(source, verbose) → Box<dyn HistoryProvider> (source from history_source)
broker_provider() → Box<dyn BrokerSummaryProvider>
trading_summary_provider() → Box<dyn TradingSummaryProvider>
intraday_provider() → Box<dyn IntradayProvider>
orderbook_provider() → Box<dyn OrderBookProvider>
```
`ProviderKind::Stockbit` implements quotes only: fundamentals are
`UNSUPPORTED`, the MSN-only capabilities say "requires --provider msn", and
`auto` history falls back to Yahoo.
`ProviderKind::Stockbit` serves quotes (and, with a session, daily history):
fundamentals are `UNSUPPORTED`, the MSN-only capabilities say "requires
--provider msn", and `auto` history uses Yahoo unless a session covers the
window.
## Ownership Module (SQLite-backed)

View file

@ -60,7 +60,12 @@ scripts/live-smoke.sh --dry-run
whose MSN key ratios contain non-finite values.
- `stockbit`: `intraday` for the ticker and `^JKSE`, and
`--provider stockbit stocks quote`, on the new binary only. A run outside the
session sees `market_status: close` and no bid/offer.
session sees `market_status: close` and no bid/offer. These run without a
Stockbit login: each side has its own config, and seeding it with a real
refresh token would rotate (and so retire) that token. Check the login
features by hand with your own session: `idx stockbit status`,
`idx stocks orderbook BBCA`, `idx --provider stockbit stocks quote BBCA` and
`idx --provider stockbit stocks history BBCA --period 1y`.
- `ownership`: `discover`, then imports the newest supported report into a temp
DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many
datacenter IPs, including GitHub-hosted runners; run it from a network IDX accepts.

View file

@ -288,7 +288,7 @@
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
sleep 2
} | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl
jq -se 'map(select(.id == 2))[0].result.tools | length == 15' out.jsonl
jq -se 'map(select(.id == 2))[0].result.tools | length == 17' out.jsonl
touch "$out"
'';
# The website's generated pages match the CLI help and the MCP

View file

@ -22,6 +22,7 @@ Commands:
config Manage configuration
cache Manage local cache
ownership Ownership intelligence (bootstrap with `ownership sync`)
stockbit Your Stockbit session (opt-in: order book, live quotes)
completions Generate shell completions
version Show idx-cli version
help Print this message or the help of the given subcommand(s)
@ -66,6 +67,7 @@ Usage: idx stocks [OPTIONS] <COMMAND>
Commands:
quote Get real-time stock quotes
intraday Today's session price by the minute (Stockbit)
orderbook Order book depth and foreign flow (Stockbit login)
history Get historical OHLC data
technical Run technical analysis on a stock
growth Run growth analysis on a stock
@ -170,6 +172,57 @@ Examples:
idx -o json stocks intraday BBCA | jq '.points[-1]'
```
### `idx stocks orderbook`
```text
The order book of one IDX stock from your own Stockbit session (`idx stockbit login`): bid and offer levels with lots and queued orders, the session's OHLC, volume, value and foreign buy, sell and net. Outside trading it is the book as the market left it, and the output says so.
Usage: idx stocks orderbook [OPTIONS] <SYMBOL>
Arguments:
<SYMBOL>
Single ticker symbol (e.g. BBCA)
Options:
--depth <DEPTH>
Price levels per side (1-50)
[default: 10]
-o, --output <OUTPUT>
Output format [default: auto = table on a terminal, json when piped]
[possible values: auto, table, json, jsonl]
--no-color
Disable colored output
-q, --quiet
Suppress non-essential output
-v, --verbose...
Increase verbosity (-v, -vv)
--provider <PROVIDER>
Market data provider [default: general.provider, else msn]
[possible values: yahoo, msn, stockbit]
--offline
Run without network requests
--no-cache
Bypass local cache
-h, --help
Print help (see a summary with '-h')
Examples:
idx stocks orderbook BBCA
idx stocks orderbook BBRI --depth 5
idx -o json stocks orderbook TLKM | jq '.offers[0]'
```
### `idx stocks history`
```text
@ -190,7 +243,7 @@ Options:
--no-color
Disable colored output
--history-provider <HISTORY_PROVIDER>
[possible values: auto, yahoo, msn]
[possible values: auto, yahoo, msn, stockbit]
-q, --quiet
Suppress non-essential output
-v, --verbose...
@ -221,7 +274,7 @@ Arguments:
Options:
--history-provider <HISTORY_PROVIDER>
[possible values: auto, yahoo, msn]
[possible values: auto, yahoo, msn, stockbit]
-o, --output <OUTPUT>
Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
--no-color
@ -1494,6 +1547,128 @@ Options:
-h, --help Print help
```
## `idx stockbit`
```text
Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, stored in the config directory with owner-only permissions, and never logs them. Stockbit rotates the refresh token on every refresh, so a browser sharing the session is logged out once idx refreshes: log in through a separate browser profile for idx. Stockbit's terms allow personal, non-commercial use.
Usage: idx stockbit [OPTIONS] <COMMAND>
Commands:
login Store a session pasted on stdin and check it with Stockbit
status Show whether a session is stored and when its tokens expire
logout Delete the stored session
help Print this message or the help of the given subcommand(s)
Options:
-o, --output <OUTPUT>
Output format [default: auto = table on a terminal, json when piped]
[possible values: auto, table, json, jsonl]
--no-color
Disable colored output
-q, --quiet
Suppress non-essential output
-v, --verbose...
Increase verbosity (-v, -vv)
--provider <PROVIDER>
Market data provider [default: general.provider, else msn]
[possible values: yahoo, msn, stockbit]
--offline
Run without network requests
--no-cache
Bypass local cache
-h, --help
Print help (see a summary with '-h')
Examples:
idx stockbit login # paste the credentialStorage cookie
pbpaste | idx stockbit login
idx stockbit status
idx stockbit logout
```
### `idx stockbit login`
```text
Reads, on stdin, the value of stockbit.com's `credentialStorage` cookie (in a logged-in browser: DevTools, Application, Cookies, https://stockbit.com) or a refresh token, stores it, and checks it with one request. Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead.
Usage: idx stockbit login [OPTIONS]
Options:
-o, --output <OUTPUT>
Output format [default: auto = table on a terminal, json when piped]
[possible values: auto, table, json, jsonl]
--no-color
Disable colored output
-q, --quiet
Suppress non-essential output
-v, --verbose...
Increase verbosity (-v, -vv)
--provider <PROVIDER>
Market data provider [default: general.provider, else msn]
[possible values: yahoo, msn, stockbit]
--offline
Run without network requests
--no-cache
Bypass local cache
-h, --help
Print help (see a summary with '-h')
```
### `idx stockbit status`
```text
Show whether a session is stored and when its tokens expire
Usage: idx stockbit status [OPTIONS]
Options:
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
--no-color Disable colored output
-q, --quiet Suppress non-essential output
-v, --verbose... Increase verbosity (-v, -vv)
--provider <PROVIDER> Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit]
--offline Run without network requests
--no-cache Bypass local cache
-h, --help Print help
```
### `idx stockbit logout`
```text
Delete the stored session
Usage: idx stockbit logout [OPTIONS]
Options:
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
--no-color Disable colored output
-q, --quiet Suppress non-essential output
-v, --verbose... Increase verbosity (-v, -vv)
--provider <PROVIDER> Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit]
--offline Run without network requests
--no-cache Bypass local cache
-h, --help Print help
```
## `idx completions`
```text

View file

@ -1,12 +1,13 @@
---
title: Stockbit
description: Today's session by the minute and quotes with best bid/offer, from Stockbit's public page. Opt-in, no account.
description: Today's session by the minute and quotes with best bid/offer from Stockbit's public page, plus the order book and live quotes with your own Stockbit login. Both opt-in.
---
[Stockbit](https://stockbit.com)'s public symbol page carries a quote, the
best bid and offer, and today's price for every minute of the session. idx
reads that page directly. It needs no Stockbit account, no login and no
helper tools.
helper tools. With your own Stockbit account, idx can also read the order
book and live quotes; see [With your Stockbit login](#with-your-stockbit-login).
```bash
idx stocks intraday BBCA # today's session, by the minute
@ -31,7 +32,7 @@ idx -o json stocks intraday BBCA | jq '.points | length'
shows the book as it closed. idx leaves `bid` and `offer` out then, so the
closing book never passes for a live one.
- **Top of the book only.** One bid and one offer price, no depth. The full
order book needs a Stockbit login, which idx does not do.
order book needs your [Stockbit login](#with-your-stockbit-login).
- **Prices only.** The page has no per-minute volume or OHLC.
- **Empty sessions are data.** A suspended or delisted stock answers with
`points: []` and a `session_note` saying so (`Stockbit lists the stock as
@ -41,8 +42,9 @@ idx -o json stocks intraday BBCA | jq '.points | length'
`--provider stockbit` (or `IDX_PROVIDER=stockbit`, or
`idx config set general.provider stockbit`) changes only where quotes come
from. History still comes from Yahoo. Company data, fundamentals and
screening answer `UNSUPPORTED` under it, so use MSN for those.
from. Without a login, history still comes from Yahoo. Company data,
fundamentals and screening answer `UNSUPPORTED` under it, so use MSN for
those.
## How idx talks to Stockbit
@ -63,13 +65,63 @@ screening answer `UNSUPPORTED` under it, so use MSN for those.
Wait, or try from another network. `--provider msn` serves quotes without
Stockbit.
## With your Stockbit login
Opt-in, and only with your own account. idx then talks to Stockbit's own API
(`exodus.stockbit.com`) with your session's tokens.
```bash
idx stockbit login # paste the credentialStorage cookie
idx stockbit status # logged in? when do the tokens expire?
idx stocks orderbook BBCA # depth, queued orders, foreign flow
idx stocks orderbook BBRI --depth 5
idx --provider stockbit stocks quote BBCA # live, not delayed
idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily candles
idx stockbit logout
```
**Logging in.** In a browser logged in to stockbit.com, open DevTools,
Application, Cookies, `https://stockbit.com`, and copy the value of
`credentialStorage`. Paste it into `idx stockbit login`, or pipe it
(`pbpaste | idx stockbit login`). A refresh token on its own works too. idx
checks the session with one request and stores it in
`~/.config/idx/stockbit-session.json`, readable only by you. Tokens never
appear in output, logs or errors. On a server, set
`IDX_STOCKBIT_REFRESH_TOKEN` instead.
**One refresher per session.** Stockbit replaces the refresh token every time
it is used. Once idx refreshes your session, a browser holding the same
session has to log in again, and two tools sharing one session log each
other out. Log in through a separate browser profile (or a private window
you then close) for idx. idx processes on one machine take turns through a
lock, so they never spend the token twice.
What the login adds:
| | Without a login | With your login |
| --- | --- | --- |
| `--provider stockbit` quotes | public page, delayed | Stockbit's API, live |
| Bid and offer in quotes | best price, while open | best price, while open |
| `stocks orderbook` | `AUTHERROR` | every level with lots and queued orders, OHLC, value, trades, foreign buy/sell/net |
| `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows |
| `stocks intraday` | public page | public page |
The order book is what Stockbit's own app shows. Outside trading it is the
book as the market left it, and the output says so. Sizes are lots in the
table and shares in JSON. `--history-provider stockbit` asks for Stockbit's
candles explicitly; windows it charts only weekly or hourly (`2y`, `5y`,
`1wk`) answer `UNSUPPORTED`.
## Terms
Stockbit's [terms](https://stockbit.com/terms) allow its content for personal,
non-commercial use. They prohibit data mining, robots and redistribution
without Stockbit's written consent. idx is not affiliated with or endorsed by
Stockbit. Use this for looking at a stock now and then, not for crawling the
market or republishing prices.
market or republishing prices. With your login, automated use is tied to your
account: keep it human-paced.
Over MCP, the same data comes from the `get_intraday` tool, and from
`get_quotes` when the server runs with `IDX_PROVIDER=stockbit`.
Over MCP, the same data comes from `get_intraday`, `get_orderbook` and
`stockbit_status`, and from `get_quotes` and `get_price_history` when the
server runs with `IDX_PROVIDER=stockbit`. The server never logs in; run
`idx stockbit login` in a terminal.

View file

@ -33,6 +33,18 @@ sparkline and the last five minutes. JSON gives every minute in `points`. It
is delayed at least 10 minutes. See [Stockbit](/cli/stockbit/) for what it
covers and the terms it comes with.
## Order book (Stockbit login)
```bash
idx stocks orderbook BBCA
idx stocks orderbook BBRI --depth 5
```
With your own Stockbit session (`idx stockbit login`), `orderbook` shows
every bid and offer level with lots and queued orders, the session's OHLC,
value and trades, and foreign buy, sell and net. Without one it answers
`AUTHERROR`. See [Stockbit](/cli/stockbit/#with-your-stockbit-login).
## Company data
| Command | What it shows |
@ -156,8 +168,10 @@ idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compa
MSN is the default for quotes, company data and screening; Yahoo serves
price history. Indo Premier (IPOT) serves broker summaries and foreign flow
(IDX's own site with `flow --source idx`), and Stockbit serves `intraday`,
whichever provider is selected. Switch with `--provider msn|yahoo|stockbit`
(or `IDX_PROVIDER`) and `IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the
matching [config keys](/start/config/). `--provider stockbit` serves quotes
only, with the best bid/offer; see [Stockbit](/cli/stockbit/).
(IDX's own site with `flow --source idx`), and Stockbit serves `intraday`
and, with your login, `orderbook`, whichever provider is selected. Switch
with `--provider msn|yahoo|stockbit` (or `IDX_PROVIDER`) and
`IDX_HISTORY_PROVIDER=auto|yahoo|msn|stockbit`, or the matching
[config keys](/start/config/). `--provider stockbit` serves quotes only, with
the best bid/offer, live when you are logged in; see
[Stockbit](/cli/stockbit/).

View file

@ -5,10 +5,10 @@ description: idx-mcp serves idx's stock and ownership data to AI agents over MCP
`idx-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io)
server over stdio. It gives an agent the same data as the `idx` CLI, as
[15 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's
session by the minute, price history, screening, company data, analysis,
comparison, broker summaries, bandarmology, foreign flow, and the KSEI
ownership queries.
[17 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's
session by the minute, the order book (with a Stockbit login), price
history, screening, company data, analysis, comparison, broker summaries,
bandarmology, foreign flow, and the KSEI ownership queries.
It shares idx's [config, cache and ownership database](/start/config/#files-idx-keeps),
so `idx ownership sync` in a terminal also feeds the server.
@ -46,6 +46,7 @@ body, so the agent can decide whether to retry:
| --- | --- |
| tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed |
| `get_intraday` `tail` | 400 minutes (default: the whole session) |
| `get_orderbook` `depth` | 50 levels per side (default 10) |
| `screen_stocks` `limit` | 200 |
| `search_investors` `limit` | 100 |
| `get_ownership_changes` `limit` | 300 per page (default 200); page with `offset` |

View file

@ -92,6 +92,15 @@ Every IDX holding (above 1%) of one investor, matched by name.
| --- | --- | --- | --- |
| `entity` | string | yes | Investor name or part of it, e.g. "DWIMURIA"; the closest match is used. |
## `get_orderbook`
Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, and ask the user to run `idx stockbit login` in a terminal.
| Input | Type | Required | Description |
| --- | --- | --- | --- |
| `depth` | integer | no | Price levels per side (default 10, at most 50). |
| `symbol` | string | yes | IDX ticker, e.g. "BBCA". |
## `get_ownership_changes`
Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last.
@ -162,3 +171,9 @@ Search investor names in the KSEI ownership database.
| --- | --- | --- | --- |
| `limit` | integer | no | Maximum results (default 20, at most 100). |
| `query` | string | yes | Full-text query over investor names. |
## `stockbit_status`
Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with `idx stockbit login` in a terminal.
No inputs.

View file

@ -29,7 +29,7 @@ in scripts. Ask for JSON with `-o json`, `IDX_OUTPUT=json`, or
```toml
[general]
provider = "msn" # msn | yahoo | stockbit
history_provider = "auto" # auto | yahoo | msn
history_provider = "auto" # auto | yahoo | msn | stockbit
exchange = "JK"
output = "auto" # auto | table | json | jsonl
color = true
@ -56,6 +56,8 @@ fundamental_ttl = 3600
| `IDX_CACHE_FUNDAMENTAL_TTL` | `cache.fundamental_ttl` |
| `IDX_OWNERSHIP_SNAPSHOT_MANIFEST` | `ownership.snapshot_manifest` |
| `IDX_CURL_IMPERSONATE_BIN` | path to a `curl_chrome*` binary |
| `IDX_STOCKBIT_REFRESH_TOKEN` | seeds a [Stockbit session](/cli/stockbit/#with-your-stockbit-login) when none is stored |
| `IDX_STOCKBIT_ACCESS_TOKEN` | seeds the session's access token |
## Files idx keeps
@ -64,6 +66,7 @@ fundamental_ttl = 3600
| Config | `~/.config/idx/config.toml` |
| Cache | `~/.cache/idx/` (`idx cache info`, `idx cache clear`) |
| Ownership database | `~/.local/share/idx/ownership.db` |
| Stockbit session (after `idx stockbit login`) | `~/.config/idx/stockbit-session.json`, owner-only |
The MCP server reads the same config, cache and database.

View file

@ -139,7 +139,7 @@ TLKM.JK 2,250 <span class="up">+0.45%</span> 87,159,900
<div>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="7" width="16" height="12" rx="3"></rect><path d="M12 3v4M9 13h.01M15 13h.01"></path></svg>
<h2>Agents</h2>
<p>JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 15 tools for any MCP client.</p>
<p>JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 17 tools for any MCP client.</p>
<code>npx -y idx-mcp</code>
</div>
</div>