From 74e5bb0536cd78db6b88c64e24227759ff9b7fe7 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 23 Oct 2026 04:58:32 +0000 Subject: [PATCH 01/33] 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 --- README.md | 11 +- ROADMAP.md | 27 +- crates/idx-cli/src/cli/mod.rs | 8 + crates/idx-cli/src/cli/stocks.rs | 25 +- crates/idx-cli/src/config.rs | 3 + crates/idx-cli/src/output/mod.rs | 20 +- crates/idx-cli/src/output/table.rs | 191 +++++- crates/idx-cli/tests/cli.rs | 21 + crates/idx-cli/tests/golden/cases.toml | 32 + .../tests/golden/completions-bash.golden | 349 ++++++++-- .../golden/error-intraday-blocked.json.golden | 4 + .../error-intraday-blocked.table.golden | 4 + .../error-intraday-unknown-symbol.json.golden | 4 + ...error-intraday-unknown-symbol.table.golden | 4 + .../golden/error-invalid-provider.json.golden | 2 +- .../error-invalid-provider.table.golden | 2 +- ...ockbit-fundamental-unsupported.json.golden | 4 + ...ckbit-fundamental-unsupported.table.golden | 4 + crates/idx-cli/tests/golden/help-cache.golden | 15 +- .../idx-cli/tests/golden/help-config.golden | 5 + .../tests/golden/help-ownership.golden | 5 + crates/idx-cli/tests/golden/help-root.golden | 5 + .../idx-cli/tests/golden/help-stocks.golden | 16 +- .../tests/golden/stockbit-quote.json.golden | 4 + .../tests/golden/stockbit-quote.table.golden | 12 + .../golden/stocks-intraday-index.table.golden | 22 + .../stocks-intraday-suspended.json.golden | 4 + .../stocks-intraday-suspended.table.golden | 7 + .../tests/golden/stocks-intraday.json.golden | 4 + .../tests/golden/stocks-intraday.table.golden | 22 + crates/idx-core/src/api/mock.rs | 61 +- crates/idx-core/src/api/mod.rs | 41 +- crates/idx-core/src/api/msn/map.rs | 8 + crates/idx-core/src/api/stockbit/mod.rs | 193 ++++++ crates/idx-core/src/api/stockbit/parse.rs | 440 ++++++++++++ crates/idx-core/src/api/types.rs | 35 + crates/idx-core/src/api/yahoo/map.rs | 4 + crates/idx-core/src/client.rs | 2 +- crates/idx-core/src/config.rs | 9 +- crates/idx-core/src/error.rs | 16 +- crates/idx-core/src/market.rs | 40 +- .../tests/fixtures/stockbit_blocked_403.html | 20 + .../tests/fixtures/stockbit_symbol_bbca.html | 1 + .../tests/fixtures/stockbit_symbol_ihsg.html | 1 + .../tests/fixtures/stockbit_symbol_maba.html | 1 + .../tests/fixtures/stockbit_symbol_myrx.html | 1 + .../fixtures/stockbit_symbol_zzzz_404.html | 1 + crates/idx-mcp/src/params.rs | 9 + crates/idx-mcp/src/server.rs | 33 +- crates/idx-mcp/tests/golden/tools.json | 29 +- crates/idx-mcp/tests/mcp.rs | 65 +- docs/ARCHITECTURE.md | 47 +- docs/DESIGN.md | 2 +- docs/SMOKE.md | 10 +- flake.nix | 2 +- scripts/live-smoke.sh | 33 +- web/astro.config.mjs | 2 +- web/src/content/docs/cli/output.md | 2 +- web/src/content/docs/cli/reference.md | 637 +++++++++++------- web/src/content/docs/cli/stockbit.md | 75 +++ web/src/content/docs/cli/stocks.md | 22 +- web/src/content/docs/mcp/index.md | 8 +- web/src/content/docs/mcp/tools.md | 11 +- web/src/content/docs/start/config.md | 2 +- web/src/pages/index.astro | 2 +- 65 files changed, 2320 insertions(+), 381 deletions(-) create mode 100644 crates/idx-cli/tests/golden/error-intraday-blocked.json.golden create mode 100644 crates/idx-cli/tests/golden/error-intraday-blocked.table.golden create mode 100644 crates/idx-cli/tests/golden/error-intraday-unknown-symbol.json.golden create mode 100644 crates/idx-cli/tests/golden/error-intraday-unknown-symbol.table.golden create mode 100644 crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.json.golden create mode 100644 crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.table.golden create mode 100644 crates/idx-cli/tests/golden/stockbit-quote.json.golden create mode 100644 crates/idx-cli/tests/golden/stockbit-quote.table.golden create mode 100644 crates/idx-cli/tests/golden/stocks-intraday-index.table.golden create mode 100644 crates/idx-cli/tests/golden/stocks-intraday-suspended.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-intraday-suspended.table.golden create mode 100644 crates/idx-cli/tests/golden/stocks-intraday.json.golden create mode 100644 crates/idx-cli/tests/golden/stocks-intraday.table.golden create mode 100644 crates/idx-core/src/api/stockbit/mod.rs create mode 100644 crates/idx-core/src/api/stockbit/parse.rs create mode 100644 crates/idx-core/tests/fixtures/stockbit_blocked_403.html create mode 100644 crates/idx-core/tests/fixtures/stockbit_symbol_bbca.html create mode 100644 crates/idx-core/tests/fixtures/stockbit_symbol_ihsg.html create mode 100644 crates/idx-core/tests/fixtures/stockbit_symbol_maba.html create mode 100644 crates/idx-core/tests/fixtures/stockbit_symbol_myrx.html create mode 100644 crates/idx-core/tests/fixtures/stockbit_symbol_zzzz_404.html create mode 100644 web/src/content/docs/cli/stockbit.md diff --git a/README.md b/README.md index 4e3b22a..bd6b5b4 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ idx stocks history BBCA --period 3mo idx stocks technical BBCA idx stocks fundamental BBCA idx stocks compare BBCA BBRI BMRI +idx stocks intraday BBCA # today by the minute (Stockbit, delayed) idx -o json stocks quote BBCA # Ownership: download the maintained snapshot once, then query locally @@ -90,8 +91,12 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31 - Analysis: `technical`, `growth`, `valuation`, `risk`, and a combined `fundamental` report; multi-symbol `compare`; MSN stock `screen` - Providers: MSN is the default for quotes, fundamentals, and screening; - Yahoo serves history. Switch with `IDX_PROVIDER=msn|yahoo` and + Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo` and `IDX_HISTORY_PROVIDER=auto|yahoo|msn` +- Stockbit, opt-in and without a login: `stocks intraday` (today's price by + the minute) and `--provider stockbit` quotes with the best bid/offer while + the market is open. Delayed at least 10 minutes, for personal use under + Stockbit's terms; see the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/) - Local file cache with TTL, and `--offline` mode with stale-cache fallback ### Ownership (KSEI shareholders above 1%) @@ -158,8 +163,8 @@ change it to `auto` with `idx config set general.output auto`. ## MCP server -`idx-mcp` serves the same data to MCP clients over stdio, as 14 tools: -`get_quotes`, `get_price_history`, `screen_stocks`, `get_company`, +`idx-mcp` serves the same data to MCP clients over stdio, as 15 tools: +`get_quotes`, `get_intraday`, `get_price_history`, `screen_stocks`, `get_company`, `analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`, `get_foreign_flow`, `get_shareholders`, `get_investor_holdings`, `search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as diff --git a/ROADMAP.md b/ROADMAP.md index 56cf036..d7c453c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,16 +4,23 @@ Open work only. Shipped behavior is described in `README.md` and `docs/`; history lives in git. ## Stocks -- `market summary`: IHSG index and market breadth +- `market summary`: market breadth and an index overview (index levels alone + are already `stocks intraday ^JKSE` and `--provider stockbit stocks quote IHSG`) - `market movers`: top gainers, losers, and volume - `market sectors`: sector performance overview - `screen query ""`: filter stocks by expression - `screen presets` / `screen run `: built-in screener presets (graduate `screen` out of `stocks` only if these grow into a richer workflow) - Multi-period financial statements (today `financials` is single-period) -- Order book (bid/offer depth): no free, login-free source exists. IDX sells - delayed depth to vendors and Stockbit's needs a user login. Revisit if a - permitted source appears; do not ship the closing best bid/offer as one. +- Order book depth: no free, login-free source exists (IDX sells delayed depth + to vendors). Planned as the Stockbit login follow-on: opt-in with the + user's own account, the depth ladder, intraday OHLCV and token refresh from + `exodus.stockbit.com`; fzn0x/idx (MIT) maps the endpoints. The public page + already gives the best bid/offer (L1) while the market is open. +- Observe Stockbit's `market_hour.status` in the pre-open (08:45-09:00) and + the closing auction (15:50-16:00) before `--watch` or alerts build on it; + `open`, `break` and `close` are known, and the bid/offer gate hides the book + for anything but `open`. - Bandarmology follow-ups: a daily series (per-session readings and the cumulative top-N net) and a price-move phase (quiet accumulation, markup), calibration of the `top_concentration_v1` thresholds across more sessions, @@ -26,7 +33,8 @@ history lives in git. - `watchlist`: named symbol sets usable by other commands - `alerts`: price alerts with a daemon - CSV/TSV output -- Additional providers (Alpha Vantage, Twelve Data, IDX official) +- Additional providers (Alpha Vantage, Twelve Data); IDX's own site serves + only `stocks flow --source idx` today ## Agent integration - JSON Schema export for command output @@ -39,11 +47,16 @@ history lives in git. - `stocks flow --source idx` depends on IDX's Cloudflare passing a curl-impersonate profile (only `curl_safari184` in October 2026) and on the network; the default IPOT source rounds to one decimal. +- Stockbit's terms call its data delayed at least 10 minutes and for + personal, non-commercial use (on 2026-10-23 the last trade was within a + minute of the clock); its CDN refuses some user agents with a 403 + (`UPSTREAMBLOCKED`). Its order-book sizes are not shown: the page does not + say whether they are lots or shares. - Yahoo can return `429` from datacenter IPs; requests retry with backoff but can still fail. - `stocks technical` reports the SMA200 trend as unavailable when fewer than 200 daily candles are returned. -- MSN's `low-pe` screen returned no priced stocks on 2026-10-14 (upstream; - other filters work). +- MSN's `low-pe` screen returns no priced stocks (`PARSEERROR`; still so on + 2026-10-23, upstream; other filters work). - MSN history for IDX is price-only (`1mo`, `3mo`, `1y`); Yahoo stays the default OHLCV source. diff --git a/crates/idx-cli/src/cli/mod.rs b/crates/idx-cli/src/cli/mod.rs index 1ced634..4b4ac70 100644 --- a/crates/idx-cli/src/cli/mod.rs +++ b/crates/idx-cli/src/cli/mod.rs @@ -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, #[arg(long, global = true, help = "Run without network requests")] pub offline: bool, #[arg(long, global = true, help = "Bypass local cache")] diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index d27e576..e538a34 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -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, }, + #[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, diff --git a/crates/idx-cli/src/config.rs b/crates/idx-cli/src/config.rs index a0ad8f3..69eac1f 100644 --- a/crates/idx-cli/src/config.rs +++ b/crates/idx-cli/src/config.rs @@ -34,6 +34,9 @@ impl IdxConfig { pub fn load_with_cli(cli: &Cli) -> Result { 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; diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 3429e04..62d35b2 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -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, diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 4481b62..6fc60bd 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -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) -> Ve .collect() } +fn pct_cell(change_pct: Option, 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) -> String { + value.map(format_idr).unwrap_or_else(|| "-".to_string()) +} + +fn wib_minute(time: chrono::DateTime) -> 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 = 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, } } diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index abdade7..82bf3fc 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -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") diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 2cb5311..00e1ff3 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -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]] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index aff5968..998698f 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -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 " + 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 @@ -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 " + 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 @@ -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 " + 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 @@ -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 " + 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 @@ -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 " + opts="-o -q -v -h --depth --format --output --no-color --quiet --verbose --provider --offline --no-cache --help " 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 " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " 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 " + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " 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 " + opts="-o -q -v -h --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 @@ -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 " + opts="-o -q -v -h --source --output --no-color --quiet --verbose --provider --offline --no-cache --help " 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 ..." + opts="-o -q -v -h --date --from --to --investor --board --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 @@ -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 " + opts="-o -q -v -h --date --from --to --investor --board --output --no-color --quiet --verbose --provider --offline --no-cache --help " 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 ..." + 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 @@ -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 " + opts="-o -q -v -h --forecast --history --annual --quarterly --output --no-color --quiet --verbose --provider --offline --no-cache --help " 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 " + opts="-o -q -v -h --statement --output --no-color --quiet --verbose --provider --offline --no-cache --help " 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 " + opts="-o -q -v -h --days --from --to --source --output --no-color --quiet --verbose --provider --offline --no-cache --help " 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 " + 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 @@ -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 " + 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 @@ -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 " + opts="-o -q -v -h --period --interval --history-provider --output --no-color --quiet --verbose --provider --offline --no-cache --help " 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 " + 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 @@ -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 " + 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 " + opts="-o -q -v -h --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 @@ -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 " + 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 @@ -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 ..." + 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 @@ -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 " + 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 @@ -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 " + 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 @@ -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 " + opts="-o -q -v -h --history-provider --output --no-color --quiet --verbose --provider --offline --no-cache --help " 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 " + 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 @@ -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=() ;; diff --git a/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden b/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden new file mode 100644 index 0000000..402cc2b --- /dev/null +++ b/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden @@ -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"} diff --git a/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden b/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden new file mode 100644 index 0000000..fd793c7 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.json.golden b/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.json.golden new file mode 100644 index 0000000..33601b3 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.json.golden @@ -0,0 +1,4 @@ +exit: 3 +--- stdout +--- stderr +{"code":"SYMBOLNOTFOUND","error":true,"message":"symbol not found: ZZZZ.JK"} diff --git a/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.table.golden b/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.table.golden new file mode 100644 index 0000000..0673de7 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-intraday-unknown-symbol.table.golden @@ -0,0 +1,4 @@ +exit: 3 +--- stdout +--- stderr +Error: symbol not found: ZZZZ.JK diff --git a/crates/idx-cli/tests/golden/error-invalid-provider.json.golden b/crates/idx-cli/tests/golden/error-invalid-provider.json.golden index 7e9cef0..02963d8 100644 --- a/crates/idx-cli/tests/golden/error-invalid-provider.json.golden +++ b/crates/idx-cli/tests/golden/error-invalid-provider.json.golden @@ -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)"} diff --git a/crates/idx-cli/tests/golden/error-invalid-provider.table.golden b/crates/idx-cli/tests/golden/error-invalid-provider.table.golden index 37464ed..3be5d0a 100644 --- a/crates/idx-cli/tests/golden/error-invalid-provider.table.golden +++ b/crates/idx-cli/tests/golden/error-invalid-provider.table.golden @@ -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) diff --git a/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.json.golden b/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.json.golden new file mode 100644 index 0000000..85f90cb --- /dev/null +++ b/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.json.golden @@ -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"} diff --git a/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.table.golden b/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.table.golden new file mode 100644 index 0000000..905987e --- /dev/null +++ b/crates/idx-cli/tests/golden/error-stockbit-fundamental-unsupported.table.golden @@ -0,0 +1,4 @@ +exit: 5 +--- stdout +--- stderr +Error: unsupported: BBCA.JK: Stockbit has no fundamentals; command requires --provider msn or yahoo diff --git a/crates/idx-cli/tests/golden/help-cache.golden b/crates/idx-cli/tests/golden/help-cache.golden index 27f05b0..95c3001 100644 --- a/crates/idx-cli/tests/golden/help-cache.golden +++ b/crates/idx-cli/tests/golden/help-cache.golden @@ -10,11 +10,12 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --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 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 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 diff --git a/crates/idx-cli/tests/golden/help-config.golden b/crates/idx-cli/tests/golden/help-config.golden index 845a21d..305e163 100644 --- a/crates/idx-cli/tests/golden/help-config.golden +++ b/crates/idx-cli/tests/golden/help-config.golden @@ -28,6 +28,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests diff --git a/crates/idx-cli/tests/golden/help-ownership.golden b/crates/idx-cli/tests/golden/help-ownership.golden index a7e4741..202f0da 100644 --- a/crates/idx-cli/tests/golden/help-ownership.golden +++ b/crates/idx-cli/tests/golden/help-ownership.golden @@ -40,6 +40,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests diff --git a/crates/idx-cli/tests/golden/help-root.golden b/crates/idx-cli/tests/golden/help-root.golden index 2814cde..5593677 100644 --- a/crates/idx-cli/tests/golden/help-root.golden +++ b/crates/idx-cli/tests/golden/help-root.golden @@ -29,6 +29,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests diff --git a/crates/idx-cli/tests/golden/help-stocks.golden b/crates/idx-cli/tests/golden/help-stocks.golden index 368e4a9..af937f3 100644 --- a/crates/idx-cli/tests/golden/help-stocks.golden +++ b/crates/idx-cli/tests/golden/help-stocks.golden @@ -6,6 +6,7 @@ Usage: idx stocks [OPTIONS] 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 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 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 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 diff --git a/crates/idx-cli/tests/golden/stockbit-quote.json.golden b/crates/idx-cli/tests/golden/stockbit-quote.json.golden new file mode 100644 index 0000000..5f33145 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-quote.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-quote.table.golden b/crates/idx-cli/tests/golden/stockbit-quote.table.golden new file mode 100644 index 0000000..e8ee346 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-quote.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stocks-intraday-index.table.golden b/crates/idx-cli/tests/golden/stocks-intraday-index.table.golden new file mode 100644 index 0000000..b284ebc --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-intraday-index.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stocks-intraday-suspended.json.golden b/crates/idx-cli/tests/golden/stocks-intraday-suspended.json.golden new file mode 100644 index 0000000..f29ffce --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-intraday-suspended.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stocks-intraday-suspended.table.golden b/crates/idx-cli/tests/golden/stocks-intraday-suspended.table.golden new file mode 100644 index 0000000..bfb97a3 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-intraday-suspended.table.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stocks-intraday.json.golden b/crates/idx-cli/tests/golden/stocks-intraday.json.golden new file mode 100644 index 0000000..54882c9 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-intraday.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"quote":{"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":"2026-10-22T16:14:00+07:00","behind_session":false,"market_status":"close"},"points":[{"time":"2026-10-22T08:58:00+07:00","price":5125.0},{"time":"2026-10-22T09:00:00+07:00","price":5125.0},{"time":"2026-10-22T09:01:00+07:00","price":5175.0},{"time":"2026-10-22T09:02:00+07:00","price":5150.0},{"time":"2026-10-22T09:03:00+07:00","price":5175.0},{"time":"2026-10-22T09:04:00+07:00","price":5175.0},{"time":"2026-10-22T09:05:00+07:00","price":5175.0},{"time":"2026-10-22T09:06:00+07:00","price":5175.0},{"time":"2026-10-22T09:07:00+07:00","price":5200.0},{"time":"2026-10-22T09:08:00+07:00","price":5175.0},{"time":"2026-10-22T09:09:00+07:00","price":5200.0},{"time":"2026-10-22T09:10:00+07:00","price":5200.0},{"time":"2026-10-22T09:11:00+07:00","price":5200.0},{"time":"2026-10-22T09:12:00+07:00","price":5200.0},{"time":"2026-10-22T09:13:00+07:00","price":5200.0},{"time":"2026-10-22T09:14:00+07:00","price":5225.0},{"time":"2026-10-22T09:15:00+07:00","price":5200.0},{"time":"2026-10-22T09:16:00+07:00","price":5225.0},{"time":"2026-10-22T09:17:00+07:00","price":5225.0},{"time":"2026-10-22T09:18:00+07:00","price":5200.0},{"time":"2026-10-22T09:19:00+07:00","price":5225.0},{"time":"2026-10-22T09:20:00+07:00","price":5250.0},{"time":"2026-10-22T09:21:00+07:00","price":5225.0},{"time":"2026-10-22T09:22:00+07:00","price":5225.0},{"time":"2026-10-22T09:23:00+07:00","price":5225.0},{"time":"2026-10-22T09:24:00+07:00","price":5225.0},{"time":"2026-10-22T09:25:00+07:00","price":5200.0},{"time":"2026-10-22T09:26:00+07:00","price":5200.0},{"time":"2026-10-22T09:27:00+07:00","price":5225.0},{"time":"2026-10-22T09:28:00+07:00","price":5225.0},{"time":"2026-10-22T09:29:00+07:00","price":5250.0},{"time":"2026-10-22T09:30:00+07:00","price":5225.0},{"time":"2026-10-22T09:31:00+07:00","price":5225.0},{"time":"2026-10-22T09:32:00+07:00","price":5200.0},{"time":"2026-10-22T09:33:00+07:00","price":5225.0},{"time":"2026-10-22T09:34:00+07:00","price":5225.0},{"time":"2026-10-22T09:35:00+07:00","price":5225.0},{"time":"2026-10-22T09:36:00+07:00","price":5250.0},{"time":"2026-10-22T09:37:00+07:00","price":5225.0},{"time":"2026-10-22T09:38:00+07:00","price":5225.0},{"time":"2026-10-22T09:39:00+07:00","price":5225.0},{"time":"2026-10-22T09:40:00+07:00","price":5225.0},{"time":"2026-10-22T09:41:00+07:00","price":5225.0},{"time":"2026-10-22T09:42:00+07:00","price":5225.0},{"time":"2026-10-22T09:43:00+07:00","price":5225.0},{"time":"2026-10-22T09:44:00+07:00","price":5225.0},{"time":"2026-10-22T09:45:00+07:00","price":5200.0},{"time":"2026-10-22T09:46:00+07:00","price":5200.0},{"time":"2026-10-22T09:47:00+07:00","price":5225.0},{"time":"2026-10-22T09:48:00+07:00","price":5200.0},{"time":"2026-10-22T09:49:00+07:00","price":5225.0},{"time":"2026-10-22T09:50:00+07:00","price":5225.0},{"time":"2026-10-22T09:51:00+07:00","price":5225.0},{"time":"2026-10-22T09:52:00+07:00","price":5225.0},{"time":"2026-10-22T09:53:00+07:00","price":5225.0},{"time":"2026-10-22T09:54:00+07:00","price":5200.0},{"time":"2026-10-22T09:55:00+07:00","price":5225.0},{"time":"2026-10-22T09:56:00+07:00","price":5225.0},{"time":"2026-10-22T09:57:00+07:00","price":5225.0},{"time":"2026-10-22T09:58:00+07:00","price":5225.0},{"time":"2026-10-22T09:59:00+07:00","price":5225.0},{"time":"2026-10-22T10:00:00+07:00","price":5250.0},{"time":"2026-10-22T10:01:00+07:00","price":5250.0},{"time":"2026-10-22T10:02:00+07:00","price":5250.0},{"time":"2026-10-22T10:03:00+07:00","price":5250.0},{"time":"2026-10-22T10:04:00+07:00","price":5250.0},{"time":"2026-10-22T10:05:00+07:00","price":5225.0},{"time":"2026-10-22T10:06:00+07:00","price":5225.0},{"time":"2026-10-22T10:07:00+07:00","price":5225.0},{"time":"2026-10-22T10:08:00+07:00","price":5225.0},{"time":"2026-10-22T10:09:00+07:00","price":5225.0},{"time":"2026-10-22T10:10:00+07:00","price":5225.0},{"time":"2026-10-22T10:11:00+07:00","price":5225.0},{"time":"2026-10-22T10:12:00+07:00","price":5225.0},{"time":"2026-10-22T10:13:00+07:00","price":5225.0},{"time":"2026-10-22T10:14:00+07:00","price":5225.0},{"time":"2026-10-22T10:15:00+07:00","price":5225.0},{"time":"2026-10-22T10:16:00+07:00","price":5225.0},{"time":"2026-10-22T10:17:00+07:00","price":5225.0},{"time":"2026-10-22T10:18:00+07:00","price":5225.0},{"time":"2026-10-22T10:19:00+07:00","price":5225.0},{"time":"2026-10-22T10:20:00+07:00","price":5225.0},{"time":"2026-10-22T10:21:00+07:00","price":5225.0},{"time":"2026-10-22T10:22:00+07:00","price":5250.0},{"time":"2026-10-22T10:23:00+07:00","price":5250.0},{"time":"2026-10-22T10:24:00+07:00","price":5250.0},{"time":"2026-10-22T10:25:00+07:00","price":5250.0},{"time":"2026-10-22T10:26:00+07:00","price":5250.0},{"time":"2026-10-22T10:27:00+07:00","price":5250.0},{"time":"2026-10-22T10:28:00+07:00","price":5250.0},{"time":"2026-10-22T10:29:00+07:00","price":5250.0},{"time":"2026-10-22T10:30:00+07:00","price":5225.0},{"time":"2026-10-22T10:31:00+07:00","price":5250.0},{"time":"2026-10-22T10:32:00+07:00","price":5250.0},{"time":"2026-10-22T10:33:00+07:00","price":5250.0},{"time":"2026-10-22T10:34:00+07:00","price":5225.0},{"time":"2026-10-22T10:35:00+07:00","price":5250.0},{"time":"2026-10-22T10:36:00+07:00","price":5250.0},{"time":"2026-10-22T10:37:00+07:00","price":5250.0},{"time":"2026-10-22T10:38:00+07:00","price":5225.0},{"time":"2026-10-22T10:39:00+07:00","price":5250.0},{"time":"2026-10-22T10:40:00+07:00","price":5250.0},{"time":"2026-10-22T10:41:00+07:00","price":5250.0},{"time":"2026-10-22T10:42:00+07:00","price":5225.0},{"time":"2026-10-22T10:43:00+07:00","price":5225.0},{"time":"2026-10-22T10:44:00+07:00","price":5200.0},{"time":"2026-10-22T10:45:00+07:00","price":5200.0},{"time":"2026-10-22T10:46:00+07:00","price":5200.0},{"time":"2026-10-22T10:47:00+07:00","price":5200.0},{"time":"2026-10-22T10:48:00+07:00","price":5225.0},{"time":"2026-10-22T10:49:00+07:00","price":5225.0},{"time":"2026-10-22T10:50:00+07:00","price":5225.0},{"time":"2026-10-22T10:51:00+07:00","price":5200.0},{"time":"2026-10-22T10:52:00+07:00","price":5225.0},{"time":"2026-10-22T10:53:00+07:00","price":5225.0},{"time":"2026-10-22T10:54:00+07:00","price":5200.0},{"time":"2026-10-22T10:55:00+07:00","price":5200.0},{"time":"2026-10-22T10:56:00+07:00","price":5225.0},{"time":"2026-10-22T10:57:00+07:00","price":5200.0},{"time":"2026-10-22T10:58:00+07:00","price":5200.0},{"time":"2026-10-22T10:59:00+07:00","price":5225.0},{"time":"2026-10-22T11:00:00+07:00","price":5200.0},{"time":"2026-10-22T11:01:00+07:00","price":5200.0},{"time":"2026-10-22T11:02:00+07:00","price":5225.0},{"time":"2026-10-22T11:03:00+07:00","price":5225.0},{"time":"2026-10-22T11:04:00+07:00","price":5200.0},{"time":"2026-10-22T11:05:00+07:00","price":5225.0},{"time":"2026-10-22T11:06:00+07:00","price":5250.0},{"time":"2026-10-22T11:07:00+07:00","price":5225.0},{"time":"2026-10-22T11:08:00+07:00","price":5225.0},{"time":"2026-10-22T11:09:00+07:00","price":5250.0},{"time":"2026-10-22T11:10:00+07:00","price":5250.0},{"time":"2026-10-22T11:11:00+07:00","price":5225.0},{"time":"2026-10-22T11:12:00+07:00","price":5250.0},{"time":"2026-10-22T11:13:00+07:00","price":5250.0},{"time":"2026-10-22T11:14:00+07:00","price":5250.0},{"time":"2026-10-22T11:15:00+07:00","price":5250.0},{"time":"2026-10-22T11:16:00+07:00","price":5250.0},{"time":"2026-10-22T11:17:00+07:00","price":5250.0},{"time":"2026-10-22T11:18:00+07:00","price":5225.0},{"time":"2026-10-22T11:19:00+07:00","price":5225.0},{"time":"2026-10-22T11:20:00+07:00","price":5225.0},{"time":"2026-10-22T11:21:00+07:00","price":5225.0},{"time":"2026-10-22T11:22:00+07:00","price":5200.0},{"time":"2026-10-22T11:23:00+07:00","price":5225.0},{"time":"2026-10-22T11:24:00+07:00","price":5200.0},{"time":"2026-10-22T11:25:00+07:00","price":5200.0},{"time":"2026-10-22T11:26:00+07:00","price":5225.0},{"time":"2026-10-22T11:27:00+07:00","price":5225.0},{"time":"2026-10-22T11:28:00+07:00","price":5200.0},{"time":"2026-10-22T11:29:00+07:00","price":5225.0},{"time":"2026-10-22T11:30:00+07:00","price":5225.0},{"time":"2026-10-22T11:31:00+07:00","price":5200.0},{"time":"2026-10-22T11:32:00+07:00","price":5200.0},{"time":"2026-10-22T11:33:00+07:00","price":5225.0},{"time":"2026-10-22T11:34:00+07:00","price":5225.0},{"time":"2026-10-22T11:35:00+07:00","price":5200.0},{"time":"2026-10-22T11:36:00+07:00","price":5225.0},{"time":"2026-10-22T11:37:00+07:00","price":5225.0},{"time":"2026-10-22T11:38:00+07:00","price":5225.0},{"time":"2026-10-22T11:39:00+07:00","price":5200.0},{"time":"2026-10-22T11:40:00+07:00","price":5225.0},{"time":"2026-10-22T11:41:00+07:00","price":5225.0},{"time":"2026-10-22T11:42:00+07:00","price":5200.0},{"time":"2026-10-22T11:43:00+07:00","price":5225.0},{"time":"2026-10-22T11:44:00+07:00","price":5200.0},{"time":"2026-10-22T11:45:00+07:00","price":5225.0},{"time":"2026-10-22T11:46:00+07:00","price":5225.0},{"time":"2026-10-22T11:47:00+07:00","price":5225.0},{"time":"2026-10-22T11:48:00+07:00","price":5250.0},{"time":"2026-10-22T11:49:00+07:00","price":5250.0},{"time":"2026-10-22T11:50:00+07:00","price":5225.0},{"time":"2026-10-22T11:51:00+07:00","price":5225.0},{"time":"2026-10-22T11:52:00+07:00","price":5225.0},{"time":"2026-10-22T11:53:00+07:00","price":5225.0},{"time":"2026-10-22T11:54:00+07:00","price":5225.0},{"time":"2026-10-22T11:55:00+07:00","price":5225.0},{"time":"2026-10-22T11:56:00+07:00","price":5225.0},{"time":"2026-10-22T11:57:00+07:00","price":5225.0},{"time":"2026-10-22T11:58:00+07:00","price":5250.0},{"time":"2026-10-22T11:59:00+07:00","price":5250.0},{"time":"2026-10-22T13:30:00+07:00","price":5225.0},{"time":"2026-10-22T13:31:00+07:00","price":5200.0},{"time":"2026-10-22T13:32:00+07:00","price":5200.0},{"time":"2026-10-22T13:33:00+07:00","price":5200.0},{"time":"2026-10-22T13:34:00+07:00","price":5200.0},{"time":"2026-10-22T13:35:00+07:00","price":5225.0},{"time":"2026-10-22T13:36:00+07:00","price":5225.0},{"time":"2026-10-22T13:37:00+07:00","price":5225.0},{"time":"2026-10-22T13:38:00+07:00","price":5200.0},{"time":"2026-10-22T13:39:00+07:00","price":5225.0},{"time":"2026-10-22T13:40:00+07:00","price":5200.0},{"time":"2026-10-22T13:41:00+07:00","price":5225.0},{"time":"2026-10-22T13:42:00+07:00","price":5225.0},{"time":"2026-10-22T13:43:00+07:00","price":5200.0},{"time":"2026-10-22T13:44:00+07:00","price":5200.0},{"time":"2026-10-22T13:45:00+07:00","price":5175.0},{"time":"2026-10-22T13:46:00+07:00","price":5175.0},{"time":"2026-10-22T13:47:00+07:00","price":5175.0},{"time":"2026-10-22T13:48:00+07:00","price":5175.0},{"time":"2026-10-22T13:49:00+07:00","price":5200.0},{"time":"2026-10-22T13:50:00+07:00","price":5200.0},{"time":"2026-10-22T13:51:00+07:00","price":5175.0},{"time":"2026-10-22T13:52:00+07:00","price":5200.0},{"time":"2026-10-22T13:53:00+07:00","price":5200.0},{"time":"2026-10-22T13:54:00+07:00","price":5175.0},{"time":"2026-10-22T13:55:00+07:00","price":5175.0},{"time":"2026-10-22T13:56:00+07:00","price":5175.0},{"time":"2026-10-22T13:57:00+07:00","price":5200.0},{"time":"2026-10-22T13:58:00+07:00","price":5200.0},{"time":"2026-10-22T13:59:00+07:00","price":5200.0},{"time":"2026-10-22T14:00:00+07:00","price":5200.0},{"time":"2026-10-22T14:01:00+07:00","price":5175.0},{"time":"2026-10-22T14:02:00+07:00","price":5200.0},{"time":"2026-10-22T14:03:00+07:00","price":5200.0},{"time":"2026-10-22T14:04:00+07:00","price":5175.0},{"time":"2026-10-22T14:05:00+07:00","price":5175.0},{"time":"2026-10-22T14:06:00+07:00","price":5175.0},{"time":"2026-10-22T14:07:00+07:00","price":5175.0},{"time":"2026-10-22T14:08:00+07:00","price":5175.0},{"time":"2026-10-22T14:09:00+07:00","price":5175.0},{"time":"2026-10-22T14:10:00+07:00","price":5175.0},{"time":"2026-10-22T14:11:00+07:00","price":5200.0},{"time":"2026-10-22T14:12:00+07:00","price":5200.0},{"time":"2026-10-22T14:13:00+07:00","price":5200.0},{"time":"2026-10-22T14:14:00+07:00","price":5175.0},{"time":"2026-10-22T14:15:00+07:00","price":5200.0},{"time":"2026-10-22T14:16:00+07:00","price":5175.0},{"time":"2026-10-22T14:17:00+07:00","price":5175.0},{"time":"2026-10-22T14:18:00+07:00","price":5175.0},{"time":"2026-10-22T14:19:00+07:00","price":5175.0},{"time":"2026-10-22T14:20:00+07:00","price":5200.0},{"time":"2026-10-22T14:21:00+07:00","price":5200.0},{"time":"2026-10-22T14:22:00+07:00","price":5175.0},{"time":"2026-10-22T14:23:00+07:00","price":5175.0},{"time":"2026-10-22T14:24:00+07:00","price":5200.0},{"time":"2026-10-22T14:25:00+07:00","price":5175.0},{"time":"2026-10-22T14:26:00+07:00","price":5200.0},{"time":"2026-10-22T14:27:00+07:00","price":5200.0},{"time":"2026-10-22T14:28:00+07:00","price":5200.0},{"time":"2026-10-22T14:29:00+07:00","price":5200.0},{"time":"2026-10-22T14:30:00+07:00","price":5175.0},{"time":"2026-10-22T14:31:00+07:00","price":5200.0},{"time":"2026-10-22T14:32:00+07:00","price":5175.0},{"time":"2026-10-22T14:33:00+07:00","price":5200.0},{"time":"2026-10-22T14:34:00+07:00","price":5200.0},{"time":"2026-10-22T14:35:00+07:00","price":5200.0},{"time":"2026-10-22T14:36:00+07:00","price":5225.0},{"time":"2026-10-22T14:37:00+07:00","price":5200.0},{"time":"2026-10-22T14:38:00+07:00","price":5200.0},{"time":"2026-10-22T14:39:00+07:00","price":5200.0},{"time":"2026-10-22T14:40:00+07:00","price":5200.0},{"time":"2026-10-22T14:41:00+07:00","price":5200.0},{"time":"2026-10-22T14:42:00+07:00","price":5200.0},{"time":"2026-10-22T14:43:00+07:00","price":5200.0},{"time":"2026-10-22T14:44:00+07:00","price":5200.0},{"time":"2026-10-22T14:45:00+07:00","price":5200.0},{"time":"2026-10-22T14:46:00+07:00","price":5200.0},{"time":"2026-10-22T14:47:00+07:00","price":5175.0},{"time":"2026-10-22T14:48:00+07:00","price":5175.0},{"time":"2026-10-22T14:49:00+07:00","price":5200.0},{"time":"2026-10-22T14:50:00+07:00","price":5200.0},{"time":"2026-10-22T14:51:00+07:00","price":5200.0},{"time":"2026-10-22T14:52:00+07:00","price":5175.0},{"time":"2026-10-22T14:53:00+07:00","price":5200.0},{"time":"2026-10-22T14:54:00+07:00","price":5200.0},{"time":"2026-10-22T14:55:00+07:00","price":5225.0},{"time":"2026-10-22T14:56:00+07:00","price":5225.0},{"time":"2026-10-22T14:57:00+07:00","price":5225.0},{"time":"2026-10-22T14:58:00+07:00","price":5200.0},{"time":"2026-10-22T14:59:00+07:00","price":5200.0},{"time":"2026-10-22T15:00:00+07:00","price":5175.0},{"time":"2026-10-22T15:01:00+07:00","price":5200.0},{"time":"2026-10-22T15:02:00+07:00","price":5200.0},{"time":"2026-10-22T15:03:00+07:00","price":5200.0},{"time":"2026-10-22T15:04:00+07:00","price":5200.0},{"time":"2026-10-22T15:05:00+07:00","price":5200.0},{"time":"2026-10-22T15:06:00+07:00","price":5200.0},{"time":"2026-10-22T15:07:00+07:00","price":5175.0},{"time":"2026-10-22T15:08:00+07:00","price":5200.0},{"time":"2026-10-22T15:09:00+07:00","price":5200.0},{"time":"2026-10-22T15:10:00+07:00","price":5175.0},{"time":"2026-10-22T15:11:00+07:00","price":5175.0},{"time":"2026-10-22T15:12:00+07:00","price":5175.0},{"time":"2026-10-22T15:13:00+07:00","price":5200.0},{"time":"2026-10-22T15:14:00+07:00","price":5175.0},{"time":"2026-10-22T15:15:00+07:00","price":5175.0},{"time":"2026-10-22T15:16:00+07:00","price":5175.0},{"time":"2026-10-22T15:17:00+07:00","price":5200.0},{"time":"2026-10-22T15:18:00+07:00","price":5175.0},{"time":"2026-10-22T15:19:00+07:00","price":5175.0},{"time":"2026-10-22T15:20:00+07:00","price":5150.0},{"time":"2026-10-22T15:21:00+07:00","price":5175.0},{"time":"2026-10-22T15:22:00+07:00","price":5175.0},{"time":"2026-10-22T15:23:00+07:00","price":5175.0},{"time":"2026-10-22T15:24:00+07:00","price":5175.0},{"time":"2026-10-22T15:25:00+07:00","price":5150.0},{"time":"2026-10-22T15:26:00+07:00","price":5175.0},{"time":"2026-10-22T15:27:00+07:00","price":5175.0},{"time":"2026-10-22T15:28:00+07:00","price":5175.0},{"time":"2026-10-22T15:29:00+07:00","price":5175.0},{"time":"2026-10-22T15:30:00+07:00","price":5200.0},{"time":"2026-10-22T15:31:00+07:00","price":5200.0},{"time":"2026-10-22T15:32:00+07:00","price":5200.0},{"time":"2026-10-22T15:33:00+07:00","price":5200.0},{"time":"2026-10-22T15:34:00+07:00","price":5200.0},{"time":"2026-10-22T15:35:00+07:00","price":5175.0},{"time":"2026-10-22T15:36:00+07:00","price":5200.0},{"time":"2026-10-22T15:37:00+07:00","price":5175.0},{"time":"2026-10-22T15:38:00+07:00","price":5175.0},{"time":"2026-10-22T15:39:00+07:00","price":5175.0},{"time":"2026-10-22T15:40:00+07:00","price":5150.0},{"time":"2026-10-22T15:41:00+07:00","price":5150.0},{"time":"2026-10-22T15:42:00+07:00","price":5175.0},{"time":"2026-10-22T15:43:00+07:00","price":5175.0},{"time":"2026-10-22T15:44:00+07:00","price":5200.0},{"time":"2026-10-22T15:45:00+07:00","price":5200.0},{"time":"2026-10-22T15:46:00+07:00","price":5175.0},{"time":"2026-10-22T15:47:00+07:00","price":5200.0},{"time":"2026-10-22T15:48:00+07:00","price":5175.0},{"time":"2026-10-22T15:49:00+07:00","price":5175.0},{"time":"2026-10-22T16:00:00+07:00","price":5175.0},{"time":"2026-10-22T16:02:00+07:00","price":5175.0},{"time":"2026-10-22T16:03:00+07:00","price":5175.0},{"time":"2026-10-22T16:04:00+07:00","price":5175.0},{"time":"2026-10-22T16:05:00+07:00","price":5175.0},{"time":"2026-10-22T16:06:00+07:00","price":5175.0},{"time":"2026-10-22T16:07:00+07:00","price":5175.0},{"time":"2026-10-22T16:08:00+07:00","price":5175.0},{"time":"2026-10-22T16:09:00+07:00","price":5175.0},{"time":"2026-10-22T16:10:00+07:00","price":5175.0},{"time":"2026-10-22T16:11:00+07:00","price":5175.0},{"time":"2026-10-22T16:12:00+07:00","price":5175.0},{"time":"2026-10-22T16:13:00+07:00","price":5175.0},{"time":"2026-10-22T16:14:00+07:00","price":5175.0}]} +--- stderr diff --git a/crates/idx-cli/tests/golden/stocks-intraday.table.golden b/crates/idx-cli/tests/golden/stocks-intraday.table.golden new file mode 100644 index 0000000..35c6cf8 --- /dev/null +++ b/crates/idx-cli/tests/golden/stocks-intraday.table.golden @@ -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 diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index 64c8694..1a90c92 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -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_.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 { + 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 { + 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 { + Err(stockbit::fundamentals_unsupported(symbol)) + } +} + +impl IntradayProvider for MockStockbitProvider { + fn intraday(&self, symbol: &str) -> Result { + self.snapshot(symbol) + } +} + /// Broker summaries from `tests/fixtures/ipot_brokersummary__ /// [_][__].html`; a query without a fixture reads as an /// unknown code. diff --git a/crates/idx-core/src/api/mod.rs b/crates/idx-core/src/api/mod.rs index 3d3a8fe..8ff9fdd 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -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, 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; +} + pub trait FundamentalsProvider { fn fundamentals(&self, symbol: &str) -> Result; } @@ -221,7 +229,10 @@ fn mock_enabled() -> bool { #[cfg(feature = "testing")] fn mock_market(provider: ProviderKind) -> Option> { - 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 { Box::new(idx::IdxSiteProvider) } +pub fn intraday_provider() -> Box { + #[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(), + )), } } diff --git a/crates/idx-core/src/api/msn/map.rs b/crates/idx-core/src/api/msn/map.rs index a643841..165bd07 100644 --- a/crates/idx-core/src/api/msn/map.rs +++ b/crates/idx-core/src/api/msn/map.rs @@ -71,6 +71,10 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result Result 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 { + let code = stockbit_code(resolved)?; + let html = self.page(&code, resolved)?; + parse_symbol_page(resolved, &html) + } + + fn page(&self, code: &str, resolved: &str) -> Result { + 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 +/// `/stockbit.pace` under an exclusive file lock. When that file is +/// unusable, pacing is per process. +fn pace() { + static LAST: Mutex> = 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::() { + // 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 { + self.snapshot(symbol).map(|day| day.quote) + } +} + +impl FundamentalsProvider for StockbitProvider { + fn fundamentals(&self, symbol: &str) -> Result { + Err(fundamentals_unsupported(symbol)) + } +} + +impl IntradayProvider for StockbitProvider { + fn intraday(&self, symbol: &str) -> Result { + 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}" + ); + } + } +} diff --git a/crates/idx-core/src/api/stockbit/parse.rs b/crates/idx-core/src/api/stockbit/parse.rs new file mode 100644 index 0000000..54c5ecd --- /dev/null +++ b/crates/idx-core/src/api/stockbit/parse.rs @@ -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, + chart: Option, +} + +#[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, + time: Option, + status: Option, + orderbook: Option, + market_hour: Option, +} + +#[derive(Debug, Deserialize)] +struct RawOrderbook { + bid: Option, + offer: Option, +} + +#[derive(Debug, Deserialize)] +struct RawLevel { + #[serde(default)] + price: Value, +} + +#[derive(Debug, Deserialize)] +struct RawMarketHour { + status: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct RawChart { + #[serde(default)] + prices: Vec, +} + +#[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 { + 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 { + 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 ``. +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("").ok_or_else(missing)?; + Ok(&html[start..start + len]) +} + +fn parse_quote(resolved: &str, company: &RawCompany) -> Result { + 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, 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> { + 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> { + 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 { + let n = match value { + Value::Number(n) => n.as_f64()?, + Value::String(s) => s.trim().parse::().ok()?, + _ => return None, + }; + n.is_finite().then_some(n) +} + +fn count(value: &Value) -> Option { + match value { + Value::Number(n) => n.as_u64(), + Value::String(s) => s.trim().parse::().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}" + ); + } + } +} diff --git a/crates/idx-core/src/api/types.rs b/crates/idx-core/src/api/types.rs index deaaafa..1da6d4d 100644 --- a/crates/idx-core/src/api/types.rs +++ b/crates/idx-core/src/api/types.rs @@ -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, + /// Best offer in IDR (whole Rupiah), gated like `bid`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offer: Option, + /// 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, + /// 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, +} + +/// 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, + /// 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, } /// OHLC candle data normalized from Yahoo Finance chart indicators. diff --git a/crates/idx-core/src/api/yahoo/map.rs b/crates/idx-core/src/api/yahoo/map.rs index f349b85..7bbfc67 100644 --- a/crates/idx-core/src/api/yahoo/map.rs +++ b/crates/idx-core/src/api/yahoo/map.rs @@ -65,6 +65,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result { 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, /// True when an expired cache entry was served (offline, or after a diff --git a/crates/idx-core/src/config.rs b/crates/idx-core/src/config.rs index a76c104..3e988da 100644 --- a/crates/idx-core/src/config.rs +++ b/crates/idx-core/src/config.rs @@ -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)" ))) } } diff --git a/crates/idx-core/src/error.rs b/crates/idx-core/src/error.rs index d991c6b..84279a9 100644 --- a/crates/idx-core/src/error.rs +++ b/crates/idx-core/src/error.rs @@ -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); + } } diff --git a/crates/idx-core/src/market.rs b/crates/idx-core/src/market.rs index 748458a..e3a0c09 100644 --- a/crates/idx-core/src/market.rs +++ b/crates/idx-core/src/market.rs @@ -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>, 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, 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) -> 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, } } diff --git a/crates/idx-core/tests/fixtures/stockbit_blocked_403.html b/crates/idx-core/tests/fixtures/stockbit_blocked_403.html new file mode 100644 index 0000000..9ad7b8a --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_blocked_403.html @@ -0,0 +1,20 @@ + + +ERROR: The request could not be satisfied + +

403 ERROR

+

The request could not be satisfied.

+
+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. +
+If you provide content to customers through CloudFront, you can find steps to troubleshoot and help prevent this error by reviewing the CloudFront documentation. +
+
+
+Generated by cloudfront (CloudFront)
+Request ID: VajZoZu7lzhasdjnxU37dHaztROLXHQ6DMpJzTPZoDhkd4I2dVc2mw==
+
+
+
+ diff --git a/crates/idx-core/tests/fixtures/stockbit_symbol_bbca.html b/crates/idx-core/tests/fixtures/stockbit_symbol_bbca.html new file mode 100644 index 0000000..33945bb --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_symbol_bbca.html @@ -0,0 +1 @@ +Stockbit
diff --git a/crates/idx-core/tests/fixtures/stockbit_symbol_ihsg.html b/crates/idx-core/tests/fixtures/stockbit_symbol_ihsg.html new file mode 100644 index 0000000..a6ed659 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_symbol_ihsg.html @@ -0,0 +1 @@ +Stockbit
diff --git a/crates/idx-core/tests/fixtures/stockbit_symbol_maba.html b/crates/idx-core/tests/fixtures/stockbit_symbol_maba.html new file mode 100644 index 0000000..446208f --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_symbol_maba.html @@ -0,0 +1 @@ +Stockbit
diff --git a/crates/idx-core/tests/fixtures/stockbit_symbol_myrx.html b/crates/idx-core/tests/fixtures/stockbit_symbol_myrx.html new file mode 100644 index 0000000..d24b432 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_symbol_myrx.html @@ -0,0 +1 @@ +Stockbit
diff --git a/crates/idx-core/tests/fixtures/stockbit_symbol_zzzz_404.html b/crates/idx-core/tests/fixtures/stockbit_symbol_zzzz_404.html new file mode 100644 index 0000000..acfc10e --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_symbol_zzzz_404.html @@ -0,0 +1 @@ +Error | Stockbit - Investasi Saham Bersama Komunitas Saham Terbesar di Indonesia
diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 88ee860..6f97aea 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -29,6 +29,15 @@ pub struct HistoryParams { pub interval: Option, } +#[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, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct ScreenParams { /// Screen to run (default "top-performers"). diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 08580f9..41927c7 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -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) -> 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) -> 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) -> ToolResult { let HistoryParams { diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index d2ac9b4..375ee44 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -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": { diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index 928b160..e3902a8 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -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); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae55edd..0343bd0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -55,6 +55,9 @@ command parses arguments, makes one idx-core call, and renders the result. (`BrokerSummaryProvider`). See [Broker Summaries](#broker-summaries). - **IDX (idx.co.id)** — the daily trading summary behind `stocks flow --source idx` (`TradingSummaryProvider`). See [Foreign Flow](#foreign-flow). +- **Stockbit (public page)** — `stocks intraday` whichever provider is + selected (`IntradayProvider`), and quotes under the opt-in + `--provider stockbit`. See [Stockbit](#stockbit). ### MSN Endpoints - `https://assets.msn.com/service/`: quotes, charts, equities, earnings, @@ -151,6 +154,41 @@ WIB as the fallback. range fails on one. `foreign_net_value` is net shares at the session's average price (value over volume), an estimate. +### Stockbit +- `GET https://stockbit.com/symbol/{CODE}` needs no login. Next.js + server-renders the quote and today's chart into the page's `__NEXT_DATA__` + JSON; `api/stockbit/parse.rs` reads it purely. `props.pageProps.company` + is the quote (numbers mostly as strings, an index price as a float string), + `orderbook.{bid,offer}.price` the best bid and offer, `market_hour.status` + the market's state, and `status` `STATUS_ACTIVE|SUSPENDED|DELISTED`. + `chart.prices[]` holds one price per minute in WIB with no offset, after a + synthetic first row at the previous close; per-minute OHLC and volume are + empty. An unknown code is HTTP 404 with the 404 page. +- Stockbit's JSON API (`exodus.stockbit.com`: chart, order book) answers 401 + without a user's bearer token; it is not used. +- Bid and offer are kept only while `market_hour.status` is `open`, so a + closing book never reads as a live one. Observed 2026-10-23: `open` during + trading (bid/offer set), `break` from 11:30 on a Friday, `close` after + hours; any other value fails closed. In session the chart gained a point + every minute and the last trade was within a minute of the clock, although + Stockbit's terms call its data delayed at least 10 minutes. Order-book + sizes are dropped: the page does not say whether they are lots or shares. + `as_of` comes from `date`/`time` + only when the stock traded (a suspended stock carries today's date). + `session_note` repeats what the page says (suspended, delisted) or "no + trades this session"; it is never inferred. +- CloudFront in front of the page answers a static 403 to some user agents + (`curl/`, `axios/`, `Go-http-client/`, empty) and does not fingerprint TLS, + so a plain ureq request with `idx-cli/ (+repo)` passes. A 403 is + `UPSTREAMBLOCKED` and is not retried. Requests are at least 1 s apart + across the user's idx processes (the last request's time sits in + `/stockbit.pace` under a file lock, so a shell loop of `idx` calls is + paced too), and snapshots are cached for 5 s (at most `quote_ttl`), which + coalesces an agent's quote-then-intraday burst across MCP calls. +- Stockbit's terms call its data delayed at least 10 minutes and for + personal use; frontends say so. Fixtures (`tests/fixtures/stockbit_*`) are + pages recorded 2026-10-22 after the close, cut to the fields read. + ### Capability Gating ``` MarketDataProvider = QuoteProvider + FundamentalsProvider @@ -161,8 +199,13 @@ Factory functions: history_provider(kind, mode, verbose) → Result<(ProviderKind, Box)> broker_provider() → Box trading_summary_provider() → Box + intraday_provider() → Box ``` +`ProviderKind::Stockbit` implements quotes only: fundamentals are +`UNSUPPORTED`, the MSN-only capabilities say "requires --provider msn", and +`auto` history falls back to Yahoo. + ## Ownership Module (SQLite-backed) Unlike the `stocks` module (live-fetch), ownership is **import-then-query**: @@ -239,7 +282,9 @@ CLI flags > environment variables > config file > defaults bidirectional overrides replaced before it reaches a terminal table. - Exit codes by class: `0` success, `2` `INVALIDINPUT`/`CONFIGERROR` and clap usage errors, `3` `SYMBOLNOTFOUND`/`NOMARKETDATA`/`CACHEMISS`, `4` `HTTP`/`RATELIMITED`/ - `PROVIDERUNAVAILABLE`/`OFFLINE`/`AUTHERROR`, `5` `UNSUPPORTED`, `1` everything else + `PROVIDERUNAVAILABLE`/`OFFLINE`/`AUTHERROR`/`UPSTREAMBLOCKED`, `5` `UNSUPPORTED`, + `1` everything else. `UPSTREAMBLOCKED` is an upstream refusing this client + outright (a CDN 403 page); retrying at once does not help. - A totally failed `stocks compare` emits one structured error in JSON mode; per-symbol warnings are retained for partial success, not printed ahead of a total-failure error. ## CLI Output and Offline Cache diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fbb6a02..f9e8b20 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -184,7 +184,7 @@ is another 20-30% smaller than pretty JSON. | 1 | other failure | `IO`, `DATABASEERROR`, `PARSEERROR`, ... | | 2 | invalid usage or input | clap errors, `INVALIDINPUT`, `CONFIGERROR` | | 3 | not found or no data | `SYMBOLNOTFOUND`, `NOMARKETDATA`, `CACHEMISS` | - | 4 | upstream unavailable, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE`, `AUTHERROR` | + | 4 | upstream unavailable or blocked, retry later | `HTTP`, `RATELIMITED`, `PROVIDERUNAVAILABLE`, `OFFLINE`, `AUTHERROR`, `UPSTREAMBLOCKED` | | 5 | unsupported | `UNSUPPORTED` | - JSON keeps today's top-level shape: no `{data, meta}` wrapper, so `jq` paths diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 5f56439..70f43b1 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -42,12 +42,12 @@ contents. The native macOS installer job runs separately to exercise BSD tools. ## Live drift check -Fixtures cannot tell when MSN, Yahoo, or IDX change their responses. +Fixtures cannot tell when MSN, Yahoo, IPOT, IDX, or Stockbit change their responses. `scripts/live-smoke.sh` runs shipped commands against the real upstreams and validates each JSON output shape. It is opt-in and never part of the gate. ```bash -nix develop --command scripts/live-smoke.sh # stocks + ownership +nix develop --command scripts/live-smoke.sh # stocks + stockbit + ownership nix develop --command scripts/live-smoke.sh --group stocks --symbol BBRI nix develop --command scripts/live-smoke.sh --bin ./result/bin/idx nix develop --command scripts/live-smoke.sh --compare ./old/bin/idx # A/B against an older build @@ -58,10 +58,16 @@ scripts/live-smoke.sh --dry-run `flow` and `flow --source idx` on the new binary only, so an older `--compare` build that predates them is fine), plus `compare BUMI,ADRO,AIMS`, whose MSN key ratios contain non-finite values. +- `stockbit`: `intraday` for the ticker and `^JKSE`, and + `--provider stockbit stocks quote`, on the new binary only. A run outside the + session sees `market_status: close` and no bid/offer. - `ownership`: `discover`, then imports the newest supported report into a temp DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many datacenter IPs, including GitHub-hosted runners; run it from a network IDX accepts. +A case whose upstream refuses us (`UPSTREAMBLOCKED`, an HTTP 403) prints +`BLOCKED (HTTP 403)` and counts as a failure, never as a pass. + `--compare ` runs every case with both binaries back to back, each with its own config and cache, and fails on a different exit code, any added, missing, or retyped JSON key, or changed values for `profile`, `financials`, diff --git a/flake.nix b/flake.nix index b3b9b45..bda6f06 100644 --- a/flake.nix +++ b/flake.nix @@ -288,7 +288,7 @@ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' sleep 2 } | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl - jq -se 'map(select(.id == 2))[0].result.tools | length == 14' out.jsonl + jq -se 'map(select(.id == 2))[0].result.tools | length == 15' out.jsonl touch "$out" ''; # The website's generated pages match the CLI help and the MCP diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 98dbfb9..7281509 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash # Live provider drift check: runs shipped commands against real MSN, Yahoo, -# and IDX endpoints and validates the JSON output shape. Deterministic CLI -# behavior is covered by tests/cli.rs and tests/golden.rs; this answers "do the -# upstreams still look the way our parsers expect?" +# IPOT, IDX, and Stockbit endpoints and validates the JSON output shape. +# Deterministic CLI behavior is covered by tests/cli.rs and tests/golden.rs; +# this answers "do the upstreams still look the way our parsers expect?" +# An upstream that refuses us (UPSTREAMBLOCKED, HTTP 403) is reported as +# BLOCKED, never as a pass. # # With --compare , every case also runs the old binary back to back # and fails on any difference in exit code or JSON structure, and on any value @@ -26,7 +28,8 @@ usage() { Usage: scripts/live-smoke.sh [options] Options: - --group Restrict to a group. Repeatable. Default: both + --group stocks, stockbit, or ownership. Repeatable. + Default: all three --symbol Primary ticker. Default: BBCA --bin Use an existing idx binary (skips cargo build) --compare Also run an older idx binary and diff the results @@ -46,12 +49,12 @@ while [[ $# -gt 0 ]]; do *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; esac done -[[ ${#GROUPS_SELECTED[@]} -eq 0 ]] && GROUPS_SELECTED=(stocks ownership) +[[ ${#GROUPS_SELECTED[@]} -eq 0 ]] && GROUPS_SELECTED=(stocks stockbit ownership) for group in "${GROUPS_SELECTED[@]}"; do case "$group" in - stocks|ownership) ;; - *) echo "invalid --group '$group' (expected stocks or ownership)" >&2; exit 2 ;; + stocks|stockbit|ownership) ;; + *) echo "invalid --group '$group' (expected stocks, stockbit, or ownership)" >&2; exit 2 ;; esac done @@ -99,8 +102,10 @@ def types: [paths(scalars) as $p | select(getpath($p) != null) # jq filter describing the JSON shape each command must keep. contract() { case "$1" in - "stocks quote "*|"stocks screen"*) + "stocks quote "*|"stocks screen"*|"--provider stockbit stocks quote "*) echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.price | type == "number"))' ;; + "stocks intraday "*) + echo '(.quote.symbol | type == "string") and (.quote.price | type == "number") and (.quote.market_status | type == "string") and (.points | type == "array") and all(.points[]; (.time | type == "string") and (.price | type == "number"))' ;; "stocks history "*) echo 'type == "array" and length > 0 and all(.[]; (.date | type == "string") and (.close | type == "number"))' ;; "stocks technical "*) @@ -172,6 +177,11 @@ run_case() { fi fi if (( status != 0 )); then + if grep -q 'UPSTREAMBLOCKED' "$log.stderr"; then + FAILURES+=("$name: BLOCKED, upstream refused us with HTTP 403 (see $log.stderr)") + echo " BLOCKED (HTTP 403)" + return + fi FAILURES+=("$name: exit $status (see $log.stderr)") echo " FAIL exit" return @@ -238,6 +248,13 @@ if selected stocks; then run_new_case flow-idx "stocks flow $SYMBOL --days 3 --source idx" fi +if selected stockbit; then + # Stockbit's public page (no login): one request per case, 1 s apart. + run_new_case intraday "stocks intraday $SYMBOL" + run_new_case intraday-index "stocks intraday ^JKSE" + run_new_case quote-stockbit "--provider stockbit stocks quote $SYMBOL" +fi + if selected ownership; then run_case discover "ownership discover --limit 1" if (( ! DRY_RUN )) && [[ -s "$LOG_DIR/$(printf '%02d' "$TOTAL")_discover.stdout" ]]; then diff --git a/web/astro.config.mjs b/web/astro.config.mjs index b9a0091..c745c23 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -31,7 +31,7 @@ export default defineConfig({ }, { label: 'CLI', - items: ['cli/stocks', 'cli/ownership', 'cli/output', 'cli/reference'], + items: ['cli/stocks', 'cli/stockbit', 'cli/ownership', 'cli/output', 'cli/reference'], }, { label: 'MCP server', diff --git a/web/src/content/docs/cli/output.md b/web/src/content/docs/cli/output.md index ed72dba..60a5659 100644 --- a/web/src/content/docs/cli/output.md +++ b/web/src/content/docs/cli/output.md @@ -32,7 +32,7 @@ stdout is not a terminal, and with `--no-color`. | `0` | success | | | `2` | invalid input or config | fix the command | | `3` | not found, no data, or no cached data offline | check the ticker or name; go online | -| `4` | upstream unavailable | retry later | +| `4` | upstream unavailable or blocked | retry later; for `UPSTREAMBLOCKED`, wait or change network | | `5` | unsupported | use another command or provider | | `1` | anything else | read the message | diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 5a89111..8cf8a18 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -41,6 +41,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -60,6 +65,7 @@ Usage: idx stocks [OPTIONS] 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 @@ -80,13 +86,14 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks quote` @@ -100,19 +107,67 @@ Arguments: ... One or more symbols, comma-separated or space-separated; `-` reads them from stdin Options: - -o, --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 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 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 Examples: idx stocks quote BBCA idx stocks quote BBCA,BBRI,BMRI idx -o json stocks quote BBCA echo BBCA BBRI | idx stocks quote - + idx --provider stockbit stocks quote BBCA # with bid/offer while the market is open +``` + +### `idx stocks intraday` + +```text +Today's session of one IDX stock or index, by the minute, from Stockbit's public page (no login): the quote, the best bid/offer while the market is open, a sparkline, and the last minutes. Delayed at least 10 minutes per Stockbit's terms; prices only, no per-minute volume. A stock that did not trade has no minutes, which is not an error. Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45). + +Usage: idx stocks intraday [OPTIONS] + +Arguments: + + Single ticker symbol (e.g. BBCA) + +Options: + -o, --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 + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stocks intraday BBCA + idx stocks intraday ^JKSE + idx -o json stocks intraday BBCA | jq '.points[-1]' ``` ### `idx stocks history` @@ -140,6 +195,8 @@ Options: Suppress non-essential output -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] --offline Run without network requests --no-cache @@ -173,6 +230,8 @@ Options: Suppress non-essential output -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] --offline Run without network requests --no-cache @@ -196,13 +255,14 @@ Arguments: Single ticker symbol (e.g. BBCA) Options: - -o, --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 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 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 Examples: idx stocks growth BBCA @@ -220,13 +280,14 @@ Arguments: Single ticker symbol (e.g. BBCA) Options: - -o, --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 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 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 Examples: idx stocks valuation BBCA @@ -244,13 +305,14 @@ Arguments: Single ticker symbol (e.g. BBCA) Options: - -o, --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 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 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 Examples: idx stocks risk BBCA @@ -268,13 +330,14 @@ Arguments: Single ticker symbol (e.g. BBCA) Options: - -o, --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 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 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 Examples: idx stocks fundamental BBCA @@ -337,6 +400,11 @@ Options: [default: all] + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -409,17 +477,22 @@ Options: [default: all] + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests - --no-cache - Bypass local cache - --top Tier behind the headline signal: 1, 3, 5 or 10 brokers per side [default: 5] + --no-cache + Bypass local cache + -h, --help Print help (see a summary with '-h') @@ -474,6 +547,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -502,13 +580,14 @@ Arguments: Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks financials` @@ -527,6 +606,7 @@ Options: --no-color Disable colored output -q, --quiet Suppress non-essential output -v, --verbose... Increase verbosity (-v, -vv) + --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 @@ -548,17 +628,18 @@ Arguments: Options: - --forecast Only include forward earnings rows - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --history Only include historical earnings rows - --no-color Disable colored output - --annual Only include annual periods - -q, --quiet Suppress non-essential output - --quarterly Only include quarterly periods - -v, --verbose... Increase verbosity (-v, -vv) - --offline Run without network requests - --no-cache Bypass local cache - -h, --help Print help + --forecast Only include forward earnings rows + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --history Only include historical earnings rows + --no-color Disable colored output + --annual Only include annual periods + -q, --quiet Suppress non-essential output + --quarterly Only include quarterly periods + -v, --verbose... Increase verbosity (-v, -vv) + --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 Examples: idx stocks earnings BBCA @@ -577,13 +658,14 @@ Arguments: Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks insights` @@ -597,13 +679,14 @@ Arguments: Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks news` @@ -617,14 +700,15 @@ Arguments: Options: - --limit [default: 10] - -o, --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 + --limit [default: 10] + -o, --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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks screen` @@ -635,16 +719,17 @@ MSN screener Usage: idx stocks screen [OPTIONS] Options: - --filter [default: top-performers] - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - --region [default: id] - --limit [default: 50] - -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 + --filter [default: top-performers] + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + --region [default: id] + --limit [default: 50] + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx stocks compare` @@ -658,13 +743,14 @@ Arguments: ... One or more symbols, comma-separated or space-separated; `-` reads them from stdin Options: - -o, --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 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 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 Examples: idx stocks compare BBCA BBRI BMRI @@ -704,6 +790,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -733,13 +824,14 @@ Create default configuration file Usage: idx config init [OPTIONS] Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx config get` @@ -753,13 +845,14 @@ Arguments: Key such as general.provider (see `idx config --help`) Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx config set` @@ -774,13 +867,14 @@ Arguments: Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx config path` @@ -791,13 +885,14 @@ Print configuration file path Usage: idx config path [OPTIONS] Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ## `idx cache` @@ -813,13 +908,14 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx cache info` @@ -830,13 +926,14 @@ Show cache usage information Usage: idx cache info [OPTIONS] Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx cache clear` @@ -847,13 +944,14 @@ Clear all cached files Usage: idx cache clear [OPTIONS] Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ## `idx ownership` @@ -899,6 +997,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -924,15 +1027,16 @@ Discover the latest IDX-hosted ownership report URLs Usage: idx ownership discover [OPTIONS] Options: - --family Report family to discover: above1 (default), all, above5, or investor-type [default: above1] - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --limit Maximum number of discovered report URLs to print [default: 6] - --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 + --family Report family to discover: above1 (default), all, above5, or investor-type [default: above1] + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --limit Maximum number of discovered report URLs to print [default: 6] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --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 Examples: idx ownership discover --limit 1 @@ -977,6 +1081,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -1028,6 +1137,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -1056,14 +1170,15 @@ Arguments: Options: - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --source [default: all] - --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 format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --source [default: all] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx ownership entity` @@ -1077,13 +1192,14 @@ Arguments: Entity name or part of one, case-insensitive; the shortest match is shown Options: - -o, --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 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 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 Examples: idx ownership entity "ASTRA INTERNATIONAL" @@ -1121,6 +1237,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -1149,6 +1270,7 @@ Options: --no-color Disable colored output -q, --quiet Suppress non-essential output -v, --verbose... Increase verbosity (-v, -vv) + --provider 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 @@ -1162,15 +1284,16 @@ Rank tickers by ownership concentration Usage: idx ownership concentration [OPTIONS] Options: - --by [default: hhi] - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --no-color Disable colored output - --top [default: 20] - -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 + --by [default: hhi] + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --no-color Disable colored output + --top [default: 20] + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx ownership flow` @@ -1184,13 +1307,14 @@ Arguments: Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx ownership changes` @@ -1223,6 +1347,11 @@ Options: -v, --verbose... Increase verbosity (-v, -vv) + --provider + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + --offline Run without network requests @@ -1248,15 +1377,16 @@ Arguments: Ticker code or entity name to start from Options: - --depth Traversal depth (default 2) [default: 2] - -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] - --format Output format: table or dot (Graphviz) [default: table] - --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 + --depth Traversal depth (default 2) [default: 2] + -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] + --format Output format: table or dot (Graphviz) [default: table] + --no-color Disable colored output + -q, --quiet Suppress non-essential output + -v, --verbose... Increase verbosity (-v, -vv) + --provider Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx ownership resolve` @@ -1273,13 +1403,14 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` #### `idx ownership resolve unresolved` @@ -1290,14 +1421,15 @@ List unresolved or low-confidence entity aliases Usage: idx ownership resolve unresolved [OPTIONS] Options: - --limit [default: 50] - -o, --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 + --limit [default: 50] + -o, --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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` #### `idx ownership resolve map` @@ -1312,13 +1444,14 @@ Arguments: The canonical entity name to map to Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` #### `idx ownership resolve merge` @@ -1333,13 +1466,14 @@ Arguments: Entity ID to merge and delete Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ### `idx ownership releases` @@ -1350,13 +1484,14 @@ List imported KSEI releases Usage: idx ownership releases [OPTIONS] Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ## `idx completions` @@ -1370,13 +1505,14 @@ Arguments: [possible values: bash, zsh, fish] Options: - -o, --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 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help ``` ## `idx version` @@ -1387,11 +1523,12 @@ Show idx-cli version Usage: idx version [OPTIONS] Options: - -o, --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 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 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 ``` diff --git a/web/src/content/docs/cli/stockbit.md b/web/src/content/docs/cli/stockbit.md new file mode 100644 index 0000000..e8bec2a --- /dev/null +++ b/web/src/content/docs/cli/stockbit.md @@ -0,0 +1,75 @@ +--- +title: Stockbit +description: Today's session by the minute and quotes with best bid/offer, from Stockbit's public page. Opt-in, no account. +--- + +[Stockbit](https://stockbit.com)'s public symbol page carries a quote, the +best bid and offer, and today's price for every minute of the session. idx +reads that page directly. It needs no Stockbit account, no login and no +helper tools. + +```bash +idx stocks intraday BBCA # today's session, by the minute +idx stocks intraday ^JKSE # an index (or IHSG, LQ45) +idx --provider stockbit stocks quote BBCA # quote with bid/offer +idx -o json stocks intraday BBCA | jq '.points | length' +``` + +## What you get + +| | `stocks intraday` | `--provider stockbit stocks quote` | +| --- | --- | --- | +| Price, change, previous close, volume | yes | yes | +| Best bid and offer | while the market is open | while the market is open | +| Market status (`market_status`) | yes | yes | +| Price per minute (`points`) | yes | no | +| Market cap, 52-week range | no | no | + +- **Delayed.** Stockbit's terms describe its market data as "a real-time + snapshot, delayed at least 10 minutes". It is not a live feed. +- **Bid and offer only while trading.** Outside the session, Stockbit still + shows the book as it closed. idx leaves `bid` and `offer` out then, so the + closing book never passes for a live one. +- **Top of the book only.** One bid and one offer price, no depth. The full + order book needs a Stockbit login, which idx does not do. +- **Prices only.** The page has no per-minute volume or OHLC. +- **Empty sessions are data.** A suspended or delisted stock answers with + `points: []` and a `session_note` saying so (`Stockbit lists the stock as + suspended`). An active stock with no volume says `no trades this session`, + even when its chart is a flat line at the previous close. Neither is an + error. + +`--provider stockbit` (or `IDX_PROVIDER=stockbit`, or +`idx config set general.provider stockbit`) changes only where quotes come +from. History still comes from Yahoo. Company data, fundamentals and +screening answer `UNSUPPORTED` under it, so use MSN for those. + +## How idx talks to Stockbit + +- One request per symbol to `https://stockbit.com/symbol/`, at least one + second apart across all your idx processes (a shell loop of `idx` calls + included), cached for 5 seconds. +- An honest user agent, `idx-cli/ (+https://github.com/0xrsydn/idx-cli)`, + not a browser disguise. Stockbit's CDN refuses some generic agents + (`curl/…`) outright but lets this one through. No curl-impersonate + needed. +- No retries. If Stockbit's CDN refuses your machine, the command fails at + once with `UPSTREAMBLOCKED` (exit `4`): + + ```json + {"error":true,"code":"UPSTREAMBLOCKED","message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); ..."} + ``` + + Wait, or try from another network. `--provider msn` serves quotes without + Stockbit. + +## Terms + +Stockbit's [terms](https://stockbit.com/terms) allow its content for personal, +non-commercial use. They prohibit data mining, robots and redistribution +without Stockbit's written consent. idx is not affiliated with or endorsed by +Stockbit. Use this for looking at a stock now and then, not for crawling the +market or republishing prices. + +Over MCP, the same data comes from the `get_intraday` tool, and from +`get_quotes` when the server runs with `IDX_PROVIDER=stockbit`. diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index e7ad744..dbb2936 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -19,6 +19,20 @@ session, the table says so under it, and JSON sets `behind_session`: the provider may still show the previous close. `history` takes `--period` (`1d` to `5y`) and `--interval` (`1d`, `1wk`, `1mo`). +## Today by the minute + +```bash +idx stocks intraday BBCA +idx stocks intraday ^JKSE +idx -o json stocks intraday BBCA | jq '.points[-1]' +``` + +`intraday` shows today's session from Stockbit's public page, without a +login: the quote, the best bid and offer while the market is open, a +sparkline and the last five minutes. JSON gives every minute in `points`. It +is delayed at least 10 minutes. See [Stockbit](/cli/stockbit/) for what it +covers and the terms it comes with. + ## Company data | Command | What it shows | @@ -142,6 +156,8 @@ idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compa MSN is the default for quotes, company data and screening; Yahoo serves price history. Indo Premier (IPOT) serves broker summaries and foreign flow -(IDX's own site with `flow --source idx`), whichever provider is selected. Switch with `IDX_PROVIDER=msn|yahoo` and -`IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the matching -[config keys](/start/config/). +(IDX's own site with `flow --source idx`), and Stockbit serves `intraday`, +whichever provider is selected. Switch with `--provider msn|yahoo|stockbit` +(or `IDX_PROVIDER`) and `IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the +matching [config keys](/start/config/). `--provider stockbit` serves quotes +only, with the best bid/offer; see [Stockbit](/cli/stockbit/). diff --git a/web/src/content/docs/mcp/index.md b/web/src/content/docs/mcp/index.md index b95f58b..bbd67a4 100644 --- a/web/src/content/docs/mcp/index.md +++ b/web/src/content/docs/mcp/index.md @@ -5,8 +5,9 @@ description: idx-mcp serves idx's stock and ownership data to AI agents over MCP `idx-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio. It gives an agent the same data as the `idx` CLI, as -[14 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, price history, screening, company data, -analysis, comparison, broker summaries, bandarmology, foreign flow, and the KSEI +[15 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's +session by the minute, price history, screening, company data, analysis, +comparison, broker summaries, bandarmology, foreign flow, and the KSEI ownership queries. It shares idx's [config, cache and ownership database](/start/config/#files-idx-keeps), @@ -27,7 +28,7 @@ Tools that fetch live data return: } ``` -`source` is the provider (`msn` or `yahoo`). Results are cached on disk; +`source` is the provider (`msn`, `yahoo`, `ipot`, `idx` or `stockbit`). Results are cached on disk; `stale` is true when an expired cache entry was served because a fresh fetch failed. Ownership tools return their data directly, from the local database. @@ -44,6 +45,7 @@ body, so the agent can decide whether to retry: | Input | Limit | | --- | --- | | tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed | +| `get_intraday` `tail` | 400 minutes (default: the whole session) | | `screen_stocks` `limit` | 200 | | `search_investors` `limit` | 100 | | `get_ownership_changes` `limit` | 300 per page (default 200); page with `offset` | diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 13aa94d..f105b5b 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -75,6 +75,15 @@ Foreign investors' buying and selling of one IDX ticker, session by session (old | `symbol` | string | yes | IDX ticker, e.g. "BBCA". | | `to` | string | no | Last session of the range, YYYY-MM-DD (default today). | +## `get_intraday` + +Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `symbol` | string | yes | IDX ticker, e.g. "BBCA"; indices as "^JKSE" or "IHSG". | +| `tail` | integer | no | Only the latest N minutes (default: the whole session, at most 400). | + ## `get_investor_holdings` Every IDX holding (above 1%) of one investor, matched by name. @@ -107,7 +116,7 @@ OHLCV price history for one IDX ticker. ## `get_quotes` -Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. +Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. "open", "close"), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes. | Input | Type | Required | Description | | --- | --- | --- | --- | diff --git a/web/src/content/docs/start/config.md b/web/src/content/docs/start/config.md index 4304b4d..d026b50 100644 --- a/web/src/content/docs/start/config.md +++ b/web/src/content/docs/start/config.md @@ -28,7 +28,7 @@ in scripts. Ask for JSON with `-o json`, `IDX_OUTPUT=json`, or ```toml [general] -provider = "msn" # msn | yahoo +provider = "msn" # msn | yahoo | stockbit history_provider = "auto" # auto | yahoo | msn exchange = "JK" output = "auto" # auto | table | json | jsonl diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 191d008..2d07090 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -139,7 +139,7 @@ TLKM.JK 2,250 +0.45% 87,159,900

Agents

-

JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 11 tools for any MCP client.

+

JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 15 tools for any MCP client.

npx -y idx-mcp
From 570439369e3a7e0fa5c82777e4ea28afbe0cac71 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 23 Oct 2026 07:15:24 +0000 Subject: [PATCH 02/33] docs(web): Stockbit intraday on the quickstart, landing page and agent notes --- web/astro.config.mjs | 2 +- web/src/content/docs/agents/index.md | 6 +++++- web/src/content/docs/start/quickstart.md | 1 + web/src/pages/index.astro | 4 ++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/web/astro.config.mjs b/web/astro.config.mjs index c745c23..5a07446 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -56,7 +56,7 @@ export default defineConfig({ projectName: 'idx-cli', details: [ '- `idx` is a CLI; `idx-mcp` is an MCP server over the same library. Both read the same config, cache and ownership database.', - '- Output is JSON whenever stdout is not a terminal. Errors are one JSON object on stderr; exit codes: 2 invalid input, 3 not found, 4 upstream unavailable, 5 unsupported, 1 other.', + '- Output is JSON whenever stdout is not a terminal. Errors are one JSON object on stderr; exit codes: 2 invalid input, 3 not found, 4 upstream unavailable or blocked, 5 unsupported, 1 other.', '- Every page is also available as Markdown by appending `.md` to its path, for example `/agents/install.md`.', ].join('\n'), }), diff --git a/web/src/content/docs/agents/index.md b/web/src/content/docs/agents/index.md index 6e4a460..78f4470 100644 --- a/web/src/content/docs/agents/index.md +++ b/web/src/content/docs/agents/index.md @@ -19,10 +19,14 @@ What an agent needs to know: - On failure stdout is empty and stderr holds one JSON object, `{"error": true, "code": "...", "message": "..."}`. - Exit codes say what to do next: `2` fix the input, `3` not found (or no - cached data offline), `4` provider unavailable, retry later, `5` + cached data offline), `4` provider unavailable or blocked, retry later, `5` unsupported, `1` other. - Tickers are IDX codes (`BBCA`); `.JK` is optional. Several tickers can be passed at once to `stocks quote` and `stocks compare`. +- `stocks intraday` and `--provider stockbit` read Stockbit's public page, + paced to one request a second across idx processes. Ask for the symbols + you need rather than looping over the market; on `UPSTREAMBLOCKED`, stop + instead of retrying. - Ownership queries need `idx ownership sync` once; after that they run offline. `idx -o json ownership releases` shows which months exist. diff --git a/web/src/content/docs/start/quickstart.md b/web/src/content/docs/start/quickstart.md index 6a85719..33ebefe 100644 --- a/web/src/content/docs/start/quickstart.md +++ b/web/src/content/docs/start/quickstart.md @@ -11,6 +11,7 @@ Tickers are IDX codes; the `.JK` suffix is optional. idx stocks quote BBCA # one quote idx stocks quote BBCA BBRI BMRI # several (spaces or commas) idx stocks history BBCA --period 1y # daily OHLCV +idx stocks intraday BBCA # today by the minute (Stockbit) idx stocks technical BBCA # trend, RSI, MACD, moving averages idx stocks fundamental BBCA # growth, valuation and risk together idx stocks compare BBCA BBRI BMRI # fundamentals side by side diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 2d07090..63e55f9 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -127,7 +127,7 @@ TLKM.JK 2,250 +0.45% 87,159,900

Stocks

-

Quotes, OHLC history, financial statements and a screener. Technical, growth, valuation and risk analysis, and side-by-side compare.

+

Quotes, today's session by the minute, OHLC history, financial statements and a screener. Technical, growth, valuation and risk analysis, and side-by-side compare.

idx stocks fundamental BBRI
@@ -219,7 +219,7 @@ TLKM.JK 2,250 +0.45% 87,159,900
2
invalid input or config
3
not found, or no cached data offline
-
4
upstream unavailable, retry later
+
4
upstream unavailable or blocked, retry later
5
unsupported
From b5ae9b98de6bc9bffbb74805d7d333eee6dc2944 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 23 Oct 2026 08:35:57 +0000 Subject: [PATCH 03/33] feat: Stockbit login for the order book, live quotes and daily history Opt-in, with the user's own Stockbit session from a pasted credentialStorage cookie (or IDX_STOCKBIT_REFRESH_TOKEN): - idx stockbit login/status/logout; session file 0600, tokens never shown - rotating refresh under a cross-process lock; one refresh+retry on 401 - stocks orderbook: depth with lots and orders, OHLC, value, foreign flow - --provider stockbit quotes from the session API when logged in - --history-provider stockbit, and auto under the stockbit provider for 5d-1y daily windows - MCP get_orderbook and stockbit_status (17 tools) Endpoints, units and recorded responses from fzn0x/idx (MIT). --- Cargo.lock | 1 + README.md | 18 +- ROADMAP.md | 11 +- crates/idx-cli/src/cli/mod.rs | 3 + crates/idx-cli/src/cli/stockbit.rs | 136 ++++ crates/idx-cli/src/cli/stocks.rs | 38 +- crates/idx-cli/src/main.rs | 6 + crates/idx-cli/src/output/mod.rs | 13 +- crates/idx-cli/src/output/table.rs | 119 +++- crates/idx-cli/tests/cli.rs | 60 ++ crates/idx-cli/tests/golden/cases.toml | 31 + .../tests/golden/completions-bash.golden | 350 +++++++++- .../error-orderbook-no-session.json.golden | 4 + .../error-orderbook-no-session.table.golden | 4 + .../error-stockbit-history-window.json.golden | 4 + ...error-stockbit-history-window.table.golden | 4 + crates/idx-cli/tests/golden/help-root.golden | 1 + .../idx-cli/tests/golden/help-stocks.golden | 1 + .../tests/golden/stockbit-quote.table.golden | 2 +- .../stockbit-session-history.json.golden | 4 + .../stockbit-session-history.table.golden | 51 ++ .../stockbit-session-orderbook.json.golden | 4 + .../stockbit-session-orderbook.table.golden | 17 + .../golden/stockbit-session-quote.json.golden | 4 + .../stockbit-session-quote.table.golden | 9 + .../stockbit-status-logged-out.json.golden | 4 + .../stockbit-status-logged-out.table.golden | 8 + crates/idx-core/Cargo.toml | 2 + crates/idx-core/src/analysis/mod.rs | 18 +- crates/idx-core/src/api/mock.rs | 81 ++- crates/idx-core/src/api/mod.rs | 149 ++++- crates/idx-core/src/api/stockbit/auth.rs | 607 ++++++++++++++++++ crates/idx-core/src/api/stockbit/exodus.rs | 543 ++++++++++++++++ crates/idx-core/src/api/stockbit/mod.rs | 96 ++- crates/idx-core/src/api/stockbit/parse.rs | 2 +- crates/idx-core/src/api/types.rs | 46 ++ crates/idx-core/src/config.rs | 7 +- crates/idx-core/src/market.rs | 53 +- .../stockbit_exodus_chart_candle_bbca_1m.json | 1 + .../stockbit_exodus_chart_candle_bbca_1y.json | 1 + ..._credential_storage_cookie_constructed.txt | 1 + ...kbit_exodus_login_refresh_constructed.json | 13 + .../stockbit_exodus_market_session.json | 1 + .../stockbit_exodus_orderbook_bbca.json | 1 + .../stockbit_exodus_orderbook_ihsg.json | 1 + .../stockbit_exodus_orderbook_zzzz.json | 1 + .../stockbit_exodus_unauthorized.json | 1 + crates/idx-mcp/src/params.rs | 9 + crates/idx-mcp/src/server.rs | 38 +- crates/idx-mcp/tests/golden/tools.json | 35 + crates/idx-mcp/tests/mcp.rs | 43 +- docs/ARCHITECTURE.md | 43 +- docs/SMOKE.md | 7 +- flake.nix | 2 +- web/src/content/docs/cli/reference.md | 179 +++++- web/src/content/docs/cli/stockbit.md | 68 +- web/src/content/docs/cli/stocks.md | 24 +- web/src/content/docs/mcp/index.md | 9 +- web/src/content/docs/mcp/tools.md | 15 + web/src/content/docs/start/config.md | 5 +- web/src/pages/index.astro | 2 +- 61 files changed, 2882 insertions(+), 129 deletions(-) create mode 100644 crates/idx-cli/src/cli/stockbit.rs create mode 100644 crates/idx-cli/tests/golden/error-orderbook-no-session.json.golden create mode 100644 crates/idx-cli/tests/golden/error-orderbook-no-session.table.golden create mode 100644 crates/idx-cli/tests/golden/error-stockbit-history-window.json.golden create mode 100644 crates/idx-cli/tests/golden/error-stockbit-history-window.table.golden create mode 100644 crates/idx-cli/tests/golden/stockbit-session-history.json.golden create mode 100644 crates/idx-cli/tests/golden/stockbit-session-history.table.golden create mode 100644 crates/idx-cli/tests/golden/stockbit-session-orderbook.json.golden create mode 100644 crates/idx-cli/tests/golden/stockbit-session-orderbook.table.golden create mode 100644 crates/idx-cli/tests/golden/stockbit-session-quote.json.golden create mode 100644 crates/idx-cli/tests/golden/stockbit-session-quote.table.golden create mode 100644 crates/idx-cli/tests/golden/stockbit-status-logged-out.json.golden create mode 100644 crates/idx-cli/tests/golden/stockbit-status-logged-out.table.golden create mode 100644 crates/idx-core/src/api/stockbit/auth.rs create mode 100644 crates/idx-core/src/api/stockbit/exodus.rs create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_market_session.json create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca.json create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_ihsg.json create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_zzzz.json create mode 100644 crates/idx-core/tests/fixtures/stockbit_exodus_unauthorized.json diff --git a/Cargo.lock b/Cargo.lock index ad2b9bc..41c6c3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -979,6 +979,7 @@ dependencies = [ name = "idx-core" version = "0.4.0" dependencies = [ + "base64", "chrono", "clap", "directories", diff --git a/README.md b/README.md index bd6b5b4..f41ab94 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,14 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31 - Analysis: `technical`, `growth`, `valuation`, `risk`, and a combined `fundamental` report; multi-symbol `compare`; MSN stock `screen` - Providers: MSN is the default for quotes, fundamentals, and screening; - Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo` and - `IDX_HISTORY_PROVIDER=auto|yahoo|msn` -- Stockbit, opt-in and without a login: `stocks intraday` (today's price by - the minute) and `--provider stockbit` quotes with the best bid/offer while - the market is open. Delayed at least 10 minutes, for personal use under - Stockbit's terms; see the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/) + Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo|stockbit` + and `IDX_HISTORY_PROVIDER=auto|yahoo|msn|stockbit` +- Stockbit, opt-in: without a login, `stocks intraday` (today's price by the + minute) and `--provider stockbit` quotes with the best bid/offer while the + market is open, delayed at least 10 minutes. With your own account + (`idx stockbit login`): `stocks orderbook` depth and foreign flow, live + quotes and Stockbit daily history. Personal use under Stockbit's terms; see + the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/) - Local file cache with TTL, and `--offline` mode with stale-cache fallback ### Ownership (KSEI shareholders above 1%) @@ -163,8 +165,8 @@ change it to `auto` with `idx config set general.output auto`. ## MCP server -`idx-mcp` serves the same data to MCP clients over stdio, as 15 tools: -`get_quotes`, `get_intraday`, `get_price_history`, `screen_stocks`, `get_company`, +`idx-mcp` serves the same data to MCP clients over stdio, as 17 tools: +`get_quotes`, `get_intraday`, `get_orderbook`, `stockbit_status`, `get_price_history`, `screen_stocks`, `get_company`, `analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`, `get_foreign_flow`, `get_shareholders`, `get_investor_holdings`, `search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as diff --git a/ROADMAP.md b/ROADMAP.md index d7c453c..088d705 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,11 +12,12 @@ history lives in git. - `screen presets` / `screen run `: built-in screener presets (graduate `screen` out of `stocks` only if these grow into a richer workflow) - Multi-period financial statements (today `financials` is single-period) -- Order book depth: no free, login-free source exists (IDX sells delayed depth - to vendors). Planned as the Stockbit login follow-on: opt-in with the - user's own account, the depth ladder, intraday OHLCV and token refresh from - `exodus.stockbit.com`; fzn0x/idx (MIT) maps the endpoints. The public page - already gives the best bid/offer (L1) while the market is open. +- Order book depth without a login: no free source exists (IDX sells delayed + depth to vendors); `stocks orderbook` needs the user's Stockbit login. +- Stockbit login follow-ups: a browser capture for `idx stockbit login` + (Chrome DevTools, as fzn0x/idx does) instead of pasting the cookie, + intraday OHLCV from the session's `1d` chart, and observing the session + API's open-market state name (assumed `STATE_NAME_MARKET_OPEN`). - Observe Stockbit's `market_hour.status` in the pre-open (08:45-09:00) and the closing auction (15:50-16:00) before `--watch` or alerts build on it; `open`, `break` and `close` are known, and the bid/offer gate hides the book diff --git a/crates/idx-cli/src/cli/mod.rs b/crates/idx-cli/src/cli/mod.rs index 4b4ac70..59643e6 100644 --- a/crates/idx-cli/src/cli/mod.rs +++ b/crates/idx-cli/src/cli/mod.rs @@ -2,6 +2,7 @@ pub mod cache; pub mod config; #[cfg(feature = "ownership")] pub mod ownership; +pub mod stockbit; pub mod stocks; use clap::{Parser, Subcommand, ValueEnum}; @@ -69,6 +70,8 @@ pub enum Commands { #[cfg(feature = "ownership")] #[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")] Ownership(ownership::OwnershipCmd), + #[command(about = "Your Stockbit session (opt-in: order book, live quotes)")] + Stockbit(stockbit::StockbitCmd), #[command(about = "Generate shell completions")] Completions { shell: Shell }, #[command(about = "Show idx-cli version")] diff --git a/crates/idx-cli/src/cli/stockbit.rs b/crates/idx-cli/src/cli/stockbit.rs new file mode 100644 index 0000000..7b9147f --- /dev/null +++ b/crates/idx-cli/src/cli/stockbit.rs @@ -0,0 +1,136 @@ +use std::io::{IsTerminal, Read}; + +use clap::{Args, Subcommand}; + +use crate::output::text::terminal_safe; +use crate::output::{OutputFormat, json}; +use idx_core::api::stockbit::{self, auth::SessionStatus, auth::TokenInfo}; +use idx_core::error::IdxError; +use idx_core::runtime; + +#[derive(Debug, Args)] +#[command( + about = "Your Stockbit session (opt-in: order book, live quotes)", + long_about = "Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` \ +quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, \ +stored in the config directory with owner-only permissions, and never logs them. Stockbit \ +rotates the refresh token on every refresh, so a browser sharing the session is logged out \ +once idx refreshes: log in through a separate browser profile for idx. Stockbit's terms \ +allow personal, non-commercial use.", + after_help = "Examples:\n idx stockbit login # paste the credentialStorage cookie\n pbpaste | idx stockbit login\n idx stockbit status\n idx stockbit logout" +)] +pub struct StockbitCmd { + #[command(subcommand)] + pub command: StockbitSubcommand, +} + +#[derive(Debug, Subcommand)] +pub enum StockbitSubcommand { + #[command( + about = "Store a session pasted on stdin and check it with Stockbit", + long_about = "Reads, on stdin, the value of stockbit.com's `credentialStorage` cookie \ +(in a logged-in browser: DevTools, Application, Cookies, https://stockbit.com) or a refresh \ +token, stores it, and checks it with one request. Servers can set \ +IDX_STOCKBIT_REFRESH_TOKEN instead." + )] + Login, + #[command(about = "Show whether a session is stored and when its tokens expire")] + Status, + #[command(about = "Delete the stored session")] + Logout, +} + +pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> { + match &cmd.command { + StockbitSubcommand::Login => { + let status = stockbit::login(&read_paste()?)?; + if format.is_json() { + return json::print_json(&status); + } + println!("Stored and checked your Stockbit session."); + print_status(&status); + } + StockbitSubcommand::Status => { + let status = stockbit::session_status()?; + if format.is_json() { + return json::print_json(&status); + } + print_status(&status); + } + StockbitSubcommand::Logout => { + let (removed, status) = stockbit::logout()?; + if status.logged_in { + runtime::warn( + "IDX_STOCKBIT_REFRESH_TOKEN or IDX_STOCKBIT_ACCESS_TOKEN still provides a session", + ); + } + if format.is_json() { + return json::print_json(&serde_json::json!({ + "removed": removed, + "logged_in": status.logged_in, + "session_file": status.session_file, + })); + } + if removed { + println!("Removed the stored Stockbit session."); + } else { + println!("No Stockbit session was stored."); + } + } + } + Ok(()) +} + +/// The pasted session: one line from a terminal, else all of stdin. +fn read_paste() -> Result { + let stdin = std::io::stdin(); + let mut raw = String::new(); + if stdin.is_terminal() { + eprintln!( + "Paste the value of stockbit.com's `credentialStorage` cookie (DevTools > \ + Application > Cookies) or a refresh token, then press Enter:" + ); + stdin + .read_line(&mut raw) + .map_err(|e| IdxError::Io(e.to_string()))?; + } else { + stdin + .lock() + .read_to_string(&mut raw) + .map_err(|e| IdxError::Io(e.to_string()))?; + } + if raw.trim().is_empty() { + return Err(IdxError::InvalidInput( + "nothing pasted: pipe or paste the credentialStorage cookie value or a refresh token" + .to_string(), + )); + } + Ok(raw) +} + +fn print_status(status: &SessionStatus) { + println!( + "logged in: {}", + if status.logged_in { "yes" } else { "no" } + ); + println!("access token: {}", token_line(&status.access)); + println!("refresh token: {}", token_line(&status.refresh)); + println!("session file: {}", terminal_safe(&status.session_file)); + if !status.logged_in { + println!("Run `idx stockbit login` to add one."); + } +} + +fn token_line(token: &TokenInfo) -> String { + match (token.present, token.expires_at, token.expires_in_secs) { + (false, _, _) => "none".to_string(), + (true, Some(at), Some(secs)) if secs > 0 => format!( + "expires {} UTC (in {}h {}m)", + at.format("%Y-%m-%d %H:%M"), + secs / 3600, + secs % 3600 / 60 + ), + (true, Some(at), _) => format!("expired {} UTC", at.format("%Y-%m-%d %H:%M")), + (true, None, _) => "present (expiry unknown)".to_string(), + } +} diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index e538a34..84ec04c 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -6,8 +6,8 @@ use crate::config::{HistoryProviderKind, IdxConfig}; use crate::output::{ render_bandar, render_broker_summary, render_compare, render_earnings, render_financials, render_foreign_flow, render_fundamental, render_growth, render_history, render_insights, - render_intraday, render_news, render_profile, render_quotes, render_risk, render_screener, - render_sentiment, render_technical, render_valuation, + render_intraday, render_news, render_orderbook, render_profile, render_quotes, render_risk, + render_screener, render_sentiment, render_technical, render_valuation, }; use idx_core::analysis::BandarQuery; use idx_core::analysis::bandar::DEFAULT_TOP; @@ -23,6 +23,8 @@ use idx_core::{CachePolicy, Idx, runtime}; /// separated by whitespace or commas. /// Most symbols one `bandar` screen fetches, one request each. const MAX_BANDAR_SYMBOLS: usize = 100; +/// Most price levels shown per side of an order book. +const MAX_BOOK_DEPTH: usize = 50; fn expand_symbols(args: &[String]) -> Result, IdxError> { let mut symbols = Vec::new(); @@ -146,6 +148,21 @@ Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45).", /// Single ticker symbol (e.g. BBCA). symbol: String, }, + #[command( + about = "Order book depth and foreign flow (Stockbit login)", + long_about = "The order book of one IDX stock from your own Stockbit session (`idx stockbit \ +login`): bid and offer levels with lots and queued orders, the session's OHLC, volume, value \ +and foreign buy, sell and net. Outside trading it is the book as the market left it, and \ +the output says so.", + after_help = "Examples:\n idx stocks orderbook BBCA\n idx stocks orderbook BBRI --depth 5\n idx -o json stocks orderbook TLKM | jq '.offers[0]'" + )] + Orderbook { + /// Single ticker symbol (e.g. BBCA). + symbol: String, + /// Price levels per side (1-50). + #[arg(long, default_value_t = 10)] + depth: usize, + }, #[command( about = "Get historical OHLC data", after_help = "Examples:\n idx stocks history BBCA --period 3mo\n idx stocks history BBCA --period 1y --interval 1wk" @@ -332,13 +349,26 @@ pub fn handle( let day = idx.market().intraday(symbol, policy)?; render_intraday(&day.data, format, no_color) } + StocksSubcommand::Orderbook { symbol, depth } => { + if !(1..=MAX_BOOK_DEPTH).contains(depth) { + return Err(IdxError::InvalidInput(format!( + "--depth must be 1 to {MAX_BOOK_DEPTH} (got {depth})" + ))); + } + let mut book = idx.market().orderbook(symbol, policy)?.data; + book.bids.truncate(*depth); + book.offers.truncate(*depth); + render_orderbook(&book, format, no_color) + } StocksSubcommand::History { symbol, period, interval, history_provider, } => { - let source = idx.market().history_source(*history_provider); + let source = idx + .market() + .history_source(*history_provider, period, interval); announce_history_fallback(&idx, source, config); let history = idx .market() @@ -350,7 +380,7 @@ pub fn handle( symbol, history_provider, } => { - let source = idx.market().history_source(*history_provider); + let source = idx.analysis().technical_source(*history_provider); announce_history_fallback(&idx, source, config); let report = idx.analysis().technical(symbol, source, policy)?; render_technical(&report.data, format, no_color) diff --git a/crates/idx-cli/src/main.rs b/crates/idx-cli/src/main.rs index f5a7dec..bbaab3f 100644 --- a/crates/idx-cli/src/main.rs +++ b/crates/idx-cli/src/main.rs @@ -73,6 +73,12 @@ fn run() -> Result<(), IdxError> { return Err(err); } } + Commands::Stockbit(cmd) => { + if let Err(err) = cli::stockbit::handle(cmd, &config.output) { + emit_error(&err, &config.output); + return Err(err); + } + } #[cfg(feature = "ownership")] Commands::Ownership(cmd) => { if let Err(err) = cli::ownership::handle(&cmd.command, &config) { diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 62d35b2..ac13fc6 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -15,7 +15,7 @@ use idx_core::analysis::fundamental::{ use idx_core::analysis::report::TechnicalReport; use idx_core::api::types::{ CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc, - Quote, SentimentData, + OrderBook, Quote, SentimentData, }; use idx_core::broker::types::BrokerSummary; use idx_core::config::ProviderKind; @@ -92,6 +92,17 @@ pub fn render_intraday( } } +pub fn render_orderbook( + book: &OrderBook, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_orderbook(book, no_color), + OutputFormat::Json | OutputFormat::Jsonl => json::print_json(book), + } +} + pub fn render_broker_summary( summary: &BrokerSummary, format: &OutputFormat, diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 6fc60bd..ffd99b3 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -11,8 +11,8 @@ use idx_core::analysis::fundamental::{ use idx_core::analysis::report::TechnicalReport; use idx_core::analysis::signals::Signal; use idx_core::api::types::{ - CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, Intraday, - NewsItem, Ohlc, Quote, SentimentData, + BookLevel, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, + Intraday, NewsItem, Ohlc, OrderBook, Quote, SentimentData, }; use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary}; use idx_core::error::IdxError; @@ -128,6 +128,13 @@ fn pct_cell(change_pct: Option, no_color: bool) -> Cell { /// Printed under every Stockbit table: what the data is and is not. const STOCKBIT_SOURCE_NOTE: &str = "Source: Stockbit's public page, delayed at least 10 \ minutes (Stockbit's terms); bid/offer only while the market is open."; +/// Under `--provider stockbit` quotes, which may come from either source. +const STOCKBIT_QUOTE_NOTE: &str = "Source: Stockbit, your session when logged in (`idx \ +stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); \ +bid/offer only while the market is open."; +/// Under the order book, which comes from the user's own session. +const STOCKBIT_SESSION_NOTE: &str = + "Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms)."; fn opt_idr(value: Option) -> String { value.map(format_idr).unwrap_or_else(|| "-".to_string()) @@ -160,7 +167,12 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx cell(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())), cell(opt_idr(q.bid)), cell(opt_idr(q.offer)), - cell(q.market_status.as_deref().unwrap_or("-")), + cell( + q.market_status + .as_deref() + .map(market_words) + .unwrap_or_else(|| "-".to_string()), + ), cell(q.as_of.map(wib_minute).unwrap_or_else(|| "-".to_string())), ]); } @@ -177,7 +189,7 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx for note in stale_quote_notes(quotes, chrono::Utc::now()) { println!("{}", terminal_safe(¬e)); } - println!("{STOCKBIT_SOURCE_NOTE}"); + println!("{STOCKBIT_QUOTE_NOTE}"); Ok(()) } @@ -259,6 +271,105 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> { Ok(()) } +/// Stockbit's market state for people: `STATE_NAME_MARKET_CLOSED` reads +/// `closed`. +fn market_words(state: &str) -> String { + state + .strip_prefix("STATE_NAME_MARKET_") + .unwrap_or(state) + .replace('_', " ") + .to_lowercase() +} + +pub fn print_orderbook(book: &OrderBook, no_color: bool) -> Result<(), IdxError> { + let change = match (book.change, book.change_pct) { + (Some(c), Some(p)) => format!(" {c:+} ({p:+.2}%)"), + (Some(c), None) => format!(" {c:+}"), + _ => String::new(), + }; + let change = match book.change { + _ if no_color || change.is_empty() => change, + Some(c) if c >= 0 => change.green(), + _ => change.red(), + }; + let name = book + .name + .as_deref() + .map(|n| format!(" {}", terminal_safe(n))) + .unwrap_or_default(); + println!( + "{}{change}", + format!( + "{}{name} {}", + terminal_safe(&book.symbol), + format_idr(book.price) + ) + .bold() + ); + let count = |v: Option| v.map(format_u64).unwrap_or_else(|| "-".to_string()); + println!( + "prev close {} · open {} · high {} · low {} · volume {} · value {} · {} trades", + opt_idr(book.prev_close), + opt_idr(book.open), + opt_idr(book.high), + opt_idr(book.low), + count(book.volume), + count(book.value), + count(book.frequency) + ); + if book.foreign_buy.is_some() || book.foreign_sell.is_some() { + println!( + "foreign buy {} · sell {} · net {}", + opt_idr(book.foreign_buy), + opt_idr(book.foreign_sell), + opt_idr(book.foreign_net) + ); + } + match book.market_status.as_deref() { + Some(idx_core::api::stockbit::exodus::MARKET_OPEN) => println!("market open"), + Some(state) => println!( + "market {}: the book as the market left it", + terminal_safe(&market_words(state)) + ), + None => println!("market state unknown: the book may not be live"), + } + if let Some(note) = &book.session_note { + println!("Note: {}", terminal_safe(note)); + } + if book.bids.is_empty() && book.offers.is_empty() { + println!("No bids or offers."); + } else { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec!["ORDERS", "LOTS", "BID", "OFFER", "LOTS", "ORDERS"]); + let side = |level: Option<&BookLevel>| match level { + Some(l) => [ + format_u64(l.orders), + format_u64(l.shares / 100), + format_idr(l.price), + ], + None => ["".to_string(), "".to_string(), "".to_string()], + }; + for i in 0..book.bids.len().max(book.offers.len()) { + let [bid_orders, bid_lots, bid] = side(book.bids.get(i)); + let [offer_orders, offer_lots, offer] = side(book.offers.get(i)); + table.add_row(vec![ + cell(bid_orders), + cell(bid_lots), + cell(bid), + cell(offer), + cell(offer_lots), + cell(offer_orders), + ]); + } + println!("{table}"); + } + println!("{STOCKBIT_SESSION_NOTE}"); + Ok(()) +} + /// Whole Rupiah for stocks, two decimals for an index level. fn format_price(price: f64) -> String { let cents = (price * 100.0).round() as i64; diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index 82bf3fc..e8902ef 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2137,6 +2137,66 @@ fn provider_flag_overrides_env() { assert_eq!(quotes[0]["market_cap"], Value::Null); } +#[test] +fn stockbit_login_stores_a_private_session_and_logout_removes_it() { + let root = test_env_dir("stockbit-login"); + let cookie = fs::read_to_string(fixture_path( + "stockbit_exodus_credential_storage_cookie_constructed.txt", + )) + .expect("cookie fixture"); + let session = root.join("config/idx/stockbit-session.json"); + + let status = run_success_json( + bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stockbit", "login"]) + .write_stdin(cookie.clone()), + ); + assert_eq!(status["logged_in"], true); + assert_eq!(status["refresh"]["present"], true); + let stored = fs::read_to_string(&session).expect("session file"); + assert!(stored.contains("\"refresh\"")); + // Status shows expiries, never a token. + assert!(!serde_json::to_string(&status).unwrap().contains("eyJ")); + #[cfg(unix)] + assert_eq!( + fs::metadata(&session).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + let book = run_success_json( + bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stocks", "orderbook", "BBCA"]), + ); + assert_eq!(book["offers"][0]["shares"], 264_462_500); + + let out = run_success_json( + bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stockbit", "logout"]), + ); + assert_eq!(out["removed"], true); + assert!(!session.exists()); + run_error_json( + bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + // The book fetched above is still cached for a few seconds. + .args(["-o", "json", "--no-cache", "stocks", "orderbook", "BBCA"]), + "AUTHERROR", + ); + + // Anything that is not a session is refused and stores nothing. + run_error_json( + bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stockbit", "login"]) + .write_stdin("not a token"), + "INVALIDINPUT", + ); + assert!(!session.exists()); +} + #[test] fn invalid_quote_ttl_env_returns_non_zero() { test_bin("invalid-quote-ttl") diff --git a/crates/idx-cli/tests/golden/cases.toml b/crates/idx-cli/tests/golden/cases.toml index 00e1ff3..ded196e 100644 --- a/crates/idx-cli/tests/golden/cases.toml +++ b/crates/idx-cli/tests/golden/cases.toml @@ -263,6 +263,28 @@ args = ["stocks", "intraday", "MABA"] name = "stockbit-quote" args = ["--provider", "stockbit", "stocks", "quote", "BBCA,MABA"] +# With a session: IDX_STOCKBIT_REFRESH_TOKEN seeds an unsigned test token that +# expires in 2100; the mock serves the recorded exodus responses. + +[[case]] +name = "stockbit-session-orderbook" +args = ["stocks", "orderbook", "BBCA", "--depth", "3"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "stockbit-session-quote" +args = ["--provider", "stockbit", "stocks", "quote", "BBCA"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "stockbit-session-history" +args = ["--provider", "stockbit", "stocks", "history", "BBCA", "--period", "1mo"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + +[[case]] +name = "stockbit-status-logged-out" +args = ["stockbit", "status"] + # --- stocks: broker summaries (IPOT) and foreign flow (IDX) -------------------- [[case]] @@ -366,6 +388,15 @@ args = ["stocks", "intraday", "ZZZZ"] name = "error-stockbit-fundamental-unsupported" args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"] +[[case]] +name = "error-orderbook-no-session" +args = ["stocks", "orderbook", "BBCA"] + +[[case]] +name = "error-stockbit-history-window" +args = ["stocks", "history", "BBCA", "--period", "5y", "--history-provider", "stockbit"] +env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" } + # --- ownership -------------------------------------------------------------------- [[case]] diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index 998698f..0c79a03 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -33,6 +33,9 @@ _idx() { idx,ownership) cmd="idx__ownership" ;; + idx,stockbit) + cmd="idx__stockbit" + ;; idx,stocks) cmd="idx__stocks" ;; @@ -102,6 +105,9 @@ _idx() { idx__help,ownership) cmd="idx__help__ownership" ;; + idx__help,stockbit) + cmd="idx__help__stockbit" + ;; idx__help,stocks) cmd="idx__help__stocks" ;; @@ -174,6 +180,15 @@ _idx() { idx__help__ownership__resolve,unresolved) cmd="idx__help__ownership__resolve__unresolved" ;; + idx__help__stockbit,login) + cmd="idx__help__stockbit__login" + ;; + idx__help__stockbit,logout) + cmd="idx__help__stockbit__logout" + ;; + idx__help__stockbit,status) + cmd="idx__help__stockbit__status" + ;; idx__help__stocks,bandar) cmd="idx__help__stocks__bandar" ;; @@ -210,6 +225,9 @@ _idx() { idx__help__stocks,news) cmd="idx__help__stocks__news" ;; + idx__help__stocks,orderbook) + cmd="idx__help__stocks__orderbook" + ;; idx__help__stocks,profile) cmd="idx__help__stocks__profile" ;; @@ -348,6 +366,30 @@ _idx() { idx__ownership__resolve__help,unresolved) cmd="idx__ownership__resolve__help__unresolved" ;; + idx__stockbit,help) + cmd="idx__stockbit__help" + ;; + idx__stockbit,login) + cmd="idx__stockbit__login" + ;; + idx__stockbit,logout) + cmd="idx__stockbit__logout" + ;; + idx__stockbit,status) + cmd="idx__stockbit__status" + ;; + idx__stockbit__help,help) + cmd="idx__stockbit__help__help" + ;; + idx__stockbit__help,login) + cmd="idx__stockbit__help__login" + ;; + idx__stockbit__help,logout) + cmd="idx__stockbit__help__logout" + ;; + idx__stockbit__help,status) + cmd="idx__stockbit__help__status" + ;; idx__stocks,bandar) cmd="idx__stocks__bandar" ;; @@ -387,6 +429,9 @@ _idx() { idx__stocks,news) cmd="idx__stocks__news" ;; + idx__stocks,orderbook) + cmd="idx__stocks__orderbook" + ;; idx__stocks,profile) cmd="idx__stocks__profile" ;; @@ -447,6 +492,9 @@ _idx() { idx__stocks__help,news) cmd="idx__stocks__help__news" ;; + idx__stocks__help,orderbook) + cmd="idx__stocks__help__orderbook" + ;; idx__stocks__help,profile) cmd="idx__stocks__help__profile" ;; @@ -475,7 +523,7 @@ _idx() { case "${cmd}" in idx) - opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership completions version help" + opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership stockbit completions version help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -875,7 +923,7 @@ _idx() { return 0 ;; idx__help) - opts="stocks config cache ownership completions version help" + opts="stocks config cache ownership stockbit completions version help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1266,8 +1314,64 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stockbit) + opts="login status logout" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stockbit__login) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stockbit__logout) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__help__stockbit__status) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks) - opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare" + opts="quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1448,6 +1552,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__help__stocks__orderbook) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__help__stocks__profile) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2392,8 +2510,182 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stockbit) + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help login status logout help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__help) + opts="login status logout help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__help__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__help__login) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__help__logout) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__help__status) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__login) + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__logout) + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + idx__stockbit__status) + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks) - opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" + opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2691,7 +2983,7 @@ _idx() { return 0 ;; idx__stocks__help) - opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" + opts="quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2886,6 +3178,20 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__help__orderbook) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__help__profile) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -3000,7 +3306,7 @@ _idx() { return 0 ;; --history-provider) - COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}")) + COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}")) return 0 ;; --output) @@ -3104,6 +3410,36 @@ _idx() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + idx__stocks__orderbook) + opts="-o -q -v -h --depth --output --no-color --quiet --verbose --provider --offline --no-cache --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --depth) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + -o) + COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; idx__stocks__profile) opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -3254,7 +3590,7 @@ _idx() { fi case "${prev}" in --history-provider) - COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}")) + COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}")) return 0 ;; --output) diff --git a/crates/idx-cli/tests/golden/error-orderbook-no-session.json.golden b/crates/idx-cli/tests/golden/error-orderbook-no-session.json.golden new file mode 100644 index 0000000..d38ac26 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-orderbook-no-session.json.golden @@ -0,0 +1,4 @@ +exit: 4 +--- stdout +--- stderr +{"code":"AUTHERROR","error":true,"message":"auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)"} diff --git a/crates/idx-cli/tests/golden/error-orderbook-no-session.table.golden b/crates/idx-cli/tests/golden/error-orderbook-no-session.table.golden new file mode 100644 index 0000000..e1fcda0 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-orderbook-no-session.table.golden @@ -0,0 +1,4 @@ +exit: 4 +--- stdout +--- stderr +Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN) diff --git a/crates/idx-cli/tests/golden/error-stockbit-history-window.json.golden b/crates/idx-cli/tests/golden/error-stockbit-history-window.json.golden new file mode 100644 index 0000000..5051558 --- /dev/null +++ b/crates/idx-cli/tests/golden/error-stockbit-history-window.json.golden @@ -0,0 +1,4 @@ +exit: 5 +--- stdout +--- stderr +{"code":"UNSUPPORTED","error":true,"message":"unsupported: Stockbit history covers 5d to 1y of daily candles, not 5y at 1d; use --history-provider yahoo"} diff --git a/crates/idx-cli/tests/golden/error-stockbit-history-window.table.golden b/crates/idx-cli/tests/golden/error-stockbit-history-window.table.golden new file mode 100644 index 0000000..5a39fac --- /dev/null +++ b/crates/idx-cli/tests/golden/error-stockbit-history-window.table.golden @@ -0,0 +1,4 @@ +exit: 5 +--- stdout +--- stderr +Error: unsupported: Stockbit history covers 5d to 1y of daily candles, not 5y at 1d; use --history-provider yahoo diff --git a/crates/idx-cli/tests/golden/help-root.golden b/crates/idx-cli/tests/golden/help-root.golden index 5593677..588684d 100644 --- a/crates/idx-cli/tests/golden/help-root.golden +++ b/crates/idx-cli/tests/golden/help-root.golden @@ -10,6 +10,7 @@ Commands: config Manage configuration cache Manage local cache ownership Ownership intelligence (bootstrap with `ownership sync`) + stockbit Your Stockbit session (opt-in: order book, live quotes) completions Generate shell completions version Show idx-cli version help Print this message or the help of the given subcommand(s) diff --git a/crates/idx-cli/tests/golden/help-stocks.golden b/crates/idx-cli/tests/golden/help-stocks.golden index af937f3..5970553 100644 --- a/crates/idx-cli/tests/golden/help-stocks.golden +++ b/crates/idx-cli/tests/golden/help-stocks.golden @@ -7,6 +7,7 @@ Usage: idx stocks [OPTIONS] Commands: quote Get real-time stock quotes intraday Today's session price by the minute (Stockbit) + orderbook Order book depth and foreign flow (Stockbit login) history Get historical OHLC data technical Run technical analysis on a stock growth Run growth analysis on a stock diff --git a/crates/idx-cli/tests/golden/stockbit-quote.table.golden b/crates/idx-cli/tests/golden/stockbit-quote.table.golden index e8ee346..2dff9e7 100644 --- a/crates/idx-cli/tests/golden/stockbit-quote.table.golden +++ b/crates/idx-cli/tests/golden/stockbit-quote.table.golden @@ -8,5 +8,5 @@ exit: 0 │ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │ └─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘ Note: MABA.JK: Stockbit lists the stock as suspended -Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. +Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. --- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-session-history.json.golden b/crates/idx-cli/tests/golden/stockbit-session-history.json.golden new file mode 100644 index 0000000..4eeecbb --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-history.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"date":"2026-09-17","open":6400,"high":6400,"low":6325,"close":6350,"volume":61812000},{"date":"2026-09-18","open":6325,"high":6350,"low":6225,"close":6300,"volume":176753700},{"date":"2026-09-21","open":6300,"high":6300,"low":6225,"close":6225,"volume":69914100},{"date":"2026-09-22","open":6225,"high":6250,"low":6175,"close":6200,"volume":93211200},{"date":"2026-09-23","open":6200,"high":6300,"low":6200,"close":6300,"volume":96826100},{"date":"2026-09-24","open":6250,"high":6275,"low":6225,"close":6225,"volume":75648600},{"date":"2026-09-25","open":6225,"high":6275,"low":6200,"close":6250,"volume":89447400},{"date":"2026-09-28","open":6225,"high":6250,"low":6175,"close":6225,"volume":112107600},{"date":"2026-09-29","open":6150,"high":6225,"low":6125,"close":6150,"volume":149783400},{"date":"2026-09-30","open":6100,"high":6150,"low":6050,"close":6075,"volume":221335700},{"date":"2026-10-01","open":6050,"high":6150,"low":6000,"close":6000,"volume":118044900},{"date":"2026-10-02","open":6000,"high":6150,"low":5975,"close":6100,"volume":131920300},{"date":"2026-10-05","open":6100,"high":6175,"low":6075,"close":6175,"volume":89905700},{"date":"2026-10-06","open":6175,"high":6200,"low":6100,"close":6100,"volume":152173400},{"date":"2026-10-07","open":6075,"high":6100,"low":5975,"close":6050,"volume":162690800},{"date":"2026-10-08","open":6100,"high":6150,"low":6000,"close":6000,"volume":113140300},{"date":"2026-10-09","open":6025,"high":6075,"low":5975,"close":6050,"volume":87098600},{"date":"2026-10-12","open":6050,"high":6100,"low":5950,"close":5950,"volume":97965100},{"date":"2026-10-13","open":5925,"high":5950,"low":5850,"close":5950,"volume":122716900},{"date":"2026-10-14","open":6000,"high":6125,"low":5950,"close":6125,"volume":136303100},{"date":"2026-10-15","open":6075,"high":6125,"low":6025,"close":6075,"volume":134763200},{"date":"2026-10-16","open":5825,"high":5825,"low":5175,"close":5175,"volume":1241098200}] +--- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-session-history.table.golden b/crates/idx-cli/tests/golden/stockbit-session-history.table.golden new file mode 100644 index 0000000..7bc35c6 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-history.table.golden @@ -0,0 +1,51 @@ +exit: 0 +--- stdout +History for BBCA.JK +┌────────────┬───────┬───────┬───────┬───────┬───────────────┐ +│ DATE ┆ OPEN ┆ HIGH ┆ LOW ┆ CLOSE ┆ VOLUME │ +╞════════════╪═══════╪═══════╪═══════╪═══════╪═══════════════╡ +│ 2026-09-17 ┆ 6,400 ┆ 6,400 ┆ 6,325 ┆ 6,350 ┆ 61,812,000 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-09-18 ┆ 6,325 ┆ 6,350 ┆ 6,225 ┆ 6,300 ┆ 176,753,700 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-09-21 ┆ 6,300 ┆ 6,300 ┆ 6,225 ┆ 6,225 ┆ 69,914,100 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-09-22 ┆ 6,225 ┆ 6,250 ┆ 6,175 ┆ 6,200 ┆ 93,211,200 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-09-23 ┆ 6,200 ┆ 6,300 ┆ 6,200 ┆ 6,300 ┆ 96,826,100 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-09-24 ┆ 6,250 ┆ 6,275 ┆ 6,225 ┆ 6,225 ┆ 75,648,600 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-09-25 ┆ 6,225 ┆ 6,275 ┆ 6,200 ┆ 6,250 ┆ 89,447,400 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-09-28 ┆ 6,225 ┆ 6,250 ┆ 6,175 ┆ 6,225 ┆ 112,107,600 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-09-29 ┆ 6,150 ┆ 6,225 ┆ 6,125 ┆ 6,150 ┆ 149,783,400 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-09-30 ┆ 6,100 ┆ 6,150 ┆ 6,050 ┆ 6,075 ┆ 221,335,700 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-01 ┆ 6,050 ┆ 6,150 ┆ 6,000 ┆ 6,000 ┆ 118,044,900 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-02 ┆ 6,000 ┆ 6,150 ┆ 5,975 ┆ 6,100 ┆ 131,920,300 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-05 ┆ 6,100 ┆ 6,175 ┆ 6,075 ┆ 6,175 ┆ 89,905,700 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-06 ┆ 6,175 ┆ 6,200 ┆ 6,100 ┆ 6,100 ┆ 152,173,400 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-07 ┆ 6,075 ┆ 6,100 ┆ 5,975 ┆ 6,050 ┆ 162,690,800 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-08 ┆ 6,100 ┆ 6,150 ┆ 6,000 ┆ 6,000 ┆ 113,140,300 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-09 ┆ 6,025 ┆ 6,075 ┆ 5,975 ┆ 6,050 ┆ 87,098,600 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-12 ┆ 6,050 ┆ 6,100 ┆ 5,950 ┆ 5,950 ┆ 97,965,100 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-13 ┆ 5,925 ┆ 5,950 ┆ 5,850 ┆ 5,950 ┆ 122,716,900 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-14 ┆ 6,000 ┆ 6,125 ┆ 5,950 ┆ 6,125 ┆ 136,303,100 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-15 ┆ 6,075 ┆ 6,125 ┆ 6,025 ┆ 6,075 ┆ 134,763,200 │ +├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ 2026-10-16 ┆ 5,825 ┆ 5,825 ┆ 5,175 ┆ 5,175 ┆ 1,241,098,200 │ +└────────────┴───────┴───────┴───────┴───────┴───────────────┘ +--- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-session-orderbook.json.golden b/crates/idx-cli/tests/golden/stockbit-session-orderbook.json.golden new file mode 100644 index 0000000..34939b7 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-orderbook.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"symbol":"BBCA.JK","name":"Bank Central Asia Tbk.","price":5175,"change":-900,"change_pct":-14.81,"prev_close":6075,"open":5825,"high":5825,"low":5175,"volume":1241098200,"value":6474934315000,"frequency":185072,"foreign_buy":1616461977500,"foreign_sell":3945955065000,"foreign_net":-2329493087500,"bids":[],"offers":[{"price":5175,"shares":264462500,"orders":3284},{"price":5200,"shares":10053400,"orders":337},{"price":5225,"shares":3711400,"orders":396}],"market_status":"STATE_NAME_MARKET_CLOSED","session_note":null} +--- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-session-orderbook.table.golden b/crates/idx-cli/tests/golden/stockbit-session-orderbook.table.golden new file mode 100644 index 0000000..fec3a84 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-orderbook.table.golden @@ -0,0 +1,17 @@ +exit: 0 +--- stdout +BBCA.JK Bank Central Asia Tbk. 5,175 -900 (-14.81%) +prev close 6,075 · open 5,825 · high 5,825 · low 5,175 · volume 1,241,098,200 · value 6,474,934,315,000 · 185,072 trades +foreign buy 1,616,461,977,500 · sell 3,945,955,065,000 · net -2,329,493,087,500 +market closed: the book as the market left it +┌────────┬──────┬─────┬───────┬───────────┬────────┐ +│ ORDERS ┆ LOTS ┆ BID ┆ OFFER ┆ LOTS ┆ ORDERS │ +╞════════╪══════╪═════╪═══════╪═══════════╪════════╡ +│ ┆ ┆ ┆ 5,175 ┆ 2,644,625 ┆ 3,284 │ +├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ +│ ┆ ┆ ┆ 5,200 ┆ 100,534 ┆ 337 │ +├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤ +│ ┆ ┆ ┆ 5,225 ┆ 37,114 ┆ 396 │ +└────────┴──────┴─────┴───────┴───────────┴────────┘ +Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms). +--- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-session-quote.json.golden b/crates/idx-cli/tests/golden/stockbit-session-quote.json.golden new file mode 100644 index 0000000..57399e3 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-quote.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +[{"symbol":"BBCA.JK","price":5175,"change":-900,"change_pct":-14.81,"volume":1241098200,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":6075,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"STATE_NAME_MARKET_CLOSED"}] +--- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-session-quote.table.golden b/crates/idx-cli/tests/golden/stockbit-session-quote.table.golden new file mode 100644 index 0000000..4863e68 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-session-quote.table.golden @@ -0,0 +1,9 @@ +exit: 0 +--- stdout +┌─────────┬───────┬──────┬─────────┬───────────────┬─────┬───────┬────────┬───────┐ +│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ BID ┆ OFFER ┆ MARKET ┆ AS OF │ +╞═════════╪═══════╪══════╪═════════╪═══════════════╪═════╪═══════╪════════╪═══════╡ +│ BBCA.JK ┆ 5,175 ┆ -900 ┆ -14.81% ┆ 1,241,098,200 ┆ - ┆ - ┆ closed ┆ - │ +└─────────┴───────┴──────┴─────────┴───────────────┴─────┴───────┴────────┴───────┘ +Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. +--- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-status-logged-out.json.golden b/crates/idx-cli/tests/golden/stockbit-status-logged-out.json.golden new file mode 100644 index 0000000..af3e2e4 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-status-logged-out.json.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout +{"logged_in":false,"access":{"present":false,"expires_at":null,"expires_in_secs":null},"refresh":{"present":false,"expires_at":null,"expires_in_secs":null},"session_file":"/config/idx/stockbit-session.json"} +--- stderr diff --git a/crates/idx-cli/tests/golden/stockbit-status-logged-out.table.golden b/crates/idx-cli/tests/golden/stockbit-status-logged-out.table.golden new file mode 100644 index 0000000..cf15909 --- /dev/null +++ b/crates/idx-cli/tests/golden/stockbit-status-logged-out.table.golden @@ -0,0 +1,8 @@ +exit: 0 +--- stdout +logged in: no +access token: none +refresh token: none +session file: /config/idx/stockbit-session.json +Run `idx stockbit login` to add one. +--- stderr diff --git a/crates/idx-core/Cargo.toml b/crates/idx-core/Cargo.toml index 187ff54..f84e780 100644 --- a/crates/idx-core/Cargo.toml +++ b/crates/idx-core/Cargo.toml @@ -30,6 +30,8 @@ directories = "5" chrono.workspace = true thiserror = "2" fastrand = "2" +# Decodes a Stockbit session JWT's expiry (`exp`); never verifies or signs. +base64 = "0.22" rusqlite = { workspace = true, optional = true } quick-xml = { version = "0.37", optional = true } sha2 = { workspace = true, optional = true } diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index b0741f2..c20672c 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -11,6 +11,7 @@ use crate::api::history_provider; use crate::api::types::{Fundamentals, Interval, Period}; use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; +use crate::config::HistoryProviderKind; use crate::error::IdxError; use crate::market::HistorySource; use bandar::{BandarReport, analyze_bandar, check_top}; @@ -110,8 +111,16 @@ impl Analysis<'_> { Ok(BandarScreen { reports, failures }) } + /// Where `technical` reads its year of daily history from, for an + /// optional `--history-provider` override. + pub fn technical_source(&self, mode: Option) -> HistorySource { + self.idx + .market() + .history_source(mode, &Period::OneYear, &Interval::Day) + } + /// Technical analysis over one year of daily history from `source` - /// (see `Market::history_source`). + /// (see `technical_source`). pub fn technical( &self, symbol: &str, @@ -119,9 +128,8 @@ impl Analysis<'_> { policy: CachePolicy, ) -> Result, IdxError> { let idx = self.idx; - let (provider_kind, provider) = - history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?; - let bucket = format!("{}-technical", provider_kind.as_str()); + let provider = history_provider(source.provider, idx.config.verbose); + let bucket = format!("{}-technical", source.provider.as_str()); let resolved = idx.resolve(symbol)?; cached_fetch( &idx.cache, @@ -130,7 +138,7 @@ impl Analysis<'_> { key: &resolved, ttl_secs: idx.config.quote_ttl, subject: &resolved, - source: provider_kind.as_str(), + source: source.provider.as_str(), }, policy, || provider.history(&resolved, &Period::OneYear, &Interval::Day), diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index 1a90c92..f392967 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -1,10 +1,11 @@ //! Fixture-backed provider for tests, enabled by the `testing` feature and //! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it. -use super::types::{Bar, Fundamentals, Interval, Intraday, Period, Quote}; +use super::stockbit::exodus; +use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote}; use super::{ - BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, QuoteProvider, - TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo, + BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, + OrderBookProvider, QuoteProvider, TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo, }; use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery}; use crate::config::ProviderKind; @@ -135,22 +136,31 @@ impl HistoryProvider for MockProvider { } } -/// Stockbit symbol pages from `tests/fixtures/stockbit_symbol_.html`; -/// a code without a fixture is Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` -/// (e.g. `403`) answers that HTTP status instead, and `IDX_MOCK_ERROR` fails -/// as for the other providers. +/// Stockbit from fixtures. Without a session (none stored or seeded through +/// `IDX_STOCKBIT_REFRESH_TOKEN`): the public page, +/// `tests/fixtures/stockbit_symbol_.html`. With one: the session API, +/// `tests/fixtures/stockbit_exodus_orderbook_.json` and +/// `stockbit_exodus_chart_candle__.json`, with the market state +/// from `stockbit_exodus_market_session.json`. A code without a fixture is +/// Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` (e.g. `403`) answers that HTTP +/// status instead, and `IDX_MOCK_ERROR` fails as for the other providers. pub struct MockStockbitProvider; impl MockStockbitProvider { - fn snapshot(&self, symbol: &str) -> Result { + fn failure(&self, symbol: &str) -> Option { if std::env::var("IDX_MOCK_ERROR").is_ok() { - return Err(IdxError::ProviderUnavailable); + return Some(IdxError::ProviderUnavailable); } - if let Ok(status) = std::env::var("IDX_MOCK_STOCKBIT_STATUS") { - return Err(stockbit::status_error( - status.parse().unwrap_or(500), - symbol, - )); + let status = std::env::var("IDX_MOCK_STOCKBIT_STATUS").ok()?; + Some(stockbit::status_error( + status.parse().unwrap_or(500), + symbol, + )) + } + + fn snapshot(&self, symbol: &str) -> Result { + if let Some(err) = self.failure(symbol) { + return Err(err); } let code = stockbit::stockbit_code(symbol)?; let path = format!( @@ -162,10 +172,32 @@ impl MockStockbitProvider { Err(_) => Err(stockbit::status_error(404, symbol)), } } + + /// A session API fixture, or the error a request would meet. + fn exodus(&self, symbol: &str, name: &str) -> Result { + if !stockbit::has_session() { + return Err(IdxError::AuthError(stockbit::auth::NO_SESSION.to_string())); + } + if let Some(err) = self.failure(symbol) { + return Err(err); + } + std::fs::read_to_string(format!("tests/fixtures/stockbit_exodus_{name}")) + .map_err(|_| IdxError::SymbolNotFound(symbol.to_string())) + } + + fn book(&self, symbol: &str) -> Result { + let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase(); + let body = self.exodus(symbol, &format!("orderbook_{code}.json"))?; + let state = self.exodus(symbol, "market_session.json")?; + exodus::parse_orderbook(symbol, &body, exodus::parse_market_status(&state)) + } } impl QuoteProvider for MockStockbitProvider { fn quote(&self, symbol: &str) -> Result { + if stockbit::has_session() { + return self.book(symbol).map(|book| exodus::quote_from_book(&book)); + } let mut quote = self.snapshot(symbol)?.quote; // Quotes are judged against the clock (behind the session, a week // old); a fixed trade time would make their output age. @@ -186,6 +218,27 @@ impl IntradayProvider for MockStockbitProvider { } } +impl OrderBookProvider for MockStockbitProvider { + fn orderbook(&self, symbol: &str) -> Result { + self.book(symbol) + } +} + +impl HistoryProvider for MockStockbitProvider { + fn history( + &self, + symbol: &str, + period: &Period, + interval: &Interval, + ) -> Result, IdxError> { + let window = exodus::history_window(period, interval) + .ok_or_else(|| exodus::unsupported_window(period, interval))?; + let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase(); + let body = self.exodus(symbol, &format!("chart_candle_{code}_{window}.json"))?; + exodus::daily_for_period(&body, period) + } +} + /// Broker summaries from `tests/fixtures/ipot_brokersummary__ /// [_][__].html`; a query without a fixture reads as an /// unknown code. diff --git a/crates/idx-core/src/api/mod.rs b/crates/idx-core/src/api/mod.rs index 8ff9fdd..ae9cd0f 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -14,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind}; use crate::error::IdxError; use types::{ Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, - Intraday, NewsItem, Period, Quote, SentimentData, + Intraday, NewsItem, OrderBook, Period, Quote, SentimentData, }; /// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp @@ -72,6 +72,12 @@ pub trait IntradayProvider { fn intraday(&self, symbol: &str) -> Result; } +/// The order book of one IDX stock: needs the user's Stockbit session. +pub trait OrderBookProvider { + /// `symbol` is resolved, e.g. `BBCA.JK`. + fn orderbook(&self, symbol: &str) -> Result; +} + pub trait FundamentalsProvider { fn fundamentals(&self, symbol: &str) -> Result; } @@ -242,9 +248,12 @@ fn mock_market(_provider: ProviderKind) -> Option> { #[cfg(feature = "testing")] fn mock_history(provider: ProviderKind, verbose: bool) -> Option> { - Some(Box::new( - mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose), - )) + match provider { + ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)), + _ => Some(Box::new( + mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose), + )), + } } #[cfg(not(feature = "testing"))] @@ -285,7 +294,7 @@ fn build_selected_provider( news: Some(Box::new(msn::MsnProvider::new(verbose))), screener: Some(Box::new(msn::MsnProvider::new(verbose))), }, - // Quotes only; history falls back to Yahoo (see `history_source`). + // Quotes only; history needs a session (see `history_source`). ProviderKind::Stockbit => SelectedProvider { kind: ProviderKind::Stockbit, market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())), @@ -328,6 +337,14 @@ pub fn intraday_provider() -> Box { Box::new(stockbit::StockbitProvider::new()) } +pub fn orderbook_provider() -> Box { + #[cfg(feature = "testing")] + if mock::enabled() { + return Box::new(mock::MockStockbitProvider); + } + Box::new(stockbit::StockbitProvider::new()) +} + /// Today in WIB, the date IDX trades on. pub(crate) fn today_wib() -> chrono::NaiveDate { #[cfg(feature = "testing")] @@ -338,40 +355,44 @@ pub(crate) fn today_wib() -> chrono::NaiveDate { } /// The provider that serves history for `provider` under `history_mode`. -pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind { +/// `auto` uses Stockbit when it is the selected provider, the user has a +/// session and the window is one of its daily charts; otherwise Yahoo, whose +/// candles are full OHLCV for any window. +pub fn history_source( + provider: ProviderKind, + history_mode: HistoryProviderKind, + period: &Period, + interval: &Interval, + stockbit_session: bool, +) -> ProviderKind { match history_mode { HistoryProviderKind::Yahoo => ProviderKind::Yahoo, HistoryProviderKind::Msn => ProviderKind::Msn, - HistoryProviderKind::Auto => match provider { - ProviderKind::Yahoo | ProviderKind::Msn | ProviderKind::Stockbit => ProviderKind::Yahoo, - }, + HistoryProviderKind::Stockbit => ProviderKind::Stockbit, + HistoryProviderKind::Auto => { + if provider == ProviderKind::Stockbit + && stockbit_session + && stockbit::exodus::history_window(period, interval).is_some() + { + ProviderKind::Stockbit + } else { + ProviderKind::Yahoo + } + } } } -/// Resolves a history provider based on the selected market data provider and -/// history provider strategy. -/// -/// `history_mode=auto` keeps using Yahoo for IDX history because Yahoo provides -/// full OHLCV candles. Explicit `msn` opts into MSN's price-only chart feed. -pub fn history_provider( - provider: ProviderKind, - history_mode: HistoryProviderKind, - verbose: bool, -) -> Result<(ProviderKind, Box), IdxError> { - let resolved = history_source(provider, history_mode); - +/// The history provider for a source from `history_source`. +pub fn history_provider(source: ProviderKind, verbose: bool) -> Box { if mock_enabled() - && let Some(mock) = mock_history(resolved, verbose) + && let Some(mock) = mock_history(source, verbose) { - return Ok((resolved, mock)); + return mock; } - - match resolved { - ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))), - ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))), - ProviderKind::Stockbit => Err(IdxError::Unsupported( - "Stockbit serves no price history; use --history-provider yahoo or msn".to_string(), - )), + match source { + ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)), + ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)), + ProviderKind::Stockbit => Box::new(stockbit::StockbitProvider::new()), } } @@ -472,4 +493,72 @@ mod tests { "unsupported: screen: command requires --provider msn" ); } + + #[test] + fn auto_history_uses_stockbit_only_with_a_session_and_a_daily_window() { + use super::history_source; + use crate::api::types::{Interval, Period}; + use crate::config::HistoryProviderKind::{Auto, Stockbit, Yahoo}; + + let source = |provider, mode, period, interval, session| { + history_source(provider, mode, &period, &interval, session) + }; + let year = (Period::OneYear, Interval::Day); + assert_eq!( + source( + ProviderKind::Stockbit, + Auto, + year.0.clone(), + year.1.clone(), + true + ), + ProviderKind::Stockbit + ); + // No session, a window Stockbit charts weekly, or another provider. + assert_eq!( + source( + ProviderKind::Stockbit, + Auto, + year.0.clone(), + year.1.clone(), + false + ), + ProviderKind::Yahoo + ); + assert_eq!( + source( + ProviderKind::Stockbit, + Auto, + Period::FiveYears, + Interval::Day, + true + ), + ProviderKind::Yahoo + ); + assert_eq!( + source( + ProviderKind::Msn, + Auto, + year.0.clone(), + year.1.clone(), + true + ), + ProviderKind::Yahoo + ); + // An explicit choice wins either way. + assert_eq!( + source( + ProviderKind::Msn, + Stockbit, + year.0.clone(), + year.1.clone(), + false + ), + ProviderKind::Stockbit + ); + assert_eq!( + source(ProviderKind::Stockbit, Yahoo, year.0, year.1, true), + ProviderKind::Yahoo + ); + } } diff --git a/crates/idx-core/src/api/stockbit/auth.rs b/crates/idx-core/src/api/stockbit/auth.rs new file mode 100644 index 0000000..93da93b --- /dev/null +++ b/crates/idx-core/src/api/stockbit/auth.rs @@ -0,0 +1,607 @@ +//! The user's own Stockbit session: two bearer JWTs, where they are kept, and +//! how they are refreshed. Opt-in; nothing here runs without a session. +//! +//! - The **access** token (about a day) authorises every +//! `exodus.stockbit.com` request. +//! - The **refresh** token (about a week) is spent on `POST /login/refresh`, +//! which answers a new access token *and a new refresh token*, retiring the +//! one presented. So exactly one party may refresh a session: two idx +//! processes refresh under a file lock and re-read what the other wrote, and +//! a browser sharing the session gets logged out once idx refreshes. +//! +//! The session lives in `/stockbit-session.json` (mode 0600). +//! `IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` seed fields the +//! file lacks, for servers. Tokens never reach logs, errors or cache keys. +//! The token shapes follow fzn0x/idx (MIT). + +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; + +use base64::Engine; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::IdxError; + +pub const ENV_REFRESH: &str = "IDX_STOCKBIT_REFRESH_TOKEN"; +pub const ENV_ACCESS: &str = "IDX_STOCKBIT_ACCESS_TOKEN"; +const SESSION_FILE: &str = "stockbit-session.json"; +const LOCK_FILE: &str = "stockbit-session.lock"; +/// Refresh this long before the access token expires, so no request races +/// the expiry. +const REFRESH_BUFFER_SECS: i64 = 60 * 60; + +pub(crate) const NO_SESSION: &str = + "no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)"; + +/// The persisted token pair. Expiries are read from the tokens themselves. +#[derive(Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct Session { + #[serde(default)] + pub access: String, + #[serde(default)] + pub refresh: String, +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session") + .field("access", &(!self.access.is_empty()).then_some("")) + .field( + "refresh", + &(!self.refresh.is_empty()).then_some(""), + ) + .finish() + } +} + +impl Session { + pub fn is_empty(&self) -> bool { + self.access.is_empty() && self.refresh.is_empty() + } +} + +/// A token's presence and expiry, never its value. +#[derive(Debug, Clone, Serialize)] +pub struct TokenInfo { + pub present: bool, + /// When the token expires; `null` when absent or not a JWT. + pub expires_at: Option>, + /// Seconds until expiry, negative once expired. + pub expires_in_secs: Option, +} + +impl TokenInfo { + fn of(token: &str, now: i64) -> Self { + let present = !token.is_empty(); + let exp = present.then(|| token_expiry(token)).flatten(); + Self { + present, + expires_at: exp.and_then(|e| DateTime::from_timestamp(e, 0)), + expires_in_secs: exp.map(|e| e - now), + } + } +} + +/// What `idx stockbit status` and the MCP `stockbit_status` tool report. +#[derive(Debug, Clone, Serialize)] +pub struct SessionStatus { + /// A refresh or access token is stored or seeded (live or not). + pub logged_in: bool, + pub access: TokenInfo, + pub refresh: TokenInfo, + pub session_file: String, +} + +/// Reads, writes and refreshes the session. +#[derive(Debug, Clone)] +pub struct SessionStore { + dir: PathBuf, + seed: Session, +} + +impl SessionStore { + /// The session beside `config.toml`, seeded from the environment. + pub fn open() -> Result { + let config = crate::config::config_path()?; + let dir = config + .parent() + .ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))?; + let env = |name| { + std::env::var(name) + .map(|v| normalize_token(&v)) + .unwrap_or_default() + }; + Ok(Self::at( + dir.to_path_buf(), + Session { + access: env(ENV_ACCESS), + refresh: env(ENV_REFRESH), + }, + )) + } + + pub fn at(dir: PathBuf, seed: Session) -> Self { + Self { dir, seed } + } + + pub fn path(&self) -> PathBuf { + self.dir.join(SESSION_FILE) + } + + /// The file's tokens, each missing one taken from the seed. + pub fn load(&self) -> Session { + let mut session = std::fs::read_to_string(self.path()) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or_default(); + session.access = normalize_token(&session.access); + session.refresh = normalize_token(&session.refresh); + if session.access.is_empty() { + session.access.clone_from(&self.seed.access); + } + if session.refresh.is_empty() { + session.refresh.clone_from(&self.seed.refresh); + } + session + } + + pub fn has_session(&self) -> bool { + !self.load().is_empty() + } + + /// Writes the session atomically, readable by the owner only. + pub fn save(&self, session: &Session) -> Result<(), IdxError> { + let io = |e: std::io::Error| IdxError::Io(format!("stockbit session: {e}")); + std::fs::create_dir_all(&self.dir).map_err(io)?; + let tmp = self.dir.join(format!("{SESSION_FILE}.tmp")); + let body = serde_json::to_vec_pretty(session) + .map_err(|e| IdxError::Io(format!("stockbit session: {e}")))?; + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = options.open(&tmp).map_err(io)?; + std::io::Write::write_all(&mut file, &body).map_err(io)?; + file.sync_all().map_err(io)?; + drop(file); + std::fs::rename(&tmp, self.path()).map_err(io) + } + + /// Deletes the stored session; false when there was none. Seeds from the + /// environment are not affected. + pub fn clear(&self) -> Result { + match std::fs::remove_file(self.path()) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(IdxError::Io(format!("stockbit session: {e}"))), + } + } + + pub fn status(&self, now: i64) -> SessionStatus { + let session = self.load(); + SessionStatus { + logged_in: !session.is_empty(), + access: TokenInfo::of(&session.access, now), + refresh: TokenInfo::of(&session.refresh, now), + session_file: self.path().display().to_string(), + } + } + + /// An access token good for at least `REFRESH_BUFFER_SECS`, refreshing + /// with `refresher` (refresh token in, response body out) when it is not. + pub fn access( + &self, + refresher: &dyn Fn(&str) -> Result, + now: i64, + ) -> Result { + let current = self.load(); + if current.is_empty() { + return Err(IdxError::AuthError(NO_SESSION.to_string())); + } + if fresh(¤t.access, now) { + return Ok(current.access); + } + self.locked(|| { + // Another process may have refreshed while this one waited. + let session = self.load(); + if fresh(&session.access, now) { + return Ok(session.access); + } + match self.rotate(&session, refresher) { + Ok(access) => Ok(access), + // A failed refresh must not block a token that still works. + Err(_) if token_expiry(&session.access).is_some_and(|exp| now < exp) => { + Ok(session.access) + } + Err(e) => Err(e), + } + }) + } + + /// A new access token after the server rejected `rejected`, unless + /// another process already replaced it. + pub fn after_rejection( + &self, + rejected: &str, + refresher: &dyn Fn(&str) -> Result, + ) -> Result { + self.locked(|| { + let session = self.load(); + if !session.access.is_empty() && session.access != rejected { + return Ok(session.access); + } + self.rotate(&session, refresher) + }) + } + + /// Spends the refresh token and stores the new pair. + fn rotate( + &self, + session: &Session, + refresher: &dyn Fn(&str) -> Result, + ) -> Result { + if session.refresh.is_empty() { + return Err(IdxError::AuthError( + "the Stockbit session has no refresh token; run `idx stockbit login`".to_string(), + )); + } + let body = refresher(&session.refresh)?; + let next = parse_refresh(&body, session)?; + self.save(&next)?; + Ok(next.access) + } + + /// Runs `f` holding the session lock, so only one process spends the + /// rotating refresh token at a time. + fn locked(&self, f: impl FnOnce() -> Result) -> Result { + let io = |e: std::io::Error| IdxError::Io(format!("stockbit session lock: {e}")); + std::fs::create_dir_all(&self.dir).map_err(io)?; + let lock = lock_file(&self.dir.join(LOCK_FILE)).map_err(io)?; + lock.lock().map_err(io)?; + let result = f(); + drop(lock); + result + } +} + +fn lock_file(path: &Path) -> std::io::Result { + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) +} + +fn fresh(access: &str, now: i64) -> bool { + token_expiry(access).is_some_and(|exp| now < exp - REFRESH_BUFFER_SECS) +} + +/// Strips whitespace and a leading `Bearer `. +pub fn normalize_token(raw: &str) -> String { + let t = raw.trim(); + t.strip_prefix("Bearer ") + .or_else(|| t.strip_prefix("bearer ")) + .unwrap_or(t) + .trim() + .to_string() +} + +/// A JWT's `exp` (Unix seconds), read without verifying the signature: idx +/// only carries these tokens. +pub fn token_expiry(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload.trim_end_matches('=')) + .ok()?; + serde_json::from_slice::(&bytes) + .ok()? + .get("exp")? + .as_i64() +} + +/// Three dot-separated base64url parts with a JSON header (`eyJ`). +fn looks_like_jwt(value: &str) -> bool { + let mut parts = value.split('.'); + matches!(parts.next(), Some(h) if h.starts_with("eyJ")) + && parts.next().is_some_and(|p| !p.is_empty()) + && parts.next().is_some_and(|p| !p.is_empty()) + && parts.next().is_none() +} + +/// The JWT under key `slot` anywhere in `value`: a bare string or an object +/// with `token`. Stockbit's envelopes have moved between versions. +fn find_token(value: &Value, slot: &str) -> Option { + match value { + Value::Object(map) => { + let direct = map.get(slot).and_then(|hit| { + hit.as_str() + .or_else(|| hit.get("token").and_then(Value::as_str)) + .filter(|t| looks_like_jwt(t)) + .map(str::to_string) + }); + direct.or_else(|| map.values().find_map(|child| find_token(child, slot))) + } + Value::Array(items) => items.iter().find_map(|child| find_token(child, slot)), + _ => None, + } +} + +/// The new pair from a `/login/refresh` response; a response that rotates +/// only the access token keeps the presented refresh token. +pub fn parse_refresh(body: &str, previous: &Session) -> Result { + let value: Value = serde_json::from_str(body) + .map_err(|e| IdxError::ParseError(format!("stockbit refresh response: {e}")))?; + let access = find_token(&value, "access").ok_or_else(|| { + IdxError::ParseError("stockbit refresh response carried no access token".to_string()) + })?; + Ok(Session { + access, + refresh: find_token(&value, "refresh").unwrap_or_else(|| previous.refresh.clone()), + }) +} + +/// A session from what a user pastes: the `credentialStorage` cookie of +/// stockbit.com (percent-encoded JSON), that JSON decoded, or a bare refresh +/// token. +pub fn session_from_paste(raw: &str) -> Option { + let raw = raw.trim(); + let decoded = percent_decode(raw); + for candidate in [decoded.as_str(), raw] { + if let Ok(value) = serde_json::from_str::(candidate) { + let session = Session { + access: find_token(&value, "access").unwrap_or_default(), + refresh: find_token(&value, "refresh").unwrap_or_default(), + }; + if !session.is_empty() { + return Some(session); + } + } + } + let token = normalize_token(raw); + looks_like_jwt(&token).then(|| Session { + access: String::new(), + refresh: token, + }) +} + +/// `%XX` escapes decoded; anything else kept as is. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && let Some(hex) = s.get(i + 1..i + 3) + && let Ok(byte) = u8::from_str_radix(hex, 16) + { + out.push(byte); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + + fn fixture(name: &str) -> String { + std::fs::read_to_string(format!( + "{}/tests/fixtures/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() + } + + /// An unsigned JWT expiring at `exp`. + fn jwt(exp: i64, tag: &str) -> String { + let enc = |v: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v); + format!( + "{}.{}.{}", + enc(r#"{"alg":"none"}"#), + enc(&format!(r#"{{"exp":{exp},"tag":"{tag}"}}"#)), + enc("sig") + ) + } + + fn refresh_body(access: &str, refresh: &str) -> String { + format!( + r#"{{"data":{{"access":{{"token":"{access}"}},"refresh":{{"token":"{refresh}"}}}}}}"# + ) + } + + struct TempDir(PathBuf); + impl TempDir { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir() + .join(format!("idx-stockbit-auth-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + Self(dir) + } + } + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + const NOW: i64 = 1_800_000_000; + + #[test] + fn reads_the_pair_from_a_refresh_response_and_the_cookie() { + let pair = parse_refresh( + &fixture("stockbit_exodus_login_refresh_constructed.json"), + &Session::default(), + ) + .unwrap(); + assert_eq!(token_expiry(&pair.access), Some(1_792_385_008)); + assert!(looks_like_jwt(&pair.refresh)); + + let pasted = session_from_paste(&fixture( + "stockbit_exodus_credential_storage_cookie_constructed.txt", + )) + .unwrap(); + assert!(looks_like_jwt(&pasted.access) && looks_like_jwt(&pasted.refresh)); + + let bare = jwt(NOW, "r"); + assert_eq!( + session_from_paste(&format!(" Bearer {bare}\n")), + Some(Session { + access: String::new(), + refresh: bare + }) + ); + assert_eq!(session_from_paste("not a token"), None); + } + + #[test] + fn a_refresh_without_a_new_refresh_token_keeps_the_old_one() { + let previous = Session { + access: String::new(), + refresh: jwt(NOW, "old"), + }; + let access = jwt(NOW + 86_400, "a"); + let next = + parse_refresh(&format!(r#"{{"data":{{"access":"{access}"}}}}"#), &previous).unwrap(); + assert_eq!( + next, + Session { + access, + refresh: previous.refresh + } + ); + } + + #[test] + fn a_fresh_access_token_is_used_without_refreshing() { + let dir = TempDir::new("fresh"); + let store = SessionStore::at(dir.0.clone(), Session::default()); + let access = jwt(NOW + 2 * REFRESH_BUFFER_SECS, "a"); + store + .save(&Session { + access: access.clone(), + refresh: jwt(NOW + 7 * 86_400, "r"), + }) + .unwrap(); + let refresher = |_: &str| -> Result { panic!("must not refresh") }; + assert_eq!(store.access(&refresher, NOW).unwrap(), access); + } + + #[test] + fn a_stale_access_token_rotates_both_tokens_and_persists_them() { + let dir = TempDir::new("rotate"); + let store = SessionStore::at(dir.0.clone(), Session::default()); + let old_refresh = jwt(NOW + 7 * 86_400, "r1"); + store + .save(&Session { + access: jwt(NOW + 60, "a1"), + refresh: old_refresh.clone(), + }) + .unwrap(); + let (new_access, new_refresh) = (jwt(NOW + 86_400, "a2"), jwt(NOW + 8 * 86_400, "r2")); + let calls = Cell::new(0); + let refresher = |presented: &str| { + calls.set(calls.get() + 1); + assert_eq!(presented, old_refresh); + Ok(refresh_body(&new_access, &new_refresh)) + }; + assert_eq!(store.access(&refresher, NOW).unwrap(), new_access); + assert_eq!(calls.get(), 1); + // The rotated pair is what the next process reads. + assert_eq!( + store.load(), + Session { + access: new_access, + refresh: new_refresh + } + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(store.path()) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + } + + #[test] + fn a_rejected_token_already_replaced_by_another_process_is_not_refreshed_again() { + let dir = TempDir::new("rejected"); + let store = SessionStore::at(dir.0.clone(), Session::default()); + let replaced = jwt(NOW + 86_400, "theirs"); + store + .save(&Session { + access: replaced.clone(), + refresh: jwt(NOW + 86_400, "r"), + }) + .unwrap(); + let refresher = |_: &str| -> Result { panic!("must not refresh") }; + assert_eq!( + store + .after_rejection(&jwt(NOW, "mine"), &refresher) + .unwrap(), + replaced + ); + } + + #[test] + fn no_session_is_an_auth_error_and_the_file_beats_the_seed() { + let dir = TempDir::new("seed"); + let empty = SessionStore::at(dir.0.clone(), Session::default()); + let refresher = |_: &str| -> Result { unreachable!() }; + assert!(matches!( + empty.access(&refresher, NOW), + Err(IdxError::AuthError(_)) + )); + + let seeded = SessionStore::at( + dir.0.clone(), + Session { + access: String::new(), + refresh: jwt(NOW, "env"), + }, + ); + assert!(seeded.has_session()); + let stored = jwt(NOW, "file"); + seeded + .save(&Session { + access: String::new(), + refresh: stored.clone(), + }) + .unwrap(); + assert_eq!(seeded.load().refresh, stored); + } + + #[test] + fn status_and_debug_never_show_a_token() { + let dir = TempDir::new("status"); + let token = jwt(NOW + 100, "secret"); + let store = SessionStore::at( + dir.0.clone(), + Session { + access: token.clone(), + refresh: String::new(), + }, + ); + let status = store.status(NOW); + assert!(status.logged_in); + assert_eq!(status.access.expires_in_secs, Some(100)); + assert!(!status.refresh.present); + let shown = format!( + "{} {:?}", + serde_json::to_string(&status).unwrap(), + store.load() + ); + assert!(!shown.contains(&token), "{shown}"); + } +} diff --git a/crates/idx-core/src/api/stockbit/exodus.rs b/crates/idx-core/src/api/stockbit/exodus.rs new file mode 100644 index 0000000..1bd3163 --- /dev/null +++ b/crates/idx-core/src/api/stockbit/exodus.rs @@ -0,0 +1,543 @@ +//! Stockbit's own market-data API, `https://exodus.stockbit.com`, with the +//! user's session (see `auth`). Opt-in: used only when a session is stored. +//! +//! - `GET /company-price-feed/v2/orderbook/companies/{CODE}`: last price, +//! OHLC, volume (shares), value, frequency, foreign buy/sell/net (IDR) and +//! the depth ladder `bid[]`/`offer[]` of `{price, que_num, volume}` (shares). +//! - `GET /company-price-feed/market-time/session`: the market's state, +//! `data.detail.regular.state_name` (e.g. `STATE_NAME_MARKET_CLOSED`). +//! - `GET /charts/{CODE}/daily?timeframe=1m|3m|1y&chart_type=PRICE_CHART_TYPE_CANDLE`: +//! daily candles, `formatted_date` `YYYY-MM-DD`, string OHLC, `value` the +//! close and `volume` in lots. +//! - `POST /login/refresh` with the refresh token as bearer rotates the pair. +//! +//! A 401 (or a JSON 403) spends one refresh and retries once. Requests share +//! the public page's pacing. Endpoints and units were mapped by fzn0x/idx +//! (MIT); `tests/fixtures/stockbit_exodus_*` are its recordings. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use chrono::{Months, NaiveDate}; +use serde_json::Value; + +use super::auth::SessionStore; +use super::parse::number; +use super::{USER_AGENT, pace, stockbit_code}; +use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote}; +use crate::error::IdxError; + +const BASE_URL: &str = "https://exodus.stockbit.com"; +const REFRESH_PATH: &str = "/login/refresh"; +/// The state in which the book is live. Assumed from Stockbit's naming +/// (only `STATE_NAME_MARKET_CLOSED` has been recorded); any other state hides +/// bid/offer in quotes, as on the public page. +pub const MARKET_OPEN: &str = "STATE_NAME_MARKET_OPEN"; +/// The market's state changes a few times a day; one read serves this long. +const MARKET_STATE_TTL: Duration = Duration::from_secs(30); + +pub(crate) struct Exodus { + agent: ureq::Agent, + store: SessionStore, +} + +struct Reply { + status: u16, + body: String, +} + +impl Reply { + /// The server refused the access token: 401, or a 403 with a JSON body + /// (a CDN block is an HTML page). + fn rejected(&self) -> bool { + self.status == 401 + || (self.status == 403 && serde_json::from_str::(&self.body).is_ok()) + } +} + +impl Exodus { + pub(crate) fn new(store: SessionStore) -> Self { + let agent = ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(5))) + .timeout_recv_body(Some(Duration::from_secs(10))) + .timeout_global(Some(Duration::from_secs(20))) + .http_status_as_error(false) + .build() + .into(); + Self { agent, store } + } + + pub(crate) fn store(&self) -> &SessionStore { + &self.store + } + + /// One authorised request (the market's state), refreshing the session + /// when needed: proves Stockbit accepts it. + pub(crate) fn verify(&self) -> Result<(), IdxError> { + self.get("/company-price-feed/market-time/session", "market session") + .map(|_| ()) + } + + pub(crate) fn orderbook(&self, resolved: &str) -> Result { + let code = stockbit_code(resolved)?; + let body = self.get( + &format!("/company-price-feed/v2/orderbook/companies/{code}"), + resolved, + )?; + parse_orderbook(resolved, &body, self.market_status()) + } + + pub(crate) fn quote(&self, resolved: &str) -> Result { + self.orderbook(resolved).map(|book| quote_from_book(&book)) + } + + /// Daily candles covering `period`, oldest first. + pub(crate) fn daily( + &self, + resolved: &str, + period: &Period, + interval: &Interval, + ) -> Result, IdxError> { + let window = + history_window(period, interval).ok_or_else(|| unsupported_window(period, interval))?; + let code = stockbit_code(resolved)?; + let body = self.get( + &format!("/charts/{code}/daily?timeframe={window}&chart_type=PRICE_CHART_TYPE_CANDLE"), + resolved, + )?; + daily_for_period(&body, period) + } + + /// The market's state, read at most every `MARKET_STATE_TTL` per process. + /// Unknown on any failure, which hides bid/offer. + fn market_status(&self) -> Option { + static CACHE: Mutex)>> = Mutex::new(None); + let mut cached = CACHE.lock().unwrap_or_else(|p| p.into_inner()); + if let Some((at, state)) = cached.as_ref() + && at.elapsed() < MARKET_STATE_TTL + { + return state.clone(); + } + let state = self + .get("/company-price-feed/market-time/session", "market session") + .ok() + .and_then(|body| parse_market_status(&body)); + *cached = Some((Instant::now(), state.clone())); + state + } + + fn get(&self, path: &str, subject: &str) -> Result { + let refresher = |refresh: &str| self.refresh(refresh); + let token = self.store.access(&refresher, unix_now())?; + let mut reply = self.send(path, &token)?; + if reply.rejected() { + let token = self.store.after_rejection(&token, &refresher)?; + reply = self.send(path, &token)?; + if reply.rejected() { + return Err(IdxError::AuthError( + "Stockbit rejected the session; run `idx stockbit login` again".to_string(), + )); + } + } + classify(reply, subject) + } + + fn send(&self, path: &str, token: &str) -> Result { + pace(); + let response = self + .agent + .get(format!("{BASE_URL}{path}")) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json") + .header("Origin", "https://stockbit.com") + .header("Referer", "https://stockbit.com/") + .header("Authorization", format!("Bearer {token}")) + .call() + .map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?; + read_reply(response) + } + + /// `POST /login/refresh`; the response body carries the new pair. + fn refresh(&self, refresh: &str) -> Result { + pace(); + let response = self + .agent + .post(format!("{BASE_URL}{REFRESH_PATH}")) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("Origin", "https://stockbit.com") + .header("Referer", "https://stockbit.com/") + .header("Authorization", format!("Bearer {refresh}")) + .send("{}") + .map_err(|e| IdxError::Http(format!("stockbit refresh: {e}")))?; + let reply = read_reply(response)?; + match reply.status { + 200..=299 => Ok(reply.body), + 401 | 403 => Err(IdxError::AuthError( + "Stockbit rejected the refresh token (expired or revoked); run `idx stockbit \ + login` again" + .to_string(), + )), + status => Err(IdxError::Http(format!("stockbit refresh: HTTP {status}"))), + } + } +} + +fn read_reply(response: ureq::http::Response) -> Result { + let status = response.status().as_u16(); + let body = response + .into_body() + .read_to_string() + .map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?; + Ok(Reply { status, body }) +} + +fn unix_now() -> i64 { + chrono::Utc::now().timestamp() +} + +/// A non-rejected reply as data or the error its status means. +fn classify(reply: Reply, subject: &str) -> Result { + match reply.status { + 200..=299 => Ok(reply.body), + 404 if error_type(&reply.body).as_deref() == Some("NOT_FOUND") => { + Err(IdxError::SymbolNotFound(subject.to_string())) + } + 429 => Err(IdxError::RateLimited), + 403 => Err(IdxError::UpstreamBlocked( + "exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \ + retry a block. Try again later or from another network" + .to_string(), + )), + status => Err(IdxError::Http(format!( + "stockbit api: HTTP {status}{}", + message(&reply.body) + .map(|m| format!(": {m}")) + .unwrap_or_default() + ))), + } +} + +fn error_type(body: &str) -> Option { + serde_json::from_str::(body) + .ok()? + .get("error_type")? + .as_str() + .map(str::to_string) +} + +fn message(body: &str) -> Option { + serde_json::from_str::(body) + .ok()? + .get("message")? + .as_str() + .map(str::to_string) +} + +fn data(body: &str, what: &str) -> Result { + let mut value: Value = serde_json::from_str(body) + .map_err(|e| IdxError::ParseError(format!("stockbit {what}: {e}")))?; + match value.get_mut("data") { + Some(data) => Ok(data.take()), + None => Err(IdxError::ParseError(format!("stockbit {what}: no data"))), + } +} + +fn whole(value: Option<&Value>) -> Option { + value.and_then(number).map(|n| n.round() as i64) +} + +fn count(value: Option<&Value>) -> Option { + value + .and_then(number) + .filter(|n| *n >= 0.0) + .map(|n| n.round() as u64) +} + +/// The order book and session figures; `market_status` comes from the +/// market-time endpoint. +pub fn parse_orderbook( + resolved: &str, + body: &str, + market_status: Option, +) -> Result { + let d = data(body, "order book")?; + let price = whole(d.get("lastprice").or_else(|| d.get("close"))) + .ok_or_else(|| IdxError::ParseError("stockbit order book: no last price".to_string()))?; + let levels = |key: &str| -> Vec { + d.get(key) + .and_then(Value::as_array) + .map(|rows| { + rows.iter() + .filter_map(|row| { + Some(BookLevel { + price: whole(row.get("price")).filter(|p| *p > 0)?, + shares: count(row.get("volume")).unwrap_or(0), + orders: count(row.get("que_num")).unwrap_or(0), + }) + }) + .collect() + }) + .unwrap_or_default() + }; + let listing = d.get("status").and_then(Value::as_str).unwrap_or_default(); + let volume = count(d.get("volume")); + let session_note = if !listing.is_empty() && !listing.eq_ignore_ascii_case("active") { + Some(format!( + "Stockbit lists the stock as {}", + listing.to_ascii_lowercase() + )) + } else if volume == Some(0) { + Some("no trades this session".to_string()) + } else { + None + }; + Ok(OrderBook { + symbol: resolved.to_string(), + name: d.get("name").and_then(Value::as_str).map(str::to_string), + price, + change: whole(d.get("change")), + change_pct: d.get("percentage_change").and_then(number), + prev_close: whole(d.get("previous")), + open: whole(d.get("open")), + high: whole(d.get("high")), + low: whole(d.get("low")), + volume, + value: count(d.get("value")), + frequency: count(d.get("frequency")), + foreign_buy: whole(d.get("fbuy")), + foreign_sell: whole(d.get("fsell")), + foreign_net: whole(d.get("fnet")), + bids: levels("bid"), + offers: levels("offer"), + market_status, + session_note, + }) +} + +/// A quote from the order book; bid/offer only while the market is open. +pub fn quote_from_book(book: &OrderBook) -> Quote { + let open = book.market_status.as_deref() == Some(MARKET_OPEN); + let best = |levels: &[BookLevel]| open.then(|| levels.first().map(|l| l.price)).flatten(); + Quote { + symbol: book.symbol.clone(), + price: book.price, + change: book.change, + change_pct: book.change_pct, + volume: book.volume, + market_cap: None, + week52_high: None, + week52_low: None, + week52_position: None, + range_signal: None, + prev_close: book.prev_close, + avg_volume: None, + as_of: None, + behind_session: false, + bid: best(&book.bids), + offer: best(&book.offers), + market_status: book.market_status.clone(), + session_note: book.session_note.clone(), + } +} + +pub fn parse_market_status(body: &str) -> Option { + data(body, "market session") + .ok()? + .pointer("/detail/regular/state_name")? + .as_str() + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// The chart window holding `period` of daily candles: Stockbit picks the +/// bar size from the window (`1w` is hourly, `3y`/`5y` weekly), so only the +/// daily windows qualify. +pub fn history_window(period: &Period, interval: &Interval) -> Option<&'static str> { + match (interval, period) { + (Interval::Day, Period::FiveDays | Period::OneMonth) => Some("1m"), + (Interval::Day, Period::ThreeMonths) => Some("3m"), + (Interval::Day, Period::SixMonths | Period::OneYear) => Some("1y"), + _ => None, + } +} + +pub fn unsupported_window(period: &Period, interval: &Interval) -> IdxError { + IdxError::Unsupported(format!( + "Stockbit history covers 5d to 1y of daily candles, not {} at {}; use \ + --history-provider yahoo", + period.as_str(), + interval.as_str() + )) +} + +/// The candles of `period` from a daily chart response. +pub fn daily_for_period(body: &str, period: &Period) -> Result, IdxError> { + Ok(trim_to_period(parse_daily(body)?, period)) +} + +/// Daily candles, oldest first. Volume arrives in lots and is returned in +/// shares, as from the other providers. +pub fn parse_daily(body: &str) -> Result, IdxError> { + let d = data(body, "chart")?; + let rows = d + .get("prices") + .and_then(Value::as_array) + .ok_or_else(|| IdxError::ParseError("stockbit chart: no prices".to_string()))?; + let mut bars = Vec::with_capacity(rows.len()); + for row in rows { + let Some(close) = whole(row.get("value")) else { + continue; + }; + let Some(date) = row + .get("formatted_date") + .and_then(Value::as_str) + .and_then(|d| NaiveDate::parse_from_str(d.trim(), "%Y-%m-%d").ok()) + else { + return Err(IdxError::ParseError(format!( + "stockbit chart: row without a daily date: {row}" + ))); + }; + bars.push(Ohlc { + date, + open: whole(row.get("open")).unwrap_or(close), + high: whole(row.get("high")).unwrap_or(close), + low: whole(row.get("low")).unwrap_or(close), + close, + volume: count(row.get("volume")).unwrap_or(0).saturating_mul(100), + }); + } + bars.sort_by_key(|b| b.date); + Ok(bars) +} + +/// The candles within `period` of the latest one (5 sessions for `5d`). +fn trim_to_period(mut bars: Vec, period: &Period) -> Vec { + let Some(last) = bars.last().map(|b| b.date) else { + return bars; + }; + let months = match period { + Period::FiveDays => { + let start = bars.len().saturating_sub(5); + return bars.split_off(start); + } + Period::OneMonth => 1, + Period::ThreeMonths => 3, + Period::SixMonths => 6, + _ => 12, + }; + match last.checked_sub_months(Months::new(months)) { + Some(start) => bars.into_iter().filter(|b| b.date > start).collect(), + None => bars, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(name: &str) -> String { + std::fs::read_to_string(format!( + "{}/tests/fixtures/stockbit_exodus_{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() + } + + #[test] + fn order_book_carries_depth_in_shares_and_foreign_flow() { + let book = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap(); + assert_eq!( + (book.price, book.prev_close, book.change), + (5175, Some(6075), Some(-900)) + ); + assert_eq!( + (book.open, book.high, book.low), + (Some(5825), Some(5825), Some(5175)) + ); + assert_eq!(book.foreign_net, Some(-2_329_493_087_500)); + // BBCA closed at its lower limit: no bids, offers best price first. + assert!(book.bids.is_empty()); + assert_eq!( + book.offers[0], + BookLevel { + price: 5175, + shares: 264_462_500, + orders: 3284 + } + ); + assert!(book.offers.windows(2).all(|w| w[0].price < w[1].price)); + assert_eq!(book.session_note, None); + } + + #[test] + fn quote_shows_the_best_levels_only_while_the_market_is_open() { + let mut book = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap(); + let closed = quote_from_book(&book); + assert_eq!((closed.bid, closed.offer), (None, None)); + book.market_status = Some(MARKET_OPEN.to_string()); + let open = quote_from_book(&book); + assert_eq!((open.bid, open.offer), (None, Some(5175))); + assert_eq!(open.price, 5175); + } + + #[test] + fn market_status_is_read_verbatim() { + assert_eq!( + parse_market_status(&fixture("market_session.json")).as_deref(), + Some("STATE_NAME_MARKET_CLOSED") + ); + } + + #[test] + fn errors_map_to_their_codes() { + let reply = |status, body: &str| Reply { + status, + body: body.to_string(), + }; + assert!(matches!( + classify(reply(404, &fixture("orderbook_zzzz.json")), "ZZZZ.JK"), + Err(IdxError::SymbolNotFound(s)) if s == "ZZZZ.JK" + )); + assert!(reply(401, &fixture("unauthorized.json")).rejected()); + assert!(reply(403, &fixture("unauthorized.json")).rejected()); + let blocked = reply(403, "Request blocked."); + assert!(!blocked.rejected()); + assert!(matches!( + classify(blocked, "BBCA.JK"), + Err(IdxError::UpstreamBlocked(_)) + )); + assert!(matches!( + classify(reply(429, ""), "x"), + Err(IdxError::RateLimited) + )); + } + + #[test] + fn daily_candles_convert_lots_to_shares_and_trim_to_the_period() { + let year = parse_daily(&fixture("chart_candle_bbca_1y.json")).unwrap(); + assert_eq!(year.len(), 240); + assert!(year.windows(2).all(|w| w[0].date < w[1].date)); + let month = parse_daily(&fixture("chart_candle_bbca_1m.json")).unwrap(); + let first = &month[0]; + assert_eq!(first.date.to_string(), "2026-09-16"); + assert_eq!( + (first.open, first.high, first.low, first.close), + (6350, 6425, 6325, 6325) + ); + assert_eq!(first.volume, 72_157_400); + + let six = trim_to_period(year.clone(), &Period::SixMonths); + assert_eq!(six.first().unwrap().date.to_string(), "2026-04-17"); + assert_eq!(trim_to_period(month, &Period::FiveDays).len(), 5); + } + + #[test] + fn only_daily_windows_map_to_a_chart() { + assert_eq!(history_window(&Period::OneYear, &Interval::Day), Some("1y")); + assert_eq!(history_window(&Period::TwoYears, &Interval::Day), None); + assert_eq!(history_window(&Period::OneYear, &Interval::Week), None); + assert_eq!(history_window(&Period::OneDay, &Interval::Day), None); + } +} diff --git a/crates/idx-core/src/api/stockbit/mod.rs b/crates/idx-core/src/api/stockbit/mod.rs index 8eb0d33..d0b0175 100644 --- a/crates/idx-core/src/api/stockbit/mod.rs +++ b/crates/idx-core/src/api/stockbit/mod.rs @@ -15,6 +15,8 @@ //! - Stockbit's terms describe its market data as delayed at least 10 //! minutes and for personal use; frontends say so. +pub mod auth; +pub mod exodus; mod parse; use std::fs::{File, OpenOptions}; @@ -24,9 +26,13 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub use parse::{parse_symbol_page, status_error, stockbit_code}; -use super::types::{Fundamentals, Intraday, Quote}; -use super::{FundamentalsProvider, IntradayProvider, QuoteProvider}; +use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote}; +use super::{ + FundamentalsProvider, HistoryProvider, IntradayProvider, OrderBookProvider, QuoteProvider, +}; use crate::error::IdxError; +use auth::SessionStore; +use exodus::Exodus; pub const SOURCE: &str = "stockbit"; /// Identifies idx-cli and where to reach its maintainers. @@ -45,8 +51,13 @@ pub(crate) const CACHE_TTL_SECS: u64 = 5; const MIN_INTERVAL: Duration = Duration::from_secs(1); const BASE_URL: &str = "https://stockbit.com/symbol/"; +/// Quotes come from the user's session when one is stored (live, from +/// `exodus`), else from the public page. The order book and history need the +/// session; intraday always reads the public page. pub struct StockbitProvider { agent: ureq::Agent, + /// `None` when the config directory cannot be resolved. + exodus: Option, } impl StockbitProvider { @@ -59,7 +70,20 @@ impl StockbitProvider { .timeout_global(Some(Duration::from_secs(20))) .build() .into(); - Self { agent } + Self { + agent, + exodus: SessionStore::open().ok().map(Exodus::new), + } + } + + /// The session API when the user has a session. + fn session(&self) -> Option<&Exodus> { + self.exodus.as_ref().filter(|e| e.store().has_session()) + } + + fn require_session(&self) -> Result<&Exodus, IdxError> { + self.session() + .ok_or_else(|| IdxError::AuthError(auth::NO_SESSION.to_string())) } /// The quote and today's series for a resolved symbol (`BBCA.JK`). @@ -99,7 +123,7 @@ impl Default for StockbitProvider { /// `idx` calls, a CLI beside idx-mcp) share the time of the last request in /// `/stockbit.pace` under an exclusive file lock. When that file is /// unusable, pacing is per process. -fn pace() { +pub(crate) fn pace() { static LAST: Mutex> = Mutex::new(None); let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); if let Some(prev) = *last { @@ -151,7 +175,10 @@ fn now_ms() -> u128 { impl QuoteProvider for StockbitProvider { fn quote(&self, symbol: &str) -> Result { - self.snapshot(symbol).map(|day| day.quote) + match self.session() { + Some(exodus) => exodus.quote(symbol), + None => self.snapshot(symbol).map(|day| day.quote), + } } } @@ -167,12 +194,71 @@ impl IntradayProvider for StockbitProvider { } } +impl OrderBookProvider for StockbitProvider { + fn orderbook(&self, symbol: &str) -> Result { + self.require_session()?.orderbook(symbol) + } +} + +impl HistoryProvider for StockbitProvider { + fn history( + &self, + symbol: &str, + period: &Period, + interval: &Interval, + ) -> Result, IdxError> { + self.require_session()?.daily(symbol, period, interval) + } +} + +/// Whether the user has a Stockbit session stored or seeded. +pub fn has_session() -> bool { + SessionStore::open().is_ok_and(|store| store.has_session()) +} + pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError { IdxError::Unsupported(format!( "{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo" )) } +/// Stores a pasted session (stockbit.com's `credentialStorage` cookie or a +/// refresh token) and checks it with one request, which refreshes it when the +/// access token is missing or stale. A session Stockbit refuses is removed. +pub fn login(pasted: &str) -> Result { + let session = auth::session_from_paste(pasted).ok_or_else(|| { + IdxError::InvalidInput( + "not a Stockbit session: paste the value of stockbit.com's `credentialStorage` \ + cookie, or a refresh token" + .to_string(), + ) + })?; + let store = SessionStore::open()?; + store.save(&session)?; + #[cfg(feature = "testing")] + let check = !crate::api::mock::enabled(); + #[cfg(not(feature = "testing"))] + let check = true; + if check && let Err(err) = Exodus::new(store.clone()).verify() { + let _ = store.clear(); + return Err(err); + } + Ok(store.status(chrono::Utc::now().timestamp())) +} + +/// The stored session's state, without a request and without any token. +pub fn session_status() -> Result { + Ok(SessionStore::open()?.status(chrono::Utc::now().timestamp())) +} + +/// Deletes the stored session: whether there was one, and the state after +/// (still logged in when `IDX_STOCKBIT_*` seeds one). +pub fn logout() -> Result<(bool, auth::SessionStatus), IdxError> { + let store = SessionStore::open()?; + let removed = store.clear()?; + Ok((removed, store.status(chrono::Utc::now().timestamp()))) +} + #[cfg(test)] mod tests { use super::USER_AGENT; diff --git a/crates/idx-core/src/api/stockbit/parse.rs b/crates/idx-core/src/api/stockbit/parse.rs index 54c5ecd..9ecbd5c 100644 --- a/crates/idx-core/src/api/stockbit/parse.rs +++ b/crates/idx-core/src/api/stockbit/parse.rs @@ -271,7 +271,7 @@ fn wib(local: NaiveDateTime) -> Option> { /// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`, /// `"NA"` and empty strings are absent. -fn number(value: &Value) -> Option { +pub(super) fn number(value: &Value) -> Option { let n = match value { Value::Number(n) => n.as_f64()?, Value::String(s) => s.trim().parse::().ok()?, diff --git a/crates/idx-core/src/api/types.rs b/crates/idx-core/src/api/types.rs index 1da6d4d..72d5463 100644 --- a/crates/idx-core/src/api/types.rs +++ b/crates/idx-core/src/api/types.rs @@ -94,6 +94,52 @@ pub struct Intraday { pub points: Vec, } +/// One price level of an order book. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookLevel { + /// Limit price in IDR (whole Rupiah). + pub price: i64, + /// Size resting at this price, in shares (1 lot = 100 shares). + pub shares: u64, + /// Orders queued at this price. + pub orders: u64, +} + +/// The order book of one IDX stock from the user's Stockbit session, with +/// the session's figures and foreign flow. Outside trading it is the book as +/// the market left it; `market_status` says which. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderBook { + pub symbol: String, + pub name: Option, + /// Last price in IDR (whole Rupiah). + pub price: i64, + pub change: Option, + pub change_pct: Option, + pub prev_close: Option, + pub open: Option, + pub high: Option, + pub low: Option, + /// Session volume in shares. + pub volume: Option, + /// Session value traded in IDR. + pub value: Option, + /// Trades in the session. + pub frequency: Option, + /// Foreign buy, sell and net value over the session, IDR. + pub foreign_buy: Option, + pub foreign_sell: Option, + pub foreign_net: Option, + /// Best price first. + pub bids: Vec, + /// Best price first. + pub offers: Vec, + /// Stockbit's session state, verbatim (e.g. `STATE_NAME_MARKET_CLOSED`). + pub market_status: Option, + /// Stockbit's listing status when it is not active (e.g. suspended). + pub session_note: Option, +} + /// OHLC candle data normalized from Yahoo Finance chart indicators. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Ohlc { diff --git a/crates/idx-core/src/config.rs b/crates/idx-core/src/config.rs index 3e988da..a14d505 100644 --- a/crates/idx-core/src/config.rs +++ b/crates/idx-core/src/config.rs @@ -31,6 +31,8 @@ pub enum HistoryProviderKind { Auto, Yahoo, Msn, + // Needs the user's Stockbit session (`idx stockbit login`). + Stockbit, } impl ProviderKind { @@ -63,6 +65,7 @@ impl HistoryProviderKind { Self::Auto => "auto", Self::Yahoo => "yahoo", Self::Msn => "msn", + Self::Stockbit => "stockbit", } } @@ -73,9 +76,11 @@ impl HistoryProviderKind { Ok(Self::Yahoo) } else if value.eq_ignore_ascii_case("msn") { Ok(Self::Msn) + } else if value.eq_ignore_ascii_case("stockbit") { + Ok(Self::Stockbit) } else { Err(IdxError::ConfigError(format!( - "invalid history provider '{value}' (expected auto, yahoo, or msn)" + "invalid history provider '{value}' (expected auto, yahoo, msn, or stockbit)" ))) } } diff --git a/crates/idx-core/src/market.rs b/crates/idx-core/src/market.rs index e3a0c09..151ad49 100644 --- a/crates/idx-core/src/market.rs +++ b/crates/idx-core/src/market.rs @@ -6,8 +6,10 @@ use std::str::FromStr; use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday}; -use crate::api::types::{Interval, Intraday, Ohlc, Period, Quote}; -use crate::api::{history_provider, history_source, idx_offset, intraday_provider, stockbit}; +use crate::api::types::{Interval, Intraday, Ohlc, OrderBook, Period, Quote}; +use crate::api::{ + history_provider, history_source, idx_offset, intraday_provider, orderbook_provider, stockbit, +}; use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch}; use crate::config::{HistoryProviderKind, ProviderKind}; use crate::error::IdxError; @@ -91,11 +93,20 @@ impl Market<'_> { } /// Resolves the history source for an optional `--history-provider` - /// override, without fetching. - pub fn history_source(&self, mode: Option) -> HistorySource { + /// override and the window asked for, without fetching. + pub fn history_source( + &self, + mode: Option, + period: &Period, + interval: &Interval, + ) -> HistorySource { let mode = mode.unwrap_or(self.idx.config.history_provider); let selected = self.idx.provider.kind(); - let provider = history_source(selected, mode); + // Only `auto` under the stockbit provider looks for a session. + let session = mode == HistoryProviderKind::Auto + && selected == ProviderKind::Stockbit + && stockbit::has_session(); + let provider = history_source(selected, mode, period, interval, session); HistorySource { provider, mode, @@ -112,9 +123,8 @@ impl Market<'_> { policy: CachePolicy, ) -> Result>, IdxError> { let idx = self.idx; - let (provider_kind, provider) = - history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?; - let bucket = format!("{}-history", provider_kind.as_str()); + let provider = history_provider(source.provider, idx.config.verbose); + let bucket = format!("{}-history", source.provider.as_str()); let resolved = idx.resolve(symbol)?; let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str()); cached_fetch( @@ -124,7 +134,7 @@ impl Market<'_> { key: &key, ttl_secs: idx.config.quote_ttl, subject: &resolved, - source: provider_kind.as_str(), + source: source.provider.as_str(), }, policy, || provider.history(&resolved, period, interval), @@ -132,6 +142,31 @@ impl Market<'_> { ) } + /// The order book of one IDX stock from the user's Stockbit session, + /// whichever market provider is selected. + pub fn orderbook( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, IdxError> { + let idx = self.idx; + let resolved = idx.resolve(symbol)?; + let provider = orderbook_provider(); + cached_fetch( + &idx.cache, + CacheSpec { + bucket: "stockbit-orderbook", + key: &resolved, + ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS), + subject: &resolved, + source: stockbit::SOURCE, + }, + policy, + || provider.orderbook(&resolved), + Ok, + ) + } + /// Screener results, ranked locally and truncated to `query.limit`. pub fn screen( &self, diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json b/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json new file mode 100644 index 0000000..7e1e1c1 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json @@ -0,0 +1 @@ +{"data":{"cagr":"", "change":-1225, "drawdown":"", "markingpoint":"", "percentage":"-19.14", "prices":[{"date":"1789491600000", "formatted_date":"2026-09-16", "xlabel":"", "value":"6325", "percentage":"-1.17", "change":-75, "open":"6350", "high":"6425", "low":"6325", "volume":"721574"}, {"date":"1789578000000", "formatted_date":"2026-09-17", "xlabel":"", "value":"6350", "percentage":"-0.78", "change":-50, "open":"6400", "high":"6400", "low":"6325", "volume":"618120"}, {"date":"1789664400000", "formatted_date":"2026-09-18", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6325", "high":"6350", "low":"6225", "volume":"1767537"}, {"date":"1789923600000", "formatted_date":"2026-09-21", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6300", "high":"6300", "low":"6225", "volume":"699141"}, {"date":"1790010000000", "formatted_date":"2026-09-22", "xlabel":"", "value":"6200", "percentage":"-3.12", "change":-200, "open":"6225", "high":"6250", "low":"6175", "volume":"932112"}, {"date":"1790096400000", "formatted_date":"2026-09-23", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6200", "high":"6300", "low":"6200", "volume":"968261"}, {"date":"1790182800000", "formatted_date":"2026-09-24", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6250", "high":"6275", "low":"6225", "volume":"756486"}, {"date":"1790269200000", "formatted_date":"2026-09-25", "xlabel":"", "value":"6250", "percentage":"-2.34", "change":-150, "open":"6225", "high":"6275", "low":"6200", "volume":"894474"}, {"date":"1790528400000", "formatted_date":"2026-09-28", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6225", "high":"6250", "low":"6175", "volume":"1121076"}, {"date":"1790614800000", "formatted_date":"2026-09-29", "xlabel":"", "value":"6150", "percentage":"-3.91", "change":-250, "open":"6150", "high":"6225", "low":"6125", "volume":"1497834"}, {"date":"1790701200000", "formatted_date":"2026-09-30", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6100", "high":"6150", "low":"6050", "volume":"2213357"}, {"date":"1790787600000", "formatted_date":"2026-10-01", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6050", "high":"6150", "low":"6000", "volume":"1180449"}, {"date":"1790874000000", "formatted_date":"2026-10-02", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6000", "high":"6150", "low":"5975", "volume":"1319203"}, {"date":"1791133200000", "formatted_date":"2026-10-05", "xlabel":"", "value":"6175", "percentage":"-3.52", "change":-225, "open":"6100", "high":"6175", "low":"6075", "volume":"899057"}, {"date":"1791219600000", "formatted_date":"2026-10-06", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6175", "high":"6200", "low":"6100", "volume":"1521734"}, {"date":"1791306000000", "formatted_date":"2026-10-07", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6075", "high":"6100", "low":"5975", "volume":"1626908"}, {"date":"1791392400000", "formatted_date":"2026-10-08", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6100", "high":"6150", "low":"6000", "volume":"1131403"}, {"date":"1791478800000", "formatted_date":"2026-10-09", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6025", "high":"6075", "low":"5975", "volume":"870986"}, {"date":"1791738000000", "formatted_date":"2026-10-12", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"6050", "high":"6100", "low":"5950", "volume":"979651"}, {"date":"1791824400000", "formatted_date":"2026-10-13", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"5925", "high":"5950", "low":"5850", "volume":"1227169"}, {"date":"1791910800000", "formatted_date":"2026-10-14", "xlabel":"", "value":"6125", "percentage":"-4.30", "change":-275, "open":"6000", "high":"6125", "low":"5950", "volume":"1363031"}, {"date":"1791997200000", "formatted_date":"2026-10-15", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6075", "high":"6125", "low":"6025", "volume":"1347632"}, {"date":"1792083600000", "formatted_date":"2026-10-16", "xlabel":"", "value":"5175", "percentage":"-19.14", "change":-1225, "open":"5825", "high":"5825", "low":"5175", "volume":"12410982"}], "timeframe":"1m", "xaxisopt":"", "previous":6400, "line_weight":2, "previous_timeframe_price":{"date":"", "formatted_date":"", "xlabel":"", "value":"", "percentage":"", "change":0, "open":"", "high":"", "low":"", "volume":""}, "chart_type":"PRICE_CHART_TYPE_CANDLE", "interval_in_minutes":0, "allowed_chart_type":["PRICE_CHART_TYPE_LINE", "PRICE_CHART_TYPE_CANDLE"], "max_candles":24}, "message":"Successfully retrieved company daily chart"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json b/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json new file mode 100644 index 0000000..544010b --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json @@ -0,0 +1 @@ +{"data":{"cagr":"", "change":-2075, "drawdown":"", "markingpoint":"", "percentage":"-28.62", "prices":[{"date":"1760547600000", "formatted_date":"2025-10-16", "xlabel":"", "value":"7300", "percentage":"0.69", "change":50, "open":"7250", "high":"7325", "low":"7225", "volume":"1206112"}, {"date":"1760634000000", "formatted_date":"2025-10-17", "xlabel":"", "value":"7500", "percentage":"3.45", "change":250, "open":"7300", "high":"7525", "low":"7275", "volume":"1573344"}, {"date":"1760893200000", "formatted_date":"2025-10-20", "xlabel":"", "value":"7875", "percentage":"8.62", "change":625, "open":"7700", "high":"8000", "low":"7625", "volume":"3934218"}, {"date":"1760979600000", "formatted_date":"2025-10-21", "xlabel":"", "value":"8475", "percentage":"16.90", "change":1225, "open":"8100", "high":"8475", "low":"8100", "volume":"5298026"}, {"date":"1761066000000", "formatted_date":"2025-10-22", "xlabel":"", "value":"8200", "percentage":"13.10", "change":950, "open":"8700", "high":"8700", "low":"8200", "volume":"3929572"}, {"date":"1761152400000", "formatted_date":"2025-10-23", "xlabel":"", "value":"8325", "percentage":"14.83", "change":1075, "open":"8200", "high":"8350", "low":"8150", "volume":"1870039"}, {"date":"1761238800000", "formatted_date":"2025-10-24", "xlabel":"", "value":"8275", "percentage":"14.14", "change":1025, "open":"8300", "high":"8375", "low":"8275", "volume":"1333551"}, {"date":"1761498000000", "formatted_date":"2025-10-27", "xlabel":"", "value":"8350", "percentage":"15.17", "change":1100, "open":"8325", "high":"8425", "low":"8200", "volume":"1822756"}, {"date":"1761584400000", "formatted_date":"2025-10-28", "xlabel":"", "value":"8275", "percentage":"14.14", "change":1025, "open":"8350", "high":"8400", "low":"8250", "volume":"1802700"}, {"date":"1761670800000", "formatted_date":"2025-10-29", "xlabel":"", "value":"8600", "percentage":"18.62", "change":1350, "open":"8300", "high":"8600", "low":"8275", "volume":"2391568"}, {"date":"1761757200000", "formatted_date":"2025-10-30", "xlabel":"", "value":"8525", "percentage":"17.59", "change":1275, "open":"8575", "high":"8750", "low":"8475", "volume":"2590098"}, {"date":"1761843600000", "formatted_date":"2025-10-31", "xlabel":"", "value":"8525", "percentage":"17.59", "change":1275, "open":"8550", "high":"8650", "low":"8525", "volume":"1770880"}, {"date":"1762102800000", "formatted_date":"2025-11-03", "xlabel":"", "value":"8650", "percentage":"19.31", "change":1400, "open":"8650", "high":"8650", "low":"8525", "volume":"1150071"}, {"date":"1762189200000", "formatted_date":"2025-11-04", "xlabel":"", "value":"8650", "percentage":"19.31", "change":1400, "open":"8650", "high":"8700", "low":"8625", "volume":"1552315"}, {"date":"1762275600000", "formatted_date":"2025-11-05", "xlabel":"", "value":"8700", "percentage":"20.00", "change":1450, "open":"8625", "high":"8700", "low":"8600", "volume":"1257315"}, {"date":"1762362000000", "formatted_date":"2025-11-06", "xlabel":"", "value":"8550", "percentage":"17.93", "change":1300, "open":"8625", "high":"8675", "low":"8550", "volume":"897569"}, {"date":"1762448400000", "formatted_date":"2025-11-07", "xlabel":"", "value":"8675", "percentage":"19.66", "change":1425, "open":"8550", "high":"8675", "low":"8550", "volume":"752419"}, {"date":"1762707600000", "formatted_date":"2025-11-10", "xlabel":"", "value":"8575", "percentage":"18.28", "change":1325, "open":"8675", "high":"8675", "low":"8575", "volume":"798977"}, {"date":"1762794000000", "formatted_date":"2025-11-11", "xlabel":"", "value":"8400", "percentage":"15.86", "change":1150, "open":"8575", "high":"8600", "low":"8400", "volume":"1308976"}, {"date":"1762880400000", "formatted_date":"2025-11-12", "xlabel":"", "value":"8500", "percentage":"17.24", "change":1250, "open":"8425", "high":"8550", "low":"8400", "volume":"854156"}, {"date":"1762966800000", "formatted_date":"2025-11-13", "xlabel":"", "value":"8375", "percentage":"15.52", "change":1125, "open":"8400", "high":"8475", "low":"8375", "volume":"1041138"}, {"date":"1763053200000", "formatted_date":"2025-11-14", "xlabel":"", "value":"8425", "percentage":"16.21", "change":1175, "open":"8375", "high":"8500", "low":"8375", "volume":"898153"}, {"date":"1763312400000", "formatted_date":"2025-11-17", "xlabel":"", "value":"8575", "percentage":"18.28", "change":1325, "open":"8500", "high":"8575", "low":"8475", "volume":"1294111"}, {"date":"1763398800000", "formatted_date":"2025-11-18", "xlabel":"", "value":"8400", "percentage":"15.86", "change":1150, "open":"8550", "high":"8575", "low":"8400", "volume":"1193779"}, {"date":"1763485200000", "formatted_date":"2025-11-19", "xlabel":"", "value":"8475", "percentage":"16.90", "change":1225, "open":"8350", "high":"8500", "low":"8325", "volume":"1142097"}, {"date":"1763571600000", "formatted_date":"2025-11-20", "xlabel":"", "value":"8425", "percentage":"16.21", "change":1175, "open":"8475", "high":"8600", "low":"8400", "volume":"1240874"}, {"date":"1763658000000", "formatted_date":"2025-11-21", "xlabel":"", "value":"8400", "percentage":"15.86", "change":1150, "open":"8375", "high":"8425", "low":"8350", "volume":"1039171"}, {"date":"1763917200000", "formatted_date":"2025-11-24", "xlabel":"", "value":"8475", "percentage":"16.90", "change":1225, "open":"8400", "high":"8475", "low":"8350", "volume":"2795906"}, {"date":"1764003600000", "formatted_date":"2025-11-25", "xlabel":"", "value":"8500", "percentage":"17.24", "change":1250, "open":"8475", "high":"8500", "low":"8375", "volume":"988077"}, {"date":"1764090000000", "formatted_date":"2025-11-26", "xlabel":"", "value":"8425", "percentage":"16.21", "change":1175, "open":"8400", "high":"8450", "low":"8375", "volume":"770944"}, {"date":"1764176400000", "formatted_date":"2025-11-27", "xlabel":"", "value":"8325", "percentage":"14.83", "change":1075, "open":"8425", "high":"8450", "low":"8325", "volume":"710513"}, {"date":"1764262800000", "formatted_date":"2025-11-28", "xlabel":"", "value":"8275", "percentage":"14.14", "change":1025, "open":"8325", "high":"8350", "low":"8275", "volume":"675593"}, {"date":"1764522000000", "formatted_date":"2025-12-01", "xlabel":"", "value":"8400", "percentage":"15.86", "change":1150, "open":"8450", "high":"8450", "low":"8350", "volume":"763592"}, {"date":"1764608400000", "formatted_date":"2025-12-02", "xlabel":"", "value":"8375", "percentage":"15.52", "change":1125, "open":"8350", "high":"8425", "low":"8350", "volume":"970440"}, {"date":"1764694800000", "formatted_date":"2025-12-03", "xlabel":"", "value":"8300", "percentage":"14.48", "change":1050, "open":"8350", "high":"8400", "low":"8300", "volume":"1035509"}, {"date":"1764781200000", "formatted_date":"2025-12-04", "xlabel":"", "value":"8225", "percentage":"13.45", "change":975, "open":"8325", "high":"8350", "low":"8225", "volume":"714104"}, {"date":"1764867600000", "formatted_date":"2025-12-05", "xlabel":"", "value":"8300", "percentage":"14.48", "change":1050, "open":"8200", "high":"8300", "low":"8200", "volume":"711623"}, {"date":"1765126800000", "formatted_date":"2025-12-08", "xlabel":"", "value":"8300", "percentage":"14.48", "change":1050, "open":"8350", "high":"8350", "low":"8250", "volume":"617452"}, {"date":"1765213200000", "formatted_date":"2025-12-09", "xlabel":"", "value":"8100", "percentage":"11.72", "change":850, "open":"8300", "high":"8300", "low":"8100", "volume":"1130572"}, {"date":"1765299600000", "formatted_date":"2025-12-10", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"8100", "high":"8175", "low":"8025", "volume":"1434183"}, {"date":"1765386000000", "formatted_date":"2025-12-11", "xlabel":"", "value":"8000", "percentage":"10.34", "change":750, "open":"8175", "high":"8200", "low":"7900", "volume":"1407660"}, {"date":"1765472400000", "formatted_date":"2025-12-12", "xlabel":"", "value":"8000", "percentage":"10.34", "change":750, "open":"7900", "high":"8000", "low":"7850", "volume":"1000068"}, {"date":"1765731600000", "formatted_date":"2025-12-15", "xlabel":"", "value":"8300", "percentage":"14.48", "change":1050, "open":"7925", "high":"8300", "low":"7925", "volume":"1431057"}, {"date":"1765818000000", "formatted_date":"2025-12-16", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"8375", "high":"8375", "low":"8025", "volume":"1223320"}, {"date":"1765904400000", "formatted_date":"2025-12-17", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8000", "high":"8125", "low":"7975", "volume":"898027"}, {"date":"1765990800000", "formatted_date":"2025-12-18", "xlabel":"", "value":"8175", "percentage":"12.76", "change":925, "open":"8025", "high":"8250", "low":"8025", "volume":"884972"}, {"date":"1766077200000", "formatted_date":"2025-12-19", "xlabel":"", "value":"8050", "percentage":"11.03", "change":800, "open":"8200", "high":"8225", "low":"8050", "volume":"1310698"}, {"date":"1766336400000", "formatted_date":"2025-12-22", "xlabel":"", "value":"8175", "percentage":"12.76", "change":925, "open":"8050", "high":"8175", "low":"8025", "volume":"724242"}, {"date":"1766422800000", "formatted_date":"2025-12-23", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8100", "high":"8125", "low":"7975", "volume":"850441"}, {"date":"1766509200000", "formatted_date":"2025-12-24", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8025", "high":"8075", "low":"8000", "volume":"712605"}, {"date":"1766941200000", "formatted_date":"2025-12-29", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8025", "high":"8025", "low":"7950", "volume":"783994"}, {"date":"1767027600000", "formatted_date":"2025-12-30", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"7950", "high":"8175", "low":"7950", "volume":"1019956"}, {"date":"1767286800000", "formatted_date":"2026-01-02", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8075", "high":"8075", "low":"8000", "volume":"686124"}, {"date":"1767546000000", "formatted_date":"2026-01-05", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"8000", "high":"8100", "low":"8000", "volume":"734088"}, {"date":"1767632400000", "formatted_date":"2026-01-06", "xlabel":"", "value":"8175", "percentage":"12.76", "change":925, "open":"8175", "high":"8175", "low":"8075", "volume":"976243"}, {"date":"1767718800000", "formatted_date":"2026-01-07", "xlabel":"", "value":"8150", "percentage":"12.41", "change":900, "open":"8125", "high":"8175", "low":"8025", "volume":"931525"}, {"date":"1767805200000", "formatted_date":"2026-01-08", "xlabel":"", "value":"8050", "percentage":"11.03", "change":800, "open":"8100", "high":"8150", "low":"8050", "volume":"798237"}, {"date":"1767891600000", "formatted_date":"2026-01-09", "xlabel":"", "value":"8125", "percentage":"12.07", "change":875, "open":"8125", "high":"8175", "low":"8050", "volume":"794847"}, {"date":"1768150800000", "formatted_date":"2026-01-12", "xlabel":"", "value":"8025", "percentage":"10.69", "change":775, "open":"8075", "high":"8100", "low":"8000", "volume":"829357"}, {"date":"1768237200000", "formatted_date":"2026-01-13", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"8025", "high":"8075", "low":"8000", "volume":"805363"}, {"date":"1768323600000", "formatted_date":"2026-01-14", "xlabel":"", "value":"8000", "percentage":"10.34", "change":750, "open":"8025", "high":"8050", "low":"7950", "volume":"1057243"}, {"date":"1768410000000", "formatted_date":"2026-01-15", "xlabel":"", "value":"8075", "percentage":"11.38", "change":825, "open":"8000", "high":"8175", "low":"7975", "volume":"1517531"}, {"date":"1768755600000", "formatted_date":"2026-01-19", "xlabel":"", "value":"8125", "percentage":"12.07", "change":875, "open":"8075", "high":"8125", "low":"7975", "volume":"1721000"}, {"date":"1768842000000", "formatted_date":"2026-01-20", "xlabel":"", "value":"8000", "percentage":"10.34", "change":750, "open":"8050", "high":"8125", "low":"8000", "volume":"826725"}, {"date":"1768928400000", "formatted_date":"2026-01-21", "xlabel":"", "value":"7700", "percentage":"6.21", "change":450, "open":"7925", "high":"7950", "low":"7700", "volume":"3018256"}, {"date":"1769014800000", "formatted_date":"2026-01-22", "xlabel":"", "value":"7650", "percentage":"5.52", "change":400, "open":"7700", "high":"7775", "low":"7600", "volume":"2680259"}, {"date":"1769101200000", "formatted_date":"2026-01-23", "xlabel":"", "value":"7650", "percentage":"5.52", "change":400, "open":"7600", "high":"7675", "low":"7550", "volume":"2028786"}, {"date":"1769360400000", "formatted_date":"2026-01-26", "xlabel":"", "value":"7650", "percentage":"5.52", "change":400, "open":"7550", "high":"7675", "low":"7525", "volume":"2130441"}, {"date":"1769446800000", "formatted_date":"2026-01-27", "xlabel":"", "value":"7500", "percentage":"3.45", "change":250, "open":"7575", "high":"7625", "low":"7500", "volume":"2598309"}, {"date":"1769533200000", "formatted_date":"2026-01-28", "xlabel":"", "value":"7025", "percentage":"-3.10", "change":-225, "open":"7050", "high":"7400", "low":"6925", "volume":"10272875"}, {"date":"1769619600000", "formatted_date":"2026-01-29", "xlabel":"", "value":"7200", "percentage":"-0.69", "change":-50, "open":"6675", "high":"7275", "low":"6375", "volume":"14329934"}, {"date":"1769706000000", "formatted_date":"2026-01-30", "xlabel":"", "value":"7400", "percentage":"2.07", "change":150, "open":"7200", "high":"7600", "low":"7200", "volume":"5284349"}, {"date":"1769965200000", "formatted_date":"2026-02-02", "xlabel":"", "value":"7600", "percentage":"4.83", "change":350, "open":"7600", "high":"7600", "low":"7325", "volume":"3269487"}, {"date":"1770051600000", "formatted_date":"2026-02-03", "xlabel":"", "value":"7650", "percentage":"5.52", "change":400, "open":"7600", "high":"7650", "low":"7375", "volume":"2060154"}, {"date":"1770138000000", "formatted_date":"2026-02-04", "xlabel":"", "value":"7800", "percentage":"7.59", "change":550, "open":"7650", "high":"7825", "low":"7600", "volume":"1933708"}, {"date":"1770224400000", "formatted_date":"2026-02-05", "xlabel":"", "value":"7800", "percentage":"7.59", "change":550, "open":"7800", "high":"7925", "low":"7800", "volume":"1423324"}, {"date":"1770310800000", "formatted_date":"2026-02-06", "xlabel":"", "value":"7675", "percentage":"5.86", "change":425, "open":"7700", "high":"7775", "low":"7600", "volume":"1763526"}, {"date":"1770570000000", "formatted_date":"2026-02-09", "xlabel":"", "value":"7500", "percentage":"3.45", "change":250, "open":"7675", "high":"7725", "low":"7450", "volume":"1747583"}, {"date":"1770656400000", "formatted_date":"2026-02-10", "xlabel":"", "value":"7475", "percentage":"3.10", "change":225, "open":"7500", "high":"7550", "low":"7450", "volume":"1277042"}, {"date":"1770742800000", "formatted_date":"2026-02-11", "xlabel":"", "value":"7450", "percentage":"2.76", "change":200, "open":"7475", "high":"7525", "low":"7400", "volume":"2135050"}, {"date":"1770829200000", "formatted_date":"2026-02-12", "xlabel":"", "value":"7325", "percentage":"1.03", "change":75, "open":"7450", "high":"7475", "low":"7300", "volume":"2374653"}, {"date":"1770915600000", "formatted_date":"2026-02-13", "xlabel":"", "value":"7200", "percentage":"-0.69", "change":-50, "open":"7325", "high":"7325", "low":"7200", "volume":"3560153"}, {"date":"1771347600000", "formatted_date":"2026-02-18", "xlabel":"", "value":"7275", "percentage":"0.34", "change":25, "open":"7225", "high":"7375", "low":"7225", "volume":"1253012"}, {"date":"1771434000000", "formatted_date":"2026-02-19", "xlabel":"", "value":"7175", "percentage":"-1.03", "change":-75, "open":"7325", "high":"7325", "low":"7175", "volume":"1334634"}, {"date":"1771520400000", "formatted_date":"2026-02-20", "xlabel":"", "value":"7225", "percentage":"-0.34", "change":-25, "open":"7175", "high":"7250", "low":"7150", "volume":"1229595"}, {"date":"1771779600000", "formatted_date":"2026-02-23", "xlabel":"", "value":"7300", "percentage":"0.69", "change":50, "open":"7300", "high":"7350", "low":"7275", "volume":"1020183"}, {"date":"1771866000000", "formatted_date":"2026-02-24", "xlabel":"", "value":"7225", "percentage":"-0.34", "change":-25, "open":"7275", "high":"7325", "low":"7225", "volume":"1592254"}, {"date":"1771952400000", "formatted_date":"2026-02-25", "xlabel":"", "value":"7325", "percentage":"1.03", "change":75, "open":"7275", "high":"7375", "low":"7250", "volume":"1332780"}, {"date":"1772038800000", "formatted_date":"2026-02-26", "xlabel":"", "value":"7300", "percentage":"0.69", "change":50, "open":"7350", "high":"7375", "low":"7200", "volume":"1278119"}, {"date":"1772125200000", "formatted_date":"2026-02-27", "xlabel":"", "value":"7175", "percentage":"-1.03", "change":-75, "open":"7200", "high":"7275", "low":"7175", "volume":"1931047"}, {"date":"1772384400000", "formatted_date":"2026-03-02", "xlabel":"", "value":"7025", "percentage":"-3.10", "change":-225, "open":"7075", "high":"7150", "low":"6975", "volume":"1561827"}, {"date":"1772470800000", "formatted_date":"2026-03-03", "xlabel":"", "value":"7075", "percentage":"-2.41", "change":-175, "open":"7075", "high":"7125", "low":"7025", "volume":"1030311"}, {"date":"1772557200000", "formatted_date":"2026-03-04", "xlabel":"", "value":"6875", "percentage":"-5.17", "change":-375, "open":"7075", "high":"7075", "low":"6825", "volume":"2165198"}, {"date":"1772643600000", "formatted_date":"2026-03-05", "xlabel":"", "value":"7100", "percentage":"-2.07", "change":-150, "open":"6975", "high":"7125", "low":"6950", "volume":"1102582"}, {"date":"1772730000000", "formatted_date":"2026-03-06", "xlabel":"", "value":"7000", "percentage":"-3.45", "change":-250, "open":"7075", "high":"7100", "low":"7000", "volume":"1027257"}, {"date":"1772989200000", "formatted_date":"2026-03-09", "xlabel":"", "value":"6875", "percentage":"-5.17", "change":-375, "open":"6900", "high":"6950", "low":"6825", "volume":"1504264"}, {"date":"1773075600000", "formatted_date":"2026-03-10", "xlabel":"", "value":"6975", "percentage":"-3.79", "change":-275, "open":"6950", "high":"7050", "low":"6925", "volume":"1299779"}, {"date":"1773162000000", "formatted_date":"2026-03-11", "xlabel":"", "value":"6825", "percentage":"-5.86", "change":-425, "open":"6975", "high":"7000", "low":"6825", "volume":"919246"}, {"date":"1773248400000", "formatted_date":"2026-03-12", "xlabel":"", "value":"6900", "percentage":"-4.83", "change":-350, "open":"6825", "high":"7025", "low":"6825", "volume":"987874"}, {"date":"1773334800000", "formatted_date":"2026-03-13", "xlabel":"", "value":"6875", "percentage":"-5.17", "change":-375, "open":"6850", "high":"7025", "low":"6850", "volume":"1079406"}, {"date":"1773594000000", "formatted_date":"2026-03-16", "xlabel":"", "value":"6750", "percentage":"-6.90", "change":-500, "open":"6850", "high":"6875", "low":"6700", "volume":"1277575"}, {"date":"1773680400000", "formatted_date":"2026-03-17", "xlabel":"", "value":"6775", "percentage":"-6.55", "change":-475, "open":"6750", "high":"6825", "low":"6750", "volume":"1609039"}, {"date":"1774371600000", "formatted_date":"2026-03-25", "xlabel":"", "value":"6900", "percentage":"-4.83", "change":-350, "open":"6675", "high":"6925", "low":"6675", "volume":"3143539"}, {"date":"1774458000000", "formatted_date":"2026-03-26", "xlabel":"", "value":"6875", "percentage":"-5.17", "change":-375, "open":"6900", "high":"6975", "low":"6850", "volume":"1459349"}, {"date":"1774544400000", "formatted_date":"2026-03-27", "xlabel":"", "value":"6700", "percentage":"-7.59", "change":-550, "open":"6775", "high":"6850", "low":"6700", "volume":"2835565"}, {"date":"1774803600000", "formatted_date":"2026-03-30", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6400", "high":"6500", "low":"6350", "volume":"2422781"}, {"date":"1774890000000", "formatted_date":"2026-03-31", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6450", "high":"6550", "low":"6450", "volume":"1506578"}, {"date":"1774976400000", "formatted_date":"2026-04-01", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6550", "high":"6650", "low":"6500", "volume":"1327981"}, {"date":"1775062800000", "formatted_date":"2026-04-02", "xlabel":"", "value":"6575", "percentage":"-9.31", "change":-675, "open":"6550", "high":"6600", "low":"6500", "volume":"727254"}, {"date":"1775408400000", "formatted_date":"2026-04-06", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6600", "high":"6625", "low":"6450", "volume":"579146"}, {"date":"1775494800000", "formatted_date":"2026-04-07", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6500", "high":"6525", "low":"6450", "volume":"651988"}, {"date":"1775581200000", "formatted_date":"2026-04-08", "xlabel":"", "value":"6750", "percentage":"-6.90", "change":-500, "open":"6650", "high":"6850", "low":"6625", "volume":"1635940"}, {"date":"1775667600000", "formatted_date":"2026-04-09", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6775", "high":"6800", "low":"6450", "volume":"1824098"}, {"date":"1775754000000", "formatted_date":"2026-04-10", "xlabel":"", "value":"6700", "percentage":"-7.59", "change":-550, "open":"6525", "high":"6700", "low":"6475", "volume":"1673361"}, {"date":"1776013200000", "formatted_date":"2026-04-13", "xlabel":"", "value":"6575", "percentage":"-9.31", "change":-675, "open":"6650", "high":"6700", "low":"6575", "volume":"819553"}, {"date":"1776099600000", "formatted_date":"2026-04-14", "xlabel":"", "value":"6750", "percentage":"-6.90", "change":-500, "open":"6675", "high":"6800", "low":"6675", "volume":"1050109"}, {"date":"1776186000000", "formatted_date":"2026-04-15", "xlabel":"", "value":"6550", "percentage":"-9.66", "change":-700, "open":"6775", "high":"6800", "low":"6550", "volume":"1048806"}, {"date":"1776272400000", "formatted_date":"2026-04-16", "xlabel":"", "value":"6525", "percentage":"-10.00", "change":-725, "open":"6575", "high":"6650", "low":"6500", "volume":"1224161"}, {"date":"1776358800000", "formatted_date":"2026-04-17", "xlabel":"", "value":"6425", "percentage":"-11.38", "change":-825, "open":"6500", "high":"6550", "low":"6425", "volume":"1568270"}, {"date":"1776618000000", "formatted_date":"2026-04-20", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6450", "high":"6575", "low":"6425", "volume":"1374357"}, {"date":"1776704400000", "formatted_date":"2026-04-21", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6475", "high":"6525", "low":"6425", "volume":"1121519"}, {"date":"1776790800000", "formatted_date":"2026-04-22", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6475", "high":"6525", "low":"6450", "volume":"956737"}, {"date":"1776877200000", "formatted_date":"2026-04-23", "xlabel":"", "value":"6425", "percentage":"-11.38", "change":-825, "open":"6450", "high":"6550", "low":"6425", "volume":"959134"}, {"date":"1776963600000", "formatted_date":"2026-04-24", "xlabel":"", "value":"6050", "percentage":"-16.55", "change":-1200, "open":"6400", "high":"6425", "low":"6050", "volume":"4350048"}, {"date":"1777222800000", "formatted_date":"2026-04-27", "xlabel":"", "value":"5975", "percentage":"-17.59", "change":-1275, "open":"6025", "high":"6050", "low":"5950", "volume":"2732631"}, {"date":"1777309200000", "formatted_date":"2026-04-28", "xlabel":"", "value":"6000", "percentage":"-17.24", "change":-1250, "open":"6025", "high":"6075", "low":"5950", "volume":"1740912"}, {"date":"1777395600000", "formatted_date":"2026-04-29", "xlabel":"", "value":"5975", "percentage":"-17.59", "change":-1275, "open":"6000", "high":"6050", "low":"5950", "volume":"1760509"}, {"date":"1777482000000", "formatted_date":"2026-04-30", "xlabel":"", "value":"5850", "percentage":"-19.31", "change":-1400, "open":"5950", "high":"5975", "low":"5800", "volume":"3034054"}, {"date":"1777827600000", "formatted_date":"2026-05-04", "xlabel":"", "value":"5900", "percentage":"-18.62", "change":-1350, "open":"5800", "high":"5950", "low":"5800", "volume":"2075667"}, {"date":"1777914000000", "formatted_date":"2026-05-05", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"5900", "high":"6075", "low":"5875", "volume":"1799319"}, {"date":"1778000400000", "formatted_date":"2026-05-06", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"5950", "high":"6000", "low":"5900", "volume":"908431"}, {"date":"1778086800000", "formatted_date":"2026-05-07", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6000", "high":"6325", "low":"6000", "volume":"2387070"}, {"date":"1778173200000", "formatted_date":"2026-05-08", "xlabel":"", "value":"6175", "percentage":"-14.83", "change":-1075, "open":"6225", "high":"6425", "low":"6175", "volume":"2017847"}, {"date":"1778432400000", "formatted_date":"2026-05-11", "xlabel":"", "value":"6150", "percentage":"-15.17", "change":-1100, "open":"6225", "high":"6275", "low":"6100", "volume":"1263013"}, {"date":"1778518800000", "formatted_date":"2026-05-12", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6125", "high":"6150", "low":"6050", "volume":"1073944"}, {"date":"1778605200000", "formatted_date":"2026-05-13", "xlabel":"", "value":"6100", "percentage":"-15.86", "change":-1150, "open":"6125", "high":"6200", "low":"6025", "volume":"1473396"}, {"date":"1779037200000", "formatted_date":"2026-05-18", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"5950", "high":"6125", "low":"5900", "volume":"2087484"}, {"date":"1779123600000", "formatted_date":"2026-05-19", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"6050", "high":"6200", "low":"5950", "volume":"1848301"}, {"date":"1779210000000", "formatted_date":"2026-05-20", "xlabel":"", "value":"5975", "percentage":"-17.59", "change":-1275, "open":"5975", "high":"6100", "low":"5875", "volume":"1951071"}, {"date":"1779296400000", "formatted_date":"2026-05-21", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"6000", "high":"6150", "low":"5925", "volume":"2109761"}, {"date":"1779382800000", "formatted_date":"2026-05-22", "xlabel":"", "value":"5900", "percentage":"-18.62", "change":-1350, "open":"5900", "high":"5975", "low":"5825", "volume":"2889197"}, {"date":"1779642000000", "formatted_date":"2026-05-25", "xlabel":"", "value":"6100", "percentage":"-15.86", "change":-1150, "open":"5950", "high":"6175", "low":"5925", "volume":"2631792"}, {"date":"1779728400000", "formatted_date":"2026-05-26", "xlabel":"", "value":"5975", "percentage":"-17.59", "change":-1275, "open":"6050", "high":"6100", "low":"5950", "volume":"2251587"}, {"date":"1779987600000", "formatted_date":"2026-05-29", "xlabel":"", "value":"5700", "percentage":"-21.38", "change":-1550, "open":"5750", "high":"5875", "low":"5700", "volume":"10152966"}, {"date":"1780333200000", "formatted_date":"2026-06-02", "xlabel":"", "value":"5825", "percentage":"-19.66", "change":-1425, "open":"5750", "high":"5875", "low":"5700", "volume":"3463052"}, {"date":"1780419600000", "formatted_date":"2026-06-03", "xlabel":"", "value":"5525", "percentage":"-23.79", "change":-1725, "open":"5775", "high":"5825", "low":"5525", "volume":"3533323"}, {"date":"1780506000000", "formatted_date":"2026-06-04", "xlabel":"", "value":"5425", "percentage":"-25.17", "change":-1825, "open":"5475", "high":"5600", "low":"5300", "volume":"5500595"}, {"date":"1780592400000", "formatted_date":"2026-06-05", "xlabel":"", "value":"5075", "percentage":"-30.00", "change":-2175, "open":"5325", "high":"5375", "low":"5075", "volume":"5726120"}, {"date":"1780851600000", "formatted_date":"2026-06-08", "xlabel":"", "value":"4850", "percentage":"-33.10", "change":-2400, "open":"4950", "high":"5050", "low":"4850", "volume":"7050933"}, {"date":"1780938000000", "formatted_date":"2026-06-09", "xlabel":"", "value":"5150", "percentage":"-28.97", "change":-2100, "open":"4850", "high":"5150", "low":"4820", "volume":"7728980"}, {"date":"1781024400000", "formatted_date":"2026-06-10", "xlabel":"", "value":"5650", "percentage":"-22.07", "change":-1600, "open":"5175", "high":"5700", "low":"5175", "volume":"7473660"}, {"date":"1781110800000", "formatted_date":"2026-06-11", "xlabel":"", "value":"5825", "percentage":"-19.66", "change":-1425, "open":"5650", "high":"5875", "low":"5550", "volume":"5432954"}, {"date":"1781197200000", "formatted_date":"2026-06-12", "xlabel":"", "value":"5925", "percentage":"-18.28", "change":-1325, "open":"6000", "high":"6100", "low":"5900", "volume":"4158723"}, {"date":"1781456400000", "formatted_date":"2026-06-15", "xlabel":"", "value":"6275", "percentage":"-13.45", "change":-975, "open":"6100", "high":"6425", "low":"6100", "volume":"4803950"}, {"date":"1781629200000", "formatted_date":"2026-06-17", "xlabel":"", "value":"6275", "percentage":"-13.45", "change":-975, "open":"6400", "high":"6550", "low":"6275", "volume":"4674943"}, {"date":"1781715600000", "formatted_date":"2026-06-18", "xlabel":"", "value":"6075", "percentage":"-16.21", "change":-1175, "open":"6250", "high":"6275", "low":"6075", "volume":"2324893"}, {"date":"1781802000000", "formatted_date":"2026-06-19", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6050", "high":"6300", "low":"6050", "volume":"3665955"}, {"date":"1782061200000", "formatted_date":"2026-06-22", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6400", "high":"6400", "low":"6125", "volume":"1934016"}, {"date":"1782147600000", "formatted_date":"2026-06-23", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6150", "high":"6200", "low":"6075", "volume":"1445619"}, {"date":"1782234000000", "formatted_date":"2026-06-24", "xlabel":"", "value":"5925", "percentage":"-18.28", "change":-1325, "open":"6125", "high":"6200", "low":"5925", "volume":"1449288"}, {"date":"1782320400000", "formatted_date":"2026-06-25", "xlabel":"", "value":"6025", "percentage":"-16.90", "change":-1225, "open":"5900", "high":"6175", "low":"5900", "volume":"1979350"}, {"date":"1782406800000", "formatted_date":"2026-06-26", "xlabel":"", "value":"6175", "percentage":"-14.83", "change":-1075, "open":"6050", "high":"6175", "low":"6025", "volume":"1633787"}, {"date":"1782666000000", "formatted_date":"2026-06-29", "xlabel":"", "value":"5925", "percentage":"-18.28", "change":-1325, "open":"6175", "high":"6200", "low":"5925", "volume":"1898866"}, {"date":"1782752400000", "formatted_date":"2026-06-30", "xlabel":"", "value":"5550", "percentage":"-23.45", "change":-1700, "open":"5775", "high":"5825", "low":"5550", "volume":"4407383"}, {"date":"1782838800000", "formatted_date":"2026-07-01", "xlabel":"", "value":"5600", "percentage":"-22.76", "change":-1650, "open":"5550", "high":"5750", "low":"5550", "volume":"1241238"}, {"date":"1782925200000", "formatted_date":"2026-07-02", "xlabel":"", "value":"5800", "percentage":"-20.00", "change":-1450, "open":"5650", "high":"5875", "low":"5625", "volume":"1451194"}, {"date":"1783011600000", "formatted_date":"2026-07-03", "xlabel":"", "value":"6050", "percentage":"-16.55", "change":-1200, "open":"5925", "high":"6150", "low":"5900", "volume":"2004652"}, {"date":"1783270800000", "formatted_date":"2026-07-06", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6050", "high":"6175", "low":"5975", "volume":"1486849"}, {"date":"1783357200000", "formatted_date":"2026-07-07", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6200", "high":"6300", "low":"6150", "volume":"1834721"}, {"date":"1783443600000", "formatted_date":"2026-07-08", "xlabel":"", "value":"6175", "percentage":"-14.83", "change":-1075, "open":"6300", "high":"6325", "low":"6175", "volume":"1812890"}, {"date":"1783530000000", "formatted_date":"2026-07-09", "xlabel":"", "value":"6200", "percentage":"-14.48", "change":-1050, "open":"6100", "high":"6200", "low":"6050", "volume":"1811558"}, {"date":"1783616400000", "formatted_date":"2026-07-10", "xlabel":"", "value":"6175", "percentage":"-14.83", "change":-1075, "open":"6300", "high":"6300", "low":"6125", "volume":"1380505"}, {"date":"1783875600000", "formatted_date":"2026-07-13", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6150", "high":"6250", "low":"6075", "volume":"1800616"}, {"date":"1783962000000", "formatted_date":"2026-07-14", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6225", "high":"6250", "low":"6075", "volume":"1598688"}, {"date":"1784048400000", "formatted_date":"2026-07-15", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6125", "high":"6200", "low":"6075", "volume":"1408249"}, {"date":"1784134800000", "formatted_date":"2026-07-16", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6125", "high":"6225", "low":"6100", "volume":"942351"}, {"date":"1784221200000", "formatted_date":"2026-07-17", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6225", "high":"6500", "low":"6200", "volume":"3191913"}, {"date":"1784480400000", "formatted_date":"2026-07-20", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6525", "high":"6625", "low":"6475", "volume":"2981833"}, {"date":"1784566800000", "formatted_date":"2026-07-21", "xlabel":"", "value":"6525", "percentage":"-10.00", "change":-725, "open":"6550", "high":"6550", "low":"6425", "volume":"1460647"}, {"date":"1784653200000", "formatted_date":"2026-07-22", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6525", "high":"6550", "low":"6400", "volume":"1121789"}, {"date":"1784739600000", "formatted_date":"2026-07-23", "xlabel":"", "value":"6275", "percentage":"-13.45", "change":-975, "open":"6450", "high":"6475", "low":"6250", "volume":"2070379"}, {"date":"1784826000000", "formatted_date":"2026-07-24", "xlabel":"", "value":"6275", "percentage":"-13.45", "change":-975, "open":"6200", "high":"6300", "low":"6125", "volume":"1914289"}, {"date":"1785085200000", "formatted_date":"2026-07-27", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6225", "high":"6325", "low":"6125", "volume":"1251425"}, {"date":"1785171600000", "formatted_date":"2026-07-28", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6250", "high":"6300", "low":"6200", "volume":"1229203"}, {"date":"1785258000000", "formatted_date":"2026-07-29", "xlabel":"", "value":"6275", "percentage":"-13.45", "change":-975, "open":"6175", "high":"6300", "low":"6175", "volume":"1223796"}, {"date":"1785344400000", "formatted_date":"2026-07-30", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6325", "high":"6450", "low":"6250", "volume":"1526632"}, {"date":"1785430800000", "formatted_date":"2026-07-31", "xlabel":"", "value":"6325", "percentage":"-12.76", "change":-925, "open":"6425", "high":"6450", "low":"6325", "volume":"1531748"}, {"date":"1785690000000", "formatted_date":"2026-08-03", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6300", "high":"6325", "low":"6275", "volume":"681437"}, {"date":"1785776400000", "formatted_date":"2026-08-04", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6375", "high":"6500", "low":"6350", "volume":"1389531"}, {"date":"1785862800000", "formatted_date":"2026-08-05", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6500", "high":"6500", "low":"6425", "volume":"1016661"}, {"date":"1785949200000", "formatted_date":"2026-08-06", "xlabel":"", "value":"6350", "percentage":"-12.41", "change":-900, "open":"6450", "high":"6475", "low":"6350", "volume":"807340"}, {"date":"1786035600000", "formatted_date":"2026-08-07", "xlabel":"", "value":"6375", "percentage":"-12.07", "change":-875, "open":"6375", "high":"6450", "low":"6325", "volume":"1119308"}, {"date":"1786294800000", "formatted_date":"2026-08-10", "xlabel":"", "value":"6375", "percentage":"-12.07", "change":-875, "open":"6400", "high":"6450", "low":"6350", "volume":"886661"}, {"date":"1786381200000", "formatted_date":"2026-08-11", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6375", "high":"6400", "low":"6200", "volume":"1124908"}, {"date":"1786467600000", "formatted_date":"2026-08-12", "xlabel":"", "value":"6350", "percentage":"-12.41", "change":-900, "open":"6300", "high":"6350", "low":"6225", "volume":"871771"}, {"date":"1786554000000", "formatted_date":"2026-08-13", "xlabel":"", "value":"6375", "percentage":"-12.07", "change":-875, "open":"6350", "high":"6375", "low":"6250", "volume":"832080"}, {"date":"1786640400000", "formatted_date":"2026-08-14", "xlabel":"", "value":"6350", "percentage":"-12.41", "change":-900, "open":"6300", "high":"6350", "low":"6275", "volume":"569535"}, {"date":"1786986000000", "formatted_date":"2026-08-18", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6375", "high":"6450", "low":"6300", "volume":"1149455"}, {"date":"1787072400000", "formatted_date":"2026-08-19", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6300", "high":"6425", "low":"6300", "volume":"681050"}, {"date":"1787158800000", "formatted_date":"2026-08-20", "xlabel":"", "value":"6400", "percentage":"-11.72", "change":-850, "open":"6350", "high":"6400", "low":"6300", "volume":"677498"}, {"date":"1787245200000", "formatted_date":"2026-08-21", "xlabel":"", "value":"6450", "percentage":"-11.03", "change":-800, "open":"6400", "high":"6475", "low":"6400", "volume":"1006843"}, {"date":"1787504400000", "formatted_date":"2026-08-24", "xlabel":"", "value":"6400", "percentage":"-11.72", "change":-850, "open":"6450", "high":"6450", "low":"6350", "volume":"812956"}, {"date":"1787677200000", "formatted_date":"2026-08-26", "xlabel":"", "value":"6350", "percentage":"-12.41", "change":-900, "open":"6450", "high":"6450", "low":"6350", "volume":"861159"}, {"date":"1787763600000", "formatted_date":"2026-08-27", "xlabel":"", "value":"6400", "percentage":"-11.72", "change":-850, "open":"6350", "high":"6425", "low":"6325", "volume":"605560"}, {"date":"1787850000000", "formatted_date":"2026-08-28", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6425", "high":"6525", "low":"6400", "volume":"1564452"}, {"date":"1788109200000", "formatted_date":"2026-08-31", "xlabel":"", "value":"6475", "percentage":"-10.69", "change":-775, "open":"6450", "high":"6525", "low":"6400", "volume":"2309045"}, {"date":"1788195600000", "formatted_date":"2026-09-01", "xlabel":"", "value":"6600", "percentage":"-8.97", "change":-650, "open":"6450", "high":"6600", "low":"6425", "volume":"1179699"}, {"date":"1788282000000", "formatted_date":"2026-09-02", "xlabel":"", "value":"6675", "percentage":"-7.93", "change":-575, "open":"6675", "high":"6725", "low":"6600", "volume":"1085374"}, {"date":"1788368400000", "formatted_date":"2026-09-03", "xlabel":"", "value":"6775", "percentage":"-6.55", "change":-475, "open":"6700", "high":"6800", "low":"6625", "volume":"1123299"}, {"date":"1788454800000", "formatted_date":"2026-09-04", "xlabel":"", "value":"6700", "percentage":"-7.59", "change":-550, "open":"6800", "high":"6850", "low":"6675", "volume":"939235"}, {"date":"1788714000000", "formatted_date":"2026-09-07", "xlabel":"", "value":"6625", "percentage":"-8.62", "change":-625, "open":"6750", "high":"6750", "low":"6600", "volume":"651180"}, {"date":"1788800400000", "formatted_date":"2026-09-08", "xlabel":"", "value":"6675", "percentage":"-7.93", "change":-575, "open":"6600", "high":"6775", "low":"6575", "volume":"1081276"}, {"date":"1788886800000", "formatted_date":"2026-09-09", "xlabel":"", "value":"6525", "percentage":"-10.00", "change":-725, "open":"6700", "high":"6700", "low":"6500", "volume":"1933751"}, {"date":"1788973200000", "formatted_date":"2026-09-10", "xlabel":"", "value":"6425", "percentage":"-11.38", "change":-825, "open":"6575", "high":"6600", "low":"6425", "volume":"1968192"}, {"date":"1789059600000", "formatted_date":"2026-09-11", "xlabel":"", "value":"6325", "percentage":"-12.76", "change":-925, "open":"6400", "high":"6400", "low":"6250", "volume":"1879227"}, {"date":"1789318800000", "formatted_date":"2026-09-14", "xlabel":"", "value":"6500", "percentage":"-10.34", "change":-750, "open":"6325", "high":"6575", "low":"6275", "volume":"2180172"}, {"date":"1789405200000", "formatted_date":"2026-09-15", "xlabel":"", "value":"6400", "percentage":"-11.72", "change":-850, "open":"6500", "high":"6500", "low":"6350", "volume":"956082"}, {"date":"1789491600000", "formatted_date":"2026-09-16", "xlabel":"", "value":"6325", "percentage":"-12.76", "change":-925, "open":"6350", "high":"6425", "low":"6325", "volume":"721574"}, {"date":"1789578000000", "formatted_date":"2026-09-17", "xlabel":"", "value":"6350", "percentage":"-12.41", "change":-900, "open":"6400", "high":"6400", "low":"6325", "volume":"618120"}, {"date":"1789664400000", "formatted_date":"2026-09-18", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6325", "high":"6350", "low":"6225", "volume":"1767537"}, {"date":"1789923600000", "formatted_date":"2026-09-21", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6300", "high":"6300", "low":"6225", "volume":"699141"}, {"date":"1790010000000", "formatted_date":"2026-09-22", "xlabel":"", "value":"6200", "percentage":"-14.48", "change":-1050, "open":"6225", "high":"6250", "low":"6175", "volume":"932112"}, {"date":"1790096400000", "formatted_date":"2026-09-23", "xlabel":"", "value":"6300", "percentage":"-13.10", "change":-950, "open":"6200", "high":"6300", "low":"6200", "volume":"968261"}, {"date":"1790182800000", "formatted_date":"2026-09-24", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6250", "high":"6275", "low":"6225", "volume":"756486"}, {"date":"1790269200000", "formatted_date":"2026-09-25", "xlabel":"", "value":"6250", "percentage":"-13.79", "change":-1000, "open":"6225", "high":"6275", "low":"6200", "volume":"894474"}, {"date":"1790528400000", "formatted_date":"2026-09-28", "xlabel":"", "value":"6225", "percentage":"-14.14", "change":-1025, "open":"6225", "high":"6250", "low":"6175", "volume":"1121076"}, {"date":"1790614800000", "formatted_date":"2026-09-29", "xlabel":"", "value":"6150", "percentage":"-15.17", "change":-1100, "open":"6150", "high":"6225", "low":"6125", "volume":"1497834"}, {"date":"1790701200000", "formatted_date":"2026-09-30", "xlabel":"", "value":"6075", "percentage":"-16.21", "change":-1175, "open":"6100", "high":"6150", "low":"6050", "volume":"2213357"}, {"date":"1790787600000", "formatted_date":"2026-10-01", "xlabel":"", "value":"6000", "percentage":"-17.24", "change":-1250, "open":"6050", "high":"6150", "low":"6000", "volume":"1180449"}, {"date":"1790874000000", "formatted_date":"2026-10-02", "xlabel":"", "value":"6100", "percentage":"-15.86", "change":-1150, "open":"6000", "high":"6150", "low":"5975", "volume":"1319203"}, {"date":"1791133200000", "formatted_date":"2026-10-05", "xlabel":"", "value":"6175", "percentage":"-14.83", "change":-1075, "open":"6100", "high":"6175", "low":"6075", "volume":"899057"}, {"date":"1791219600000", "formatted_date":"2026-10-06", "xlabel":"", "value":"6100", "percentage":"-15.86", "change":-1150, "open":"6175", "high":"6200", "low":"6100", "volume":"1521734"}, {"date":"1791306000000", "formatted_date":"2026-10-07", "xlabel":"", "value":"6050", "percentage":"-16.55", "change":-1200, "open":"6075", "high":"6100", "low":"5975", "volume":"1626908"}, {"date":"1791392400000", "formatted_date":"2026-10-08", "xlabel":"", "value":"6000", "percentage":"-17.24", "change":-1250, "open":"6100", "high":"6150", "low":"6000", "volume":"1131403"}, {"date":"1791478800000", "formatted_date":"2026-10-09", "xlabel":"", "value":"6050", "percentage":"-16.55", "change":-1200, "open":"6025", "high":"6075", "low":"5975", "volume":"870986"}, {"date":"1791738000000", "formatted_date":"2026-10-12", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"6050", "high":"6100", "low":"5950", "volume":"979651"}, {"date":"1791824400000", "formatted_date":"2026-10-13", "xlabel":"", "value":"5950", "percentage":"-17.93", "change":-1300, "open":"5925", "high":"5950", "low":"5850", "volume":"1227169"}, {"date":"1791910800000", "formatted_date":"2026-10-14", "xlabel":"", "value":"6125", "percentage":"-15.52", "change":-1125, "open":"6000", "high":"6125", "low":"5950", "volume":"1363031"}, {"date":"1791997200000", "formatted_date":"2026-10-15", "xlabel":"", "value":"6075", "percentage":"-16.21", "change":-1175, "open":"6075", "high":"6125", "low":"6025", "volume":"1347632"}, {"date":"1792083600000", "formatted_date":"2026-10-16", "xlabel":"", "value":"5175", "percentage":"-28.62", "change":-2075, "open":"5825", "high":"5825", "low":"5175", "volume":"12410982"}], "timeframe":"1y", "xaxisopt":"", "previous":7250, "line_weight":1.8, "previous_timeframe_price":{"date":"", "formatted_date":"", "xlabel":"", "value":"", "percentage":"", "change":0, "open":"", "high":"", "low":"", "volume":""}, "chart_type":"PRICE_CHART_TYPE_CANDLE", "interval_in_minutes":0, "allowed_chart_type":["PRICE_CHART_TYPE_LINE", "PRICE_CHART_TYPE_CANDLE"], "max_candles":260}, "message":"Successfully retrieved company daily chart"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt b/crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt new file mode 100644 index 0000000..3039c3a --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt @@ -0,0 +1 @@ +%7B%22state%22%3A%7B%22access%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-19T04%3A43%3A28Z%22%7D%2C%22refresh%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-25T04%3A43%3A28Z%22%7D%2C%22user%22%3A%7B%22username%22%3A%22demo%22%7D%7D%2C%22version%22%3A0%7D diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json b/crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json new file mode 100644 index 0000000..276098a --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json @@ -0,0 +1,13 @@ +{ + "message": "Successfully refresh token", + "data": { + "access": { + "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU", + "expired_at": "2026-10-19T04:43:28Z" + }, + "refresh": { + "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU", + "expired_at": "2026-10-25T04:43:28Z" + } + } +} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_market_session.json b/crates/idx-core/tests/fixtures/stockbit_exodus_market_session.json new file mode 100644 index 0000000..ef0862f --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_market_session.json @@ -0,0 +1 @@ +{"message":"Successfully get session info data", "data":{"datetime":"2026-10-18 20:15:06", "detail":{"fca":{"session":0, "state_name":"STATE_NAME_MARKET_CLOSED", "is_last_session":false, "is_end_of_day":true, "state_start_time":"", "state_end_time":"", "time_left":{"raw":44994, "formatted":"12 jam 29 menit 54 detik"}, "suspend_info":""}, "regular":{"session":0, "state_name":"STATE_NAME_MARKET_CLOSED", "is_last_session":false, "is_end_of_day":true, "state_start_time":"", "state_end_time":"", "time_left":{"raw":44994, "formatted":"12 jam 29 menit 54 detik"}, "suspend_info":""}}}} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca.json b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca.json new file mode 100644 index 0000000..80b456d --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca.json @@ -0,0 +1 @@ +{"data":{"average":5217, "bid":[], "change":-900, "close":5175, "country":"ID", "domestic":"57.05", "down":"110", "exchange":"IDX", "fbuy":1616461977500, "fnet":-2329493087500, "foreign":"42.95", "frequency":185072, "fsell":3945955065000, "high":5825, "id":"BBCA-0", "lastprice":5175, "low":5175, "offer":[{"price":"5175", "que_num":"3284", "volume":"264462500", "change_percentage":""}, {"price":"5200", "que_num":"337", "volume":"10053400", "change_percentage":""}, {"price":"5225", "que_num":"396", "volume":"3711400", "change_percentage":""}, {"price":"5250", "que_num":"516", "volume":"8250400", "change_percentage":""}, {"price":"5275", "que_num":"358", "volume":"3488100", "change_percentage":""}, {"price":"5300", "que_num":"494", "volume":"3320500", "change_percentage":""}, {"price":"5325", "que_num":"233", "volume":"4161500", "change_percentage":""}, {"price":"5350", "que_num":"367", "volume":"2907400", "change_percentage":""}, {"price":"5375", "que_num":"330", "volume":"3350700", "change_percentage":""}, {"price":"5400", "que_num":"459", "volume":"5192400", "change_percentage":""}, {"price":"5425", "que_num":"207", "volume":"2868000", "change_percentage":""}, {"price":"5450", "que_num":"222", "volume":"2512100", "change_percentage":""}, {"price":"5475", "que_num":"95", "volume":"488400", "change_percentage":""}, {"price":"5500", "que_num":"326", "volume":"2199000", "change_percentage":""}, {"price":"5525", "que_num":"46", "volume":"272000", "change_percentage":""}, {"price":"5550", "que_num":"62", "volume":"368500", "change_percentage":""}, {"price":"5575", "que_num":"42", "volume":"140900", "change_percentage":""}, {"price":"5600", "que_num":"105", "volume":"733800", "change_percentage":""}, {"price":"5625", "que_num":"29", "volume":"177500", "change_percentage":""}, {"price":"5650", "que_num":"34", "volume":"521000", "change_percentage":""}, {"price":"5675", "que_num":"26", "volume":"65600", "change_percentage":""}, {"price":"5700", "que_num":"104", "volume":"480200", "change_percentage":""}, {"price":"5725", "que_num":"25", "volume":"60800", "change_percentage":""}, {"price":"5750", "que_num":"53", "volume":"344900", "change_percentage":""}, {"price":"5775", "que_num":"21", "volume":"48900", "change_percentage":""}, {"price":"5800", "que_num":"137", "volume":"1048000", "change_percentage":""}, {"price":"5825", "que_num":"68", "volume":"709700", "change_percentage":""}, {"price":"5850", "que_num":"24", "volume":"877700", "change_percentage":""}, {"price":"5875", "que_num":"17", "volume":"568500", "change_percentage":""}, {"price":"5900", "que_num":"43", "volume":"389200", "change_percentage":""}, {"price":"5925", "que_num":"18", "volume":"145400", "change_percentage":""}, {"price":"5950", "que_num":"22", "volume":"1780500", "change_percentage":""}, {"price":"5975", "que_num":"21", "volume":"80600", "change_percentage":""}, {"price":"6000", "que_num":"176", "volume":"1976700", "change_percentage":""}, {"price":"6025", "que_num":"17", "volume":"98700", "change_percentage":""}, {"price":"6050", "que_num":"36", "volume":"233000", "change_percentage":""}, {"price":"6075", "que_num":"52", "volume":"291100", "change_percentage":""}, {"price":"6100", "que_num":"56", "volume":"278500", "change_percentage":""}, {"price":"6125", "que_num":"14", "volume":"25600", "change_percentage":""}, {"price":"6150", "que_num":"24", "volume":"173200", "change_percentage":""}, {"price":"6175", "que_num":"17", "volume":"257200", "change_percentage":""}, {"price":"6200", "que_num":"39", "volume":"118100", "change_percentage":""}, {"price":"6225", "que_num":"13", "volume":"75500", "change_percentage":""}, {"price":"6250", "que_num":"22", "volume":"169200", "change_percentage":""}, {"price":"6275", "que_num":"12", "volume":"23300", "change_percentage":""}, {"price":"6300", "que_num":"27", "volume":"27300", "change_percentage":""}, {"price":"6325", "que_num":"8", "volume":"56500", "change_percentage":""}, {"price":"6350", "que_num":"13", "volume":"87400", "change_percentage":""}, {"price":"6375", "que_num":"11", "volume":"24900", "change_percentage":""}, {"price":"6400", "que_num":"24", "volume":"91200", "change_percentage":""}, {"price":"6425", "que_num":"6", "volume":"14700", "change_percentage":""}, {"price":"6450", "que_num":"10", "volume":"219200", "change_percentage":""}, {"price":"6475", "que_num":"9", "volume":"204000", "change_percentage":""}, {"price":"6500", "que_num":"56", "volume":"283100", "change_percentage":""}, {"price":"6525", "que_num":"7", "volume":"37700", "change_percentage":""}, {"price":"6550", "que_num":"6", "volume":"25300", "change_percentage":""}, {"price":"6575", "que_num":"7", "volume":"31700", "change_percentage":""}, {"price":"6600", "que_num":"14", "volume":"33500", "change_percentage":""}, {"price":"6625", "que_num":"11", "volume":"81800", "change_percentage":""}, {"price":"6650", "que_num":"10", "volume":"67400", "change_percentage":""}, {"price":"6675", "que_num":"3", "volume":"6200", "change_percentage":""}, {"price":"6700", "que_num":"23", "volume":"140500", "change_percentage":""}, {"price":"6725", "que_num":"7", "volume":"26600", "change_percentage":""}, {"price":"6750", "que_num":"8", "volume":"47500", "change_percentage":""}, {"price":"6775", "que_num":"8", "volume":"4600", "change_percentage":""}, {"price":"6800", "que_num":"24", "volume":"119400", "change_percentage":""}, {"price":"6825", "que_num":"8", "volume":"38300", "change_percentage":""}, {"price":"6850", "que_num":"11", "volume":"95200", "change_percentage":""}, {"price":"6875", "que_num":"7", "volume":"12800", "change_percentage":""}, {"price":"6900", "que_num":"20", "volume":"111300", "change_percentage":""}, {"price":"6925", "que_num":"1", "volume":"100", "change_percentage":""}, {"price":"6950", "que_num":"9", "volume":"37500", "change_percentage":""}, {"price":"6975", "que_num":"5", "volume":"7000", "change_percentage":""}, {"price":"7000", "que_num":"39", "volume":"172300", "change_percentage":""}, {"price":"7025", "que_num":"4", "volume":"6000", "change_percentage":""}, {"price":"7050", "que_num":"3", "volume":"70200", "change_percentage":""}, {"price":"7075", "que_num":"4", "volume":"9300", "change_percentage":""}, {"price":"7100", "que_num":"12", "volume":"26500", "change_percentage":""}, {"price":"7125", "que_num":"2", "volume":"10400", "change_percentage":""}, {"price":"7150", "que_num":"1", "volume":"2500", "change_percentage":""}, {"price":"7175", "que_num":"2", "volume":"500", "change_percentage":""}, {"price":"7200", "que_num":"4", "volume":"3100", "change_percentage":""}, {"price":"7225", "que_num":"2", "volume":"200", "change_percentage":""}, {"price":"7250", "que_num":"8", "volume":"74300", "change_percentage":""}, {"price":"7275", "que_num":"36", "volume":"100500", "change_percentage":""}], "open":5825, "percentage_change":-14.81, "previous":6075, "status":"Active", "symbol":"BBCA", "symbol_2":"BBCA", "symbol_3":"BBCA", "tradable":true, "unchanged":"1118", "up":"265", "value":6474934315000, "volume":1241098200, "corp_action":{"active":false, "icon":"https://assets.stockbit.com/images/corp_action_event_icon.svg", "text":"Perusahaan Memiliki Corporate Action"}, "notation":[], "uma":false, "has_foreign_bs":true, "iepiev":{"symbol":"", "status":"STATUS_UNSPECIFIED", "iep":{"raw":0, "formatted":""}, "iev":{"raw":0, "formatted":""}, "time_left_seconds":0, "best_bid_offer":{"bid":{"price":{"raw":0, "formatted":"0"}, "quantity":{"raw":0, "formatted":"0"}, "change_percentage":""}, "offer":{"price":{"raw":5175, "formatted":"5,175"}, "quantity":{"raw":2244775, "formatted":"2,244,775"}, "change_percentage":"-14.81%"}, "time_left_seconds":13503}, "title":"", "iep_changes":{"price":{"raw":0, "formatted":""}, "percentage":{"raw":0, "formatted":""}}}, "market_data":[{"label":"All Market", "frequency":{"raw":"185072", "formatted":"185 K"}, "volume":{"raw":"1241098200", "formatted":"1.24 B"}, "value":{"raw":"6474934315000", "formatted":"6.47 T"}}, {"label":"Regular", "frequency":{"raw":"185072", "formatted":"185 K"}, "volume":{"raw":"1241098200", "formatted":"1.24 B"}, "value":{"raw":"6474934315000", "formatted":"6.47 T"}}, {"label":"Nego", "frequency":{"raw":"0", "formatted":"0"}, "volume":{"raw":"0", "formatted":"0"}, "value":{"raw":"0", "formatted":"0"}}, {"label":"Cash", "frequency":{"raw":"0", "formatted":"0"}, "volume":{"raw":"0", "formatted":"0"}, "value":{"raw":"0", "formatted":"0"}}], "name":"Bank Central Asia Tbk.", "icon_url":"https://assets.stockbit.com/logos/companies/BBCA.png", "ara":{"value":"6,200", "visible":true}, "arb":{"value":"4,400", "visible":true}, "company_type":"Saham", "total_bid_offer":{"bid":{"freq":"0", "lot":"0", "raw_lot":"0", "raw_freq":"0"}, "offer":{"freq":"9,469", "lot":"331,908,100", "raw_lot":"331908100", "raw_freq":"9469"}, "bid_percent":0}, "next_ara":{"value":"6,200", "visible":true}, "next_arb":{"value":"4,400", "visible":true}, "autoreject_time_left_in_sec":0, "auto_reject_estimation":[{"value":488400, "change_to_prev":{"value":81400, "percentage":20}, "change_to_base":{"value":483225, "percentage":9337.68}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":407000, "change_to_prev":{"value":67825, "percentage":20}, "change_to_base":{"value":401825, "percentage":7764.73}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":339175, "change_to_prev":{"value":56525, "percentage":20}, "change_to_base":{"value":334000, "percentage":6454.11}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":282650, "change_to_prev":{"value":47100, "percentage":20}, "change_to_base":{"value":277475, "percentage":5361.84}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":235550, "change_to_prev":{"value":39250, "percentage":19.99}, "change_to_base":{"value":230375, "percentage":4451.69}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":196300, "change_to_prev":{"value":32700, "percentage":19.99}, "change_to_base":{"value":191125, "percentage":3693.24}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":163600, "change_to_prev":{"value":27250, "percentage":19.99}, "change_to_base":{"value":158425, "percentage":3061.35}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":136350, "change_to_prev":{"value":22725, "percentage":20}, "change_to_base":{"value":131175, "percentage":2534.78}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":113625, "change_to_prev":{"value":18925, "percentage":19.98}, "change_to_base":{"value":108450, "percentage":2095.65}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":94700, "change_to_prev":{"value":15775, "percentage":19.99}, "change_to_base":{"value":89525, "percentage":1729.95}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":78925, "change_to_prev":{"value":13150, "percentage":19.99}, "change_to_base":{"value":73750, "percentage":1425.12}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":65775, "change_to_prev":{"value":10950, "percentage":19.97}, "change_to_base":{"value":60600, "percentage":1171.01}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":54825, "change_to_prev":{"value":9125, "percentage":19.97}, "change_to_base":{"value":49650, "percentage":959.42}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":45700, "change_to_prev":{"value":7600, "percentage":19.95}, "change_to_base":{"value":40525, "percentage":783.09}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":38100, "change_to_prev":{"value":6350, "percentage":20}, "change_to_base":{"value":32925, "percentage":636.23}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":31750, "change_to_prev":{"value":5275, "percentage":19.92}, "change_to_base":{"value":26575, "percentage":513.53}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":26475, "change_to_prev":{"value":4400, "percentage":19.93}, "change_to_base":{"value":21300, "percentage":411.59}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":22075, "change_to_prev":{"value":3675, "percentage":19.97}, "change_to_base":{"value":16900, "percentage":326.57}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":18400, "change_to_prev":{"value":3050, "percentage":19.87}, "change_to_base":{"value":13225, "percentage":255.56}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":15350, "change_to_prev":{"value":2550, "percentage":19.92}, "change_to_base":{"value":10175, "percentage":196.62}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":12800, "change_to_prev":{"value":2125, "percentage":19.91}, "change_to_base":{"value":7625, "percentage":147.34}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":10675, "change_to_prev":{"value":1775, "percentage":19.94}, "change_to_base":{"value":5500, "percentage":106.28}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":8900, "change_to_prev":{"value":1475, "percentage":19.87}, "change_to_base":{"value":3725, "percentage":71.98}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":7425, "change_to_prev":{"value":1225, "percentage":19.76}, "change_to_base":{"value":2250, "percentage":43.48}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":6200, "change_to_prev":{"value":1025, "percentage":19.81}, "change_to_base":{"value":1025, "percentage":19.81}, "type":"AUTO_REJECT_TYPE_POSITIVE"}, {"value":5175, "change_to_prev":{"value":0, "percentage":0}, "change_to_base":{"value":0, "percentage":0}, "type":"AUTO_REJECT_TYPE_BASE"}, {"value":4400, "change_to_prev":{"value":-775, "percentage":-14.98}, "change_to_base":{"value":-775, "percentage":-14.98}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":3740, "change_to_prev":{"value":-660, "percentage":-15}, "change_to_base":{"value":-1435, "percentage":-27.73}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":3180, "change_to_prev":{"value":-560, "percentage":-14.97}, "change_to_base":{"value":-1995, "percentage":-38.55}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":2710, "change_to_prev":{"value":-470, "percentage":-14.78}, "change_to_base":{"value":-2465, "percentage":-47.63}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":2310, "change_to_prev":{"value":-400, "percentage":-14.76}, "change_to_base":{"value":-2865, "percentage":-55.36}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":1965, "change_to_prev":{"value":-345, "percentage":-14.94}, "change_to_base":{"value":-3210, "percentage":-62.03}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":1675, "change_to_prev":{"value":-290, "percentage":-14.76}, "change_to_base":{"value":-3500, "percentage":-67.63}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":1425, "change_to_prev":{"value":-250, "percentage":-14.93}, "change_to_base":{"value":-3750, "percentage":-72.46}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":1215, "change_to_prev":{"value":-210, "percentage":-14.74}, "change_to_base":{"value":-3960, "percentage":-76.52}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":1035, "change_to_prev":{"value":-180, "percentage":-14.81}, "change_to_base":{"value":-4140, "percentage":-80}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":880, "change_to_prev":{"value":-155, "percentage":-14.98}, "change_to_base":{"value":-4295, "percentage":-83}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":750, "change_to_prev":{"value":-130, "percentage":-14.77}, "change_to_base":{"value":-4425, "percentage":-85.51}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":640, "change_to_prev":{"value":-110, "percentage":-14.67}, "change_to_base":{"value":-4535, "percentage":-87.63}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":545, "change_to_prev":{"value":-95, "percentage":-14.84}, "change_to_base":{"value":-4630, "percentage":-89.47}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":464, "change_to_prev":{"value":-81, "percentage":-14.86}, "change_to_base":{"value":-4711, "percentage":-91.03}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":396, "change_to_prev":{"value":-68, "percentage":-14.66}, "change_to_base":{"value":-4779, "percentage":-92.35}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":338, "change_to_prev":{"value":-58, "percentage":-14.65}, "change_to_base":{"value":-4837, "percentage":-93.47}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":288, "change_to_prev":{"value":-50, "percentage":-14.79}, "change_to_base":{"value":-4887, "percentage":-94.43}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":246, "change_to_prev":{"value":-42, "percentage":-14.58}, "change_to_base":{"value":-4929, "percentage":-95.25}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":210, "change_to_prev":{"value":-36, "percentage":-14.63}, "change_to_base":{"value":-4965, "percentage":-95.94}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":179, "change_to_prev":{"value":-31, "percentage":-14.76}, "change_to_base":{"value":-4996, "percentage":-96.54}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":153, "change_to_prev":{"value":-26, "percentage":-14.53}, "change_to_base":{"value":-5022, "percentage":-97.04}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":131, "change_to_prev":{"value":-22, "percentage":-14.38}, "change_to_base":{"value":-5044, "percentage":-97.47}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":112, "change_to_prev":{"value":-19, "percentage":-14.5}, "change_to_base":{"value":-5063, "percentage":-97.84}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}, {"value":96, "change_to_prev":{"value":-16, "percentage":-14.29}, "change_to_base":{"value":-5079, "percentage":-98.14}, "type":"AUTO_REJECT_TYPE_NEGATIVE"}], "orderbook_active_feature_mobile":"ORDER_BOOK_FEATURE_FOREIGN_BS"}, "message":"Successfully get company orderbook"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_ihsg.json b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_ihsg.json new file mode 100644 index 0000000..2e9593d --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_ihsg.json @@ -0,0 +1 @@ +{"data":{"average":0, "bid":[], "change":-678.67, "close":5185.105, "country":"ID", "domestic":"59.01", "down":"110", "exchange":"IDX", "fbuy":9176233289300, "fnet":-3397459968400, "foreign":"40.99", "frequency":2130259, "fsell":12573693257700, "high":5404.508, "id":"IHSG-0", "lastprice":5185.105, "low":5185.105, "offer":[], "open":5393.924, "percentage_change":-11.57, "previous":5863.78, "status":"Active", "symbol":"IHSG", "symbol_2":"IHSG", "symbol_3":"IHSG", "tradable":false, "unchanged":"1118", "up":"265", "value":26532049500300, "volume":66022512700, "corp_action":{"active":false, "icon":"https://assets.stockbit.com/images/corp_action_event_icon.svg", "text":"Perusahaan Memiliki Corporate Action"}, "notation":[], "uma":false, "has_foreign_bs":true, "iepiev":{"symbol":"", "status":"STATUS_UNSPECIFIED", "iep":{"raw":0, "formatted":""}, "iev":{"raw":0, "formatted":""}, "time_left_seconds":0, "best_bid_offer":{"bid":{"price":{"raw":0, "formatted":"0"}, "quantity":{"raw":0, "formatted":"0"}, "change_percentage":""}, "offer":{"price":{"raw":0, "formatted":"0"}, "quantity":{"raw":0, "formatted":"0"}, "change_percentage":""}, "time_left_seconds":11921}, "title":"", "iep_changes":{"price":{"raw":0, "formatted":""}, "percentage":{"raw":0, "formatted":""}}}, "market_data":[{"label":"All Market", "frequency":{"raw":"2133195", "formatted":"2.13 M"}, "volume":{"raw":"68613461946", "formatted":"68.6 B"}, "value":{"raw":"32013813258468", "formatted":"32 T"}}, {"label":"Regular", "frequency":{"raw":"2130259", "formatted":"2.13 M"}, "volume":{"raw":"66022512700", "formatted":"66 B"}, "value":{"raw":"26532049500300", "formatted":"26.5 T"}}, {"label":"Nego", "frequency":{"raw":"511", "formatted":"511"}, "volume":{"raw":"2524183446", "formatted":"2.52 B"}, "value":{"raw":"5475654641968", "formatted":"5.48 T"}}, {"label":"Cash", "frequency":{"raw":"2425", "formatted":"2,425"}, "volume":{"raw":"66765800", "formatted":"66.8 M"}, "value":{"raw":"6109116200", "formatted":"6.11 B"}}], "name":"Index Harga Saham Gabungan", "icon_url":"https://assets.stockbit.com/logos/companies/IHSG.png?version=1784250340348189864", "ara":{"value":"0", "visible":false}, "arb":{"value":"0", "visible":false}, "company_type":"Index", "total_bid_offer":{"bid":{"freq":"0", "lot":"0", "raw_lot":"0", "raw_freq":"0"}, "offer":{"freq":"0", "lot":"0", "raw_lot":"0", "raw_freq":"0"}, "bid_percent":0}, "next_ara":{"value":"", "visible":false}, "next_arb":{"value":"", "visible":false}, "autoreject_time_left_in_sec":0, "auto_reject_estimation":[], "orderbook_active_feature_mobile":"ORDER_BOOK_FEATURE_FOREIGN_BS"}, "message":"Successfully get company orderbook"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_zzzz.json b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_zzzz.json new file mode 100644 index 0000000..8d2183e --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_zzzz.json @@ -0,0 +1 @@ +{"message":"Company not found","error_type":"NOT_FOUND"} diff --git a/crates/idx-core/tests/fixtures/stockbit_exodus_unauthorized.json b/crates/idx-core/tests/fixtures/stockbit_exodus_unauthorized.json new file mode 100644 index 0000000..2918020 --- /dev/null +++ b/crates/idx-core/tests/fixtures/stockbit_exodus_unauthorized.json @@ -0,0 +1 @@ +{"error_type":"UNAUTHORIZED","message":"Unauthorized"} diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 6f97aea..14a3ebe 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -38,6 +38,15 @@ pub struct IntradayParams { pub tail: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OrderBookParams { + /// IDX ticker, e.g. "BBCA". + pub symbol: String, + /// Price levels per side (default 10, at most 50). + #[serde(default)] + pub depth: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct ScreenParams { /// Screen to run (default "top-performers"). diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 41927c7..1db5800 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -26,8 +26,8 @@ use tokio::sync::Semaphore; use crate::params::{ AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams, - CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, ScreenParams, - SearchParams, ShareholdersParams, SymbolsParams, SyncParams, + CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, OrderBookParams, + ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams, }; const SYNC_TOOL: &str = "ownership_sync"; @@ -45,6 +45,8 @@ const MAX_CHANGES_LIMIT: usize = 300; const CHANGES_TOP_TICKERS: usize = 10; // A session has about 340 minutes, so this is the whole day. const MAX_INTRADAY_POINTS: usize = 400; +const BOOK_DEPTH: usize = 10; +const MAX_BOOK_DEPTH: usize = 50; /// Tool calls running at once; the rest wait their turn. static CALL_SLOTS: Semaphore = Semaphore::const_new(4); @@ -158,6 +160,34 @@ per-minute volume. Empty `points` means the stock did not trade this session (se .await } + #[tool( + description = "Order book of one IDX stock from the user's own Stockbit session: \ +`bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, \ +volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is \ +Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left \ +it. AUTHERROR means there is no session: call stockbit_status, and ask the user to run \ +`idx stockbit login` in a terminal." + )] + async fn get_orderbook(&self, params: Parameters) -> ToolResult { + let OrderBookParams { symbol, depth } = params.0; + self.live(move |idx| { + let depth = bounded_limit("depth", depth, BOOK_DEPTH, MAX_BOOK_DEPTH)?; + let mut book = idx.market().orderbook(&symbol, CachePolicy::Default)?; + book.data.bids.truncate(depth); + book.data.offers.truncate(depth); + Ok(book) + }) + .await + } + + #[tool(description = "Whether a Stockbit session is stored (or seeded by \ +IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, \ +get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the \ +user adds one with `idx stockbit login` in a terminal.")] + async fn stockbit_status(&self) -> ToolResult { + blocking(idx_core::api::stockbit::session_status).await + } + #[tool(description = "OHLCV price history for one IDX ticker.")] async fn get_price_history(&self, params: Parameters) -> ToolResult { let HistoryParams { @@ -168,7 +198,7 @@ per-minute volume. Empty `points` means the stock did not trade this session (se self.live(move |idx| { let period = period.as_deref().unwrap_or("3mo").parse()?; let interval = interval.as_deref().unwrap_or("1d").parse()?; - let source = idx.market().history_source(None); + let source = idx.market().history_source(None, &period, &interval); idx.market() .history(&symbol, &period, &interval, source, CachePolicy::Default) }) @@ -365,7 +395,7 @@ are listed under `errors`; the call fails only if every analysis fails." let name = kind.as_str(); match kind { AnalysisKind::Technical => { - let source = idx.market().history_source(None); + let source = analysis.technical_source(None); report.add(name, analysis.technical(&symbol, source, policy)) } AnalysisKind::Growth => report.add(name, analysis.growth(&symbol, policy)), diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 375ee44..89afe89 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -331,6 +331,33 @@ }, "name": "get_investor_holdings" }, + { + "description": "Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, and ask the user to run `idx stockbit login` in a terminal.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "depth": { + "default": null, + "description": "Price levels per side (default 10, at most 50).", + "format": "uint", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "symbol": { + "description": "IDX ticker, e.g. \"BBCA\".", + "type": "string" + } + }, + "required": [ + "symbol" + ], + "type": "object" + }, + "name": "get_orderbook" + }, { "description": "Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last.", "inputSchema": { @@ -556,5 +583,13 @@ "type": "object" }, "name": "search_investors" + }, + { + "description": "Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with `idx stockbit login` in a terminal.", + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "stockbit_status" } ] diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index e3902a8..18406c6 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -9,7 +9,7 @@ use idx_core::config::CoreConfig; use idx_core::investor::{ImportSource, Investor}; use serde_json::{Value, json}; -const TOOLS: [&str; 15] = [ +const TOOLS: [&str; 17] = [ "analyze_bandar", "analyze_stock", "compare_stocks", @@ -18,6 +18,7 @@ const TOOLS: [&str; 15] = [ "get_foreign_flow", "get_intraday", "get_investor_holdings", + "get_orderbook", "get_ownership_changes", "get_price_history", "get_quotes", @@ -25,8 +26,15 @@ const TOOLS: [&str; 15] = [ "ownership_status", "screen_stocks", "search_investors", + "stockbit_status", ]; +/// An unsigned test token expiring in 2100, as a seeded Stockbit session. +const TEST_SESSION: (&str, &str) = ( + "IDX_STOCKBIT_REFRESH_TOKEN", + "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", +); + struct Client { child: Child, stdin: ChildStdin, @@ -362,6 +370,39 @@ fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() { ); } +#[test] +fn the_order_book_needs_a_session_and_status_never_shows_tokens() { + let mut client = Client::start(&[], false); + let status = client.ok("stockbit_status", json!({})); + assert_eq!(status["logged_in"], false); + let (is_error, body) = client.call("get_orderbook", json!({"symbol": "BBCA"})); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "AUTHERROR"); + + let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]); + let status = client.ok("stockbit_status", json!({})); + assert_eq!(status["logged_in"], true); + assert!(!status.to_string().contains(TEST_SESSION.1)); + + let book = client.ok("get_orderbook", json!({"symbol": "BBCA", "depth": 2})); + assert_fetched(&book); + assert_eq!(book["source"], "stockbit"); + let offers = book["data"]["offers"].as_array().unwrap(); + assert_eq!(offers.len(), 2); + assert_eq!( + offers[0], + json!({"price": 5175, "shares": 264_462_500, "orders": 3284}) + ); + assert_eq!(book["data"]["foreign_net"], -2_329_493_087_500_i64); + assert_eq!(book["data"]["market_status"], "STATE_NAME_MARKET_CLOSED"); + for depth in [0, 51] { + let (is_error, body) = + client.call("get_orderbook", json!({"symbol": "BBCA", "depth": depth})); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "INVALIDINPUT"); + } +} + #[test] fn errors_carry_cli_codes() { let mut client = Client::start(&[], false); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0343bd0..a289e19 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -164,8 +164,8 @@ WIB as the fallback. `chart.prices[]` holds one price per minute in WIB with no offset, after a synthetic first row at the previous close; per-minute OHLC and volume are empty. An unknown code is HTTP 404 with the 404 page. -- Stockbit's JSON API (`exodus.stockbit.com`: chart, order book) answers 401 - without a user's bearer token; it is not used. +- Stockbit's JSON API (`exodus.stockbit.com`) answers 401 without a user's + bearer token; it is used only with the user's own session (below). - Bid and offer are kept only while `market_hour.status` is `open`, so a closing book never reads as a live one. Observed 2026-10-23: `open` during trading (bid/offer set), `break` from 11:30 on a Friday, `close` after @@ -189,6 +189,35 @@ WIB as the fallback. personal use; frontends say so. Fixtures (`tests/fixtures/stockbit_*`) are pages recorded 2026-10-22 after the close, cut to the fields read. +### Stockbit session (opt-in login) +- `idx stockbit login` stores the user's session from a pasted + `credentialStorage` cookie (or a bare refresh token) in + `/stockbit-session.json`, written atomically with mode 0600; + `IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` fill fields the + file lacks. `api/stockbit/auth.rs` reads expiries from the JWTs (`exp`, + unverified) and refreshes an access token within an hour of expiry with + `POST /login/refresh` (bearer = refresh token), which rotates both tokens. + Because a refresh token is spent once, refreshes run under an exclusive + lock on `stockbit-session.lock` and re-read the file first, so concurrent + idx processes never spend the same token. A data request answered 401 (or + a JSON 403) refreshes once and retries once; a second refusal is + `AUTHERROR`. Tokens never reach output, errors, logs or cache keys + (`Session`'s `Debug` redacts them). +- `api/stockbit/exodus.rs` (endpoints and units mapped by fzn0x/idx, MIT; + its recordings are `tests/fixtures/stockbit_exodus_*`): + `/company-price-feed/v2/orderbook/companies/{CODE}` gives the depth ladder + (`volume` in shares, `que_num` orders), OHLC, value, frequency and foreign + buy/sell/net; `/company-price-feed/market-time/session` the market state + (`STATE_NAME_MARKET_CLOSED`, cached 30 s per process); `/charts/{CODE}/daily` + daily candles for `1m`/`3m`/`1y` windows (volume in lots, returned in + shares). Requests share the public page's cross-process pacing. +- With a session, `--provider stockbit` quotes come from the order book (bid + and offer only in `STATE_NAME_MARKET_OPEN`, a name assumed until observed + in session), `stocks orderbook` works, and `auto` history under the stockbit + provider uses Stockbit's daily windows (`5d` to `1y`, trimmed from `1m`, + `3m` or `1y`), Yahoo otherwise. Without one, quotes use the public page and + the order book is `AUTHERROR`. + ### Capability Gating ``` MarketDataProvider = QuoteProvider + FundamentalsProvider @@ -196,15 +225,17 @@ HistoryProvider = separate trait, not all providers implement Factory functions: default_provider(kind) → Box - history_provider(kind, mode, verbose) → Result<(ProviderKind, Box)> + history_provider(source, verbose) → Box (source from history_source) broker_provider() → Box trading_summary_provider() → Box intraday_provider() → Box + orderbook_provider() → Box ``` -`ProviderKind::Stockbit` implements quotes only: fundamentals are -`UNSUPPORTED`, the MSN-only capabilities say "requires --provider msn", and -`auto` history falls back to Yahoo. +`ProviderKind::Stockbit` serves quotes (and, with a session, daily history): +fundamentals are `UNSUPPORTED`, the MSN-only capabilities say "requires +--provider msn", and `auto` history uses Yahoo unless a session covers the +window. ## Ownership Module (SQLite-backed) diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 70f43b1..63df67c 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -60,7 +60,12 @@ scripts/live-smoke.sh --dry-run whose MSN key ratios contain non-finite values. - `stockbit`: `intraday` for the ticker and `^JKSE`, and `--provider stockbit stocks quote`, on the new binary only. A run outside the - session sees `market_status: close` and no bid/offer. + session sees `market_status: close` and no bid/offer. These run without a + Stockbit login: each side has its own config, and seeding it with a real + refresh token would rotate (and so retire) that token. Check the login + features by hand with your own session: `idx stockbit status`, + `idx stocks orderbook BBCA`, `idx --provider stockbit stocks quote BBCA` and + `idx --provider stockbit stocks history BBCA --period 1y`. - `ownership`: `discover`, then imports the newest supported report into a temp DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many datacenter IPs, including GitHub-hosted runners; run it from a network IDX accepts. diff --git a/flake.nix b/flake.nix index bda6f06..78bb534 100644 --- a/flake.nix +++ b/flake.nix @@ -288,7 +288,7 @@ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' sleep 2 } | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl - jq -se 'map(select(.id == 2))[0].result.tools | length == 15' out.jsonl + jq -se 'map(select(.id == 2))[0].result.tools | length == 17' out.jsonl touch "$out" ''; # The website's generated pages match the CLI help and the MCP diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 8cf8a18..d02708f 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -22,6 +22,7 @@ Commands: config Manage configuration cache Manage local cache ownership Ownership intelligence (bootstrap with `ownership sync`) + stockbit Your Stockbit session (opt-in: order book, live quotes) completions Generate shell completions version Show idx-cli version help Print this message or the help of the given subcommand(s) @@ -66,6 +67,7 @@ Usage: idx stocks [OPTIONS] Commands: quote Get real-time stock quotes intraday Today's session price by the minute (Stockbit) + orderbook Order book depth and foreign flow (Stockbit login) history Get historical OHLC data technical Run technical analysis on a stock growth Run growth analysis on a stock @@ -170,6 +172,57 @@ Examples: idx -o json stocks intraday BBCA | jq '.points[-1]' ``` +### `idx stocks orderbook` + +```text +The order book of one IDX stock from your own Stockbit session (`idx stockbit login`): bid and offer levels with lots and queued orders, the session's OHLC, volume, value and foreign buy, sell and net. Outside trading it is the book as the market left it, and the output says so. + +Usage: idx stocks orderbook [OPTIONS] + +Arguments: + + Single ticker symbol (e.g. BBCA) + +Options: + --depth + Price levels per side (1-50) + + [default: 10] + + -o, --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 + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stocks orderbook BBCA + idx stocks orderbook BBRI --depth 5 + idx -o json stocks orderbook TLKM | jq '.offers[0]' +``` + ### `idx stocks history` ```text @@ -190,7 +243,7 @@ Options: --no-color Disable colored output --history-provider - [possible values: auto, yahoo, msn] + [possible values: auto, yahoo, msn, stockbit] -q, --quiet Suppress non-essential output -v, --verbose... @@ -221,7 +274,7 @@ Arguments: Options: --history-provider - [possible values: auto, yahoo, msn] + [possible values: auto, yahoo, msn, stockbit] -o, --output Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl] --no-color @@ -1494,6 +1547,128 @@ Options: -h, --help Print help ``` +## `idx stockbit` + +```text +Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, stored in the config directory with owner-only permissions, and never logs them. Stockbit rotates the refresh token on every refresh, so a browser sharing the session is logged out once idx refreshes: log in through a separate browser profile for idx. Stockbit's terms allow personal, non-commercial use. + +Usage: idx stockbit [OPTIONS] + +Commands: + login Store a session pasted on stdin and check it with Stockbit + status Show whether a session is stored and when its tokens expire + logout Delete the stored session + help Print this message or the help of the given subcommand(s) + +Options: + -o, --output + Output 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 + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') + +Examples: + idx stockbit login # paste the credentialStorage cookie + pbpaste | idx stockbit login + idx stockbit status + idx stockbit logout +``` + +### `idx stockbit login` + +```text +Reads, on stdin, the value of stockbit.com's `credentialStorage` cookie (in a logged-in browser: DevTools, Application, Cookies, https://stockbit.com) or a refresh token, stores it, and checks it with one request. Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead. + +Usage: idx stockbit login [OPTIONS] + +Options: + -o, --output + Output 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 + Market data provider [default: general.provider, else msn] + + [possible values: yahoo, msn, stockbit] + + --offline + Run without network requests + + --no-cache + Bypass local cache + + -h, --help + Print help (see a summary with '-h') +``` + +### `idx stockbit status` + +```text +Show whether a session is stored and when its tokens expire + +Usage: idx stockbit status [OPTIONS] + +Options: + -o, --output Output 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help +``` + +### `idx stockbit logout` + +```text +Delete the stored session + +Usage: idx stockbit logout [OPTIONS] + +Options: + -o, --output Output 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 Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit] + --offline Run without network requests + --no-cache Bypass local cache + -h, --help Print help +``` + ## `idx completions` ```text diff --git a/web/src/content/docs/cli/stockbit.md b/web/src/content/docs/cli/stockbit.md index e8bec2a..a372423 100644 --- a/web/src/content/docs/cli/stockbit.md +++ b/web/src/content/docs/cli/stockbit.md @@ -1,12 +1,13 @@ --- title: Stockbit -description: Today's session by the minute and quotes with best bid/offer, from Stockbit's public page. Opt-in, no account. +description: Today's session by the minute and quotes with best bid/offer from Stockbit's public page, plus the order book and live quotes with your own Stockbit login. Both opt-in. --- [Stockbit](https://stockbit.com)'s public symbol page carries a quote, the best bid and offer, and today's price for every minute of the session. idx reads that page directly. It needs no Stockbit account, no login and no -helper tools. +helper tools. With your own Stockbit account, idx can also read the order +book and live quotes; see [With your Stockbit login](#with-your-stockbit-login). ```bash idx stocks intraday BBCA # today's session, by the minute @@ -31,7 +32,7 @@ idx -o json stocks intraday BBCA | jq '.points | length' shows the book as it closed. idx leaves `bid` and `offer` out then, so the closing book never passes for a live one. - **Top of the book only.** One bid and one offer price, no depth. The full - order book needs a Stockbit login, which idx does not do. + order book needs your [Stockbit login](#with-your-stockbit-login). - **Prices only.** The page has no per-minute volume or OHLC. - **Empty sessions are data.** A suspended or delisted stock answers with `points: []` and a `session_note` saying so (`Stockbit lists the stock as @@ -41,8 +42,9 @@ idx -o json stocks intraday BBCA | jq '.points | length' `--provider stockbit` (or `IDX_PROVIDER=stockbit`, or `idx config set general.provider stockbit`) changes only where quotes come -from. History still comes from Yahoo. Company data, fundamentals and -screening answer `UNSUPPORTED` under it, so use MSN for those. +from. Without a login, history still comes from Yahoo. Company data, +fundamentals and screening answer `UNSUPPORTED` under it, so use MSN for +those. ## How idx talks to Stockbit @@ -63,13 +65,63 @@ screening answer `UNSUPPORTED` under it, so use MSN for those. Wait, or try from another network. `--provider msn` serves quotes without Stockbit. +## With your Stockbit login + +Opt-in, and only with your own account. idx then talks to Stockbit's own API +(`exodus.stockbit.com`) with your session's tokens. + +```bash +idx stockbit login # paste the credentialStorage cookie +idx stockbit status # logged in? when do the tokens expire? +idx stocks orderbook BBCA # depth, queued orders, foreign flow +idx stocks orderbook BBRI --depth 5 +idx --provider stockbit stocks quote BBCA # live, not delayed +idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily candles +idx stockbit logout +``` + +**Logging in.** In a browser logged in to stockbit.com, open DevTools, +Application, Cookies, `https://stockbit.com`, and copy the value of +`credentialStorage`. Paste it into `idx stockbit login`, or pipe it +(`pbpaste | idx stockbit login`). A refresh token on its own works too. idx +checks the session with one request and stores it in +`~/.config/idx/stockbit-session.json`, readable only by you. Tokens never +appear in output, logs or errors. On a server, set +`IDX_STOCKBIT_REFRESH_TOKEN` instead. + +**One refresher per session.** Stockbit replaces the refresh token every time +it is used. Once idx refreshes your session, a browser holding the same +session has to log in again, and two tools sharing one session log each +other out. Log in through a separate browser profile (or a private window +you then close) for idx. idx processes on one machine take turns through a +lock, so they never spend the token twice. + +What the login adds: + +| | Without a login | With your login | +| --- | --- | --- | +| `--provider stockbit` quotes | public page, delayed | Stockbit's API, live | +| Bid and offer in quotes | best price, while open | best price, while open | +| `stocks orderbook` | `AUTHERROR` | every level with lots and queued orders, OHLC, value, trades, foreign buy/sell/net | +| `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows | +| `stocks intraday` | public page | public page | + +The order book is what Stockbit's own app shows. Outside trading it is the +book as the market left it, and the output says so. Sizes are lots in the +table and shares in JSON. `--history-provider stockbit` asks for Stockbit's +candles explicitly; windows it charts only weekly or hourly (`2y`, `5y`, +`1wk`) answer `UNSUPPORTED`. + ## Terms Stockbit's [terms](https://stockbit.com/terms) allow its content for personal, non-commercial use. They prohibit data mining, robots and redistribution without Stockbit's written consent. idx is not affiliated with or endorsed by Stockbit. Use this for looking at a stock now and then, not for crawling the -market or republishing prices. +market or republishing prices. With your login, automated use is tied to your +account: keep it human-paced. -Over MCP, the same data comes from the `get_intraday` tool, and from -`get_quotes` when the server runs with `IDX_PROVIDER=stockbit`. +Over MCP, the same data comes from `get_intraday`, `get_orderbook` and +`stockbit_status`, and from `get_quotes` and `get_price_history` when the +server runs with `IDX_PROVIDER=stockbit`. The server never logs in; run +`idx stockbit login` in a terminal. diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index dbb2936..a64e013 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -33,6 +33,18 @@ sparkline and the last five minutes. JSON gives every minute in `points`. It is delayed at least 10 minutes. See [Stockbit](/cli/stockbit/) for what it covers and the terms it comes with. +## Order book (Stockbit login) + +```bash +idx stocks orderbook BBCA +idx stocks orderbook BBRI --depth 5 +``` + +With your own Stockbit session (`idx stockbit login`), `orderbook` shows +every bid and offer level with lots and queued orders, the session's OHLC, +value and trades, and foreign buy, sell and net. Without one it answers +`AUTHERROR`. See [Stockbit](/cli/stockbit/#with-your-stockbit-login). + ## Company data | Command | What it shows | @@ -156,8 +168,10 @@ idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compa MSN is the default for quotes, company data and screening; Yahoo serves price history. Indo Premier (IPOT) serves broker summaries and foreign flow -(IDX's own site with `flow --source idx`), and Stockbit serves `intraday`, -whichever provider is selected. Switch with `--provider msn|yahoo|stockbit` -(or `IDX_PROVIDER`) and `IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the -matching [config keys](/start/config/). `--provider stockbit` serves quotes -only, with the best bid/offer; see [Stockbit](/cli/stockbit/). +(IDX's own site with `flow --source idx`), and Stockbit serves `intraday` +and, with your login, `orderbook`, whichever provider is selected. Switch +with `--provider msn|yahoo|stockbit` (or `IDX_PROVIDER`) and +`IDX_HISTORY_PROVIDER=auto|yahoo|msn|stockbit`, or the matching +[config keys](/start/config/). `--provider stockbit` serves quotes only, with +the best bid/offer, live when you are logged in; see +[Stockbit](/cli/stockbit/). diff --git a/web/src/content/docs/mcp/index.md b/web/src/content/docs/mcp/index.md index bbd67a4..81e8628 100644 --- a/web/src/content/docs/mcp/index.md +++ b/web/src/content/docs/mcp/index.md @@ -5,10 +5,10 @@ description: idx-mcp serves idx's stock and ownership data to AI agents over MCP `idx-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio. It gives an agent the same data as the `idx` CLI, as -[15 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's -session by the minute, price history, screening, company data, analysis, -comparison, broker summaries, bandarmology, foreign flow, and the KSEI -ownership queries. +[17 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's +session by the minute, the order book (with a Stockbit login), price +history, screening, company data, analysis, comparison, broker summaries, +bandarmology, foreign flow, and the KSEI ownership queries. It shares idx's [config, cache and ownership database](/start/config/#files-idx-keeps), so `idx ownership sync` in a terminal also feeds the server. @@ -46,6 +46,7 @@ body, so the agent can decide whether to retry: | --- | --- | | tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed | | `get_intraday` `tail` | 400 minutes (default: the whole session) | +| `get_orderbook` `depth` | 50 levels per side (default 10) | | `screen_stocks` `limit` | 200 | | `search_investors` `limit` | 100 | | `get_ownership_changes` `limit` | 300 per page (default 200); page with `offset` | diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index f105b5b..f533d5c 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -92,6 +92,15 @@ Every IDX holding (above 1%) of one investor, matched by name. | --- | --- | --- | --- | | `entity` | string | yes | Investor name or part of it, e.g. "DWIMURIA"; the closest match is used. | +## `get_orderbook` + +Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, and ask the user to run `idx stockbit login` in a terminal. + +| Input | Type | Required | Description | +| --- | --- | --- | --- | +| `depth` | integer | no | Price levels per side (default 10, at most 50). | +| `symbol` | string | yes | IDX ticker, e.g. "BBCA". | + ## `get_ownership_changes` Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last. @@ -162,3 +171,9 @@ Search investor names in the KSEI ownership database. | --- | --- | --- | --- | | `limit` | integer | no | Maximum results (default 20, at most 100). | | `query` | string | yes | Full-text query over investor names. | + +## `stockbit_status` + +Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with `idx stockbit login` in a terminal. + +No inputs. diff --git a/web/src/content/docs/start/config.md b/web/src/content/docs/start/config.md index d026b50..93335d7 100644 --- a/web/src/content/docs/start/config.md +++ b/web/src/content/docs/start/config.md @@ -29,7 +29,7 @@ in scripts. Ask for JSON with `-o json`, `IDX_OUTPUT=json`, or ```toml [general] provider = "msn" # msn | yahoo | stockbit -history_provider = "auto" # auto | yahoo | msn +history_provider = "auto" # auto | yahoo | msn | stockbit exchange = "JK" output = "auto" # auto | table | json | jsonl color = true @@ -56,6 +56,8 @@ fundamental_ttl = 3600 | `IDX_CACHE_FUNDAMENTAL_TTL` | `cache.fundamental_ttl` | | `IDX_OWNERSHIP_SNAPSHOT_MANIFEST` | `ownership.snapshot_manifest` | | `IDX_CURL_IMPERSONATE_BIN` | path to a `curl_chrome*` binary | +| `IDX_STOCKBIT_REFRESH_TOKEN` | seeds a [Stockbit session](/cli/stockbit/#with-your-stockbit-login) when none is stored | +| `IDX_STOCKBIT_ACCESS_TOKEN` | seeds the session's access token | ## Files idx keeps @@ -64,6 +66,7 @@ fundamental_ttl = 3600 | Config | `~/.config/idx/config.toml` | | Cache | `~/.cache/idx/` (`idx cache info`, `idx cache clear`) | | Ownership database | `~/.local/share/idx/ownership.db` | +| Stockbit session (after `idx stockbit login`) | `~/.config/idx/stockbit-session.json`, owner-only | The MCP server reads the same config, cache and database. diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 63e55f9..e7f68b1 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -139,7 +139,7 @@ TLKM.JK 2,250 +0.45% 87,159,900

Agents

-

JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 15 tools for any MCP client.

+

JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 17 tools for any MCP client.

npx -y idx-mcp
From 1d4ab658530adf04fcb206f68b5817ae6181b8ef Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 23 Oct 2026 10:09:43 +0000 Subject: [PATCH 04/33] docs: Stockbit order-book sizes are shares, checked against a live session --- ROADMAP.md | 4 ++-- docs/ARCHITECTURE.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 088d705..52140a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -51,8 +51,8 @@ history lives in git. - Stockbit's terms call its data delayed at least 10 minutes and for personal, non-commercial use (on 2026-10-23 the last trade was within a minute of the clock); its CDN refuses some user agents with a 403 - (`UPSTREAMBLOCKED`). Its order-book sizes are not shown: the page does not - say whether they are lots or shares. + (`UPSTREAMBLOCKED`). The public page's best bid/offer sizes are not shown + yet; they are shares (equal to the session API's book on 2026-10-23). - Yahoo can return `429` from datacenter IPs; requests retry with backoff but can still fail. - `stocks technical` reports the SMA200 trend as unavailable when fewer than diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a289e19..f66b7cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -172,8 +172,9 @@ WIB as the fallback. hours; any other value fails closed. In session the chart gained a point every minute and the last trade was within a minute of the clock, although Stockbit's terms call its data delayed at least 10 minutes. Order-book - sizes are dropped: the page does not say whether they are lots or shares. - `as_of` comes from `date`/`time` + sizes are not shown; they are shares (BBCA's best bid on 2026-10-23 was + 1,357,500 on the page and 13,575 lots in the session API's book). `as_of` + comes from `date`/`time` only when the stock traded (a suspended stock carries today's date). `session_note` repeats what the page says (suspended, delisted) or "no trades this session"; it is never inferred. From 12fdc8c8e8af8f967a267ce725fb9174b0b6e62a Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 23 Oct 2026 12:29:01 +0000 Subject: [PATCH 05/33] feat(stockbit): connect a session from a local browser page idx stockbit login in a terminal now serves a page on 127.0.0.1: a bookmarklet that reads stockbit.com's session cookie in the user's own browser and opens the page with it in the URL fragment, or a read-the-clipboard button, with the manual paste as the fallback. A per-run secret guards the path, the page is no-store with a strict CSP, and it stops on success or after five minutes. --no-browser prints the URL (--port pins it for ssh -L); piped stdin keeps the paste path. A paste Stockbit refuses now restores the previous session instead of losing it. --- crates/idx-cli/src/cli/stockbit.rs | 71 +++- .../tests/golden/completions-bash.golden | 6 +- crates/idx-core/src/api/stockbit/loopback.rs | 393 ++++++++++++++++++ crates/idx-core/src/api/stockbit/mod.rs | 16 +- docs/ARCHITECTURE.md | 8 +- web/src/content/docs/cli/reference.md | 10 +- web/src/content/docs/cli/stockbit.md | 24 +- 7 files changed, 508 insertions(+), 20 deletions(-) create mode 100644 crates/idx-core/src/api/stockbit/loopback.rs diff --git a/crates/idx-cli/src/cli/stockbit.rs b/crates/idx-cli/src/cli/stockbit.rs index 7b9147f..a483adc 100644 --- a/crates/idx-cli/src/cli/stockbit.rs +++ b/crates/idx-cli/src/cli/stockbit.rs @@ -27,23 +27,46 @@ pub struct StockbitCmd { #[derive(Debug, Subcommand)] pub enum StockbitSubcommand { #[command( - about = "Store a session pasted on stdin and check it with Stockbit", - long_about = "Reads, on stdin, the value of stockbit.com's `credentialStorage` cookie \ -(in a logged-in browser: DevTools, Application, Cookies, https://stockbit.com) or a refresh \ -token, stores it, and checks it with one request. Servers can set \ + about = "Connect a session in your browser, or from stdin", + long_about = "In a terminal, opens a local page on 127.0.0.1 with two one-click ways to \ +hand idx your Stockbit session: a bookmarklet that reads stockbit.com's cookie in your own \ +browser, or a read-the-clipboard button. Without a browser (or with --no-browser), the page \ +URL is printed instead. With stdin piped, it reads the value of stockbit.com's \ +`credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin, \ +as before. Either way idx checks the session with one request. Servers can set \ IDX_STOCKBIT_REFRESH_TOKEN instead." )] - Login, + Login { + /// Print the page URL instead of opening a browser + #[arg(long)] + no_browser: bool, + /// Fixed port for the local page (for `ssh -L`); default: any free one + #[arg(long)] + port: Option, + }, #[command(about = "Show whether a session is stored and when its tokens expire")] Status, #[command(about = "Delete the stored session")] Logout, } +/// How long the local page waits for the session. +const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> { match &cmd.command { - StockbitSubcommand::Login => { - let status = stockbit::login(&read_paste()?)?; + StockbitSubcommand::Login { no_browser, port } => { + let status = if std::io::stdin().is_terminal() { + match login_in_browser(port.unwrap_or(0), !no_browser) { + Ok(()) => stockbit::session_status()?, + Err(err) => { + runtime::warn(format!("browser login unavailable ({err}); paste instead")); + stockbit::login(&read_paste()?)? + } + } + } else { + stockbit::login(&read_paste()?)? + }; if format.is_json() { return json::print_json(&status); } @@ -81,6 +104,40 @@ pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> Ok(()) } +/// Serves the local login page and waits for a session it can verify. +fn login_in_browser(port: u16, open: bool) -> Result<(), IdxError> { + let page = stockbit::loopback::LoginPage::bind(port)?; + println!("Open this page to connect Stockbit: {}", page.url()); + if open { + open_in_browser(&page.url()); + } + page.wait(LOGIN_TIMEOUT, &|pasted| { + stockbit::login(pasted) + .map(|_| ()) + .map_err(|err| err.to_string()) + }) +} + +/// Opens `url` with the desktop's default browser, best effort. +fn open_in_browser(url: &str) { + #[cfg(target_os = "macos")] + let (program, args) = ("open", vec![url]); + #[cfg(target_os = "windows")] + let (program, args) = ("cmd", vec!["/C", "start", "", url]); + #[cfg(all(unix, not(target_os = "macos")))] + let (program, args) = ("xdg-open", vec![url]); + let opened = std::process::Command::new(program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .is_ok(); + if !opened { + runtime::warn(format!("could not open a browser; open {url} yourself")); + } +} + /// The pasted session: one line from a terminal, else all of stdin. fn read_paste() -> Result { let stdin = std::io::stdin(); diff --git a/crates/idx-cli/tests/golden/completions-bash.golden b/crates/idx-cli/tests/golden/completions-bash.golden index 0c79a03..bd541ff 100644 --- a/crates/idx-cli/tests/golden/completions-bash.golden +++ b/crates/idx-cli/tests/golden/completions-bash.golden @@ -2607,12 +2607,16 @@ _idx() { return 0 ;; idx__stockbit__login) - opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help" + opts="-o -q -v -h --no-browser --port --output --no-color --quiet --verbose --provider --offline --no-cache --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --port) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --output) COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}")) return 0 diff --git a/crates/idx-core/src/api/stockbit/loopback.rs b/crates/idx-core/src/api/stockbit/loopback.rs new file mode 100644 index 0000000..b8315ed --- /dev/null +++ b/crates/idx-core/src/api/stockbit/loopback.rs @@ -0,0 +1,393 @@ +//! The local page `idx stockbit login` opens: a loopback server on +//! 127.0.0.1 that takes the pasted session, checks it, and reports back. +//! +//! Stockbit has no OAuth redirect that could hand idx a session, and the +//! browser will not give one site another site's cookie, so the page takes +//! the value two ways, both one click: +//! - **Read my clipboard**: the user copies `credentialStorage` in DevTools +//! once and clicks the button (`navigator.clipboard`, allowed on localhost). +//! - **Bookmarklet**: a link rendered with this run's port and secret that +//! reads `document.cookie` on stockbit.com and opens the page with the value +//! in the URL *fragment*, which browsers never send to the server. +//! +//! Safeguards: bound to 127.0.0.1, a random port, a per-run secret path (no +//! secret, no page), a short life, `no-store`, no referrer, and a strict CSP. +//! The session value is held in memory only on the way to `stockbit::login`; +//! it is never written to a log or the response. + +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::time::{Duration, Instant}; + +use crate::error::IdxError; + +/// Largest paste accepted (the cookie is a few KB). +const MAX_BODY: usize = 16 * 1024; +/// Requests time out instead of pinning a thread. +const IO_TIMEOUT: Duration = Duration::from_secs(10); + +/// A running login page. +pub struct LoginPage { + listener: TcpListener, + port: u16, + secret: String, +} + +impl LoginPage { + /// Binds 127.0.0.1 on `port` (0 picks a free one). + pub fn bind(port: u16) -> Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).map_err(|e| { + IdxError::Io(format!( + "cannot open the Stockbit login page on 127.0.0.1:{port}: {e}" + )) + })?; + let port = listener + .local_addr() + .map_err(|e| IdxError::Io(e.to_string()))? + .port(); + listener + .set_nonblocking(true) + .map_err(|e| IdxError::Io(e.to_string()))?; + Ok(Self { + listener, + port, + secret: random_secret(), + }) + } + + pub fn url(&self) -> String { + format!("http://127.0.0.1:{}/login/{}", self.port, self.secret) + } + + /// Serves until `verify` accepts a paste or `timeout` passes. `verify` + /// returns the error text to show on the page when the paste is refused. + pub fn wait( + &self, + timeout: Duration, + verify: &dyn Fn(&str) -> Result<(), String>, + ) -> Result<(), IdxError> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match self.listener.accept() { + Ok((stream, _)) => { + if self.serve(stream, verify) { + return Ok(()); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(40)); + } + Err(e) => return Err(IdxError::Io(format!("stockbit login page: {e}"))), + } + } + Err(IdxError::Io(format!( + "the Stockbit login page timed out after {}s", + timeout.as_secs() + ))) + } + + /// Answers one request; true when a paste was accepted. + fn serve(&self, mut stream: TcpStream, verify: &dyn Fn(&str) -> Result<(), String>) -> bool { + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + let Some(request) = read_request(&mut stream) else { + respond(&mut stream, 400, "text/plain", b"bad request"); + return false; + }; + let page = format!("/login/{}", self.secret); + match (request.method.as_str(), request.path.as_str()) { + ("GET", p) if p == page => { + respond( + &mut stream, + 200, + "text/html; charset=utf-8", + self.page().as_bytes(), + ); + } + ("POST", p) if p == page => match verify(&request.body) { + Ok(()) => { + respond( + &mut stream, + 200, + "application/json", + br#"{"ok":true,"message":"Connected. You can close this tab."}"#, + ); + return true; + } + Err(message) => { + let body = serde_json::json!({ "ok": false, "message": message }); + respond( + &mut stream, + 200, + "application/json", + body.to_string().as_bytes(), + ); + } + }, + ("GET", "/favicon.ico") => respond(&mut stream, 204, "text/plain", b""), + _ => respond(&mut stream, 404, "text/plain", b"not found"), + } + false + } + + /// The page, with this run's port and secret baked into the bookmarklet. + fn page(&self) -> String { + let target = self.url(); + // Reads stockbit.com's session cookie and opens the page with it in + // the fragment (never sent to a server, never in a referrer). + let bookmarklet = format!( + "javascript:(()=>{{const m=document.cookie.match(/(?:^|;\\s*)credentialStorage=([^;]*)/);\ +if(!m){{alert('No Stockbit session in this browser: log in to stockbit.com first.');return}}\ +window.open('{target}#t='+encodeURIComponent(m[1]))}})()" + ); + PAGE.replace("__TARGET__", &target) + .replace("__BOOKMARKLET__", &html_escape(&bookmarklet)) + } +} + +fn random_secret() -> String { + (0..4) + .map(|_| format!("{:08x}", fastrand::u32(..))) + .collect::>() + .join("") +} + +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +struct Request { + method: String, + path: String, + body: String, +} + +/// A minimal HTTP/1.1 request: request line, headers, and a bounded body. +fn read_request(stream: &mut TcpStream) -> Option { + let mut raw = Vec::new(); + let mut chunk = [0u8; 2048]; + let header_end = loop { + let n = stream.read(&mut chunk).ok()?; + if n == 0 { + return None; + } + raw.extend_from_slice(&chunk[..n]); + if let Some(at) = find(&raw, b"\r\n\r\n") { + break at + 4; + } + if raw.len() > MAX_BODY { + return None; + } + }; + let head = String::from_utf8_lossy(&raw[..header_end]).into_owned(); + let mut lines = head.lines(); + let mut request_line = lines.next()?.split_whitespace(); + let method = request_line.next()?.to_string(); + let path = request_line.next()?.split('?').next()?.to_string(); + let length = lines + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0) + .min(MAX_BODY); + let mut body = raw[header_end..].to_vec(); + while body.len() < length { + let n = stream.read(&mut chunk).ok()?; + if n == 0 { + break; + } + body.extend_from_slice(&chunk[..n]); + } + body.truncate(length); + Some(Request { + method, + path, + body: String::from_utf8_lossy(&body).into_owned(), + }) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) { + let reason = match status { + 200 => "OK", + 204 => "No Content", + 400 => "Bad Request", + _ => "Not Found", + }; + let head = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\ + Content-Length: {}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\n\ + X-Content-Type-Options: nosniff\r\n\ + Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; \ + script-src 'unsafe-inline'; connect-src 'self'\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body); + let _ = stream.flush(); +} + +const PAGE: &str = r#" + + +Connect Stockbit to idx + +
+

Connect Stockbit to idx

+

One step. idx stores the session on this machine only, and never sends it anywhere but Stockbit.

+ +
+

1. Open stockbit.com and make sure you are logged in.

+
+ +
+

2. While on stockbit.com, click this bookmarklet. It reads your Stockbit session cookie in your own browser and hands it to this page.

+

Send my Stockbit session

+

If your browser blocks it, drag the button to your bookmarks bar and click it there.

+
+ +
+

Or copy it by hand. On stockbit.com: DevTools, Application, Cookies, https://stockbit.com, copy the credentialStorage value, then paste it here.

+ +
+ + +
+
+ +

+

idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser sharing this session will be signed out once idx refreshes it. This page closes when you finish, or after a few minutes.

+
+ + +"#; + +#[cfg(test)] +mod tests { + use std::io::{Read as _, Write as _}; + use std::net::TcpStream; + + use super::*; + + fn request(url: &str, method: &str, body: &str) -> (u16, String) { + let rest = url.trim_start_matches("http://"); + let (authority, path) = rest.split_once('/').expect("path"); + let mut stream = TcpStream::connect(authority).expect("connect"); + // A server that never answers must fail the test, not hang it. + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + write!( + stream, + "{method} /{path} HTTP/1.1\r\nHost: {authority}\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .unwrap(); + let mut raw = String::new(); + stream.read_to_string(&mut raw).unwrap(); + let status = raw + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .expect("status"); + let body = raw.split("\r\n\r\n").nth(1).unwrap_or_default().to_string(); + (status, body) + } + + #[test] + fn serves_the_page_only_with_the_secret_and_accepts_a_good_paste() { + let page = LoginPage::bind(0).unwrap(); + let url = page.url(); + let bad = url.replace(&page.secret, "not-the-secret"); + + let handle = std::thread::spawn(move || { + let verify = |paste: &str| match paste.trim() { + "good" => Ok(()), + _ => Err("not a Stockbit session".to_string()), + }; + page.wait(Duration::from_secs(10), &verify) + }); + std::thread::sleep(Duration::from_millis(100)); + + assert_eq!(request(&bad, "GET", "").0, 404); + let (status, html) = request(&url, "GET", ""); + assert_eq!(status, 200); + assert!(html.contains("Connect Stockbit to idx")); + assert!(html.contains("javascript:"), "bookmarklet rendered"); + // The bookmarklet carries this run's page and a fragment, not the + // secret in a query string. + assert!(html.contains(&format!("{url}#t=")), "bookmarklet target"); + + let (status, body) = request(&url, "POST", "nonsense"); + assert_eq!(status, 200); + assert!(body.contains("\"ok\":false"), "{body}"); + + let (status, body) = request(&url, "POST", "good"); + assert_eq!(status, 200); + assert!(body.contains("\"ok\":true"), "{body}"); + handle.join().unwrap().expect("accepted"); + } + + #[test] + fn times_out_without_a_paste() { + let page = LoginPage::bind(0).unwrap(); + let started = Instant::now(); + let err = page + .wait(Duration::from_millis(150), &|_| Err("no".to_string())) + .unwrap_err(); + assert!(err.to_string().contains("timed out"), "{err}"); + assert!(started.elapsed() < Duration::from_secs(5)); + } +} diff --git a/crates/idx-core/src/api/stockbit/mod.rs b/crates/idx-core/src/api/stockbit/mod.rs index d0b0175..e27147d 100644 --- a/crates/idx-core/src/api/stockbit/mod.rs +++ b/crates/idx-core/src/api/stockbit/mod.rs @@ -17,6 +17,7 @@ pub mod auth; pub mod exodus; +pub mod loopback; mod parse; use std::fs::{File, OpenOptions}; @@ -234,18 +235,31 @@ pub fn login(pasted: &str) -> Result { ) })?; let store = SessionStore::open()?; + // A refused paste must not lose a session that already worked. + let previous = std::fs::read(store.path()).ok(); store.save(&session)?; #[cfg(feature = "testing")] let check = !crate::api::mock::enabled(); #[cfg(not(feature = "testing"))] let check = true; if check && let Err(err) = Exodus::new(store.clone()).verify() { - let _ = store.clear(); + restore(&store, previous); return Err(err); } Ok(store.status(chrono::Utc::now().timestamp())) } +fn restore(store: &SessionStore, previous: Option>) { + match previous { + Some(bytes) => { + let _ = std::fs::write(store.path(), bytes); + } + None => { + let _ = store.clear(); + } + } +} + /// The stored session's state, without a request and without any token. pub fn session_status() -> Result { Ok(SessionStore::open()?.status(chrono::Utc::now().timestamp())) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f66b7cc..ac45218 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -195,7 +195,13 @@ WIB as the fallback. `credentialStorage` cookie (or a bare refresh token) in `/stockbit-session.json`, written atomically with mode 0600; `IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` fill fields the - file lacks. `api/stockbit/auth.rs` reads expiries from the JWTs (`exp`, + file lacks. In a terminal it first serves a page on 127.0.0.1 + (`api/stockbit/loopback.rs`): a bookmarklet that reads the cookie in the + user's own browser and opens the page with it in the URL fragment, or a + clipboard button; a per-run secret guards the path, the page is `no-store` + with a strict CSP, and the server stops on success or after five minutes. + Piped stdin keeps the paste path. `api/stockbit/auth.rs` reads expiries + from the JWTs (`exp`, unverified) and refreshes an access token within an hour of expiry with `POST /login/refresh` (bearer = refresh token), which rotates both tokens. Because a refresh token is spent once, refreshes run under an exclusive diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index d02708f..1796b35 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -1555,7 +1555,7 @@ Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` qu Usage: idx stockbit [OPTIONS] Commands: - login Store a session pasted on stdin and check it with Stockbit + login Connect a session in your browser, or from stdin status Show whether a session is stored and when its tokens expire logout Delete the stored session help Print this message or the help of the given subcommand(s) @@ -1599,11 +1599,14 @@ Examples: ### `idx stockbit login` ```text -Reads, on stdin, the value of stockbit.com's `credentialStorage` cookie (in a logged-in browser: DevTools, Application, Cookies, https://stockbit.com) or a refresh token, stores it, and checks it with one request. Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead. +In a terminal, opens a local page on 127.0.0.1 with two one-click ways to hand idx your Stockbit session: a bookmarklet that reads stockbit.com's cookie in your own browser, or a read-the-clipboard button. Without a browser (or with --no-browser), the page URL is printed instead. With stdin piped, it reads the value of stockbit.com's `credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin, as before. Either way idx checks the session with one request. Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead. Usage: idx stockbit login [OPTIONS] Options: + --no-browser + Print the page URL instead of opening a browser + -o, --output Output format [default: auto = table on a terminal, json when piped] @@ -1612,6 +1615,9 @@ Options: --no-color Disable colored output + --port + Fixed port for the local page (for `ssh -L`); default: any free one + -q, --quiet Suppress non-essential output diff --git a/web/src/content/docs/cli/stockbit.md b/web/src/content/docs/cli/stockbit.md index a372423..37d74b7 100644 --- a/web/src/content/docs/cli/stockbit.md +++ b/web/src/content/docs/cli/stockbit.md @@ -80,14 +80,22 @@ idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily idx stockbit logout ``` -**Logging in.** In a browser logged in to stockbit.com, open DevTools, -Application, Cookies, `https://stockbit.com`, and copy the value of -`credentialStorage`. Paste it into `idx stockbit login`, or pipe it -(`pbpaste | idx stockbit login`). A refresh token on its own works too. idx -checks the session with one request and stores it in -`~/.config/idx/stockbit-session.json`, readable only by you. Tokens never -appear in output, logs or errors. On a server, set -`IDX_STOCKBIT_REFRESH_TOKEN` instead. +**Logging in.** `idx stockbit login` opens a small page on your own machine +(127.0.0.1, a fresh port each run). With stockbit.com open and logged in, +click the page's **Send my Stockbit session** bookmarklet: it reads +Stockbit's `credentialStorage` cookie in your browser and hands it to the +page, which checks it with one request and says "Connected". If bookmarklets +are blocked, click **Paste from clipboard** after copying the cookie, or +paste it into the box. Without a browser, `--no-browser` prints the page URL +instead, and `--port 7878` pins the port for `ssh -L 7878:127.0.0.1:7878`. +Piping the value in still works (`pbpaste | idx stockbit login`), as does a +refresh token on its own. On a server, set `IDX_STOCKBIT_REFRESH_TOKEN`. + +The session lands in `~/.config/idx/stockbit-session.json`, readable only by +you. Tokens never appear in output, logs or errors. The page takes only the +session value, keeps it in memory, and closes when it is done (or after five +minutes). A session Stockbit refuses leaves a previously stored one in +place. **One refresher per session.** Stockbit replaces the refresh token every time it is used. Once idx refreshes your session, a browser holding the same From 00c59ad9c9e6bef356f82ba5a0392cd7d25e887e Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 23 Oct 2026 13:28:12 +0000 Subject: [PATCH 06/33] fix(stockbit): answer login-page connections concurrently Browsers open idle preconnects; serving one connection at a time made the page wait out the 3 s read timeout on each, which read as "can't connect". Each connection now gets its own thread (capped) and a silent one is dropped. Regression test; measured 10.1 s -> 0.08 s with three idle sockets. Docs cover a browser on another machine (--port + ssh -L). --- crates/idx-cli/src/cli/stockbit.rs | 2 +- crates/idx-core/src/api/stockbit/loopback.rs | 184 +++++++++++++------ web/src/content/docs/cli/stockbit.md | 14 +- 3 files changed, 138 insertions(+), 62 deletions(-) diff --git a/crates/idx-cli/src/cli/stockbit.rs b/crates/idx-cli/src/cli/stockbit.rs index a483adc..3772afb 100644 --- a/crates/idx-cli/src/cli/stockbit.rs +++ b/crates/idx-cli/src/cli/stockbit.rs @@ -111,7 +111,7 @@ fn login_in_browser(port: u16, open: bool) -> Result<(), IdxError> { if open { open_in_browser(&page.url()); } - page.wait(LOGIN_TIMEOUT, &|pasted| { + page.wait(LOGIN_TIMEOUT, |pasted| { stockbit::login(pasted) .map(|_| ()) .map_err(|err| err.to_string()) diff --git a/crates/idx-core/src/api/stockbit/loopback.rs b/crates/idx-core/src/api/stockbit/loopback.rs index b8315ed..e6181be 100644 --- a/crates/idx-core/src/api/stockbit/loopback.rs +++ b/crates/idx-core/src/api/stockbit/loopback.rs @@ -23,8 +23,11 @@ use crate::error::IdxError; /// Largest paste accepted (the cookie is a few KB). const MAX_BODY: usize = 16 * 1024; -/// Requests time out instead of pinning a thread. -const IO_TIMEOUT: Duration = Duration::from_secs(10); +/// Requests time out instead of pinning a thread; short, because a browser +/// preconnect sends nothing at all. +const IO_TIMEOUT: Duration = Duration::from_secs(3); +/// Connections answered at once; beyond this, new ones are dropped. +const MAX_CONNECTIONS: usize = 16; /// A running login page. pub struct LoginPage { @@ -61,75 +64,58 @@ impl LoginPage { /// Serves until `verify` accepts a paste or `timeout` passes. `verify` /// returns the error text to show on the page when the paste is refused. - pub fn wait( - &self, - timeout: Duration, - verify: &dyn Fn(&str) -> Result<(), String>, - ) -> Result<(), IdxError> { + /// + /// Each connection gets its own thread: browsers open idle preconnects, + /// and answering them one at a time used to hold the page back for the + /// read timeout on every one. + pub fn wait(&self, timeout: Duration, verify: F) -> Result<(), IdxError> + where + F: Fn(&str) -> Result<(), String> + Send + Sync + 'static, + { + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); + let verify = std::sync::Arc::new(verify); + let page = std::sync::Arc::new(self.page()); + let path = std::sync::Arc::new(format!("/login/{}", self.secret)); + let busy = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let deadline = Instant::now() + timeout; - while Instant::now() < deadline { + loop { + if done_rx.try_recv().is_ok() { + return Ok(()); + } + if Instant::now() >= deadline { + break; + } match self.listener.accept() { Ok((stream, _)) => { - if self.serve(stream, verify) { - return Ok(()); + // Browsers open a handful; anything beyond this is junk. + if busy.load(std::sync::atomic::Ordering::Relaxed) >= MAX_CONNECTIONS { + continue; } + busy.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let (verify, page, path) = (verify.clone(), page.clone(), path.clone()); + let busy = busy.clone(); + let done = done_tx.clone(); + std::thread::spawn(move || { + if serve_once(stream, &path, &page, verify.as_ref()) { + let _ = done.send(()); + } + busy.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + }); } Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(40)); + std::thread::sleep(Duration::from_millis(20)); } Err(e) => return Err(IdxError::Io(format!("stockbit login page: {e}"))), } } Err(IdxError::Io(format!( - "the Stockbit login page timed out after {}s", - timeout.as_secs() + "the Stockbit login page timed out after {}s; its URL was {} (re-run it, or pin the \ + port with --port)", + timeout.as_secs(), + self.url() ))) } - /// Answers one request; true when a paste was accepted. - fn serve(&self, mut stream: TcpStream, verify: &dyn Fn(&str) -> Result<(), String>) -> bool { - let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); - let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); - let Some(request) = read_request(&mut stream) else { - respond(&mut stream, 400, "text/plain", b"bad request"); - return false; - }; - let page = format!("/login/{}", self.secret); - match (request.method.as_str(), request.path.as_str()) { - ("GET", p) if p == page => { - respond( - &mut stream, - 200, - "text/html; charset=utf-8", - self.page().as_bytes(), - ); - } - ("POST", p) if p == page => match verify(&request.body) { - Ok(()) => { - respond( - &mut stream, - 200, - "application/json", - br#"{"ok":true,"message":"Connected. You can close this tab."}"#, - ); - return true; - } - Err(message) => { - let body = serde_json::json!({ "ok": false, "message": message }); - respond( - &mut stream, - 200, - "application/json", - body.to_string().as_bytes(), - ); - } - }, - ("GET", "/favicon.ico") => respond(&mut stream, 204, "text/plain", b""), - _ => respond(&mut stream, 404, "text/plain", b"not found"), - } - false - } - /// The page, with this run's port and secret baked into the bookmarklet. fn page(&self) -> String { let target = self.url(); @@ -145,6 +131,54 @@ window.open('{target}#t='+encodeURIComponent(m[1]))}})()" } } +/// Answers one request; true when a paste was accepted. +fn serve_once( + mut stream: TcpStream, + page_path: &str, + page: &str, + verify: &dyn Fn(&str) -> Result<(), String>, +) -> bool { + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + let Some(request) = read_request(&mut stream) else { + // An idle preconnect or a malformed request: drop it quietly. + return false; + }; + match (request.method.as_str(), request.path.as_str()) { + ("GET", p) if p == page_path => { + respond( + &mut stream, + 200, + "text/html; charset=utf-8", + page.as_bytes(), + ); + } + ("POST", p) if p == page_path => match verify(&request.body) { + Ok(()) => { + respond( + &mut stream, + 200, + "application/json", + br#"{"ok":true,"message":"Connected. You can close this tab."}"#, + ); + return true; + } + Err(message) => { + let body = serde_json::json!({ "ok": false, "message": message }); + respond( + &mut stream, + 200, + "application/json", + body.to_string().as_bytes(), + ); + } + }, + ("GET", "/favicon.ico") => respond(&mut stream, 204, "text/plain", b""), + _ => respond(&mut stream, 404, "text/plain", b"not found"), + } + false +} + fn random_secret() -> String { (0..4) .map(|_| format!("{:08x}", fastrand::u32(..))) @@ -357,7 +391,7 @@ mod tests { "good" => Ok(()), _ => Err("not a Stockbit session".to_string()), }; - page.wait(Duration::from_secs(10), &verify) + page.wait(Duration::from_secs(10), verify) }); std::thread::sleep(Duration::from_millis(100)); @@ -385,9 +419,41 @@ mod tests { let page = LoginPage::bind(0).unwrap(); let started = Instant::now(); let err = page - .wait(Duration::from_millis(150), &|_| Err("no".to_string())) + .wait(Duration::from_millis(150), |_| Err("no".to_string())) .unwrap_err(); assert!(err.to_string().contains("timed out"), "{err}"); assert!(started.elapsed() < Duration::from_secs(5)); } + + /// Browsers open idle preconnects; one must not delay the page. + #[test] + fn an_idle_connection_does_not_delay_the_page() { + let page = LoginPage::bind(0).unwrap(); + let url = page.url(); + let handle = std::thread::spawn(move || { + page.wait(Duration::from_secs(10), |_| { + Err("not a Stockbit session".to_string()) + }) + }); + std::thread::sleep(Duration::from_millis(100)); + + // Two sockets that say nothing, as Chrome's preconnects do. + let rest = url.trim_start_matches("http://"); + let (authority, _) = rest.split_once('/').unwrap(); + let idle: Vec = (0..2) + .map(|_| TcpStream::connect(authority).unwrap()) + .collect(); + std::thread::sleep(Duration::from_millis(100)); + + let started = Instant::now(); + let (status, _) = request(&url, "GET", ""); + assert_eq!(status, 200); + assert!( + started.elapsed() < Duration::from_secs(2), + "page waited {:?} behind idle connections", + started.elapsed() + ); + drop(idle); + drop(handle); + } } diff --git a/web/src/content/docs/cli/stockbit.md b/web/src/content/docs/cli/stockbit.md index 37d74b7..e8bc6c6 100644 --- a/web/src/content/docs/cli/stockbit.md +++ b/web/src/content/docs/cli/stockbit.md @@ -94,8 +94,18 @@ refresh token on its own. On a server, set `IDX_STOCKBIT_REFRESH_TOKEN`. The session lands in `~/.config/idx/stockbit-session.json`, readable only by you. Tokens never appear in output, logs or errors. The page takes only the session value, keeps it in memory, and closes when it is done (or after five -minutes). A session Stockbit refuses leaves a previously stored one in -place. +minutes). A session Stockbit refuses leaves a previously stored one in place. + +The page listens on this machine's loopback interface only, so the browser +must run on the same machine: from another host over SSH, or from a browser +outside a container or WSL distro, `127.0.0.1` is a different machine and the +page will not load. Forward it instead, with a pinned port: + +```bash +idx stockbit login --no-browser --port 7878 +# from the machine with the browser: +ssh -L 7878:127.0.0.1:7878 # then open the printed URL +``` **One refresher per session.** Stockbit replaces the refresh token every time it is used. Once idx refreshes your session, a browser holding the same From cb532b670b6eb74b48f8eef1de836cc01bbf326a Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 23 Oct 2026 13:52:15 +0000 Subject: [PATCH 07/33] feat(stockbit): print the ssh forward when the shell is remote The login page listens on 127.0.0.1, so a browser on the Mac driving a VM could never reach it. Over SSH, idx now picks port 7878, skips opening a browser on the VM, and prints the exact ssh -L to run where the browser is. --- crates/idx-cli/src/cli/stockbit.rs | 46 +++++++++++++++++--- crates/idx-core/src/api/stockbit/loopback.rs | 5 +++ web/src/content/docs/cli/stockbit.md | 11 +++-- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/idx-cli/src/cli/stockbit.rs b/crates/idx-cli/src/cli/stockbit.rs index 3772afb..0fa2cde 100644 --- a/crates/idx-cli/src/cli/stockbit.rs +++ b/crates/idx-cli/src/cli/stockbit.rs @@ -57,7 +57,7 @@ pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> match &cmd.command { StockbitSubcommand::Login { no_browser, port } => { let status = if std::io::stdin().is_terminal() { - match login_in_browser(port.unwrap_or(0), !no_browser) { + match login_in_browser(*port, !no_browser) { Ok(()) => stockbit::session_status()?, Err(err) => { runtime::warn(format!("browser login unavailable ({err}); paste instead")); @@ -105,11 +105,24 @@ pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> } /// Serves the local login page and waits for a session it can verify. -fn login_in_browser(port: u16, open: bool) -> Result<(), IdxError> { - let page = stockbit::loopback::LoginPage::bind(port)?; +/// +/// The page is only reachable from this machine. Over SSH the browser is +/// usually somewhere else, so idx picks a stable port, skips opening a +/// browser here, and prints the forward to run on the machine with the +/// browser. +fn login_in_browser(port: Option, open: bool) -> Result<(), IdxError> { + let ssh = std::env::var("SSH_CONNECTION").ok(); + // A stable port makes the printed `ssh -L` match what the page serves. + let page = stockbit::loopback::LoginPage::bind(port.unwrap_or(if ssh.is_some() { + DEFAULT_SSH_PORT + } else { + 0 + }))?; println!("Open this page to connect Stockbit: {}", page.url()); - if open { - open_in_browser(&page.url()); + match &ssh { + Some(connection) => print_ssh_hint(connection, page.port()), + None if open => open_in_browser(&page.url()), + None => {} } page.wait(LOGIN_TIMEOUT, |pasted| { stockbit::login(pasted) @@ -118,6 +131,29 @@ fn login_in_browser(port: u16, open: bool) -> Result<(), IdxError> { }) } +/// Port the page uses over SSH, so `ssh -L 7878:…:7878` matches it. +const DEFAULT_SSH_PORT: u16 = 7878; + +/// The forward to run where the browser is, from `SSH_CONNECTION` +/// (`client_ip client_port server_ip server_port`). +fn print_ssh_hint(connection: &str, port: u16) { + let fields: Vec<&str> = connection.split_whitespace().collect(); + let target = match (std::env::var("USER").ok(), fields.get(2)) { + (Some(user), Some(server)) => format!("{user}@{server}"), + (None, Some(server)) => (*server).to_string(), + _ => "".to_string(), + }; + runtime::warn( + "this shell is over SSH: the page listens on the remote machine, so open it from the \ + machine with your browser", + ); + println!( + "\nFrom the machine you are sitting at, run (and keep it open):\n \ + ssh -L {port}:127.0.0.1:{port} {target}\n\ + then open the URL above there.\n" + ); +} + /// Opens `url` with the desktop's default browser, best effort. fn open_in_browser(url: &str) { #[cfg(target_os = "macos")] diff --git a/crates/idx-core/src/api/stockbit/loopback.rs b/crates/idx-core/src/api/stockbit/loopback.rs index e6181be..bb7bbac 100644 --- a/crates/idx-core/src/api/stockbit/loopback.rs +++ b/crates/idx-core/src/api/stockbit/loopback.rs @@ -62,6 +62,11 @@ impl LoginPage { format!("http://127.0.0.1:{}/login/{}", self.port, self.secret) } + /// The port the page listens on. + pub fn port(&self) -> u16 { + self.port + } + /// Serves until `verify` accepts a paste or `timeout` passes. `verify` /// returns the error text to show on the page when the paste is refused. /// diff --git a/web/src/content/docs/cli/stockbit.md b/web/src/content/docs/cli/stockbit.md index e8bc6c6..1c59f3f 100644 --- a/web/src/content/docs/cli/stockbit.md +++ b/web/src/content/docs/cli/stockbit.md @@ -99,12 +99,17 @@ minutes). A session Stockbit refuses leaves a previously stored one in place. The page listens on this machine's loopback interface only, so the browser must run on the same machine: from another host over SSH, or from a browser outside a container or WSL distro, `127.0.0.1` is a different machine and the -page will not load. Forward it instead, with a pinned port: +page will not load. idx notices an SSH session (`SSH_CONNECTION`) and prints +the exact command to run on the machine with the browser, on port 7878: + +```bash +ssh -L 7878:127.0.0.1:7878 # run where the browser is, keep it open +``` + +Elsewhere, pin the port yourself and forward it the same way: ```bash idx stockbit login --no-browser --port 7878 -# from the machine with the browser: -ssh -L 7878:127.0.0.1:7878 # then open the printed URL ``` **One refresher per session.** Stockbit replaces the refresh token every time From ca4d3d09c2b4fb73547348fb3f5fdd168af859c4 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Sat, 31 Oct 2026 23:58:21 +0700 Subject: [PATCH 08/33] feat(stockbit): one browser sign-in in idx-core for the CLI and MCP `stockbit::signin` is now the single sign-in flow. `idx stockbit login` waits on it in the foreground; the `stockbit_login` MCP tool runs it on a thread and `stockbit_status` reports `sign_in.state`, so an agent knows when to stop polling. Auto uses a browser unless the shell is over SSH or has no display, and falls back to the local page only when the browser cannot start. The browser way opens Stockbit's sign-in page in a profile only idx uses and reads the session cookie over DevTools. What a real sign-in showed: - The cookie is host-only on stockbit.com, so deleting by the assumed .stockbit.com removed nothing and left live tokens in the profile. It is now deleted by its own domain and path, with every other tab closed first, and read back to confirm. - A signed-out page writes the cookie without tokens; it no longer counts. - A profile belongs to one browser, so it records the executable that made it. - On macOS the config directory depends on XDG_CONFIG_HOME, so a shell and an app started from the Dock disagreed; both default locations are now checked. - A thread does not outlive main, so idx-mcp closes a waiting browser when stdin closes or on SIGTERM or Ctrl-C. Progress goes to stderr, which keeps `-o json` to one document on stdout. Google refused its own sign-in in this window on 2026-10-31, most likely over the debugging flag; password sign-in works, and the docs and roadmap say so. Claude-Session: https://claude.ai/code/session_01RqPPVaoFxLToGAXXBEoCtH --- .gitignore | 3 + Cargo.lock | 25 + ROADMAP.md | 16 +- crates/idx-cli/src/cli/stockbit.rs | 191 +-- crates/idx-cli/tests/cli.rs | 18 + crates/idx-core/src/api/stockbit/auth.rs | 103 +- crates/idx-core/src/api/stockbit/browser.rs | 1381 ++++++++++++++++++ crates/idx-core/src/api/stockbit/loopback.rs | 333 ++++- crates/idx-core/src/api/stockbit/mod.rs | 26 + crates/idx-core/src/api/stockbit/signin.rs | 425 ++++++ crates/idx-mcp/Cargo.toml | 2 +- crates/idx-mcp/src/main.rs | 42 +- crates/idx-mcp/src/params.rs | 33 + crates/idx-mcp/src/server.rs | 76 +- crates/idx-mcp/tests/golden/tools.json | 23 +- crates/idx-mcp/tests/mcp.rs | 101 +- docs/ARCHITECTURE.md | 67 +- flake.nix | 6 +- web/src/content/docs/cli/reference.md | 15 +- web/src/content/docs/cli/stockbit.md | 117 +- web/src/content/docs/mcp/tools.md | 12 +- 21 files changed, 2801 insertions(+), 214 deletions(-) create mode 100644 crates/idx-core/src/api/stockbit/browser.rs create mode 100644 crates/idx-core/src/api/stockbit/signin.rs diff --git a/.gitignore b/.gitignore index 2cc5a6b..b877eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ out.txt # Cloudflare CLI account cache .cloudflare/ + +# macOS Finder metadata +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 41c6c3b..653be63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1135,6 +1135,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1765,6 +1776,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -1967,8 +1988,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 52140a7..dc20fd7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,10 +14,18 @@ history lives in git. - Multi-period financial statements (today `financials` is single-period) - Order book depth without a login: no free source exists (IDX sells delayed depth to vendors); `stocks orderbook` needs the user's Stockbit login. -- Stockbit login follow-ups: a browser capture for `idx stockbit login` - (Chrome DevTools, as fzn0x/idx does) instead of pasting the cookie, - intraday OHLCV from the session's `1d` chart, and observing the session - API's open-market state name (assumed `STATE_NAME_MARKET_OPEN`). +- Stockbit login follow-ups: intraday OHLCV from the session's `1d` chart; + observing the session API's open-market state name (assumed + `STATE_NAME_MARKET_OPEN`; `STATE_NAME_MARKET_CLOSED` seen 2026-10-29); an + opt-in daily refresh so a session outlives the refresh token's week, once + it is known whether Stockbit caps a session's total life; and the sign-in + response (`/login/v6/*`, read over DevTools' `Fetch`) as a second signal + beside the `credentialStorage` cookie, should Stockbit rename or move it. +- Google sign-in in idx's browser window: Google refused it on 2026-10-31, + most likely over `--remote-debugging-port`. First confirm by trying Google + in a plain window on a throwaway profile; if that works, sign in with no + debugging flag and reopen the profile with it, on a blank page, to read the + cookie. - Observe Stockbit's `market_hour.status` in the pre-open (08:45-09:00) and the closing auction (15:50-16:00) before `--watch` or alerts build on it; `open`, `break` and `close` are known, and the bid/offer gate hides the book diff --git a/crates/idx-cli/src/cli/stockbit.rs b/crates/idx-cli/src/cli/stockbit.rs index 0fa2cde..991d1b6 100644 --- a/crates/idx-cli/src/cli/stockbit.rs +++ b/crates/idx-cli/src/cli/stockbit.rs @@ -4,7 +4,7 @@ use clap::{Args, Subcommand}; use crate::output::text::terminal_safe; use crate::output::{OutputFormat, json}; -use idx_core::api::stockbit::{self, auth::SessionStatus, auth::TokenInfo}; +use idx_core::api::stockbit::{self, auth::SessionStatus, auth::TokenInfo, signin}; use idx_core::error::IdxError; use idx_core::runtime; @@ -14,10 +14,9 @@ use idx_core::runtime; long_about = "Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` \ quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, \ stored in the config directory with owner-only permissions, and never logs them. Stockbit \ -rotates the refresh token on every refresh, so a browser sharing the session is logged out \ -once idx refreshes: log in through a separate browser profile for idx. Stockbit's terms \ -allow personal, non-commercial use.", - after_help = "Examples:\n idx stockbit login # paste the credentialStorage cookie\n pbpaste | idx stockbit login\n idx stockbit status\n idx stockbit logout" +rotates the refresh token on every refresh, so one session has one owner: `login` signs in \ +through a browser profile only idx uses. Stockbit's terms allow personal, non-commercial use.", + after_help = "Examples:\n idx stockbit login # sign in through a browser\n idx stockbit login --no-browser # local page: bookmarklet or paste\n pbpaste | idx stockbit login # the credentialStorage cookie on stdin\n idx stockbit status\n idx stockbit logout" )] pub struct StockbitCmd { #[command(subcommand)] @@ -27,20 +26,23 @@ pub struct StockbitCmd { #[derive(Debug, Subcommand)] pub enum StockbitSubcommand { #[command( - about = "Connect a session in your browser, or from stdin", - long_about = "In a terminal, opens a local page on 127.0.0.1 with two one-click ways to \ -hand idx your Stockbit session: a bookmarklet that reads stockbit.com's cookie in your own \ -browser, or a read-the-clipboard button. Without a browser (or with --no-browser), the page \ -URL is printed instead. With stdin piped, it reads the value of stockbit.com's \ -`credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin, \ -as before. Either way idx checks the session with one request. Servers can set \ -IDX_STOCKBIT_REFRESH_TOKEN instead." + about = "Sign in through your browser, or from stdin", + long_about = "In a terminal, idx opens Chrome (or Chromium, Edge, Brave, Helium) on \ +Stockbit's sign-in page with a profile only idx uses. Sign in there: idx reads the session \ +cookie over the DevTools protocol, checks it with one request, takes it out of that profile, \ +and closes the window. Your password, one-time codes and any reCAPTCHA stay in the browser. \ +A stored session that still works means nothing to do: idx exits at once. Over SSH, without \ +a display, without such a browser, or with --no-browser, idx serves a local page on \ +127.0.0.1 instead, with a bookmarklet that reads the cookie in your own browser and a \ +clipboard button. With stdin piped, it reads the value of stockbit.com's \ +`credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin. \ +Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead; IDX_STOCKBIT_BROWSER picks the browser." )] Login { - /// Print the page URL instead of opening a browser + /// Use the local page instead of a browser sign-in #[arg(long)] no_browser: bool, - /// Fixed port for the local page (for `ssh -L`); default: any free one + /// Port for the local page (for `ssh -L`); default 7878, or any free one #[arg(long)] port: Option, }, @@ -50,27 +52,29 @@ IDX_STOCKBIT_REFRESH_TOKEN instead." Logout, } -/// How long the local page waits for the session. -const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); - pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> { match &cmd.command { StockbitSubcommand::Login { no_browser, port } => { - let status = if std::io::stdin().is_terminal() { - match login_in_browser(*port, !no_browser) { - Ok(()) => stockbit::session_status()?, - Err(err) => { - runtime::warn(format!("browser login unavailable ({err}); paste instead")); - stockbit::login(&read_paste()?)? - } - } + let interactive = std::io::stdin().is_terminal(); + // A session that still works means there is nothing to sign in to. + let reuse = interactive && stockbit::verify_session().is_ok(); + let status = if reuse { + stockbit::session_status()? + } else if interactive { + sign_in(*no_browser, *port, format)? } else { stockbit::login(&read_paste()?)? }; if format.is_json() { return json::print_json(&status); } - println!("Stored and checked your Stockbit session."); + if reuse { + println!( + "Already signed in to Stockbit; use `idx stockbit logout` to switch accounts." + ); + } else { + println!("Stored and checked your Stockbit session."); + } print_status(&status); } StockbitSubcommand::Status => { @@ -104,73 +108,74 @@ pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> Ok(()) } -/// Serves the local login page and waits for a session it can verify. -/// -/// The page is only reachable from this machine. Over SSH the browser is -/// usually somewhere else, so idx picks a stable port, skips opening a -/// browser here, and prints the forward to run on the machine with the -/// browser. -fn login_in_browser(port: Option, open: bool) -> Result<(), IdxError> { - let ssh = std::env::var("SSH_CONNECTION").ok(); - // A stable port makes the printed `ssh -L` match what the page serves. - let page = stockbit::loopback::LoginPage::bind(port.unwrap_or(if ssh.is_some() { - DEFAULT_SSH_PORT - } else { - 0 - }))?; - println!("Open this page to connect Stockbit: {}", page.url()); - match &ssh { - Some(connection) => print_ssh_hint(connection, page.port()), - None if open => open_in_browser(&page.url()), - None => {} - } - page.wait(LOGIN_TIMEOUT, |pasted| { - stockbit::login(pasted) - .map(|_| ()) - .map_err(|err| err.to_string()) - }) -} - -/// Port the page uses over SSH, so `ssh -L 7878:…:7878` matches it. -const DEFAULT_SSH_PORT: u16 = 7878; - -/// The forward to run where the browser is, from `SSH_CONNECTION` -/// (`client_ip client_port server_ip server_port`). -fn print_ssh_hint(connection: &str, port: u16) { - let fields: Vec<&str> = connection.split_whitespace().collect(); - let target = match (std::env::var("USER").ok(), fields.get(2)) { - (Some(user), Some(server)) => format!("{user}@{server}"), - (None, Some(server)) => (*server).to_string(), - _ => "".to_string(), +/// Runs idx-core's sign-in in the foreground, telling the user on stderr +/// where to go. When neither the browser nor the page can start, the cookie +/// can still be pasted. +fn sign_in( + no_browser: bool, + port: Option, + format: &OutputFormat, +) -> Result { + let options = signin::Options { + method: if no_browser { + signin::Method::Page + } else { + signin::Method::Auto + }, + port, + open_page: !no_browser, }; - runtime::warn( - "this shell is over SSH: the page listens on the remote machine, so open it from the \ - machine with your browser", - ); - println!( - "\nFrom the machine you are sitting at, run (and keep it open):\n \ - ssh -L {port}:127.0.0.1:{port} {target}\n\ - then open the URL above there.\n" - ); + let sign_in = match signin::SignIn::start(&options) { + Ok(sign_in) => sign_in, + Err(err) => { + runtime::warn(format!("cannot start a sign-in ({err}); paste instead")); + return stockbit::login(&read_paste()?); + } + }; + describe(sign_in.started(), format); + sign_in.wait() } -/// Opens `url` with the desktop's default browser, best effort. -fn open_in_browser(url: &str) { - #[cfg(target_os = "macos")] - let (program, args) = ("open", vec![url]); - #[cfg(target_os = "windows")] - let (program, args) = ("cmd", vec!["/C", "start", "", url]); - #[cfg(all(unix, not(target_os = "macos")))] - let (program, args) = ("xdg-open", vec![url]); - let opened = std::process::Command::new(program) - .args(args) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .is_ok(); - if !opened { - runtime::warn(format!("could not open a browser; open {url} yourself")); +/// Where to sign in. These lines are the instructions, so they print even +/// with `--quiet`; on stderr, so stdout keeps only the result. +fn describe(started: &signin::Started, format: &OutputFormat) { + let say = |message: String| { + if format.is_json() { + eprintln!( + "{}", + serde_json::json!({ "level": "info", "message": message }) + ); + } else { + eprintln!("{}", terminal_safe(&message)); + } + }; + match started { + signin::Started::Browser { browser } => say(format!( + "Opened {browser} on Stockbit's sign-in page. Sign in there; idx closes the window \ + when you are done." + )), + signin::Started::Page { + url, + forward, + remote, + reason, + .. + } => { + if let Some(reason) = reason + && !remote + { + runtime::warn(format!("{reason}; using the local page")); + } + say(format!("Open this page to connect Stockbit: {url}")); + if *remote { + say(format!( + "This shell is over SSH, so the page is on the remote machine. From the \ + machine with your browser, run (and keep it open):\n {forward}\nthen open \ + the URL above there." + )); + } + } + signin::Started::Existing => {} } } @@ -194,7 +199,9 @@ fn read_paste() -> Result { } if raw.trim().is_empty() { return Err(IdxError::InvalidInput( - "nothing pasted: pipe or paste the credentialStorage cookie value or a refresh token" + "nothing on stdin: run `idx stockbit login` in a terminal to sign in through a \ + browser, pipe the credentialStorage cookie (`pbpaste | ssh idx stockbit \ + login`), or set IDX_STOCKBIT_REFRESH_TOKEN" .to_string(), )); } diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index e8902ef..c086316 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2197,6 +2197,24 @@ fn stockbit_login_stores_a_private_session_and_logout_removes_it() { assert!(!session.exists()); } +/// An agent on a headless box gets told how to log in, not "nothing pasted". +#[test] +fn stockbit_login_with_empty_stdin_says_how_to_log_in() { + let root = test_env_dir("stockbit-login-empty"); + let output = bin_with_root(&root) + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stockbit", "login"]) + .output() + .expect("run login"); + assert!(!output.status.success()); + let error: Value = serde_json::from_slice(&output.stderr).expect("JSON error"); + assert_eq!(error["code"], "INVALIDINPUT"); + let message = error["message"].as_str().unwrap(); + for hint in ["idx stockbit login", "IDX_STOCKBIT_REFRESH_TOKEN"] { + assert!(message.contains(hint), "{message}"); + } +} + #[test] fn invalid_quote_ttl_env_returns_non_zero() { test_bin("invalid-quote-ttl") diff --git a/crates/idx-core/src/api/stockbit/auth.rs b/crates/idx-core/src/api/stockbit/auth.rs index 93da93b..a20f13e 100644 --- a/crates/idx-core/src/api/stockbit/auth.rs +++ b/crates/idx-core/src/api/stockbit/auth.rs @@ -94,6 +94,57 @@ pub struct SessionStatus { pub session_file: String, } +/// Where the session and idx's browser profile live: beside `config.toml`, +/// or in the other default location when that is where they are. +/// +/// On macOS the config directory depends on the environment: a shell that +/// sets `XDG_CONFIG_HOME=~/.config` and an app started from the Dock, which +/// does not, resolve to different places, and the CLI and an MCP client must +/// still find one session. A custom `XDG_CONFIG_HOME` has no other location, +/// so a run isolated with one never reaches the user's session. +pub fn session_dir() -> Result { + let config = crate::config::config_path()?; + let primary = config + .parent() + .ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))? + .to_path_buf(); + let other = if cfg!(target_os = "macos") { + std::env::var_os("HOME").and_then(|home| other_default(&primary, Path::new(&home))) + } else { + None + }; + Ok(choose_dir(primary, other)) +} + +/// The second default config location, when `primary` is one of the two. +fn other_default(primary: &Path, home: &Path) -> Option { + let dot_config = home.join(".config/idx"); + let library = home.join("Library/Application Support/idx"); + if primary == dot_config { + Some(library) + } else if primary == library { + Some(dot_config) + } else { + None + } +} + +/// The directory holding a session, else a browser profile, else `primary`. +fn choose_dir(primary: PathBuf, other: Option) -> PathBuf { + let Some(other) = other else { + return primary; + }; + for entry in [SESSION_FILE, super::browser::PROFILE_DIR] { + if primary.join(entry).exists() { + return primary; + } + if other.join(entry).exists() { + return other; + } + } + primary +} + /// Reads, writes and refreshes the session. #[derive(Debug, Clone)] pub struct SessionStore { @@ -102,19 +153,15 @@ pub struct SessionStore { } impl SessionStore { - /// The session beside `config.toml`, seeded from the environment. + /// The session in `session_dir`, seeded from the environment. pub fn open() -> Result { - let config = crate::config::config_path()?; - let dir = config - .parent() - .ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))?; let env = |name| { std::env::var(name) .map(|v| normalize_token(&v)) .unwrap_or_default() }; Ok(Self::at( - dir.to_path_buf(), + session_dir()?, Session { access: env(ENV_ACCESS), refresh: env(ENV_REFRESH), @@ -393,6 +440,50 @@ mod tests { use super::*; + /// The two default locations pair up; anything else stands alone, so + /// a run with its own `XDG_CONFIG_HOME` cannot reach the user's session. + #[test] + fn only_the_two_default_locations_pair_up() { + let home = Path::new("/Users/me"); + let dot_config = home.join(".config/idx"); + let library = home.join("Library/Application Support/idx"); + assert_eq!(other_default(&dot_config, home), Some(library.clone())); + assert_eq!(other_default(&library, home), Some(dot_config)); + assert_eq!(other_default(Path::new("/tmp/run/idx"), home), None); + } + + /// A session anywhere beats a profile, a profile beats nothing, and with + /// neither the primary location is used. + #[test] + fn the_session_is_found_in_either_location() { + let root = std::env::temp_dir().join(format!( + "idx-session-dir-{}-{}", + std::process::id(), + fastrand::u32(..) + )); + let (primary, other) = (root.join("primary"), root.join("other")); + std::fs::create_dir_all(&primary).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + let choose = || choose_dir(primary.clone(), Some(other.clone())); + + assert_eq!(choose(), primary); + assert_eq!(choose_dir(primary.clone(), None), primary); + + std::fs::create_dir(other.join(super::super::browser::PROFILE_DIR)).unwrap(); + assert_eq!(choose(), other, "a profile says where the user signs in"); + + std::fs::write(primary.join(SESSION_FILE), "{}").unwrap(); + assert_eq!(choose(), primary, "a session beats a profile"); + + std::fs::remove_file(primary.join(SESSION_FILE)).unwrap(); + std::fs::write(other.join(SESSION_FILE), "{}").unwrap(); + assert_eq!(choose(), other); + + std::fs::write(primary.join(SESSION_FILE), "{}").unwrap(); + assert_eq!(choose(), primary, "with two, the primary one wins"); + let _ = std::fs::remove_dir_all(&root); + } + fn fixture(name: &str) -> String { std::fs::read_to_string(format!( "{}/tests/fixtures/{name}", diff --git a/crates/idx-core/src/api/stockbit/browser.rs b/crates/idx-core/src/api/stockbit/browser.rs new file mode 100644 index 0000000..51f7a9c --- /dev/null +++ b/crates/idx-core/src/api/stockbit/browser.rs @@ -0,0 +1,1381 @@ +//! Signing in to Stockbit inside a real browser, with no DevTools and no +//! copying. +//! +//! Stockbit publishes no OAuth client a third party can register, and its own +//! sign-in is a first-party flow guarded by reCAPTCHA, so a CLI cannot ask for +//! a token directly. What it can do is hand the sign-in to the browser that +//! already knows how to do it, then read the cookie that browser ends up with: +//! +//! 1. Launch a Chromium-family browser on Stockbit's sign-in page with a +//! profile directory only idx uses and `--remote-debugging-port=0`. Chrome +//! writes the port it picked and the WebSocket path to +//! `/DevToolsActivePort`. +//! 2. Connect to that endpoint over WebSocket and ask for the cookies. +//! 3. Keep asking until the `credentialStorage` cookie holds tokens `verify` +//! accepts, take it out of the profile, then close the browser. +//! +//! What Stockbit's web app does with that cookie (read from its own bundle, +//! 2026-10-29): it is the app's persisted credential store, written by +//! script, host-only on `stockbit.com`, for 30 days, as percent-encoded +//! `{"state":{"access","refresh","user"},"version":0}`. Every way in (password, +//! Google or Apple, a new device's one-time code, account recovery) and the +//! app's own `POST /login/refresh` end in that one store, so the cookie is +//! the single place a finished sign-in shows up. A signed-out page writes the +//! store too, without tokens, which is why a cookie counts only once +//! `auth::session_from_paste` finds tokens in it. +//! +//! The password, the one-time code and any reCAPTCHA stay in the browser and +//! on Stockbit's page: idx never sees them. The cookie travels in memory only, +//! and only as far as `verify`, which stores the two tokens. +//! +//! A dedicated profile is required by the browser (Chrome 136 and later refuse +//! remote debugging on the default profile) and wanted anyway: idx's session +//! never mixes with the user's everyday browsing. + +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use base64::Engine; +use serde_json::{Value, json}; + +use crate::error::IdxError; + +/// The cookie Stockbit keeps its session in. +const COOKIE: &str = "credentialStorage"; +/// The page the browser opens, which is Stockbit's own sign-in. +const SIGN_IN_URL: &str = "https://stockbit.com/login"; +/// Profile directory idx keeps its own browser session in, beside +/// `config.toml`. +pub const PROFILE_DIR: &str = "stockbit-browser"; +/// How long the browser may take to publish its DevTools endpoint. +const START_TIMEOUT: Duration = Duration::from_secs(20); +/// Pause between cookie reads. +const POLL: Duration = Duration::from_millis(500); +/// Socket timeout for one DevTools message. +const IO_TIMEOUT: Duration = Duration::from_secs(10); +/// How long the browser gets to quit after `Browser.close`. +const EXIT_GRACE: Duration = Duration::from_secs(3); +/// Largest DevTools message accepted. +const MAX_MESSAGE: u64 = 8 * 1024 * 1024; + +/// Names the browser idx starts, ahead of any it finds. +pub const ENV_BROWSER: &str = "IDX_STOCKBIT_BROWSER"; +/// File in the profile naming the executable that last opened it. A profile +/// belongs to one browser: its cookies are encrypted with that browser's own +/// key, and an older Chromium may refuse a profile a newer one wrote. +const MADE_BY: &str = "idx-browser"; + +/// A Chromium-family browser idx can drive over the DevTools protocol. +pub struct Browser { + /// What to call the browser in messages (`Google Chrome`). + pub name: String, + /// Its executable. + pub path: PathBuf, +} + +impl Browser { + /// The browser to open `profile` with: the one `IDX_STOCKBIT_BROWSER` + /// names, else the one that opened that profile before, else the first + /// supported browser installed here. + /// + /// Under the mock provider only the variable counts, so a test never + /// opens a browser it did not ask for. + pub fn find(profile: &Path) -> Result { + if let Some(path) = std::env::var_os(ENV_BROWSER) { + let path = PathBuf::from(path); + if !path.is_file() { + return Err(IdxError::ConfigError(format!( + "{ENV_BROWSER} is {}, which is not a file", + path.display() + ))); + } + return Ok(Self::at(path)); + } + #[cfg(feature = "testing")] + if crate::api::mock::enabled() { + return Err(IdxError::Unsupported(format!( + "no browser under the mock provider; set {ENV_BROWSER}" + ))); + } + if let Some(browser) = Self::recorded(profile) { + return Ok(browser); + } + candidates() + .into_iter() + .find(|(_, path)| path.is_file()) + .map(|(name, path)| Self { + name: name.to_string(), + path, + }) + .ok_or_else(|| { + IdxError::Unsupported(format!( + "no Chrome, Chromium, Edge, Brave or Helium found; set {ENV_BROWSER} to a \ + Chromium-family browser" + )) + }) + } + + /// A browser known only by its executable. + fn at(path: PathBuf) -> Self { + let name = path + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "the browser".to_string()); + Self { name, path } + } + + /// The browser that last opened `profile`, if it is still installed. + fn recorded(profile: &Path) -> Option { + let raw = std::fs::read_to_string(profile.join(MADE_BY)).ok()?; + let path = PathBuf::from(raw.trim()); + path.is_file().then(|| Self::at(path)) + } + + /// Opens the browser on Stockbit's sign-in page and connects to it. + /// + /// Returns once the browser answers over DevTools, so a browser that will + /// not start is reported here, before anyone waits on it. + pub fn open(&self, profile: &Path) -> Result { + let launched = self.launch(profile)?; + let devtools = connect(profile, &|| launched.is_running())?; + Ok(Window { launched, devtools }) + } + + /// Starts the browser on its sign-in page. + fn launch(&self, profile: &Path) -> Result { + std::fs::create_dir_all(profile) + .map_err(|e| IdxError::Io(format!("cannot create idx's browser profile: {e}")))?; + owner_only(profile); + // A port file left by an earlier run would be read as this run's. + let _ = std::fs::remove_file(profile.join("DevToolsActivePort")); + let child = Command::new(&self.path) + .args(self.args(profile)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| IdxError::Io(format!("cannot open {}: {e}", self.name)))?; + // Best effort: without it, the next run searches again. + if let Some(path) = self.path.to_str() { + let _ = std::fs::write(profile.join(MADE_BY), path); + } + Ok(Launched { + child: std::cell::RefCell::new(child), + }) + } + + /// Arguments that keep idx's sign-in in its own profile and publish a + /// DevTools port instead of a fixed one. + fn args(&self, profile: &Path) -> Vec { + vec![ + format!("--user-data-dir={}", profile.display()), + "--remote-debugging-port=0".to_string(), + "--no-first-run".to_string(), + "--no-default-browser-check".to_string(), + "--disable-search-engine-choice-screen".to_string(), + SIGN_IN_URL.to_string(), + ] + } +} + +/// A browser window idx opened on Stockbit's sign-in page. +pub struct Window { + launched: Launched, + devtools: DevTools, +} + +impl Window { + /// Waits for a session `verify` accepts, then closes the browser. + /// + /// A session already in idx's profile that still verifies is taken at + /// once. One `verify` refuses is reported once and then left alone: + /// signing in again in the open window replaces it. Setting `cancel` + /// ends the wait and closes the browser too. + pub fn wait( + mut self, + timeout: Duration, + cancel: &AtomicBool, + verify: &dyn Fn(&str) -> Result<(), IdxError>, + ) -> Result<(), IdxError> { + let launched = &self.launched; + let result = wait_for_session(&mut self.devtools, timeout, verify, &|| { + launched.is_running() && !cancel.load(Ordering::Relaxed) + }); + self.launched.exit(EXIT_GRACE); + result + } +} + +/// Connects to the browser that is starting on `profile`. +fn connect(profile: &Path, alive: &dyn Fn() -> bool) -> Result { + let (port, path) = read_endpoint(profile, START_TIMEOUT, alive)?; + DevTools::connect(port, &path) +} + +/// Waits on a running browser for a session `verify` accepts. +/// +/// Apart from `Window` so tests can drive the wait against a stand-in +/// DevTools endpoint without launching a browser. +fn wait_for_session( + devtools: &mut DevTools, + timeout: Duration, + verify: &dyn Fn(&str) -> Result<(), IdxError>, + alive: &dyn Fn() -> bool, +) -> Result<(), IdxError> { + let deadline = Instant::now() + timeout; + let mut rejected: Option = None; + let mut last_error: Option = None; + // The window is watched so closing it ends the wait instead of running + // out the clock; the first polls may not see a page yet. + let mut saw_window = false; + let mut misses = 0; + loop { + match devtools.session_cookie() { + Ok(Some(cookie)) if rejected.as_deref() != Some(cookie.value.as_str()) => { + match verify(&cookie.value) { + Ok(()) => { + // The session now lives in idx's own file. Take the + // copy out of the browser, but leave Stockbit's + // device cookies in place. + if let Err(err) = devtools.forget_session(&cookie) { + crate::runtime::warn(format!( + "could not remove the session cookie from idx's browser \ + profile ({err}); deleting that profile folder removes it" + )); + } + devtools.close(); + return Ok(()); + } + Err(err) => { + crate::runtime::warn(format!( + "Stockbit refused the session in idx's browser ({err}); sign in \ + again in that window" + )); + rejected = Some(cookie.value); + } + } + } + // A later read that works clears an earlier failure, so the + // timeout message names only what is still wrong. + Ok(_) => last_error = None, + Err(err) => last_error = Some(err.to_string()), + } + if !alive() { + devtools.close(); + return Err(IdxError::Io( + "the browser exited before idx got a session; run `idx stockbit login` again" + .to_string(), + )); + } + match devtools.has_page() { + Ok(true) => { + saw_window = true; + misses = 0; + } + Ok(false) => { + misses += 1; + if saw_window && misses >= 2 { + devtools.close(); + return Err(IdxError::Io( + "the Stockbit sign-in window was closed before the session arrived; \ + run `idx stockbit login` again" + .to_string(), + )); + } + } + Err(err) => last_error = Some(err.to_string()), + } + if Instant::now() >= deadline { + devtools.close(); + let mut message = format!( + "the Stockbit sign-in timed out after {}s and idx closed its browser window; \ + re-run `idx stockbit login`, or add --no-browser for the local page", + timeout.as_secs() + ); + if let Some(err) = last_error { + message.push_str(&format!(" (last DevTools error: {err})")); + } + return Err(IdxError::Io(message)); + } + std::thread::sleep(POLL); + } +} + +/// Where idx keeps its own browser profile. +pub fn profile_dir() -> Result { + Ok(super::auth::session_dir()?.join(PROFILE_DIR)) +} + +/// Browsers to try, in order, for this platform. +fn candidates() -> Vec<(&'static str, PathBuf)> { + let mut found = Vec::new(); + #[cfg(target_os = "macos")] + { + let homes: Vec = ["/Applications", "/System/Applications"] + .iter() + .map(PathBuf::from) + .chain(std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Applications"))) + .collect(); + let apps = [ + ( + "Google Chrome", + "Google Chrome.app/Contents/MacOS/Google Chrome", + ), + ("Chromium", "Chromium.app/Contents/MacOS/Chromium"), + ( + "Microsoft Edge", + "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ), + ("Brave", "Brave Browser.app/Contents/MacOS/Brave Browser"), + ("Helium", "Helium.app/Contents/MacOS/Helium"), + ( + "Google Chrome Canary", + "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + ), + ]; + for home in homes { + for (name, rel) in apps { + found.push((name, home.join(rel))); + } + } + } + #[cfg(target_os = "windows")] + { + let roots: Vec = ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] + .iter() + .filter_map(std::env::var_os) + .map(PathBuf::from) + .collect(); + for root in roots { + for (name, rel) in [ + ("Google Chrome", r"Google\Chrome\Application\chrome.exe"), + ("Chromium", r"Chromium\Application\chrome.exe"), + ("Microsoft Edge", r"Microsoft\Edge\Application\msedge.exe"), + ( + "Brave", + r"BraveSoftware\Brave-Browser\Application\brave.exe", + ), + ] { + found.push((name, root.join(rel))); + } + } + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let names = [ + ("Google Chrome", "google-chrome"), + ("Google Chrome", "google-chrome-stable"), + ("Chromium", "chromium"), + ("Chromium", "chromium-browser"), + ("Microsoft Edge", "microsoft-edge"), + ("Brave", "brave-browser"), + ]; + let dirs: Vec = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default(); + for (name, exe) in names { + for dir in &dirs { + found.push((name, dir.join(exe))); + } + } + } + found +} + +/// A browser process idx started and must not leave behind. +struct Launched { + // A `RefCell` because the wait needs liveness through an immutable + // borrow; the process is only touched from one thread. + child: std::cell::RefCell, +} + +impl Launched { + /// Whether the browser is still running. + fn is_running(&self) -> bool { + matches!(self.child.borrow_mut().try_wait(), Ok(None)) + } + + /// Gives the browser `grace` to quit after `Browser.close`, then kills it. + fn exit(&self, grace: Duration) { + let deadline = Instant::now() + grace; + while Instant::now() < deadline { + if !self.is_running() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + } +} + +impl Drop for Launched { + fn drop(&mut self) { + let child = self.child.get_mut(); + let _ = child.kill(); + let _ = child.wait(); + } +} + +/// Reads `/DevToolsActivePort`: the port Chrome picked, then the +/// WebSocket path to the browser endpoint. +/// +/// A browser that exits before writing it has usually handed the window to +/// one already running on the same profile, which idx cannot drive. +fn read_endpoint( + profile: &Path, + timeout: Duration, + alive: &dyn Fn() -> bool, +) -> Result<(u16, String), IdxError> { + let file = profile.join("DevToolsActivePort"); + let deadline = Instant::now() + timeout; + loop { + if let Ok(raw) = std::fs::read_to_string(&file) + && let Some((port, path)) = parse_endpoint(&raw) + { + return Ok((port, path)); + } + if !alive() { + return Err(IdxError::Io( + "the browser exited as it started; if a window for idx is already open, close \ + it and try again" + .to_string(), + )); + } + if Instant::now() >= deadline { + return Err(IdxError::Io(format!( + "the browser did not publish a DevTools port within {}s; if a window for idx is \ + already open, close it and try again", + timeout.as_secs() + ))); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// The two lines of a `DevToolsActivePort` file. +fn parse_endpoint(raw: &str) -> Option<(u16, String)> { + let mut lines = raw.lines(); + let port = lines.next()?.trim().parse::().ok()?; + let path = lines.next()?.trim(); + (port != 0 && path.starts_with('/')).then(|| (port, path.to_string())) +} + +/// Stockbit's session cookie as the browser holds it: the value, and the +/// domain and path that name it for deletion. +struct SessionCookie { + value: String, + domain: String, + path: String, +} + +/// The session cookie among DevTools' cookies, if the browser has one with +/// tokens in it; the store a signed-out page writes does not count. +fn session_cookie(cookies: &Value) -> Option { + cookies + .as_array()? + .iter() + .filter(|cookie| cookie["name"] == COOKIE) + .filter_map(|cookie| { + let domain = cookie["domain"].as_str()?; + is_stockbit(domain).then(|| SessionCookie { + value: cookie["value"].as_str().unwrap_or_default().to_string(), + domain: domain.to_string(), + path: cookie["path"].as_str().unwrap_or("/").to_string(), + }) + }) + .find(|cookie| super::auth::session_from_paste(&cookie.value).is_some()) +} + +/// `stockbit.com` or a subdomain, as a cookie's domain names it (host-only +/// cookies have no leading dot). +fn is_stockbit(domain: &str) -> bool { + let host = domain.strip_prefix('.').unwrap_or(domain); + host == "stockbit.com" || host.ends_with(".stockbit.com") +} + +/// A DevTools protocol connection: one WebSocket, one request at a time. +struct DevTools { + stream: TcpStream, + next_id: u64, +} + +impl DevTools { + fn connect(port: u16, path: &str) -> Result { + let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}")); + let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).map_err(io)?; + stream.set_read_timeout(Some(IO_TIMEOUT)).map_err(io)?; + stream.set_write_timeout(Some(IO_TIMEOUT)).map_err(io)?; + let key = base64::engine::general_purpose::STANDARD.encode(random_bytes()); + let request = format!( + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\n\ + Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + stream.write_all(request.as_bytes()).map_err(io)?; + let head = read_head(&mut stream)?; + if !head.starts_with("HTTP/1.1 101") { + return Err(IdxError::Io(format!( + "browser DevTools refused the connection: {}", + head.lines().next().unwrap_or_default() + ))); + } + Ok(Self { stream, next_id: 0 }) + } + + /// The `credentialStorage` cookie the browser holds, if it has tokens. + fn session_cookie(&mut self) -> Result, IdxError> { + let result = self.call("Storage.getCookies", json!({}))?; + Ok(session_cookie(&result["cookies"])) + } + + /// Asks the browser to quit; it usually closes the socket instead of + /// answering, which is not an error here. + fn close(&mut self) { + let _ = self + .stream + .set_read_timeout(Some(Duration::from_millis(500))); + let _ = self.call("Browser.close", json!({})); + } + + /// Whether the browser still has a window open. + fn has_page(&mut self) -> Result { + Ok(!self.pages()?.is_empty()) + } + + /// Removes the session cookie from the profile, keeping the rest, and + /// checks it is gone. + /// + /// Stockbit's script holds the session in memory, writes it back to the + /// cookie, and syncs it across its tabs, so no Stockbit page may be open + /// when the cookie goes: idx opens a blank tab of its own, closes every + /// other one, and deletes from there. The cookie is named by the domain + /// and path the browser reported, since Stockbit sets it host-only. + fn forget_session(&mut self, cookie: &SessionCookie) -> Result<(), IdxError> { + let created = self.call("Target.createTarget", json!({ "url": "about:blank" }))?; + let blank = created["targetId"] + .as_str() + .ok_or_else(|| IdxError::Io("browser DevTools opened no tab".to_string()))? + .to_string(); + for target in self.pages()? { + if target != blank { + self.call("Target.closeTarget", json!({ "targetId": target }))?; + } + } + let attached = self.call( + "Target.attachToTarget", + json!({ "targetId": blank, "flatten": true }), + )?; + let session = attached["sessionId"] + .as_str() + .ok_or_else(|| IdxError::Io("browser DevTools attached no session".to_string()))? + .to_string(); + self.call_in( + Some(&session), + "Network.deleteCookies", + json!({ "name": COOKIE, "domain": cookie.domain, "path": cookie.path }), + )?; + if self.session_cookie()?.is_some() { + return Err(IdxError::Io( + "the session cookie was still there after deleting it".to_string(), + )); + } + Ok(()) + } + + /// The ids of the browser's open tabs. + fn pages(&mut self) -> Result, IdxError> { + let targets = self.call("Target.getTargets", json!({}))?; + Ok(targets["targetInfos"] + .as_array() + .map(|infos| { + infos + .iter() + .filter(|target| target["type"] == "page") + .filter_map(|target| target["targetId"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default()) + } + + /// One request and its reply, skipping events. + fn call(&mut self, method: &str, params: Value) -> Result { + self.call_in(None, method, params) + } + + /// A request in a session (a page attached with `flatten`), or in the + /// browser itself when `session` is `None`. + fn call_in( + &mut self, + session: Option<&str>, + method: &str, + params: Value, + ) -> Result { + self.next_id += 1; + let id = self.next_id; + let mut message = json!({ "id": id, "method": method, "params": params }); + if let Some(session) = session { + message["sessionId"] = json!(session); + } + let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}")); + self.stream + .write_all(&frame(1, message.to_string().as_bytes())) + .map_err(io)?; + loop { + let payload = self.read_message()?; + let reply: Value = serde_json::from_slice(&payload) + .map_err(|e| IdxError::Io(format!("browser DevTools: {e}")))?; + if reply["id"].as_u64() != Some(id) { + continue; + } + if let Some(error) = reply.get("error") { + return Err(IdxError::Io(format!( + "browser DevTools {method}: {}", + error["message"].as_str().unwrap_or("failed") + ))); + } + return Ok(reply.get("result").cloned().unwrap_or(Value::Null)); + } + } + + /// One whole message, joining fragments and answering pings. + fn read_message(&mut self) -> Result, IdxError> { + let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}")); + let mut buffer = Vec::new(); + loop { + let (fin, opcode, payload) = read_frame(&mut self.stream)?; + match opcode { + // ping: answer so the browser keeps the socket open. + 0x9 => self.stream.write_all(&frame(0xA, &payload)).map_err(io)?, + // pong: nothing to do. + 0xA => continue, + 0x8 => { + return Err(IdxError::Io( + "browser DevTools closed the connection".to_string(), + )); + } + // text, binary, or a continuation of the message in hand. + 0x0 => buffer.extend_from_slice(&payload), + 0x1 | 0x2 => { + buffer.clear(); + buffer.extend_from_slice(&payload); + } + _ => continue, + } + if fin { + return Ok(buffer); + } + } + } +} + +/// Reads the HTTP response head, up to the empty line. +fn read_head(stream: &mut TcpStream) -> Result { + let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}")); + let mut raw = Vec::new(); + let mut byte = [0u8; 1]; + while !raw.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte).map_err(io)? == 0 { + return Err(IdxError::Io( + "browser DevTools closed the connection".to_string(), + )); + } + raw.push(byte[0]); + if raw.len() > 8192 { + return Err(IdxError::Io( + "browser DevTools sent an oversized handshake".to_string(), + )); + } + } + Ok(String::from_utf8_lossy(&raw).into_owned()) +} + +/// A masked client frame: clients must mask, servers must not. +fn frame(opcode: u8, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(payload.len() + 14); + out.push(0x80 | opcode); + let mask = 0x80; + match payload.len() { + len if len < 126 => out.push(mask | len as u8), + len if len <= u16::MAX as usize => { + out.push(mask | 126); + out.extend_from_slice(&(len as u16).to_be_bytes()); + } + len => { + out.push(mask | 127); + out.extend_from_slice(&(len as u64).to_be_bytes()); + } + } + let key = fastrand::u32(..).to_be_bytes(); + out.extend_from_slice(&key); + out.extend( + payload + .iter() + .enumerate() + .map(|(index, byte)| byte ^ key[index % 4]), + ); + out +} + +/// One frame: `(fin, opcode, payload)`. +fn read_frame(reader: &mut impl Read) -> Result<(bool, u8, Vec), IdxError> { + let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}")); + let mut head = [0u8; 2]; + reader.read_exact(&mut head).map_err(io)?; + let fin = head[0] & 0x80 != 0; + let opcode = head[0] & 0x0F; + let masked = head[1] & 0x80 != 0; + let length = match head[1] & 0x7F { + 126 => { + let mut bytes = [0u8; 2]; + reader.read_exact(&mut bytes).map_err(io)?; + u16::from_be_bytes(bytes) as u64 + } + 127 => { + let mut bytes = [0u8; 8]; + reader.read_exact(&mut bytes).map_err(io)?; + u64::from_be_bytes(bytes) + } + short => u64::from(short), + }; + if length > MAX_MESSAGE { + return Err(IdxError::Io(format!( + "browser DevTools sent an oversized message ({length} bytes)" + ))); + } + let key = if masked { + let mut key = [0u8; 4]; + reader.read_exact(&mut key).map_err(io)?; + Some(key) + } else { + None + }; + let mut payload = vec![0u8; length as usize]; + reader.read_exact(&mut payload).map_err(io)?; + if let Some(key) = key { + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= key[index % 4]; + } + } + Ok((fin, opcode, payload)) +} + +fn random_bytes() -> [u8; 16] { + let mut bytes = [0u8; 16]; + for chunk in bytes.chunks_mut(4) { + chunk.copy_from_slice(&fastrand::u32(..).to_be_bytes()); + } + bytes +} + +/// Owner-only permissions on the profile, which holds Stockbit's cookie. +fn owner_only(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)); + } + #[cfg(not(unix))] + let _ = path; +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::net::TcpListener; + + use super::*; + + /// A masked client frame round-trips through the reader. + #[test] + fn frames_round_trip() { + for payload in [b"".as_slice(), b"hi", &[b'x'; 300], &[b'y'; 70_000]] { + let raw = frame(1, payload); + assert_eq!(raw[1] & 0x80, 0x80, "client frames are masked"); + let (fin, opcode, decoded) = read_frame(&mut Cursor::new(raw)).unwrap(); + assert!(fin); + assert_eq!(opcode, 1); + assert_eq!(decoded, payload); + } + } + + /// The port file is two lines, and anything else is not an endpoint. + #[test] + fn endpoint_needs_a_port_and_a_path() { + assert_eq!( + parse_endpoint("9222\n/devtools/browser/abc\n"), + Some((9222, "/devtools/browser/abc".to_string())) + ); + assert_eq!(parse_endpoint("0\n/devtools/browser/abc"), None); + assert_eq!(parse_endpoint("9222\n"), None); + assert_eq!(parse_endpoint(""), None); + } + + /// What a signed-out Stockbit page stores: the credential store with no + /// tokens (constructed from the app's `partialize`, which drops unset + /// fields). + const SIGNED_OUT: &str = "%7B%22state%22%3A%7B%7D%2C%22version%22%3A0%7D"; + + /// A `credentialStorage` value with tokens in it. + fn session_value() -> String { + std::fs::read_to_string(format!( + "{}/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() + .trim() + .to_string() + } + + /// Only Stockbit's own cookie counts, and only once it holds tokens. + #[test] + fn only_a_stockbit_cookie_with_tokens_counts() { + let session = session_value(); + let cookies = json!([ + {"name": COOKIE, "domain": ".stockbit.example", "path": "/", "value": session}, + {"name": COOKIE, "domain": "notstockbit.com", "path": "/", "value": session}, + {"name": "other", "domain": "stockbit.com", "path": "/", "value": session}, + {"name": COOKIE, "domain": "stockbit.com", "path": "/", "value": SIGNED_OUT}, + {"name": COOKIE, "domain": "stockbit.com", "path": "/", "value": session}, + ]); + let found = session_cookie(&cookies).expect("the session cookie"); + assert_eq!(found.value, session); + assert_eq!( + found.domain, "stockbit.com", + "host-only, as Stockbit sets it" + ); + assert_eq!(found.path, "/"); + + for value in [SIGNED_OUT, ""] { + let lone = json!([{"name": COOKIE, "domain": "stockbit.com", "value": value}]); + assert!( + session_cookie(&lone).is_none(), + "{value:?} is not a session" + ); + } + assert!(session_cookie(&json!([])).is_none()); + assert!(is_stockbit(".stockbit.com") && is_stockbit("exodus.stockbit.com")); + } + + /// The browser must not share the user's profile, and must pick its port. + #[test] + fn launch_keeps_its_own_profile_and_port() { + let browser = Browser { + name: "test".to_string(), + path: PathBuf::from("/bin/true"), + }; + let args = browser.args(Path::new("/tmp/idx-profile")); + assert!(args.contains(&"--user-data-dir=/tmp/idx-profile".to_string())); + assert!(args.contains(&"--remote-debugging-port=0".to_string())); + assert_eq!(args.last().map(String::as_str), Some(SIGN_IN_URL)); + } + + /// A whole sign-in against a stand-in browser: the session is handed to + /// `verify`, every Stockbit tab is closed before the cookie is deleted by + /// its own (host-only) domain, the cookie is gone afterwards, and the + /// browser is asked to close. + #[test] + fn a_sign_in_takes_the_cookie_and_removes_it_from_the_profile() { + let profile = temp_dir("sign-in"); + let browser = FakeBrowser::start(Fake::default()); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let seen = std::cell::RefCell::new(Vec::new()); + wait_for_session( + &mut devtools, + Duration::from_secs(5), + &|pasted| { + seen.borrow_mut().push(pasted.to_string()); + Ok(()) + }, + &|| true, + ) + .unwrap(); + assert_eq!(seen.borrow().as_slice(), [session_value()]); + + let log = browser.finish(); + let at = |name: &str| log.methods.iter().position(|method| method == name); + assert!( + at("Target.closeTarget") < at("Network.deleteCookies"), + "Stockbit's tab closes before the cookie goes: {:?}", + log.methods + ); + assert_eq!(log.closed, ["T1"], "the sign-in tab, not idx's blank one"); + assert!(!log.cookie_left, "the session cookie is out of the profile"); + assert_eq!( + log.methods.last().map(String::as_str), + Some("Browser.close") + ); + let _ = std::fs::remove_dir_all(&profile); + } + + /// A cookie that survives the delete is an error, not a silent success. + #[test] + fn a_cookie_that_survives_the_delete_is_reported() { + let profile = temp_dir("sticky"); + let browser = FakeBrowser::start(Fake { + sticky: true, + ..Fake::default() + }); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let cookie = devtools.session_cookie().unwrap().expect("a session"); + let err = devtools.forget_session(&cookie).unwrap_err(); + assert!(err.to_string().contains("still there"), "{err}"); + devtools.close(); + assert!(browser.finish().cookie_left); + let _ = std::fs::remove_dir_all(&profile); + } + + /// The store a signed-out page writes is not offered to `verify`. + #[test] + fn a_signed_out_page_is_not_a_session() { + let profile = temp_dir("signed-out"); + let browser = FakeBrowser::start(Fake { + cookie: Some(SIGNED_OUT.to_string()), + ..Fake::default() + }); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let checks = std::cell::Cell::new(0); + let err = wait_for_session( + &mut devtools, + Duration::from_millis(300), + &|_| { + checks.set(checks.get() + 1); + Ok(()) + }, + &|| true, + ) + .unwrap_err(); + assert!(err.to_string().contains("timed out"), "{err}"); + assert_eq!(checks.get(), 0); + browser.finish(); + let _ = std::fs::remove_dir_all(&profile); + } + + /// A session idx refuses is reported once, and the wait goes on. + #[test] + fn a_refused_cookie_is_reported_once_and_the_wait_continues() { + let profile = temp_dir("refused"); + let browser = FakeBrowser::start(Fake::default()); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let checks = std::cell::Cell::new(0); + let err = wait_for_session( + &mut devtools, + Duration::from_millis(300), + &|_| { + checks.set(checks.get() + 1); + Err(IdxError::AuthError("expired".to_string())) + }, + &|| true, + ) + .unwrap_err(); + assert!(err.to_string().contains("timed out"), "{err}"); + assert_eq!(checks.get(), 1, "one refusal per distinct cookie"); + browser.finish(); + let _ = std::fs::remove_dir_all(&profile); + } + + /// Closing the window ends the wait at once, not at the timeout. + #[test] + fn closing_the_window_ends_the_wait() { + let profile = temp_dir("closed"); + let browser = FakeBrowser::start(Fake { + cookie: None, + pages_for: 1, + ..Fake::default() + }); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let started = Instant::now(); + let err = wait_for_session(&mut devtools, Duration::from_secs(30), &|_| Ok(()), &|| { + true + }) + .unwrap_err(); + assert!(err.to_string().contains("window was closed"), "{err}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "waited {:?}", + started.elapsed() + ); + browser.finish(); + let _ = std::fs::remove_dir_all(&profile); + } + + /// A browser that quits ends the wait too. + #[test] + fn a_browser_that_quits_ends_the_wait() { + let profile = temp_dir("quit"); + let browser = FakeBrowser::start(Fake { + cookie: None, + ..Fake::default() + }); + write_endpoint(&profile, browser.port); + let mut devtools = connect(&profile, &|| true).unwrap(); + let err = wait_for_session(&mut devtools, Duration::from_secs(30), &|_| Ok(()), &|| { + false + }) + .unwrap_err(); + assert!(err.to_string().contains("browser exited"), "{err}"); + browser.finish(); + let _ = std::fs::remove_dir_all(&profile); + } + + /// Writes a stand-in browser executable into `dir`. + #[cfg(unix)] + fn fake_executable(dir: &Path, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("fake-browser.sh"); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + script + } + + /// Launch, endpoint, WebSocket, verify, close: one sign-in end to end, + /// with a stand-in executable in place of the browser. + #[cfg(unix)] + #[test] + fn a_sign_in_starts_the_browser_and_closes_it() { + let profile = temp_dir("launch"); + let server = FakeBrowser::start(Fake::default()); + + // Writes the port file where the browser would, then waits to be + // closed. + let path = fake_executable( + &profile, + &format!( + "for arg in \"$@\"; do\n\ + case \"$arg\" in --user-data-dir=*) dir=\"${{arg#--user-data-dir=}}\" ;; esac\n\ + done\n\ + printf '%s\\n%s\\n' {port} /devtools/browser/test > \"$dir/DevToolsActivePort\"\n\ + sleep 30\n", + port = server.port + ), + ); + let browser = Browser { + name: "fake".to_string(), + path, + }; + let started = Instant::now(); + browser + .open(&profile) + .unwrap() + .wait(Duration::from_secs(5), &AtomicBool::new(false), &|_| Ok(())) + .unwrap(); + assert_eq!( + server.finish().methods.last().map(String::as_str), + Some("Browser.close") + ); + // The stand-in sleeps for 30 s; the sign-in must not wait for it. + assert!( + started.elapsed() < Duration::from_secs(10), + "the sign-in waited {:?}", + started.elapsed() + ); + let _ = std::fs::remove_dir_all(&profile); + } + + /// Cancelling ends the wait and closes the browser, so a server that + /// is exiting leaves no window behind. + #[cfg(unix)] + #[test] + fn cancelling_closes_the_browser() { + let profile = temp_dir("cancel"); + let server = FakeBrowser::start(Fake { + cookie: None, + ..Fake::default() + }); + let path = fake_executable( + &profile, + &format!( + "for arg in \"$@\"; do\n\ + case \"$arg\" in --user-data-dir=*) dir=\"${{arg#--user-data-dir=}}\" ;; esac\n\ + done\n\ + printf '%s\\n%s\\n' {port} /devtools/browser/test > \"$dir/DevToolsActivePort\"\n\ + sleep 30\n", + port = server.port + ), + ); + let browser = Browser { + name: "fake".to_string(), + path, + }; + let window = browser.open(&profile).unwrap(); + let cancel = std::sync::Arc::new(AtomicBool::new(false)); + let waiter = { + let cancel = cancel.clone(); + std::thread::spawn(move || window.wait(Duration::from_secs(30), &cancel, &|_| Ok(()))) + }; + std::thread::sleep(Duration::from_millis(200)); + let started = Instant::now(); + cancel.store(true, Ordering::Relaxed); + assert!(waiter.join().unwrap().is_err()); + assert!( + started.elapsed() < Duration::from_secs(6), + "cancelling took {:?}", + started.elapsed() + ); + assert_eq!( + server.finish().methods.last().map(String::as_str), + Some("Browser.close") + ); + let _ = std::fs::remove_dir_all(&profile); + } + + /// The browser that opened a profile is named in it, and is the one used + /// again; one no longer installed is not. + #[cfg(unix)] + #[test] + fn a_profile_remembers_its_browser() { + let profile = temp_dir("made-by"); + let path = fake_executable(&profile, "exit 0\n"); + assert!(Browser::recorded(&profile).is_none()); + let browser = Browser { + name: "Named".to_string(), + path: path.clone(), + }; + // The launch records it even when the browser then goes away. + assert!(browser.open(&profile).is_err()); + let again = Browser::recorded(&profile).expect("the recorded browser"); + assert_eq!(again.path, path); + assert_eq!(again.name, "fake-browser"); + + std::fs::remove_file(&path).unwrap(); + assert!(Browser::recorded(&profile).is_none()); + let _ = std::fs::remove_dir_all(&profile); + } + + /// A browser that hands its window to one already running exits at + /// once; that is reported then, not after the start timeout. + #[cfg(unix)] + #[test] + fn a_browser_that_exits_at_launch_fails_fast() { + let profile = temp_dir("handoff"); + let browser = Browser { + name: "fake".to_string(), + path: fake_executable(&profile, "exit 0\n"), + }; + let started = Instant::now(); + let err = browser.open(&profile).err().expect("no window"); + assert!(err.to_string().contains("exited as it started"), "{err}"); + assert!(started.elapsed() < Duration::from_secs(5)); + let _ = std::fs::remove_dir_all(&profile); + } + + /// The real thing: launches the browser named by `IDX_STOCKBIT_BROWSER` + /// and checks the wait ends on the timeout, not on a broken connection, + /// which is what proves the browser answered `Storage.getCookies`. + /// + /// Ignored by default: it opens a browser window and loads Stockbit's + /// page, so it needs a machine with a browser and a network. + #[test] + #[ignore = "opens a real browser; set IDX_STOCKBIT_BROWSER"] + fn a_real_browser_answers_the_devtools_requests() { + let profile = temp_dir("real"); + let Ok(browser) = Browser::find(&profile) else { + eprintln!("IDX_STOCKBIT_BROWSER is not set; nothing to launch"); + return; + }; + let err = browser + .open(&profile) + .unwrap() + .wait(Duration::from_secs(25), &AtomicBool::new(false), &|_| { + panic!("a fresh profile holds no session to check") + }) + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("timed out"), "{message}"); + assert!( + !message.contains("last DevTools error"), + "DevTools never answered: {message}" + ); + let _ = std::fs::remove_dir_all(&profile); + } + + /// How a stand-in browser behaves. + struct Fake { + /// The session cookie's value, if it holds one. + cookie: Option, + /// `Target.getTargets` reports pages for this many calls, then none. + pages_for: usize, + /// The cookie survives `Network.deleteCookies`. + sticky: bool, + } + + impl Default for Fake { + fn default() -> Self { + Self { + cookie: Some(session_value()), + pages_for: usize::MAX, + sticky: false, + } + } + } + + /// What a stand-in browser was asked, and what it was left holding. + struct Log { + methods: Vec, + /// Tabs closed with `Target.closeTarget`. + closed: Vec, + cookie_left: bool, + } + + /// A stand-in for a browser's DevTools endpoint: one WebSocket, scripted + /// answers, and the methods it was asked for. + struct FakeBrowser { + port: u16, + handle: Option>, + } + + impl FakeBrowser { + fn start(fake: Fake) -> Self { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || serve(listener, fake)); + Self { + port, + handle: Some(handle), + } + } + + fn finish(mut self) -> Log { + self.handle.take().unwrap().join().unwrap() + } + } + + /// Stockbit's cookie lives host-only on `stockbit.com`, and, as in + /// Chromium, a delete must name that exact domain and path. + fn serve(listener: TcpListener, fake: Fake) -> Log { + const DOMAIN: &str = "stockbit.com"; + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let head = read_head(&mut stream).unwrap(); + assert!(head.contains("Upgrade: websocket"), "{head}"); + stream + .write_all(b"HTTP/1.1 101 Switching Protocols\r\n\r\n") + .unwrap(); + let mut cookie = fake.cookie.clone(); + let mut pages = vec!["T1".to_string()]; + let mut log = Log { + methods: Vec::new(), + closed: Vec::new(), + cookie_left: false, + }; + let mut target_calls = 0; + while let Ok(payload) = read_message(&mut stream) { + let request: Value = serde_json::from_slice(&payload).unwrap(); + let method = request["method"].as_str().unwrap_or_default().to_string(); + let params = &request["params"]; + log.methods.push(method.clone()); + if method == "Browser.close" { + let reply = json!({ "id": request["id"], "result": {} }); + let _ = stream.write_all(&server_frame(reply.to_string().as_bytes())); + break; + } + let result = match method.as_str() { + "Storage.getCookies" => { + let cookies: Vec = cookie + .iter() + .map(|value| { + json!({ "name": COOKIE, "domain": DOMAIN, "path": "/", "value": value }) + }) + .collect(); + json!({ "cookies": cookies }) + } + "Target.getTargets" => { + target_calls += 1; + let infos: Vec = if target_calls <= fake.pages_for { + pages + .iter() + .map(|id| json!({ "targetId": id, "type": "page", "url": SIGN_IN_URL })) + .collect() + } else { + Vec::new() + }; + json!({ "targetInfos": infos }) + } + "Target.createTarget" => { + pages.push("BLANK".to_string()); + json!({ "targetId": "BLANK" }) + } + "Target.closeTarget" => { + let id = params["targetId"].as_str().unwrap_or_default().to_string(); + pages.retain(|page| *page != id); + log.closed.push(id); + json!({ "success": true }) + } + "Target.attachToTarget" => json!({ "sessionId": "S1" }), + "Network.deleteCookies" => { + assert_eq!(request["sessionId"], "S1", "deleted from a tab"); + if !fake.sticky + && params["name"] == COOKIE + && params["domain"] == DOMAIN + && params["path"] == "/" + { + cookie = None; + } + json!({}) + } + _ => json!({}), + }; + let reply = json!({ "id": request["id"], "result": result }); + if stream + .write_all(&server_frame(reply.to_string().as_bytes())) + .is_err() + { + break; + } + } + log.cookie_left = cookie.is_some(); + log + } + + /// One unmasked server frame. + fn server_frame(payload: &[u8]) -> Vec { + let mut out = vec![0x81]; + match payload.len() { + len if len < 126 => out.push(len as u8), + len => { + out.push(126); + out.extend_from_slice(&(len as u16).to_be_bytes()); + } + } + out.extend_from_slice(payload); + out + } + + fn read_message(stream: &mut TcpStream) -> Result, IdxError> { + let mut buffer = Vec::new(); + loop { + let (fin, _, payload) = read_frame(stream)?; + buffer.extend_from_slice(&payload); + if fin { + return Ok(buffer); + } + } + } + + fn write_endpoint(profile: &Path, port: u16) { + std::fs::write( + profile.join("DevToolsActivePort"), + format!("{port}\n/devtools/browser/test\n"), + ) + .unwrap(); + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "idx-browser-{name}-{}-{}", + std::process::id(), + fastrand::u32(..) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } +} diff --git a/crates/idx-core/src/api/stockbit/loopback.rs b/crates/idx-core/src/api/stockbit/loopback.rs index bb7bbac..b154df3 100644 --- a/crates/idx-core/src/api/stockbit/loopback.rs +++ b/crates/idx-core/src/api/stockbit/loopback.rs @@ -67,15 +67,15 @@ impl LoginPage { self.port } - /// Serves until `verify` accepts a paste or `timeout` passes. `verify` - /// returns the error text to show on the page when the paste is refused. + /// Serves until `verify` accepts a paste or `timeout` passes. The error + /// `verify` returns for a refused paste is shown on the page. /// /// Each connection gets its own thread: browsers open idle preconnects, /// and answering them one at a time used to hold the page back for the /// read timeout on every one. pub fn wait(&self, timeout: Duration, verify: F) -> Result<(), IdxError> where - F: Fn(&str) -> Result<(), String> + Send + Sync + 'static, + F: Fn(&str) -> Result<(), IdxError> + Send + Sync + 'static, { let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); let verify = std::sync::Arc::new(verify); @@ -136,12 +136,38 @@ window.open('{target}#t='+encodeURIComponent(m[1]))}})()" } } +/// The forward to run where the browser is, for the page on `port`: +/// `ssh -L :127.0.0.1: @`. +/// +/// The target comes from `SSH_CONNECTION` (`client_ip client_port server_ip +/// server_port`) when idx runs inside an SSH session, else from `USER` and +/// `HOSTNAME`, else it is left for the reader to fill in. +pub fn ssh_forward(port: u16) -> String { + let target = ssh_target().unwrap_or_else(|| "".to_string()); + format!("ssh -L {port}:127.0.0.1:{port} {target}") +} + +fn ssh_target() -> Option { + if let Ok(connection) = std::env::var("SSH_CONNECTION") { + let fields: Vec<&str> = connection.split_whitespace().collect(); + if let Some(server) = fields.get(2) { + return Some(match std::env::var("USER") { + Ok(user) => format!("{user}@{server}"), + Err(_) => (*server).to_string(), + }); + } + } + let user = std::env::var("USER").ok()?; + let host = std::env::var("HOSTNAME").ok()?; + Some(format!("{user}@{host}")) +} + /// Answers one request; true when a paste was accepted. fn serve_once( mut stream: TcpStream, page_path: &str, page: &str, - verify: &dyn Fn(&str) -> Result<(), String>, + verify: &dyn Fn(&str) -> Result<(), IdxError>, ) -> bool { let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); @@ -168,8 +194,8 @@ fn serve_once( ); return true; } - Err(message) => { - let body = serde_json::json!({ "ok": false, "message": message }); + Err(err) => { + let body = serde_json::json!({ "ok": false, "message": err.to_string() }); respond( &mut stream, 200, @@ -278,76 +304,240 @@ fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) const PAGE: &str = r#" + Connect Stockbit to idx -
-

Connect Stockbit to idx

-

One step. idx stores the session on this machine only, and never sends it anywhere but Stockbit.

- -
-

1. Open stockbit.com and make sure you are logged in.

+
+
+ +idxStockbit
-
-

2. While on stockbit.com, click this bookmarklet. It reads your Stockbit session cookie in your own browser and hands it to this page.

-

Send my Stockbit session

-

If your browser blocks it, drag the button to your bookmarks bar and click it there.

-
+
+

Connect your Stockbit account

+

idx then reads live quotes and the order book with your own session. The session stays on this machine.

+
-
-

Or copy it by hand. On stockbit.com: DevTools, Application, Cookies, https://stockbit.com, copy the credentialStorage value, then paste it here.

- -
+
+
    +
  1. + +
    +

    Sign in to Stockbit

    +

    Open stockbit.com in this browser and sign in. If the tab is already signed in, go to the next step.

    +Open stockbit.com +
    +
  2. +
  3. + +
    +

    Send the session to idx

    +

    While the Stockbit tab is open, click this button. It reads the session cookie in your own browser.

    +Send my Stockbit session +

    If the browser blocks it, drag the button to your bookmarks bar, open stockbit.com, then click it there.

    +
    +
  4. +
+ +
+Or paste the cookie by hand +
+
    +
  1. On stockbit.com, open DevTools with F12.
  2. +
  3. Go to Application, then Cookies, then https://stockbit.com.
  4. +
  5. Copy the value of credentialStorage and paste it below.
  6. +
+ +
+
-

-

idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser sharing this session will be signed out once idx refreshes it. This page closes when you finish, or after a few minutes.

+ +
+ + + +
+

idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser that shares this session signs out once idx refreshes it.

+

This page stops listening after a few minutes.

+
@@ -394,7 +584,7 @@ mod tests { let handle = std::thread::spawn(move || { let verify = |paste: &str| match paste.trim() { "good" => Ok(()), - _ => Err("not a Stockbit session".to_string()), + _ => Err(IdxError::InvalidInput("not a Stockbit session".to_string())), }; page.wait(Duration::from_secs(10), verify) }); @@ -419,12 +609,39 @@ mod tests { handle.join().unwrap().expect("accepted"); } + /// The CSP allows no fetch of its own, so a stray asset would fail + /// silently; the page also has to keep the two entry points the docs name. + #[test] + fn the_page_is_self_contained_and_keeps_both_entry_points() { + let page = LoginPage::bind(0).unwrap().page(); + for forbidden in ["