feat: login-free Stockbit intraday and quote provider

Read Stockbit's public symbol page (__NEXT_DATA__) with a plain ureq
request and an honest user agent: `stocks intraday` (today by the
minute, any provider), `--provider stockbit` quotes with the best
bid/offer only while the market is open, and MCP `get_intraday`.

- UPSTREAMBLOCKED error (exit 4) for a CDN 403; never retried
- requests paced 1 s apart across idx processes, 5 s snapshot cache
- global --provider flag (yahoo, msn, stockbit)
- roadmap: L1 ships from Stockbit; depth stays a login follow-on
This commit is contained in:
hermes 2026-10-23 04:58:32 +00:00
commit 74e5bb0536
65 changed files with 2320 additions and 381 deletions

View file

@ -71,6 +71,7 @@ idx stocks history BBCA --period 3mo
idx stocks technical BBCA
idx stocks fundamental BBCA
idx stocks compare BBCA BBRI BMRI
idx stocks intraday BBCA # today by the minute (Stockbit, delayed)
idx -o json stocks quote BBCA
# Ownership: download the maintained snapshot once, then query locally
@ -90,8 +91,12 @@ 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 `IDX_PROVIDER=msn|yahoo` and
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/)
- Local file cache with TTL, and `--offline` mode with stale-cache fallback
### Ownership (KSEI shareholders above 1%)
@ -158,8 +163,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 14 tools:
`get_quotes`, `get_price_history`, `screen_stocks`, `get_company`,
`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`,
`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

@ -4,16 +4,23 @@ Open work only. Shipped behavior is described in `README.md` and `docs/`;
history lives in git.
## Stocks
- `market summary`: IHSG index and market breadth
- `market summary`: market breadth and an index overview (index levels alone
are already `stocks intraday ^JKSE` and `--provider stockbit stocks quote IHSG`)
- `market movers`: top gainers, losers, and volume
- `market sectors`: sector performance overview
- `screen query "<EXPR>"`: filter stocks by expression
- `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 (bid/offer depth): no free, login-free source exists. IDX sells
delayed depth to vendors and Stockbit's needs a user login. Revisit if a
permitted source appears; do not ship the closing best bid/offer as one.
- 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.
- 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
for anything but `open`.
- Bandarmology follow-ups: a daily series (per-session readings and the
cumulative top-N net) and a price-move phase (quiet accumulation, markup),
calibration of the `top_concentration_v1` thresholds across more sessions,
@ -26,7 +33,8 @@ history lives in git.
- `watchlist`: named symbol sets usable by other commands
- `alerts`: price alerts with a daemon
- CSV/TSV output
- Additional providers (Alpha Vantage, Twelve Data, IDX official)
- Additional providers (Alpha Vantage, Twelve Data); IDX's own site serves
only `stocks flow --source idx` today
## Agent integration
- JSON Schema export for command output
@ -39,11 +47,16 @@ history lives in git.
- `stocks flow --source idx` depends on IDX's Cloudflare passing a
curl-impersonate profile (only `curl_safari184` in October 2026) and on the
network; the default IPOT source rounds to one decimal.
- Stockbit's terms call its data delayed at least 10 minutes and for
personal, non-commercial use (on 2026-10-23 the last trade was within a
minute of the clock); its CDN refuses some user agents with a 403
(`UPSTREAMBLOCKED`). Its order-book sizes are not shown: the page does not
say whether they are lots or shares.
- Yahoo can return `429` from datacenter IPs; requests retry with backoff but
can still fail.
- `stocks technical` reports the SMA200 trend as unavailable when fewer than
200 daily candles are returned.
- MSN's `low-pe` screen returned no priced stocks on 2026-10-14 (upstream;
other filters work).
- MSN's `low-pe` screen returns no priced stocks (`PARSEERROR`; still so on
2026-10-23, upstream; other filters work).
- MSN history for IDX is price-only (`1mo`, `3mo`, `1y`); Yahoo stays the
default OHLCV source.

View file

@ -5,6 +5,7 @@ pub mod ownership;
pub mod stocks;
use clap::{Parser, Subcommand, ValueEnum};
use idx_core::config::ProviderKind;
use crate::output::OutputSetting;
@ -39,6 +40,13 @@ pub struct Cli {
pub quiet: bool,
#[arg(short, long, global = true, action = clap::ArgAction::Count, help = "Increase verbosity (-v, -vv)")]
pub verbose: u8,
#[arg(
long,
value_enum,
global = true,
help = "Market data provider [default: general.provider, else msn]"
)]
pub provider: Option<ProviderKind>,
#[arg(long, global = true, help = "Run without network requests")]
pub offline: bool,
#[arg(long, global = true, help = "Bypass local cache")]

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_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment,
render_technical, render_valuation,
render_intraday, render_news, 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;
@ -126,13 +126,26 @@ pub struct StocksCmd {
pub enum StocksSubcommand {
#[command(
about = "Get real-time stock quotes",
after_help = "Examples:\n idx stocks quote BBCA\n idx stocks quote BBCA,BBRI,BMRI\n idx -o json stocks quote BBCA\n echo BBCA BBRI | idx stocks quote -"
after_help = "Examples:\n idx stocks quote BBCA\n idx stocks quote BBCA,BBRI,BMRI\n idx -o json stocks quote BBCA\n echo BBCA BBRI | idx stocks quote -\n idx --provider stockbit stocks quote BBCA # with bid/offer while the market is open"
)]
Quote {
/// One or more symbols, comma-separated or space-separated; `-` reads them from stdin.
#[arg(required = true)]
symbols: Vec<String>,
},
#[command(
about = "Today's session price by the minute (Stockbit)",
long_about = "Today's session of one IDX stock or index, by the minute, from Stockbit's \
public page (no login): the quote, the best bid/offer while the market is open, a sparkline, \
and the last minutes. Delayed at least 10 minutes per Stockbit's terms; prices only, no \
per-minute volume. A stock that did not trade has no minutes, which is not an error. \
Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45).",
after_help = "Examples:\n idx stocks intraday BBCA\n idx stocks intraday ^JKSE\n idx -o json stocks intraday BBCA | jq '.points[-1]'"
)]
Intraday {
/// Single ticker symbol (e.g. BBCA).
symbol: String,
},
#[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"
@ -313,7 +326,11 @@ pub fn handle(
StocksSubcommand::Quote { symbols } => {
let quotes = idx.market().quotes(&expand_symbols(symbols)?, policy)?;
let quotes: Vec<_> = quotes.into_iter().map(|q| q.data).collect();
render_quotes(&quotes, format, no_color)
render_quotes(&quotes, config.core.provider, format, no_color)
}
StocksSubcommand::Intraday { symbol } => {
let day = idx.market().intraday(symbol, policy)?;
render_intraday(&day.data, format, no_color)
}
StocksSubcommand::History {
symbol,

View file

@ -34,6 +34,9 @@ impl IdxConfig {
pub fn load_with_cli(cli: &Cli) -> Result<Self, IdxError> {
let mut core = CoreConfig::load()?;
core.verbose = cli.verbose > 0;
if let Some(provider) = cli.provider {
core.provider = provider;
}
let mut setting = OutputSetting::Auto;
let mut no_color = false;

View file

@ -14,10 +14,11 @@ use idx_core::analysis::fundamental::{
};
use idx_core::analysis::report::TechnicalReport;
use idx_core::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote,
SentimentData,
CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc,
Quote, SentimentData,
};
use idx_core::broker::types::BrokerSummary;
use idx_core::config::ProviderKind;
use idx_core::error::IdxError;
use idx_core::flow::types::ForeignFlow;
@ -67,15 +68,30 @@ impl OutputFormat {
pub fn render_quotes(
quotes: &[Quote],
provider: ProviderKind,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table if provider == ProviderKind::Stockbit => {
table::print_stockbit_quotes(quotes, no_color)
}
OutputFormat::Table => table::print_quotes(quotes, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(quotes),
}
}
pub fn render_intraday(
day: &Intraday,
format: &OutputFormat,
no_color: bool,
) -> Result<(), IdxError> {
match format {
OutputFormat::Table => table::print_intraday(day, no_color),
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(day),
}
}
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, NewsItem, Ohlc,
Quote, SentimentData,
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, Intraday,
NewsItem, Ohlc, Quote, SentimentData,
};
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
use idx_core::error::IdxError;
@ -61,12 +61,7 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
]);
for q in quotes {
let pct_cell = match q.change_pct {
None => cell("-"),
Some(pct) if no_color => cell(format!("{pct:+.2}%")),
Some(pct) if pct >= 0.0 => cell(format!("{pct:+.2}%")).fg(Color::Green),
Some(pct) => cell(format!("{pct:+.2}%")).fg(Color::Red),
};
let pct_cell = pct_cell(q.change_pct, no_color);
table.add_row(vec![
cell(&q.symbol),
cell(format_idr(q.price)),
@ -121,6 +116,182 @@ fn stale_quote_notes(quotes: &[Quote], now: chrono::DateTime<chrono::Utc>) -> Ve
.collect()
}
fn pct_cell(change_pct: Option<f64>, no_color: bool) -> Cell {
match change_pct {
None => cell("-"),
Some(pct) if no_color => cell(format!("{pct:+.2}%")),
Some(pct) if pct >= 0.0 => cell(format!("{pct:+.2}%")).fg(Color::Green),
Some(pct) => cell(format!("{pct:+.2}%")).fg(Color::Red),
}
}
/// 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.";
fn opt_idr(value: Option<i64>) -> String {
value.map(format_idr).unwrap_or_else(|| "-".to_string())
}
fn wib_minute(time: chrono::DateTime<chrono::FixedOffset>) -> String {
time.format("%Y-%m-%d %H:%M").to_string()
}
/// Quotes from `--provider stockbit`: Stockbit has no market cap or 52-week
/// range, but a best bid/offer and the market's state.
pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
"SYMBOL", "PRICE", "CHG", "CHG%", "VOLUME", "BID", "OFFER", "MARKET", "AS OF",
]);
for q in quotes {
table.add_row(vec![
cell(&q.symbol),
cell(format_idr(q.price)),
cell(
q.change
.map(|c| format!("{c:+}"))
.unwrap_or_else(|| "-".to_string()),
),
pct_cell(q.change_pct, no_color),
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.as_of.map(wib_minute).unwrap_or_else(|| "-".to_string())),
]);
}
println!("{table}");
for q in quotes {
if let Some(note) = &q.session_note {
println!(
"Note: {}: {}",
terminal_safe(&q.symbol),
terminal_safe(note)
);
}
}
for note in stale_quote_notes(quotes, chrono::Utc::now()) {
println!("{}", terminal_safe(&note));
}
println!("{STOCKBIT_SOURCE_NOTE}");
Ok(())
}
/// Minutes listed under the intraday chart.
const INTRADAY_TAIL: usize = 5;
/// Widest the intraday sparkline gets, in characters.
const SPARKLINE_WIDTH: usize = 60;
pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> {
let q = &day.quote;
let change = match (q.change, q.change_pct) {
(Some(c), Some(p)) => format!(" {c:+} ({p:+.2}%)"),
(Some(c), None) => format!(" {c:+}"),
_ => String::new(),
};
let change = match q.change {
_ if no_color || change.is_empty() => change,
Some(c) if c >= 0 => change.green(),
_ => change.red(),
};
println!(
"{}{change}",
format!("{} {}", terminal_safe(&q.symbol), format_idr(q.price)).bold()
);
let mut facts = vec![
format!("prev close {}", opt_idr(q.prev_close)),
format!(
"volume {}",
q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())
),
format!(
"market {}",
terminal_safe(q.market_status.as_deref().unwrap_or("unknown"))
),
];
if let Some(as_of) = q.as_of {
facts.push(format!("last trade {} WIB", wib_minute(as_of)));
}
println!("{}", facts.join(" · "));
if q.bid.is_some() || q.offer.is_some() {
println!("bid {} · offer {}", opt_idr(q.bid), opt_idr(q.offer));
}
if let Some(note) = &q.session_note {
println!("Note: {}", terminal_safe(note));
}
let prices: Vec<f64> = day.points.iter().map(|p| p.price).collect();
if let (Some(first), Some(last)) = (day.points.first(), day.points.last()) {
let (low, high) = prices
.iter()
.fold((f64::MAX, f64::MIN), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
println!();
println!("{}", sparkline(&prices, SPARKLINE_WIDTH));
println!(
"{} to {} WIB · low {} · high {} · {} minutes",
first.time.format("%H:%M"),
last.time.format("%H:%M"),
format_price(low),
format_price(high),
day.points.len()
);
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["TIME", "PRICE"]);
let start = day.points.len().saturating_sub(INTRADAY_TAIL);
for point in &day.points[start..] {
table.add_row(vec![
cell(point.time.format("%H:%M")),
cell(format_price(point.price)),
]);
}
println!("{table}");
} else if q.session_note.is_none() {
println!("No trades this session.");
}
println!("{STOCKBIT_SOURCE_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;
if cents % 100 == 0 {
format_idr(cents / 100)
} else {
format!("{}.{:02}", format_idr(cents / 100), (cents % 100).abs())
}
}
/// One block per bucket of points (the bucket's last price), scaled from the
/// lowest to the highest price.
fn sparkline(prices: &[f64], width: usize) -> String {
const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let width = width.min(prices.len());
if width == 0 {
return String::new();
}
let (low, high) = prices
.iter()
.fold((f64::MAX, f64::MIN), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
(1..=width)
.map(|i| {
let price = prices[i * prices.len() / width - 1];
if high > low {
BARS[((price - low) / (high - low) * 7.0).round() as usize]
} else {
BARS[3]
}
})
.collect()
}
pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> {
println!(
"{}",
@ -1410,6 +1581,10 @@ mod tests {
avg_volume: None,
as_of: Some(chrono::DateTime::parse_from_rfc3339(as_of).unwrap()),
behind_session: false,
bid: None,
offer: None,
market_status: None,
session_note: None,
}
}

View file

@ -2116,6 +2116,27 @@ fn invalid_provider_env_honors_json_output() {
);
}
#[test]
fn provider_flag_overrides_env() {
let quotes = run_success_json(
test_bin("provider-flag")
.env("IDX_PROVIDER", "msn")
.env("IDX_USE_MOCK_PROVIDER", "1")
.args([
"-o",
"json",
"--provider",
"stockbit",
"stocks",
"quote",
"BBCA",
]),
);
// Only Stockbit reports the market's state; MSN has a market cap.
assert_eq!(quotes[0]["market_status"], "close");
assert_eq!(quotes[0]["market_cap"], Value::Null);
}
#[test]
fn invalid_quote_ttl_env_returns_non_zero() {
test_bin("invalid-quote-ttl")

View file

@ -244,6 +244,25 @@ name = "yahoo-fundamental"
args = ["stocks", "fundamental", "BBCA"]
env = { IDX_PROVIDER = "yahoo" }
# --- stocks: Stockbit (intraday, and the opt-in quote provider) -----------------
[[case]]
name = "stocks-intraday"
args = ["stocks", "intraday", "BBCA"]
[[case]]
name = "stocks-intraday-index"
args = ["stocks", "intraday", "^JKSE"]
formats = ["table"]
[[case]]
name = "stocks-intraday-suspended"
args = ["stocks", "intraday", "MABA"]
[[case]]
name = "stockbit-quote"
args = ["--provider", "stockbit", "stocks", "quote", "BBCA,MABA"]
# --- stocks: broker summaries (IPOT) and foreign flow (IDX) --------------------
[[case]]
@ -334,6 +353,19 @@ env = { IDX_MOCK_ERROR = "1" }
name = "error-fetch-bing-unsupported"
args = ["ownership", "import", "--fetch-bing", "BBCA"]
[[case]]
name = "error-intraday-blocked"
args = ["stocks", "intraday", "BBCA"]
env = { IDX_MOCK_STOCKBIT_STATUS = "403" }
[[case]]
name = "error-intraday-unknown-symbol"
args = ["stocks", "intraday", "ZZZZ"]
[[case]]
name = "error-stockbit-fundamental-unsupported"
args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"]
# --- ownership --------------------------------------------------------------------
[[case]]

View file

@ -204,6 +204,9 @@ _idx() {
idx__help__stocks,insights)
cmd="idx__help__stocks__insights"
;;
idx__help__stocks,intraday)
cmd="idx__help__stocks__intraday"
;;
idx__help__stocks,news)
cmd="idx__help__stocks__news"
;;
@ -378,6 +381,9 @@ _idx() {
idx__stocks,insights)
cmd="idx__stocks__insights"
;;
idx__stocks,intraday)
cmd="idx__stocks__intraday"
;;
idx__stocks,news)
cmd="idx__stocks__news"
;;
@ -435,6 +441,9 @@ _idx() {
idx__stocks__help,insights)
cmd="idx__stocks__help__insights"
;;
idx__stocks__help,intraday)
cmd="idx__stocks__help__intraday"
;;
idx__stocks__help,news)
cmd="idx__stocks__help__news"
;;
@ -466,7 +475,7 @@ _idx() {
case "${cmd}" in
idx)
opts="-o -q -v -V -h --output --no-color --quiet --verbose --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 completions version help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -480,6 +489,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -488,7 +501,7 @@ _idx() {
return 0
;;
idx__cache)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help info clear help"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help info clear help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -502,6 +515,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -510,7 +527,7 @@ _idx() {
return 0
;;
idx__cache__clear)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help"
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
@ -524,6 +541,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -588,7 +609,7 @@ _idx() {
return 0
;;
idx__cache__info)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help"
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
@ -602,6 +623,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -610,7 +635,7 @@ _idx() {
return 0
;;
idx__completions)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help bash zsh fish"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help bash zsh fish"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -624,6 +649,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -632,7 +661,7 @@ _idx() {
return 0
;;
idx__config)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help init get set path help"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help init get set path help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -646,6 +675,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -654,7 +687,7 @@ _idx() {
return 0
;;
idx__config__get)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <KEY>"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <KEY>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -668,6 +701,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -760,7 +797,7 @@ _idx() {
return 0
;;
idx__config__init)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help"
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
@ -774,6 +811,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -782,7 +823,7 @@ _idx() {
return 0
;;
idx__config__path)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help"
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
@ -796,6 +837,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -804,7 +849,7 @@ _idx() {
return 0
;;
idx__config__set)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <KEY> <VALUE>"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <KEY> <VALUE>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -818,6 +863,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -1218,7 +1267,7 @@ _idx() {
return 0
;;
idx__help__stocks)
opts="quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare"
opts="quote intraday 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
@ -1371,6 +1420,20 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__help__stocks__intraday)
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__news)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
@ -1498,7 +1561,7 @@ _idx() {
return 0
;;
idx__ownership)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -1512,6 +1575,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -1520,7 +1587,7 @@ _idx() {
return 0
;;
idx__ownership__changes)
opts="-o -q -v -h --from --to --output --no-color --quiet --verbose --offline --no-cache --help"
opts="-o -q -v -h --from --to --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -1542,6 +1609,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -1550,7 +1621,7 @@ _idx() {
return 0
;;
idx__ownership__concentration)
opts="-o -q -v -h --by --top --output --no-color --quiet --verbose --offline --no-cache --help"
opts="-o -q -v -h --by --top --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -1572,6 +1643,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -1580,7 +1655,7 @@ _idx() {
return 0
;;
idx__ownership__cross__holders)
opts="-o -q -v -h --top --min-tickers --output --no-color --quiet --verbose --offline --no-cache --help"
opts="-o -q -v -h --top --min-tickers --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -1602,6 +1677,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -1610,7 +1689,7 @@ _idx() {
return 0
;;
idx__ownership__discover)
opts="-o -q -v -h --family --limit --output --no-color --quiet --verbose --offline --no-cache --help"
opts="-o -q -v -h --family --limit --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -1632,6 +1711,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -1640,7 +1723,7 @@ _idx() {
return 0
;;
idx__ownership__entity)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <NAME>"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <NAME>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -1654,6 +1737,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -1662,7 +1749,7 @@ _idx() {
return 0
;;
idx__ownership__flow)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --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
@ -1676,6 +1763,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -1684,7 +1775,7 @@ _idx() {
return 0
;;
idx__ownership__graph)
opts="-o -q -v -h --depth --format --output --no-color --quiet --verbose --offline --no-cache --help <ROOT>"
opts="-o -q -v -h --depth --format --output --no-color --quiet --verbose --provider --offline --no-cache --help <ROOT>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -1706,6 +1797,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -1966,7 +2061,7 @@ _idx() {
return 0
;;
idx__ownership__import)
opts="-o -q -v -h --url --file --fetch-bing --force --output --no-color --quiet --verbose --offline --no-cache --help"
opts="-o -q -v -h --url --file --fetch-bing --force --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -1992,6 +2087,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2000,7 +2099,7 @@ _idx() {
return 0
;;
idx__ownership__releases)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help"
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
@ -2014,6 +2113,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2022,7 +2125,7 @@ _idx() {
return 0
;;
idx__ownership__resolve)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help unresolved map merge help"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help unresolved map merge help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2036,6 +2139,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2114,7 +2221,7 @@ _idx() {
return 0
;;
idx__ownership__resolve__map)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <ALIAS> <ENTITY>"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <ALIAS> <ENTITY>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2128,6 +2235,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2136,7 +2247,7 @@ _idx() {
return 0
;;
idx__ownership__resolve__merge)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <KEEP> <MERGE>"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <KEEP> <MERGE>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2150,6 +2261,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2158,7 +2273,7 @@ _idx() {
return 0
;;
idx__ownership__resolve__unresolved)
opts="-o -q -v -h --limit --output --no-color --quiet --verbose --offline --no-cache --help"
opts="-o -q -v -h --limit --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2176,6 +2291,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2184,7 +2303,7 @@ _idx() {
return 0
;;
idx__ownership__search)
opts="-o -q -v -h --limit --output --no-color --quiet --verbose --offline --no-cache --help <QUERY>"
opts="-o -q -v -h --limit --output --no-color --quiet --verbose --provider --offline --no-cache --help <QUERY>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2202,6 +2321,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2210,7 +2333,7 @@ _idx() {
return 0
;;
idx__ownership__sync)
opts="-o -q -v -h --manifest --force --output --no-color --quiet --verbose --offline --no-cache --help"
opts="-o -q -v -h --manifest --force --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2228,6 +2351,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2236,7 +2363,7 @@ _idx() {
return 0
;;
idx__ownership__ticker)
opts="-o -q -v -h --source --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --source --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
@ -2254,6 +2381,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2262,7 +2393,7 @@ _idx() {
return 0
;;
idx__stocks)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help quote 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 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
@ -2276,6 +2407,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2284,7 +2419,7 @@ _idx() {
return 0
;;
idx__stocks__bandar)
opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOLS>..."
opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2322,6 +2457,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2330,7 +2469,7 @@ _idx() {
return 0
;;
idx__stocks__broker)
opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --date --from --to --investor --board --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
@ -2364,6 +2503,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2372,7 +2515,7 @@ _idx() {
return 0
;;
idx__stocks__compare)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOLS>..."
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2386,6 +2529,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2394,7 +2541,7 @@ _idx() {
return 0
;;
idx__stocks__earnings)
opts="-o -q -v -h --forecast --history --annual --quarterly --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --forecast --history --annual --quarterly --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
@ -2408,6 +2555,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2416,7 +2567,7 @@ _idx() {
return 0
;;
idx__stocks__financials)
opts="-o -q -v -h --statement --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --statement --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
@ -2434,6 +2585,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2442,7 +2597,7 @@ _idx() {
return 0
;;
idx__stocks__flow)
opts="-o -q -v -h --days --from --to --source --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --days --from --to --source --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
@ -2472,6 +2627,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2480,7 +2639,7 @@ _idx() {
return 0
;;
idx__stocks__fundamental)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --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
@ -2494,6 +2653,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2502,7 +2665,7 @@ _idx() {
return 0
;;
idx__stocks__growth)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --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
@ -2516,6 +2679,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2524,7 +2691,7 @@ _idx() {
return 0
;;
idx__stocks__help)
opts="quote history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
opts="quote intraday 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
@ -2691,6 +2858,20 @@ _idx() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
idx__stocks__help__intraday)
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__news)
opts=""
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
@ -2804,7 +2985,7 @@ _idx() {
return 0
;;
idx__stocks__history)
opts="-o -q -v -h --period --interval --history-provider --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --period --interval --history-provider --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
@ -2830,6 +3011,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2838,7 +3023,7 @@ _idx() {
return 0
;;
idx__stocks__insights)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --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
@ -2852,6 +3037,36 @@ _idx() {
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__intraday)
opts="-o -q -v -h --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
--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=()
;;
@ -2860,7 +3075,7 @@ _idx() {
return 0
;;
idx__stocks__news)
opts="-o -q -v -h --limit --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --limit --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
@ -2878,6 +3093,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2886,7 +3105,7 @@ _idx() {
return 0
;;
idx__stocks__profile)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --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
@ -2900,6 +3119,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2908,7 +3131,7 @@ _idx() {
return 0
;;
idx__stocks__quote)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOLS>..."
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2922,6 +3145,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2930,7 +3157,7 @@ _idx() {
return 0
;;
idx__stocks__risk)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --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
@ -2944,6 +3171,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2952,7 +3183,7 @@ _idx() {
return 0
;;
idx__stocks__screen)
opts="-o -q -v -h --filter --region --limit --output --no-color --quiet --verbose --offline --no-cache --help"
opts="-o -q -v -h --filter --region --limit --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -2978,6 +3209,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -2986,7 +3221,7 @@ _idx() {
return 0
;;
idx__stocks__sentiment)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --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
@ -3000,6 +3235,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -3008,7 +3247,7 @@ _idx() {
return 0
;;
idx__stocks__technical)
opts="-o -q -v -h --history-provider --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --history-provider --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
@ -3026,6 +3265,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -3034,7 +3277,7 @@ _idx() {
return 0
;;
idx__stocks__valuation)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help <SYMBOL>"
opts="-o -q -v -h --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
@ -3048,6 +3291,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
@ -3056,7 +3303,7 @@ _idx() {
return 0
;;
idx__version)
opts="-o -q -v -h --output --no-color --quiet --verbose --offline --no-cache --help"
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -3070,6 +3317,10 @@ _idx() {
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
return 0
;;
--provider)
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit"}

