mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
018d9ba936
commit
74e5bb0536
65 changed files with 2320 additions and 381 deletions
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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("es, format, no_color)
|
||||
render_quotes("es, 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(¬e));
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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=()
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: ZZZZ.JK"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 3
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: symbol not found: ZZZZ.JK
|
||||
|
|
@ -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)"}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
exit: 5
|
||||
--- stdout
|
||||
--- stderr
|
||||
Error: unsupported: BBCA.JK: Stockbit has no fundamentals; command requires --provider msn or yahoo
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
crates/idx-cli/tests/golden/stockbit-quote.json.golden
Normal file
4
crates/idx-cli/tests/golden/stockbit-quote.json.golden
Normal 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
|
||||
12
crates/idx-cli/tests/golden/stockbit-quote.table.golden
Normal file
12
crates/idx-cli/tests/golden/stockbit-quote.table.golden
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
4
crates/idx-cli/tests/golden/stocks-intraday.json.golden
Normal file
4
crates/idx-cli/tests/golden/stocks-intraday.json.golden
Normal file
File diff suppressed because one or more lines are too long
22
crates/idx-cli/tests/golden/stocks-intraday.table.golden
Normal file
22
crates/idx-cli/tests/golden/stocks-intraday.table.golden
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
193
crates/idx-core/src/api/stockbit/mod.rs
Normal file
193
crates/idx-core/src/api/stockbit/mod.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
440
crates/idx-core/src/api/stockbit/parse.rs
Normal file
440
crates/idx-core/src/api/stockbit/parse.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
20
crates/idx-core/tests/fixtures/stockbit_blocked_403.html
vendored
Normal file
20
crates/idx-core/tests/fixtures/stockbit_blocked_403.html
vendored
Normal 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>
|
||||
1
crates/idx-core/tests/fixtures/stockbit_symbol_bbca.html
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_symbol_bbca.html
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_symbol_ihsg.html
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_symbol_ihsg.html
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_symbol_maba.html
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_symbol_maba.html
vendored
Normal 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>
|
||||
1
crates/idx-core/tests/fixtures/stockbit_symbol_myrx.html
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_symbol_myrx.html
vendored
Normal 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>
|
||||
1
crates/idx-core/tests/fixtures/stockbit_symbol_zzzz_404.html
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_symbol_zzzz_404.html
vendored
Normal 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>
|
||||
|
|
@ -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").
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue