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

@ -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

@ -10,11 +10,12 @@ Commands:
help Print this message or the help of the given subcommand(s)
Options:
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
--no-color Disable colored output
-q, --quiet Suppress non-essential output
-v, --verbose... Increase verbosity (-v, -vv)
--offline Run without network requests
--no-cache Bypass local cache
-h, --help Print help
-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
--- stderr

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
@ -26,11 +27,12 @@ Commands:
help Print this message or the help of the given subcommand(s)
Options:
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
--no-color Disable colored output
-q, --quiet Suppress non-essential output
-v, --verbose... Increase verbosity (-v, -vv)
--offline Run without network requests
--no-cache Bypass local cache
-h, --help Print help
-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
--- stderr

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);