View file

@ -0,0 +1,4 @@
exit: 4
--- stdout
--- stderr
Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit

View file

@ -0,0 +1,4 @@
exit: 3
--- stdout
--- stderr
{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: ZZZZ.JK"}

View file

@ -0,0 +1,4 @@
exit: 3
--- stdout
--- stderr
Error: symbol not found: ZZZZ.JK

View file

@ -1,4 +1,4 @@
exit: 2
--- stdout
--- stderr
{"code":"CONFIGERROR","error":true,"message":"config error: invalid provider 'bogus' (expected yahoo or msn)"}
{"code":"CONFIGERROR","error":true,"message":"config error: invalid provider 'bogus' (expected yahoo, msn, or stockbit)"}

View file

@ -1,4 +1,4 @@
exit: 2
--- stdout
--- stderr
Error: config error: invalid provider 'bogus' (expected yahoo or msn)
Error: config error: invalid provider 'bogus' (expected yahoo, msn, or stockbit)

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
{"code":"UNSUPPORTED","error":true,"message":"unsupported: BBCA.JK: Stockbit has no fundamentals; command requires --provider msn or yahoo"}

View file

@ -0,0 +1,4 @@
exit: 5
--- stdout
--- stderr
Error: unsupported: BBCA.JK: Stockbit has no fundamentals; command requires --provider msn or yahoo

View file

@ -14,6 +14,7 @@ Options:
--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

View file

@ -28,6 +28,11 @@ Options:
-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

View file

@ -40,6 +40,11 @@ Options:
-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

View file

@ -29,6 +29,11 @@ Options:
-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

View file

@ -6,6 +6,7 @@ Usage: idx stocks [OPTIONS] <COMMAND>
Commands:
quote Get real-time stock quotes
intraday Today's session price by the minute (Stockbit)
history Get historical OHLC data
technical Run technical analysis on a stock
growth Run growth analysis on a stock
@ -30,6 +31,7 @@ Options:
--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

View file

@ -0,0 +1,4 @@
exit: 0
--- stdout
[{"symbol":"BBCA.JK","price":5175,"change":-100,"change_pct":-1.9,"volume":286928500,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":5275,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close"},{"symbol":"MABA.JK","price":50,"change":0,"change_pct":0.0,"volume":0,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":50,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close","session_note":"Stockbit lists the stock as suspended"}]
--- stderr

View file

@ -0,0 +1,12 @@
exit: 0
--- stdout
┌─────────┬───────┬──────┬────────┬─────────────┬─────┬───────┬────────┬───────┐
│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ BID ┆ OFFER ┆ MARKET ┆ AS OF │
╞═════════╪═══════╪══════╪════════╪═════════════╪═════╪═══════╪════════╪═══════╡
│ BBCA.JK ┆ 5,175 ┆ -100 ┆ -1.90% ┆ 286,928,500 ┆ - ┆ - ┆ close ┆ - │
├╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 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.
--- stderr

View file

@ -0,0 +1,22 @@
exit: 0
--- stdout
^JKSE.JK 5,388 +60 (+1.12%)
prev close 5,328 · volume 36,446,806,700 · market close · last trade 2026-10-22 16:14 WIB
▃▄▄▆▆▆▆▆▆▇▇▇▇▇████▇▇▇▇███▇▇▇▇███▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
08:58 to 16:14 WIB · low 5,192.41 · high 5,419.34 · 335 minutes
┌───────┬──────────┐
│ TIME ┆ PRICE │
╞═══════╪══════════╡
│ 16:10 ┆ 5,387.90 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤
│ 16:11 ┆ 5,387.90 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤
│ 16:12 ┆ 5,387.90 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤
│ 16:13 ┆ 5,387.90 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┤
│ 16:14 ┆ 5,387.90 │
└───────┴──────────┘
Source: Stockbit's 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
{"quote":{"symbol":"MABA.JK","price":50,"change":0,"change_pct":0.0,"volume":0,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":50,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close","session_note":"Stockbit lists the stock as suspended"},"points":[]}
--- stderr

View file

@ -0,0 +1,7 @@
exit: 0
--- stdout
MABA.JK 50 +0 (+0.00%)
prev close 50 · volume 0 · market close
Note: 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.
--- stderr

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,22 @@
exit: 0
--- stdout
BBCA.JK 5,175 -100 (-1.90%)
prev close 5,275 · volume 286,928,500 · market close · last trade 2026-10-22 16:14 WIB
▄▅▇█▅▇▇▇▅▇▇▇▇▇▇██▇▅▇▅▅███▇▅▇▅▇▇▇▅▇▅▅▄▅▄▅▄▄▅▅▅▅▅▇▅▄▄▄▄▄▄▂▄▄▄▄
08:58 to 16:14 WIB · low 5,125 · high 5,250 · 335 minutes
┌───────┬───────┐
│ TIME ┆ PRICE │
╞═══════╪═══════╡
│ 16:10 ┆ 5,175 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 16:11 ┆ 5,175 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 16:12 ┆ 5,175 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 16:13 ┆ 5,175 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 16:14 ┆ 5,175 │
└───────┴───────┘
Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
--- stderr

View file

@ -1,10 +1,10 @@
//! 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, Period, Quote};
use super::types::{Bar, Fundamentals, Interval, Intraday, Period, Quote};
use super::{
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider,
TradingSummaryProvider, idx, ipot, msn, yahoo,
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, QuoteProvider,
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
};
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
use crate::config::ProviderKind;
@ -45,6 +45,10 @@ impl MockProvider {
match provider {
ProviderKind::Yahoo => Self::from_yahoo_fixtures(history_verbose),
ProviderKind::Msn => Self::from_msn_fixtures(),
// `MockStockbitProvider` serves Stockbit; it has no history.
ProviderKind::Stockbit => Self::with_error(IdxError::Unsupported(
"Stockbit serves no price history".to_string(),
)),
}
}
@ -131,6 +135,57 @@ 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.
pub struct MockStockbitProvider;
impl MockStockbitProvider {
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
if std::env::var("IDX_MOCK_ERROR").is_ok() {
return Err(IdxError::ProviderUnavailable);
}
if let Ok(status) = std::env::var("IDX_MOCK_STOCKBIT_STATUS") {
return Err(stockbit::status_error(
status.parse().unwrap_or(500),
symbol,
));
}
let code = stockbit::stockbit_code(symbol)?;
let path = format!(
"tests/fixtures/stockbit_symbol_{}.html",
code.to_ascii_lowercase()
);
match std::fs::read_to_string(path) {
Ok(html) => stockbit::parse_symbol_page(symbol, &html),
Err(_) => Err(stockbit::status_error(404, symbol)),
}
}
}
impl QuoteProvider for MockStockbitProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
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.
quote.as_of = None;
Ok(quote)
}
}
impl FundamentalsProvider for MockStockbitProvider {
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
Err(stockbit::fundamentals_unsupported(symbol))
}
}
impl IntradayProvider for MockStockbitProvider {
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError> {
self.snapshot(symbol)
}
}
/// 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

@ -3,6 +3,7 @@ pub mod ipot;
#[cfg(feature = "testing")]
pub mod mock;
pub mod msn;
pub mod stockbit;
pub mod types;
pub mod yahoo;
@ -13,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind};
use crate::error::IdxError;
use types::{
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
NewsItem, Period, Quote, SentimentData,
Intraday, NewsItem, Period, Quote, SentimentData,
};
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
@ -64,6 +65,13 @@ pub trait TradingSummaryProvider {
) -> Result<Vec<crate::flow::types::StockDay>, IdxError>;
}
/// Today's session of one IDX symbol: quote and price per minute. Served by
/// Stockbit's public page whichever market provider is selected.
pub trait IntradayProvider {
/// `symbol` is resolved, e.g. `BBCA.JK`.
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError>;
}
pub trait FundamentalsProvider {
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
}
@ -221,7 +229,10 @@ fn mock_enabled() -> bool {
#[cfg(feature = "testing")]
fn mock_market(provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
Some(Box::new(mock::MockProvider::from_fixtures(provider)))
match provider {
ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)),
_ => Some(Box::new(mock::MockProvider::from_fixtures(provider))),
}
}
#[cfg(not(feature = "testing"))]
@ -274,6 +285,18 @@ 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`).
ProviderKind::Stockbit => SelectedProvider {
kind: ProviderKind::Stockbit,
market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())),
profile: None,
financials: None,
earnings: None,
sentiment: None,
insights: None,
news: None,
screener: None,
},
}
}
@ -297,6 +320,14 @@ pub fn trading_summary_provider() -> Box<dyn TradingSummaryProvider> {
Box::new(idx::IdxSiteProvider)
}
pub fn intraday_provider() -> Box<dyn IntradayProvider> {
#[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")]
@ -312,8 +343,7 @@ pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind)
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
HistoryProviderKind::Msn => ProviderKind::Msn,
HistoryProviderKind::Auto => match provider {
ProviderKind::Yahoo => ProviderKind::Yahoo,
ProviderKind::Msn => ProviderKind::Yahoo,
ProviderKind::Yahoo | ProviderKind::Msn | ProviderKind::Stockbit => ProviderKind::Yahoo,
},
}
}
@ -339,6 +369,9 @@ pub fn history_provider(
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(),
)),
}
}

View file

@ -71,6 +71,10 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
avg_volume: round_u64(quote.average_volume),
as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()),
behind_session: false,
bid: None,
offer: None,
market_status: None,
session_note: None,
})
}
@ -720,6 +724,10 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
avg_volume: round_u64(q.average_volume),
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
behind_session: false,
bid: None,
offer: None,
market_status: None,
session_note: None,
})
})
.collect();

View file

@ -0,0 +1,193 @@
//! Stockbit's public symbol page: a quote, the best bid/offer and today's
//! price per minute for one IDX symbol, without a login or key.
//!
//! `GET https://stockbit.com/symbol/{CODE}` server-renders everything into
//! the page's `__NEXT_DATA__` JSON (see `parse`). Stockbit's own JSON API
//! (`exodus.stockbit.com`) needs a logged-in bearer token and is not used.
//!
//! Access rules this module keeps:
//! - An honest user agent. CloudFront in front of the page answers a static
//! HTTP 403 to `curl/`, `axios/`, `Go-http-client/` and empty agents; it
//! does not fingerprint TLS, so no browser impersonation is involved.
//! - One request per symbol, at least `MIN_INTERVAL` apart across every idx
//! process of the user (see `pace`), no retries, and a 403 surfaces as
//! `UPSTREAMBLOCKED` at once.
//! - Stockbit's terms describe its market data as delayed at least 10
//! minutes and for personal use; frontends say so.
mod parse;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::sync::Mutex;
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 crate::error::IdxError;
pub const SOURCE: &str = "stockbit";
/// Identifies idx-cli and where to reach its maintainers.
pub const USER_AGENT: &str = concat!(
"idx-cli/",
env!("CARGO_PKG_VERSION"),
" (+",
env!("CARGO_PKG_REPOSITORY"),
")"
);
/// Longest a snapshot is served from cache: enough to coalesce a burst (an
/// agent asking for a quote and the intraday series), short enough that a
/// session view stays current.
pub(crate) const CACHE_TTL_SECS: u64 = 5;
/// Least time between two requests to stockbit.com from one process.
const MIN_INTERVAL: Duration = Duration::from_secs(1);
const BASE_URL: &str = "https://stockbit.com/symbol/";
pub struct StockbitProvider {
agent: ureq::Agent,
}
impl StockbitProvider {
pub fn new() -> Self {
let agent = ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_recv_body(Some(Duration::from_secs(10)))
// A server that stalls before sending headers must not hold a
// call (and an MCP call slot) forever.
.timeout_global(Some(Duration::from_secs(20)))
.build()
.into();
Self { agent }
}
/// The quote and today's series for a resolved symbol (`BBCA.JK`).
pub fn snapshot(&self, resolved: &str) -> Result<Intraday, IdxError> {
let code = stockbit_code(resolved)?;
let html = self.page(&code, resolved)?;
parse_symbol_page(resolved, &html)
}
fn page(&self, code: &str, resolved: &str) -> Result<String, IdxError> {
pace();
let response = self
.agent
.get(format!("{BASE_URL}{code}"))
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html")
.call();
match response {
Ok(ok) => ok
.into_body()
.read_to_string()
.map_err(|e| IdxError::Http(format!("stockbit: {e}"))),
Err(ureq::Error::StatusCode(status)) => Err(status_error(status, resolved)),
Err(err) => Err(IdxError::Http(format!("stockbit: {err}"))),
}
}
}
impl Default for StockbitProvider {
fn default() -> Self {
Self::new()
}
}
/// Waits until `MIN_INTERVAL` has passed since the previous request. Threads
/// of one process queue on a mutex; separate processes (a shell loop of
/// `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() {
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 {
let wait = MIN_INTERVAL.saturating_sub(prev.elapsed());
if !wait.is_zero() {
std::thread::sleep(wait);
}
}
// Best effort: the in-process pacing above already holds.
let _ = pace_across_processes();
*last = Some(Instant::now());
}
fn pace_across_processes() -> std::io::Result<()> {
let dir = crate::cache::cache_dir().map_err(|e| std::io::Error::other(e.to_string()))?;
std::fs::create_dir_all(&dir)?;
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(dir.join("stockbit.pace"))?;
// Released when `file` is dropped, after the new time is written.
file.lock()?;
let mut raw = String::new();
file.read_to_string(&mut raw)?;
if let Ok(prev_ms) = raw.trim().parse::<u128>() {
// A clock that went backwards waits at most one interval.
let elapsed = now_ms().saturating_sub(prev_ms);
let wait = MIN_INTERVAL.as_millis().saturating_sub(elapsed);
if wait > 0 {
std::thread::sleep(Duration::from_millis(wait as u64));
}
}
stamp(&mut file)
}
fn stamp(file: &mut File) -> std::io::Result<()> {
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
write!(file, "{}", now_ms())
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis())
}
impl QuoteProvider for StockbitProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
self.snapshot(symbol).map(|day| day.quote)
}
}
impl FundamentalsProvider for StockbitProvider {
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
Err(fundamentals_unsupported(symbol))
}
}
impl IntradayProvider for StockbitProvider {
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError> {
self.snapshot(symbol)
}
}
pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError {
IdxError::Unsupported(format!(
"{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo"
))
}
#[cfg(test)]
mod tests {
use super::USER_AGENT;
/// CloudFront answers HTTP 403 to agents containing these (probed
/// 2026-10-22); the honest agent must never match one.
#[test]
fn user_agent_is_honest_and_not_on_the_cdn_blocklist() {
assert!(USER_AGENT.starts_with("idx-cli/"));
assert!(USER_AGENT.contains("github.com/0xrsydn/idx-cli"));
for blocked in ["curl/", "axios/", "Go-http-client/"] {
assert!(
!USER_AGENT.contains(blocked),
"{USER_AGENT} contains {blocked}"
);
}
}
}

View file

@ -0,0 +1,440 @@
//! Pure parsing of a Stockbit symbol page: the `__NEXT_DATA__` JSON that
//! Next.js renders into `https://stockbit.com/symbol/{CODE}`. No I/O.
//!
//! Shapes (recorded 2026-10-22 after the close, see
//! `tests/fixtures/stockbit_symbol_*.html`):
//! - `props.pageProps.company`: the quote. Numbers are mostly strings
//! (`price: "5175"`, an index `"5387.90478515625"`, `change: "+59.54"`),
//! `percentage` is a JSON number, `orderbook.{bid,offer}.price` is
//! `"5175.000000"` or `"N/A"`, `market_hour.status` is e.g. `close`, and
//! `status` is `STATUS_ACTIVE`, `STATUS_SUSPENDED` or `STATUS_DELISTED`.
//! `date`/`time` (`"22 Oct 2026"`, `"Thu 16:14"`) are the last trade only
//! when the stock traded: a suspended one carries today's date.
//! - `props.pageProps.chart.prices[]`: one row per minute,
//! `formatted_date: "2026-10-22 16:14:00"` (WIB, no offset) and
//! `value: "5175"`. The first row is a synthetic anchor at the previous
//! close with an empty `formatted_date`. Per-minute OHLC and volume are
//! empty strings, so only the price is kept.
//! - An unknown code renders the 404 page: `page: "/404"`, empty pageProps.
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
use serde::Deserialize;
use serde_json::Value;
use crate::api::idx_offset;
use crate::api::types::{Intraday, IntradayPoint, Quote};
use crate::error::IdxError;
/// Stockbit's market status while continuous trading runs. Bid and offer are
/// reported only then: outside it they are the closing book.
const MARKET_OPEN: &str = "open";
#[derive(Debug, Deserialize)]
struct NextData {
page: String,
props: Props,
}
#[derive(Debug, Deserialize)]
struct Props {
#[serde(rename = "pageProps", default)]
page_props: PageProps,
}
#[derive(Debug, Default, Deserialize)]
struct PageProps {
company: Option<RawCompany>,
chart: Option<RawChart>,
}
#[derive(Debug, Deserialize)]
struct RawCompany {
price: Value,
#[serde(default)]
previous: Value,
#[serde(default)]
change: Value,
#[serde(default)]
percentage: Value,
#[serde(default)]
volume: Value,
date: Option<String>,
time: Option<String>,
status: Option<String>,
orderbook: Option<RawOrderbook>,
market_hour: Option<RawMarketHour>,
}
#[derive(Debug, Deserialize)]
struct RawOrderbook {
bid: Option<RawLevel>,
offer: Option<RawLevel>,
}
#[derive(Debug, Deserialize)]
struct RawLevel {
#[serde(default)]
price: Value,
}
#[derive(Debug, Deserialize)]
struct RawMarketHour {
status: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawChart {
#[serde(default)]
prices: Vec<RawPoint>,
}
#[derive(Debug, Deserialize)]
struct RawPoint {
#[serde(default)]
formatted_date: String,
#[serde(default)]
value: Value,
}
/// Stockbit's code for a resolved symbol: `BBCA.JK` -> `BBCA`, and Yahoo's
/// index tickers to Stockbit's (`^JKSE` -> `IHSG`, `^JKLQ45` -> `LQ45`).
pub fn stockbit_code(resolved: &str) -> Result<String, IdxError> {
let unsupported = || {
IdxError::Unsupported(format!(
"{resolved}: Stockbit covers IDX (.JK) symbols only"
))
};
let code = resolved.strip_suffix(".JK").ok_or_else(unsupported)?;
let code = match code.strip_prefix('^') {
Some("JKSE") => "IHSG",
Some("JKLQ45") => "LQ45",
Some(_) => return Err(unsupported()),
None => code,
};
if code.is_empty() || !code.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(unsupported());
}
Ok(code.to_string())
}
/// The error for a non-success HTTP status from stockbit.com. A 403 is
/// CloudFront's static block page (it refuses some user agents outright),
/// not a challenge, so nothing is retried.
pub fn status_error(status: u16, resolved: &str) -> IdxError {
match status {
403 => IdxError::UpstreamBlocked(
"stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \
block. Try again later or from another network; `--provider msn` serves quotes \
without Stockbit"
.to_string(),
),
404 => IdxError::SymbolNotFound(resolved.to_string()),
429 => IdxError::RateLimited,
_ => IdxError::Http(format!("stockbit: HTTP {status}")),
}
}
/// The quote and today's per-minute prices from a symbol page, stamped with
/// `resolved` (e.g. `BBCA.JK`).
pub fn parse_symbol_page(resolved: &str, html: &str) -> Result<Intraday, IdxError> {
let data: NextData = serde_json::from_str(next_data(html)?)
.map_err(|e| layout_error(format!("__NEXT_DATA__ is not the expected JSON: {e}")))?;
if data.page == "/404" {
return Err(IdxError::SymbolNotFound(resolved.to_string()));
}
let company = data
.props
.page_props
.company
.ok_or_else(|| layout_error("no company in the page".to_string()))?;
let points = parse_points(&data.props.page_props.chart.unwrap_or_default())?;
let quote = parse_quote(resolved, &company)?;
Ok(Intraday { quote, points })
}
fn layout_error(detail: String) -> IdxError {
IdxError::ParseError(format!(
"stockbit symbol page: {detail} (the page layout changed?)"
))
}
/// The JSON inside `<script id="__NEXT_DATA__" ...>...</script>`.
fn next_data(html: &str) -> Result<&str, IdxError> {
let missing = || layout_error("no __NEXT_DATA__ script".to_string());
let tag = html.find("id=\"__NEXT_DATA__\"").ok_or_else(missing)?;
let start = tag + html[tag..].find('>').ok_or_else(missing)? + 1;
let len = html[start..].find("</script>").ok_or_else(missing)?;
Ok(&html[start..start + len])
}
fn parse_quote(resolved: &str, company: &RawCompany) -> Result<Quote, IdxError> {
let price = number(&company.price)
.ok_or_else(|| layout_error(format!("price {} is not a number", company.price)))?;
let price = price.round() as i64;
let prev_close = number(&company.previous).map(|p| p.round() as i64);
let change = number(&company.change)
.map(|c| c.round() as i64)
.or_else(|| prev_close.map(|prev| price - prev));
let volume = count(&company.volume);
let listing = company.status.as_deref().unwrap_or_default();
let market_status = company
.market_hour
.as_ref()
.and_then(|m| m.status.clone())
.filter(|s| !s.is_empty());
let open = market_status.as_deref() == Some(MARKET_OPEN);
let level = |side: Option<&RawLevel>| {
side.and_then(|l| number(&l.price))
.filter(|p| *p > 0.0)
.map(|p| p.round() as i64)
};
let book = company.orderbook.as_ref();
let (bid, offer) = if open {
(
level(book.and_then(|b| b.bid.as_ref())),
level(book.and_then(|b| b.offer.as_ref())),
)
} else {
(None, None)
};
let traded = volume.is_some_and(|v| v > 0);
let as_of = if listing == "STATUS_ACTIVE" && traded {
last_trade(company.date.as_deref(), company.time.as_deref())
} else {
None
};
// Facts about this symbol only; the market's state is `market_status`.
// An untraded stock can still have a flat chart at the previous close.
let session_note = match listing {
"STATUS_SUSPENDED" => Some("Stockbit lists the stock as suspended".to_string()),
"STATUS_DELISTED" => Some("Stockbit lists the stock as delisted".to_string()),
_ if volume == Some(0) => Some("no trades this session".to_string()),
_ => None,
};
Ok(Quote {
symbol: resolved.to_string(),
price,
change,
change_pct: number(&company.percentage),
volume,
market_cap: None,
week52_high: None,
week52_low: None,
week52_position: None,
range_signal: None,
prev_close,
avg_volume: None,
as_of,
behind_session: false,
bid,
offer,
market_status,
session_note,
})
}
/// Today's prices, oldest first, one per minute. The anchor row (no
/// timestamp) is dropped; any other row that does not parse means the
/// layout changed.
fn parse_points(chart: &RawChart) -> Result<Vec<IntradayPoint>, IdxError> {
let mut points = Vec::with_capacity(chart.prices.len());
for row in &chart.prices {
if row.formatted_date.is_empty() {
continue;
}
let bad = || layout_error(format!("chart row {:?} {}", row.formatted_date, row.value));
let local = NaiveDateTime::parse_from_str(&row.formatted_date, "%Y-%m-%d %H:%M:%S")
.map_err(|_| bad())?;
let time = wib(local).ok_or_else(bad)?;
let price = number(&row.value).ok_or_else(bad)?;
points.push(IntradayPoint { time, price });
}
points.sort_by_key(|p| p.time);
// A repeated minute keeps its last row.
points.reverse();
points.dedup_by_key(|p| p.time);
points.reverse();
Ok(points)
}
/// `"22 Oct 2026"` and `"Thu 16:14"` as a WIB time.
fn last_trade(date: Option<&str>, time: Option<&str>) -> Option<DateTime<FixedOffset>> {
let date = NaiveDate::parse_from_str(date?.trim(), "%d %b %Y").ok()?;
let clock = time?.trim().rsplit(' ').next()?;
let clock = NaiveTime::parse_from_str(clock, "%H:%M").ok()?;
wib(date.and_time(clock))
}
fn wib(local: NaiveDateTime) -> Option<DateTime<FixedOffset>> {
idx_offset().from_local_datetime(&local).single()
}
/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`,
/// `"NA"` and empty strings are absent.
fn number(value: &Value) -> Option<f64> {
let n = match value {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.trim().parse::<f64>().ok()?,
_ => return None,
};
n.is_finite().then_some(n)
}
fn count(value: &Value) -> Option<u64> {
match value {
Value::Number(n) => n.as_u64(),
Value::String(s) => s.trim().parse::<u64>().ok(),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
}
/// The recorded BBCA page with Stockbit's market status set to `status`.
fn bbca_with_status(status: &str) -> String {
let page = fixture("stockbit_symbol_bbca.html");
assert!(page.contains(r#""status":"close""#));
page.replace(r#""status":"close""#, &format!(r#""status":"{status}""#))
}
#[test]
fn closed_session_keeps_the_series_and_hides_the_closing_book() {
let day = parse_symbol_page("BBCA.JK", &fixture("stockbit_symbol_bbca.html")).unwrap();
let q = &day.quote;
assert_eq!(
(q.price, q.prev_close, q.change),
(5175, Some(5275), Some(-100))
);
assert_eq!(q.change_pct, Some(-1.9));
assert_eq!(q.volume, Some(286_928_500));
assert_eq!(q.market_status.as_deref(), Some("close"));
assert_eq!((q.bid, q.offer), (None, None));
assert_eq!(q.session_note, None);
assert_eq!(q.as_of.unwrap().to_rfc3339(), "2026-10-22T16:14:00+07:00");
// 336 rows less the anchor at the previous close.
assert_eq!(day.points.len(), 335);
let first = &day.points[0];
assert_eq!(first.time.to_rfc3339(), "2026-10-22T08:58:00+07:00");
assert_eq!(first.price, 5125.0);
let last = day.points.last().unwrap();
assert_eq!(last.time.to_rfc3339(), "2026-10-22T16:14:00+07:00");
assert_eq!(last.price, 5175.0);
assert!(day.points.windows(2).all(|w| w[0].time < w[1].time));
}
#[test]
fn open_market_reports_best_bid_and_offer() {
let day = parse_symbol_page("BBCA.JK", &bbca_with_status(MARKET_OPEN)).unwrap();
assert_eq!((day.quote.bid, day.quote.offer), (Some(5175), Some(5200)));
assert_eq!(day.quote.market_status.as_deref(), Some(MARKET_OPEN));
}
#[test]
fn any_other_market_status_hides_bid_and_offer() {
for status in ["pre_open", "break", "OPEN", ""] {
let day = parse_symbol_page("BBCA.JK", &bbca_with_status(status)).unwrap();
assert_eq!((day.quote.bid, day.quote.offer), (None, None), "{status:?}");
}
}
#[test]
fn index_keeps_decimals_in_the_series_and_rounds_the_quote() {
let day = parse_symbol_page("^JKSE.JK", &fixture("stockbit_symbol_ihsg.html")).unwrap();
let q = &day.quote;
assert_eq!(
(q.price, q.prev_close, q.change),
(5388, Some(5328), Some(60))
);
// An index has no book even while open.
assert_eq!((q.bid, q.offer), (None, None));
assert_eq!(day.points.last().unwrap().price, 5387.90);
}
#[test]
fn suspended_and_delisted_stocks_are_empty_sessions_not_errors() {
for (file, note) in [
(
"stockbit_symbol_maba.html",
"Stockbit lists the stock as suspended",
),
(
"stockbit_symbol_myrx.html",
"Stockbit lists the stock as delisted",
),
] {
let day = parse_symbol_page("X.JK", &fixture(file)).unwrap();
assert!(day.points.is_empty(), "{file}");
assert_eq!(day.quote.session_note.as_deref(), Some(note), "{file}");
// The page's date/time is not a trade time when nothing traded.
assert_eq!(day.quote.as_of, None, "{file}");
}
}
/// HOME and BIMA on 2026-10-22: active, a flat chart at the previous
/// close, volume 0. Without a note they read as a traded flat session.
#[test]
fn an_untraded_active_stock_says_so_even_with_a_flat_chart() {
let page = fixture("stockbit_symbol_bbca.html");
assert!(page.contains(r#""volume":"286928500""#));
let page = page.replace(r#""volume":"286928500""#, r#""volume":"0""#);
let day = parse_symbol_page("BBCA.JK", &page).unwrap();
assert!(!day.points.is_empty());
assert_eq!(
day.quote.session_note.as_deref(),
Some("no trades this session")
);
assert_eq!(day.quote.as_of, None);
}
#[test]
fn unknown_code_is_symbol_not_found() {
let err = parse_symbol_page("ZZZZ.JK", &fixture("stockbit_symbol_zzzz_404.html"));
assert!(matches!(err, Err(IdxError::SymbolNotFound(s)) if s == "ZZZZ.JK"));
}
#[test]
fn a_page_without_next_data_is_a_layout_change() {
let err = parse_symbol_page("BBCA.JK", &fixture("stockbit_blocked_403.html"));
assert!(matches!(err, Err(IdxError::ParseError(_))), "{err:?}");
}
#[test]
fn http_403_is_upstream_blocked_and_404_is_not_found() {
assert!(matches!(
status_error(403, "BBCA.JK"),
IdxError::UpstreamBlocked(_)
));
assert!(
matches!(status_error(404, "BBCA.JK"), IdxError::SymbolNotFound(s) if s == "BBCA.JK")
);
assert!(matches!(
status_error(429, "BBCA.JK"),
IdxError::RateLimited
));
assert!(matches!(status_error(502, "BBCA.JK"), IdxError::Http(_)));
}
#[test]
fn codes_map_idx_symbols_and_index_tickers() {
assert_eq!(stockbit_code("BBCA.JK").unwrap(), "BBCA");
assert_eq!(stockbit_code("^JKSE.JK").unwrap(), "IHSG");
assert_eq!(stockbit_code("^JKLQ45.JK").unwrap(), "LQ45");
for other in ["AAPL.US", "^GSPC.JK", "BRK-B.JK", ".JK"] {
assert!(
matches!(stockbit_code(other), Err(IdxError::Unsupported(_))),
"{other}"
);
}
}
}

View file

@ -57,6 +57,41 @@ pub struct Quote {
/// quote is returned, not when it is cached.
#[serde(default)]
pub behind_session: bool,
/// Best bid in IDR (whole Rupiah). Stockbit only, and only while its
/// market status is `open`: outside trading it is the closing book, not
/// a live one. Absent from JSON when unknown.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bid: Option<i64>,
/// Best offer in IDR (whole Rupiah), gated like `bid`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offer: Option<i64>,
/// The provider's own session state, verbatim (Stockbit
/// `market_hour.status`, e.g. `close`). Absent for providers without one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub market_status: Option<String>,
/// What the provider says about this symbol's session: suspended,
/// delisted, or no trades. Never inferred.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_note: Option<String>,
}
/// One price of today's session, minute by minute (Stockbit's chart).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntradayPoint {
/// The minute, in WIB.
pub time: DateTime<FixedOffset>,
/// Last price at that minute in IDR; indices carry two decimals.
pub price: f64,
}
/// The latest session of one IDX symbol: its quote and the price per minute.
/// Stockbit's public page carries no per-minute OHLC or volume.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Intraday {
pub quote: Quote,
/// Oldest first; empty when the symbol did not trade (suspended,
/// delisted, or no session yet), never an error.
pub points: Vec<IntradayPoint>,
}
/// OHLC candle data normalized from Yahoo Finance chart indicators.

View file

@ -65,6 +65,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
.map(|t| t.with_timezone(&crate::api::idx_offset())),
behind_session: false,
bid: None,
offer: None,
market_status: None,
session_note: None,
})
}

View file

@ -41,7 +41,7 @@ impl CachePolicy {
#[derive(Debug, Clone, Serialize)]
pub struct Fetched<T> {
pub data: T,
/// Provider that produced the data (`msn` or `yahoo`).
/// Provider that produced the data (e.g. `msn`, `yahoo`, `ipot`, `stockbit`).
pub source: &'static str,
pub fetched_at: DateTime<Utc>,
/// True when an expired cache entry was served (offline, or after a

View file

@ -13,10 +13,14 @@ use crate::error::IdxError;
pub const OUTPUT_VALUES: &[&str] = &["auto", "table", "json", "jsonl"];
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "clap", value(rename_all = "lower"))]
pub enum ProviderKind {
Yahoo,
Msn,
// Opt-in: quotes from Stockbit's public page (delayed, no login).
Stockbit,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
@ -34,6 +38,7 @@ impl ProviderKind {
match self {
Self::Yahoo => "yahoo",
Self::Msn => "msn",
Self::Stockbit => "stockbit",
}
}
@ -42,9 +47,11 @@ impl ProviderKind {
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 provider '{value}' (expected yahoo or msn)"
"invalid provider '{value}' (expected yahoo, msn, or stockbit)"
)))
}
}

View file

@ -34,6 +34,10 @@ pub enum IdxError {
InvalidInput(String),
#[error("no market data: {0}")]
NoMarketData(String),
/// The upstream refused this client outright (e.g. a CDN's HTTP 403
/// block page). Retrying does not help; the message says what might.
#[error("upstream blocked: {0}")]
UpstreamBlocked(String),
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
@ -53,6 +57,7 @@ pub enum ErrorCode {
PdfParseError,
InvalidInput,
NoMarketData,
UpstreamBlocked,
}
/// The stable wire form frontends emit, e.g. `SYMBOLNOTFOUND`.
@ -80,6 +85,7 @@ impl IdxError {
Self::PdfParseError(_) => ErrorCode::PdfParseError,
Self::InvalidInput(_) => ErrorCode::InvalidInput,
Self::NoMarketData(_) => ErrorCode::NoMarketData,
Self::UpstreamBlocked(_) => ErrorCode::UpstreamBlocked,
}
}
@ -93,7 +99,8 @@ impl IdxError {
| ErrorCode::RateLimited
| ErrorCode::ProviderUnavailable
| ErrorCode::Offline
| ErrorCode::AuthError => 4,
| ErrorCode::AuthError
| ErrorCode::UpstreamBlocked => 4,
ErrorCode::Unsupported => 5,
ErrorCode::Io
| ErrorCode::ParseError
@ -119,4 +126,11 @@ mod tests {
let unsupported = IdxError::Unsupported("history unavailable".to_string());
assert_eq!(unsupported.code(), ErrorCode::Unsupported);
}
#[test]
fn upstream_blocked_has_its_own_wire_code_and_network_exit() {
let blocked = IdxError::UpstreamBlocked("stockbit.com: HTTP 403".to_string());
assert_eq!(blocked.code().to_string(), "UPSTREAMBLOCKED");
assert_eq!(blocked.exit_code(), 4);
}
}

View file

@ -6,8 +6,8 @@ use std::str::FromStr;
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
use crate::api::types::{Interval, Ohlc, Period, Quote};
use crate::api::{history_provider, history_source, idx_offset};
use crate::api::types::{Interval, Intraday, Ohlc, Period, Quote};
use crate::api::{history_provider, history_source, idx_offset, intraday_provider, stockbit};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::config::{HistoryProviderKind, ProviderKind};
use crate::error::IdxError;
@ -34,6 +34,10 @@ impl Market<'_> {
) -> Result<Vec<Fetched<Quote>>, IdxError> {
let idx = self.idx;
let bucket = idx.bucket("quote");
let ttl_secs = match idx.provider.kind() {
ProviderKind::Stockbit => idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
_ => idx.config.quote_ttl,
};
let session = latest_session(Utc::now());
let mut quotes = Vec::with_capacity(symbols.len());
for symbol in symbols {
@ -43,7 +47,7 @@ impl Market<'_> {
CacheSpec {
bucket: &bucket,
key: &resolved,
ttl_secs: idx.config.quote_ttl,
ttl_secs,
subject: &resolved,
source: idx.provider.kind().as_str(),
},
@ -60,6 +64,32 @@ impl Market<'_> {
Ok(quotes)
}
/// Today's session of one IDX symbol from Stockbit's public page,
/// whichever market provider is selected: the quote (bid/offer only while
/// the market is open) and the price per minute.
pub fn intraday(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<Intraday>, IdxError> {
let idx = self.idx;
let resolved = idx.resolve(symbol)?;
let provider = intraday_provider();
cached_fetch(
&idx.cache,
CacheSpec {
bucket: "stockbit-intraday",
key: &resolved,
ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
subject: &resolved,
source: stockbit::SOURCE,
},
policy,
|| provider.intraday(&resolved),
Ok,
)
}
/// Resolves the history source for an optional `--history-provider`
/// override, without fetching.
pub fn history_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
@ -380,6 +410,10 @@ mod tests {
avg_volume: None,
as_of: None,
behind_session: false,
bid: None,
offer: None,
market_status: None,
session_note: None,
}
}

View file

@ -0,0 +1,20 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
<TITLE>ERROR: The request could not be satisfied</TITLE>
</HEAD><BODY>
<H1>403 ERROR</H1>
<H2>The request could not be satisfied.</H2>
<HR noshade size="1px">
Request blocked.
We can't connect to the server for this app or website at this time. There might be too much traffic or a configuration error. Try again later, or contact the app or website owner.
<BR clear="all">
If you provide content to customers through CloudFront, you can find steps to troubleshoot and help prevent this error by reviewing the CloudFront documentation.
<BR clear="all">
<HR noshade size="1px">
<PRE>
Generated by cloudfront (CloudFront)
Request ID: VajZoZu7lzhasdjnxU37dHaztROLXHQ6DMpJzTPZoDhkd4I2dVc2mw==
</PRE>
<ADDRESS>
</ADDRESS>
</BODY></HTML>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><title>Stockbit</title></head><body><div id="__next"></div><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"symbol":"MABA","company":{"symbol":"MABA","name":"Marga Abhinaya Abadi Tbk","price":"50","previous":"50","change":"0.00","percentage":0,"volume":"0","date":"22 Oct 2026","time":"Thu 04:07","updated":"2022-10-17T01:22:50+07:00","status":"STATUS_SUSPENDED","tradeable":0,"type_company":"Saham","exchange":"IDX","orderbook":{"bid":{"price":"N/A","volume":"N/A"},"offer":{"price":"N/A","volume":"N/A"}},"market_hour":{"status":"close","time_left":52792,"formatted_time_left":"14 jam 39 menit 52 detik","suspend_info":""}},"chart":{"cagr":"","change":0,"drawdown":"","markingpoint":"","percentage":"","prices":[],"timeframe":"today","xaxisopt":"","previous":50,"line_weight":0,"previous_timeframe_price":null,"chart_type":"PRICE_CHART_TYPE_UNSPECIFIED","interval_in_minutes":0,"allowed_chart_type":[],"max_candles":0}},"__N_SSP":true},"page":"/symbol/[symbol]","query":{"symbol":"MABA"},"buildId":"v3.54.5","isFallback":false,"gssp":true}</script></body></html>

View file

@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><title>Stockbit</title></head><body><div id="__next"></div><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"symbol":"MYRX","company":{"symbol":"MYRX","name":"Hanson International Tbk.","price":"50","previous":"50","change":"0.00","percentage":0,"volume":"0","date":"21 Jul 2025","time":"Mon 07:56","updated":"2025-07-21T08:53:24+07:00","status":"STATUS_DELISTED","tradeable":0,"type_company":"Saham","exchange":"IDX","orderbook":{"bid":{"price":"N/A","volume":"N/A"},"offer":{"price":"N/A","volume":"N/A"}},"market_hour":{"status":"close","time_left":52561,"formatted_time_left":"14 jam 36 menit 1 detik","suspend_info":""}},"chart":{"cagr":"","change":0,"drawdown":"","markingpoint":"","percentage":"","prices":[],"timeframe":"today","xaxisopt":"","previous":50,"line_weight":0,"previous_timeframe_price":null,"chart_type":"PRICE_CHART_TYPE_UNSPECIFIED","interval_in_minutes":0,"allowed_chart_type":[],"max_candles":0}},"__N_SSP":true},"page":"/symbol/[symbol]","query":{"symbol":"MYRX"},"buildId":"v3.54.5","isFallback":false,"gssp":true}</script></body></html>

View file

@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><title>Error | Stockbit - Investasi Saham Bersama Komunitas Saham Terbesar di Indonesia</title></head><body><div id="__next"></div><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{},"__N_SSP":true},"page":"/404","query":{},"buildId":"v3.54.5","isFallback":false,"gssp":true}</script></body></html>

View file

@ -29,6 +29,15 @@ pub struct HistoryParams {
pub interval: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct IntradayParams {
/// IDX ticker, e.g. "BBCA"; indices as "^JKSE" or "IHSG".
pub symbol: String,
/// Only the latest N minutes (default: the whole session, at most 400).
#[serde(default)]
pub tail: 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, ScreenParams, SearchParams,
ShareholdersParams, SymbolsParams, SyncParams,
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, ScreenParams,
SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
};
const SYNC_TOOL: &str = "ownership_sync";
@ -43,6 +43,8 @@ const MAX_SEARCH_LIMIT: usize = 100;
// Pages above a few hundred rows (~40 KB) overflow some agents' tool output.
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;
/// Tool calls running at once; the rest wait their turn.
static CALL_SLOTS: Semaphore = Semaphore::const_new(4);
@ -117,7 +119,11 @@ impl IdxServer {
description = "Latest quotes (price, change, volume, 52-week range, market cap) \
for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true \
when that is before the latest IDX session, so the price is not that session's close: the \
provider lags, the stock did not trade, or the day was an exchange holiday."
provider lags, the stock did not trade, or the day was an exchange holiday. When the server \
runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. \
\"open\", \"close\"), `bid`/`offer` (best price, only while the market is open) and \
`session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 \
minutes."
)]
async fn get_quotes(&self, params: Parameters<SymbolsParams>) -> ToolResult {
let symbols = match checked_symbols(params.0.symbols) {
@ -131,6 +137,27 @@ provider lags, the stock did not trade, or the day was an exchange holiday."
.await
}
#[tool(
description = "Today's session of one IDX stock or index from Stockbit's public page \
(no login), whichever provider the server uses: `quote` (price, change, volume, \
`market_status`, `bid`/`offer` only while the market is open, `session_note`) and \
`points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no \
per-minute volume. Empty `points` means the stock did not trade this session (see \
`session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine \
(HTTP 403): do not retry at once."
)]
async fn get_intraday(&self, params: Parameters<IntradayParams>) -> ToolResult {
let IntradayParams { symbol, tail } = params.0;
self.live(move |idx| {
let tail = bounded_limit("tail", tail, MAX_INTRADAY_POINTS, MAX_INTRADAY_POINTS)?;
let mut day = idx.market().intraday(&symbol, CachePolicy::Default)?;
let points = &mut day.data.points;
points.drain(..points.len().saturating_sub(tail));
Ok(day)
})
.await
}
#[tool(description = "OHLCV price history for one IDX ticker.")]
async fn get_price_history(&self, params: Parameters<HistoryParams>) -> ToolResult {
let HistoryParams {

View file

@ -287,6 +287,33 @@
},
"name": "get_foreign_flow"
},
{
"description": "Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"symbol": {
"description": "IDX ticker, e.g. \"BBCA\"; indices as \"^JKSE\" or \"IHSG\".",
"type": "string"
},
"tail": {
"default": null,
"description": "Only the latest N minutes (default: the whole session, at most 400).",
"format": "uint",
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"required": [
"symbol"
],
"type": "object"
},
"name": "get_intraday"
},
{
"description": "Every IDX holding (above 1%) of one investor, matched by name.",
"inputSchema": {
@ -397,7 +424,7 @@
"name": "get_price_history"
},
{
"description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday.",
"description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. \"open\", \"close\"), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {

View file

@ -9,13 +9,14 @@ use idx_core::config::CoreConfig;
use idx_core::investor::{ImportSource, Investor};
use serde_json::{Value, json};
const TOOLS: [&str; 14] = [
const TOOLS: [&str; 15] = [
"analyze_bandar",
"analyze_stock",
"compare_stocks",
"get_broker_summary",
"get_company",
"get_foreign_flow",
"get_intraday",
"get_investor_holdings",
"get_ownership_changes",
"get_price_history",
@ -36,6 +37,10 @@ struct Client {
impl Client {
fn start(args: &[&str], seed: bool) -> Self {
Self::start_with_env(args, seed, &[])
}
fn start_with_env(args: &[&str], seed: bool, env: &[(&str, &str)]) -> Self {
let home = tempdir::Dir::new();
if seed {
seed_ownership_db(&home.path().join("data/idx/ownership.db"));
@ -50,6 +55,7 @@ impl Client {
.env("XDG_CACHE_HOME", home.path().join("cache"))
.env("XDG_DATA_HOME", home.path().join("data"))
.env("IDX_USE_MOCK_PROVIDER", "1")
.envs(env.iter().copied())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
@ -299,6 +305,63 @@ fn live_tools_wrap_data_with_freshness() {
assert_eq!(compared["failures"], json!([]));
}
#[test]
fn intraday_is_stockbit_whichever_provider_and_empty_sessions_are_data() {
let mut client = Client::start(&[], false);
let day = client.ok("get_intraday", json!({"symbol": "BBCA"}));
assert_fetched(&day);
assert_eq!(day["source"], "stockbit");
let quote = &day["data"]["quote"];
assert_eq!(quote["symbol"], "BBCA.JK");
assert_eq!(quote["market_status"], "close");
// The closing book is not reported as a live one.
assert!(
quote.get("bid").is_none() && quote.get("offer").is_none(),
"{quote}"
);
assert_eq!(day["data"]["points"].as_array().unwrap().len(), 335);
let tail = client.ok("get_intraday", json!({"symbol": "BBCA", "tail": 2}));
let points = tail["data"]["points"].as_array().unwrap();
assert_eq!(points.len(), 2);
assert_eq!(points[1]["time"], "2026-10-22T16:14:00+07:00");
let suspended = client.ok("get_intraday", json!({"symbol": "MABA"}));
assert_eq!(suspended["data"]["points"], json!([]));
assert_eq!(
suspended["data"]["quote"]["session_note"],
"Stockbit lists the stock as suspended"
);
for (arguments, code) in [
(json!({"symbol": "ZZZZ"}), "SYMBOLNOTFOUND"),
(json!({"symbol": "BBCA", "tail": 0}), "INVALIDINPUT"),
(json!({"symbol": "BBCA", "tail": 401}), "INVALIDINPUT"),
] {
let (is_error, body) = client.call("get_intraday", arguments.clone());
assert!(is_error, "{arguments}: {body}");
assert_eq!(body["code"], code, "{arguments}: {body}");
}
}
#[test]
fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() {
let mut client = Client::start_with_env(&[], false, &[("IDX_PROVIDER", "stockbit")]);
let quotes = client.ok("get_quotes", json!({"symbols": ["BBCA"]}));
assert_eq!(quotes["quotes"][0]["source"], "stockbit");
assert_eq!(quotes["quotes"][0]["data"]["market_status"], "close");
let mut blocked = Client::start_with_env(&[], false, &[("IDX_MOCK_STOCKBIT_STATUS", "403")]);
let (is_error, body) = blocked.call("get_intraday", json!({"symbol": "BBCA"}));
assert!(is_error, "{body}");
assert_eq!(body["code"], "UPSTREAMBLOCKED");
assert!(
body["message"].as_str().unwrap().contains("does not retry"),
"{body}"
);
}
#[test]
fn errors_carry_cli_codes() {
let mut client = Client::start(&[], false);

View file

@ -55,6 +55,9 @@ command parses arguments, makes one idx-core call, and renders the result.
(`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries).
- **IDX (idx.co.id)** — the daily trading summary behind `stocks flow
--source idx` (`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow).
- **Stockbit (public page)** — `stocks intraday` whichever provider is
selected (`IntradayProvider`), and quotes under the opt-in
`--provider stockbit`. See [Stockbit](#stockbit).
### MSN Endpoints
- `https://assets.msn.com/service/`: quotes, charts, equities, earnings,
@ -151,6 +154,41 @@ WIB as the fallback.
range fails on one. `foreign_net_value` is net shares at the session's
average price (value over volume), an estimate.
### Stockbit
- `GET https://stockbit.com/symbol/{CODE}` needs no login. Next.js
server-renders the quote and today's chart into the page's `__NEXT_DATA__`
JSON; `api/stockbit/parse.rs` reads it purely. `props.pageProps.company`
is the quote (numbers mostly as strings, an index price as a float string),
`orderbook.{bid,offer}.price` the best bid and offer, `market_hour.status`
the market's state, and `status` `STATUS_ACTIVE|SUSPENDED|DELISTED`.
`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.
- 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
hours; any other value fails closed. In session the chart gained a point
every minute and the last trade was within a minute of the clock, although
Stockbit's terms call its data delayed at least 10 minutes. Order-book
sizes are dropped: the page does not say whether they are lots or shares.
`as_of` comes from `date`/`time`
only when the stock traded (a suspended stock carries today's date).
`session_note` repeats what the page says (suspended, delisted) or "no
trades this session"; it is never inferred.
- CloudFront in front of the page answers a static 403 to some user agents
(`curl/`, `axios/`, `Go-http-client/`, empty) and does not fingerprint TLS,
so a plain ureq request with `idx-cli/<version> (+repo)` passes. A 403 is
`UPSTREAMBLOCKED` and is not retried. Requests are at least 1 s apart
across the user's idx processes (the last request's time sits in
`<cache>/stockbit.pace` under a file lock, so a shell loop of `idx` calls is
paced too), and snapshots are cached for 5 s (at most `quote_ttl`), which
coalesces an agent's quote-then-intraday burst across MCP calls.
- Stockbit's terms call its data delayed at least 10 minutes and for
personal use; frontends say so. Fixtures (`tests/fixtures/stockbit_*`) are
pages recorded 2026-10-22 after the close, cut to the fields read.
### Capability Gating
```
MarketDataProvider = QuoteProvider + FundamentalsProvider
@ -161,8 +199,13 @@ Factory functions:
history_provider(kind, mode, verbose) → Result<(ProviderKind, Box<dyn HistoryProvider>)>
broker_provider() → Box<dyn BrokerSummaryProvider>
trading_summary_provider() → Box<dyn TradingSummaryProvider>
intraday_provider() → Box<dyn IntradayProvider>
```
`ProviderKind::Stockbit` implements quotes only: fundamentals are
`UNSUPPORTED`, the MSN-only capabilities say "requires --provider msn", and
`auto` history falls back to Yahoo.
## Ownership Module (SQLite-backed)
Unlike the `stocks` module (live-fetch), ownership is **import-then-query**:
@ -239,7 +282,9 @@ CLI flags > environment variables > config file > defaults
bidirectional overrides replaced before it reaches a terminal table.
- Exit codes by class: `0` success, `2` `INVALIDINPUT`/`CONFIGERROR` and clap usage
errors, `3` `SYMBOLNOTFOUND`/`NOMARKETDATA`/`CACHEMISS`, `4` `HTTP`/`RATELIMITED`/
`PROVIDERUNAVAILABLE`/`OFFLINE`/`AUTHERROR`, `5` `UNSUPPORTED`, `1` everything else
`PROVIDERUNAVAILABLE`/`OFFLINE`/`AUTHERROR`/`UPSTREAMBLOCKED`, `5` `UNSUPPORTED`,
`1` everything else. `UPSTREAMBLOCKED` is an upstream refusing this client
outright (a CDN 403 page); retrying at once does not help.
- A totally failed `stocks compare` emits one structured error in JSON mode; per-symbol warnings are retained for partial success, not printed ahead of a total-failure error.
## CLI Output and Offline Cache

View file

@ -184,7 +184,7 @@ is another 20-30% smaller than pretty JSON.
| 1 | other failure | `IO`, `DATABASEERROR`, `PARSEERROR`, ... |
| 2 | invalid usage or input | clap errors, `INVALIDINPUT`, `CONFIGERROR` |
| 3 | not found or no data | `SYMBOLNOTFOUND`, `NOMARKETDATA`, `CACHEMISS` |
| 4 | upstream unavailable, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE`, `AUTHERROR` |
| 4 | upstream unavailable or blocked, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE`, `AUTHERROR`, `UPSTREAMBLOCKED` |
| 5 | unsupported | `UNSUPPORTED` |
- JSON keeps today's top-level shape: no `{data, meta}` wrapper, so `jq` paths

View file

@ -42,12 +42,12 @@ contents. The native macOS installer job runs separately to exercise BSD tools.
## Live drift check
Fixtures cannot tell when MSN, Yahoo, or IDX change their responses.
Fixtures cannot tell when MSN, Yahoo, IPOT, IDX, or Stockbit change their responses.
`scripts/live-smoke.sh` runs shipped commands against the real upstreams and
validates each JSON output shape. It is opt-in and never part of the gate.
```bash
nix develop --command scripts/live-smoke.sh # stocks + ownership
nix develop --command scripts/live-smoke.sh # stocks + stockbit + ownership
nix develop --command scripts/live-smoke.sh --group stocks --symbol BBRI
nix develop --command scripts/live-smoke.sh --bin ./result/bin/idx
nix develop --command scripts/live-smoke.sh --compare ./old/bin/idx # A/B against an older build
@ -58,10 +58,16 @@ scripts/live-smoke.sh --dry-run
`flow` and `flow --source idx` on the new binary only, so an older `--compare` build that predates
them is fine), plus `compare BUMI,ADRO,AIMS`,
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.
- `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.
A case whose upstream refuses us (`UPSTREAMBLOCKED`, an HTTP 403) prints
`BLOCKED (HTTP 403)` and counts as a failure, never as a pass.
`--compare <old-idx>` runs every case with both binaries back to back, each
with its own config and cache, and fails on a different exit code, any added,
missing, or retyped JSON key, or changed values for `profile`, `financials`,

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 == 14' out.jsonl
jq -se 'map(select(.id == 2))[0].result.tools | length == 15' out.jsonl
touch "$out"
'';
# The website's generated pages match the CLI help and the MCP

View file

@ -1,8 +1,10 @@
#!/usr/bin/env bash
# Live provider drift check: runs shipped commands against real MSN, Yahoo,
# and IDX endpoints and validates the JSON output shape. Deterministic CLI
# behavior is covered by tests/cli.rs and tests/golden.rs; this answers "do the
# upstreams still look the way our parsers expect?"
# IPOT, IDX, and Stockbit endpoints and validates the JSON output shape.
# Deterministic CLI behavior is covered by tests/cli.rs and tests/golden.rs;
# this answers "do the upstreams still look the way our parsers expect?"
# An upstream that refuses us (UPSTREAMBLOCKED, HTTP 403) is reported as
# BLOCKED, never as a pass.
#
# With --compare <old-idx>, every case also runs the old binary back to back
# and fails on any difference in exit code or JSON structure, and on any value
@ -26,7 +28,8 @@ usage() {
Usage: scripts/live-smoke.sh [options]
Options:
--group <stocks|ownership> Restrict to a group. Repeatable. Default: both
--group <name> stocks, stockbit, or ownership. Repeatable.
Default: all three
--symbol <ticker> Primary ticker. Default: BBCA
--bin <path> Use an existing idx binary (skips cargo build)
--compare <old-idx> Also run an older idx binary and diff the results
@ -46,12 +49,12 @@ while [[ $# -gt 0 ]]; do
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ ${#GROUPS_SELECTED[@]} -eq 0 ]] && GROUPS_SELECTED=(stocks ownership)
[[ ${#GROUPS_SELECTED[@]} -eq 0 ]] && GROUPS_SELECTED=(stocks stockbit ownership)
for group in "${GROUPS_SELECTED[@]}"; do
case "$group" in
stocks|ownership) ;;
*) echo "invalid --group '$group' (expected stocks or ownership)" >&2; exit 2 ;;
stocks|stockbit|ownership) ;;
*) echo "invalid --group '$group' (expected stocks, stockbit, or ownership)" >&2; exit 2 ;;
esac
done
@ -99,8 +102,10 @@ def types: [paths(scalars) as $p | select(getpath($p) != null)
# jq filter describing the JSON shape each command must keep.
contract() {
case "$1" in
"stocks quote "*|"stocks screen"*)
"stocks quote "*|"stocks screen"*|"--provider stockbit stocks quote "*)
echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.price | type == "number"))' ;;
"stocks intraday "*)
echo '(.quote.symbol | type == "string") and (.quote.price | type == "number") and (.quote.market_status | type == "string") and (.points | type == "array") and all(.points[]; (.time | type == "string") and (.price | type == "number"))' ;;
"stocks history "*)
echo 'type == "array" and length > 0 and all(.[]; (.date | type == "string") and (.close | type == "number"))' ;;
"stocks technical "*)
@ -172,6 +177,11 @@ run_case() {
fi
fi
if (( status != 0 )); then
if grep -q 'UPSTREAMBLOCKED' "$log.stderr"; then
FAILURES+=("$name: BLOCKED, upstream refused us with HTTP 403 (see $log.stderr)")
echo " BLOCKED (HTTP 403)"
return
fi
FAILURES+=("$name: exit $status (see $log.stderr)")
echo " FAIL exit"
return
@ -238,6 +248,13 @@ if selected stocks; then
run_new_case flow-idx "stocks flow $SYMBOL --days 3 --source idx"
fi
if selected stockbit; then
# Stockbit's public page (no login): one request per case, 1 s apart.
run_new_case intraday "stocks intraday $SYMBOL"
run_new_case intraday-index "stocks intraday ^JKSE"
run_new_case quote-stockbit "--provider stockbit stocks quote $SYMBOL"
fi
if selected ownership; then
run_case discover "ownership discover --limit 1"
if (( ! DRY_RUN )) && [[ -s "$LOG_DIR/$(printf '%02d' "$TOTAL")_discover.stdout" ]]; then

View file

@ -31,7 +31,7 @@ export default defineConfig({
},
{
label: 'CLI',
items: ['cli/stocks', 'cli/ownership', 'cli/output', 'cli/reference'],
items: ['cli/stocks', 'cli/stockbit', 'cli/ownership', 'cli/output', 'cli/reference'],
},
{
label: 'MCP server',

View file

@ -32,7 +32,7 @@ stdout is not a terminal, and with `--no-color`.
| `0` | success | |
| `2` | invalid input or config | fix the command |
| `3` | not found, no data, or no cached data offline | check the ticker or name; go online |
| `4` | upstream unavailable | retry later |
| `4` | upstream unavailable or blocked | retry later; for `UPSTREAMBLOCKED`, wait or change network |
| `5` | unsupported | use another command or provider |
| `1` | anything else | read the message |

View file

@ -41,6 +41,11 @@ Options:
-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
@ -60,6 +65,7 @@ Usage: idx stocks [OPTIONS] <COMMAND>
Commands:
quote Get real-time stock quotes
intraday Today's session price by the minute (Stockbit)
history Get historical OHLC data
technical Run technical analysis on a stock
growth Run growth analysis on a stock
@ -84,6 +90,7 @@ Options:
--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
@ -104,6 +111,7 @@ Options:
--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
@ -113,6 +121,53 @@ Examples:
idx stocks quote BBCA,BBRI,BMRI
idx -o json stocks quote BBCA
echo BBCA BBRI | idx stocks quote -
idx --provider stockbit stocks quote BBCA # with bid/offer while the market is open
```
### `idx stocks intraday`
```text
Today's session of one IDX stock or index, by the minute, from Stockbit's public page (no login): the quote, the best bid/offer while the market is open, a sparkline, and the last minutes. Delayed at least 10 minutes per Stockbit's terms; prices only, no per-minute volume. A stock that did not trade has no minutes, which is not an error. Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45).
Usage: idx stocks intraday [OPTIONS] <SYMBOL>
Arguments:
<SYMBOL>
Single ticker symbol (e.g. BBCA)
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 stocks intraday BBCA
idx stocks intraday ^JKSE
idx -o json stocks intraday BBCA | jq '.points[-1]'
```
### `idx stocks history`
@ -140,6 +195,8 @@ Options:
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
@ -173,6 +230,8 @@ Options:
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
@ -200,6 +259,7 @@ Options:
--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
@ -224,6 +284,7 @@ Options:
--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
@ -248,6 +309,7 @@ Options:
--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
@ -272,6 +334,7 @@ Options:
--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
@ -337,6 +400,11 @@ Options:
[default: all]
--provider <PROVIDER>
Market data provider [default: general.provider, else msn]
[possible values: yahoo, msn, stockbit]
--offline
Run without network requests
@ -409,17 +477,22 @@ Options:
[default: all]
--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
--top <TOP>
Tier behind the headline signal: 1, 3, 5 or 10 brokers per side
[default: 5]
--no-cache
Bypass local cache
-h, --help
Print help (see a summary with '-h')
@ -474,6 +547,11 @@ Options:
-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
@ -506,6 +584,7 @@ Options:
--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
@ -527,6 +606,7 @@ Options:
--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
@ -556,6 +636,7 @@ Options:
-q, --quiet Suppress non-essential output
--quarterly Only include quarterly periods
-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
@ -581,6 +662,7 @@ Options:
--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
@ -601,6 +683,7 @@ Options:
--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
@ -622,6 +705,7 @@ Options:
--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
@ -642,6 +726,7 @@ Options:
--limit <LIMIT> [default: 50]
-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
@ -662,6 +747,7 @@ Options:
--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
@ -704,6 +790,11 @@ Options:
-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
@ -737,6 +828,7 @@ Options:
--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
@ -757,6 +849,7 @@ Options:
--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
@ -778,6 +871,7 @@ Options:
--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
@ -795,6 +889,7 @@ Options:
--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
@ -817,6 +912,7 @@ Options:
--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
@ -834,6 +930,7 @@ Options:
--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
@ -851,6 +948,7 @@ Options:
--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
@ -899,6 +997,11 @@ Options:
-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
@ -930,6 +1033,7 @@ Options:
--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
@ -977,6 +1081,11 @@ Options:
-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
@ -1028,6 +1137,11 @@ Options:
-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
@ -1061,6 +1175,7 @@ Options:
--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
@ -1081,6 +1196,7 @@ Options:
--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
@ -1121,6 +1237,11 @@ Options:
-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
@ -1149,6 +1270,7 @@ Options:
--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
@ -1168,6 +1290,7 @@ Options:
--top <TOP> [default: 20]
-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
@ -1188,6 +1311,7 @@ Options:
--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
@ -1223,6 +1347,11 @@ Options:
-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
@ -1254,6 +1383,7 @@ Options:
--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
@ -1277,6 +1407,7 @@ Options:
--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
@ -1295,6 +1426,7 @@ Options:
--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
@ -1316,6 +1448,7 @@ Options:
--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
@ -1337,6 +1470,7 @@ Options:
--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
@ -1354,6 +1488,7 @@ Options:
--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
@ -1374,6 +1509,7 @@ Options:
--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
@ -1391,6 +1527,7 @@ Options:
--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

View file

@ -0,0 +1,75 @@
---
title: Stockbit
description: Today's session by the minute and quotes with best bid/offer, from Stockbit's public page. Opt-in, no account.
---
[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.
```bash
idx stocks intraday BBCA # today's session, by the minute
idx stocks intraday ^JKSE # an index (or IHSG, LQ45)
idx --provider stockbit stocks quote BBCA # quote with bid/offer
idx -o json stocks intraday BBCA | jq '.points | length'
```
## What you get
| | `stocks intraday` | `--provider stockbit stocks quote` |
| --- | --- | --- |
| Price, change, previous close, volume | yes | yes |
| Best bid and offer | while the market is open | while the market is open |
| Market status (`market_status`) | yes | yes |
| Price per minute (`points`) | yes | no |
| Market cap, 52-week range | no | no |
- **Delayed.** Stockbit's terms describe its market data as "a real-time
snapshot, delayed at least 10 minutes". It is not a live feed.
- **Bid and offer only while trading.** Outside the session, Stockbit still
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.
- **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
suspended`). An active stock with no volume says `no trades this session`,
even when its chart is a flat line at the previous close. Neither is an
error.
`--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.
## How idx talks to Stockbit
- One request per symbol to `https://stockbit.com/symbol/<CODE>`, at least one
second apart across all your idx processes (a shell loop of `idx` calls
included), cached for 5 seconds.
- An honest user agent, `idx-cli/<version> (+https://github.com/0xrsydn/idx-cli)`,
not a browser disguise. Stockbit's CDN refuses some generic agents
(`curl/…`) outright but lets this one through. No curl-impersonate
needed.
- No retries. If Stockbit's CDN refuses your machine, the command fails at
once with `UPSTREAMBLOCKED` (exit `4`):
```json
{"error":true,"code":"UPSTREAMBLOCKED","message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); ..."}
```
Wait, or try from another network. `--provider msn` serves quotes without
Stockbit.
## 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.
Over MCP, the same data comes from the `get_intraday` tool, and from
`get_quotes` when the server runs with `IDX_PROVIDER=stockbit`.

View file

@ -19,6 +19,20 @@ session, the table says so under it, and JSON sets `behind_session`: the
provider may still show the previous close. `history` takes `--period` (`1d`
to `5y`) and `--interval` (`1d`, `1wk`, `1mo`).
## Today by the minute
```bash
idx stocks intraday BBCA
idx stocks intraday ^JKSE
idx -o json stocks intraday BBCA | jq '.points[-1]'
```
`intraday` shows today's session from Stockbit's public page, without a
login: the quote, the best bid and offer while the market is open, a
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.
## Company data
| Command | What it shows |
@ -142,6 +156,8 @@ 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`), whichever provider is selected. Switch with `IDX_PROVIDER=msn|yahoo` and
`IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the matching
[config keys](/start/config/).
(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/).

View file

@ -5,8 +5,9 @@ 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
[14 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, price history, screening, company data,
analysis, comparison, broker summaries, bandarmology, foreign flow, and the KSEI
[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.
It shares idx's [config, cache and ownership database](/start/config/#files-idx-keeps),
@ -27,7 +28,7 @@ Tools that fetch live data return:
}
```
`source` is the provider (`msn` or `yahoo`). Results are cached on disk;
`source` is the provider (`msn`, `yahoo`, `ipot`, `idx` or `stockbit`). Results are cached on disk;
`stale` is true when an expired cache entry was served because a fresh fetch
failed. Ownership tools return their data directly, from the local
database.
@ -44,6 +45,7 @@ body, so the agent can decide whether to retry:
| Input | Limit |
| --- | --- |
| tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed |
| `get_intraday` `tail` | 400 minutes (default: the whole session) |
| `screen_stocks` `limit` | 200 |
| `search_investors` `limit` | 100 |
| `get_ownership_changes` `limit` | 300 per page (default 200); page with `offset` |

View file

@ -75,6 +75,15 @@ Foreign investors' buying and selling of one IDX ticker, session by session (old
| `symbol` | string | yes | IDX ticker, e.g. "BBCA". |
| `to` | string | no | Last session of the range, YYYY-MM-DD (default today). |
## `get_intraday`
Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.
| Input | Type | Required | Description |
| --- | --- | --- | --- |
| `symbol` | string | yes | IDX ticker, e.g. "BBCA"; indices as "^JKSE" or "IHSG". |
| `tail` | integer | no | Only the latest N minutes (default: the whole session, at most 400). |
## `get_investor_holdings`
Every IDX holding (above 1%) of one investor, matched by name.
@ -107,7 +116,7 @@ OHLCV price history for one IDX ticker.
## `get_quotes`
Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday.
Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. "open", "close"), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.
| Input | Type | Required | Description |
| --- | --- | --- | --- |

View file

@ -28,7 +28,7 @@ in scripts. Ask for JSON with `-o json`, `IDX_OUTPUT=json`, or
```toml
[general]
provider = "msn" # msn | yahoo
provider = "msn" # msn | yahoo | stockbit
history_provider = "auto" # auto | yahoo | msn
exchange = "JK"
output = "auto" # auto | table | json | jsonl

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: 11 tools for any MCP client.</p>
<p>JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 15 tools for any MCP client.</p>
<code>npx -y idx-mcp</code>
</div>
</div>