mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
feat: Stockbit login for the order book, live quotes and daily history
Opt-in, with the user's own Stockbit session from a pasted credentialStorage cookie (or IDX_STOCKBIT_REFRESH_TOKEN): - idx stockbit login/status/logout; session file 0600, tokens never shown - rotating refresh under a cross-process lock; one refresh+retry on 401 - stocks orderbook: depth with lots and orders, OHLC, value, foreign flow - --provider stockbit quotes from the session API when logged in - --history-provider stockbit, and auto under the stockbit provider for 5d-1y daily windows - MCP get_orderbook and stockbit_status (17 tools) Endpoints, units and recorded responses from fzn0x/idx (MIT).
This commit is contained in:
parent
570439369e
commit
b5ae9b98de
61 changed files with 2882 additions and 129 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -979,6 +979,7 @@ dependencies = [
|
||||||
name = "idx-core"
|
name = "idx-core"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"directories",
|
"directories",
|
||||||
|
|
|
||||||
18
README.md
18
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
|
- Analysis: `technical`, `growth`, `valuation`, `risk`, and a combined
|
||||||
`fundamental` report; multi-symbol `compare`; MSN stock `screen`
|
`fundamental` report; multi-symbol `compare`; MSN stock `screen`
|
||||||
- Providers: MSN is the default for quotes, fundamentals, and screening;
|
- Providers: MSN is the default for quotes, fundamentals, and screening;
|
||||||
Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo` and
|
Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo|stockbit`
|
||||||
`IDX_HISTORY_PROVIDER=auto|yahoo|msn`
|
and `IDX_HISTORY_PROVIDER=auto|yahoo|msn|stockbit`
|
||||||
- Stockbit, opt-in and without a login: `stocks intraday` (today's price by
|
- Stockbit, opt-in: without a login, `stocks intraday` (today's price by the
|
||||||
the minute) and `--provider stockbit` quotes with the best bid/offer while
|
minute) and `--provider stockbit` quotes with the best bid/offer while the
|
||||||
the market is open. Delayed at least 10 minutes, for personal use under
|
market is open, delayed at least 10 minutes. With your own account
|
||||||
Stockbit's terms; see the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
|
(`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
|
- Local file cache with TTL, and `--offline` mode with stale-cache fallback
|
||||||
|
|
||||||
### Ownership (KSEI shareholders above 1%)
|
### Ownership (KSEI shareholders above 1%)
|
||||||
|
|
@ -163,8 +165,8 @@ change it to `auto` with `idx config set general.output auto`.
|
||||||
|
|
||||||
## MCP server
|
## MCP server
|
||||||
|
|
||||||
`idx-mcp` serves the same data to MCP clients over stdio, as 15 tools:
|
`idx-mcp` serves the same data to MCP clients over stdio, as 17 tools:
|
||||||
`get_quotes`, `get_intraday`, `get_price_history`, `screen_stocks`, `get_company`,
|
`get_quotes`, `get_intraday`, `get_orderbook`, `stockbit_status`, `get_price_history`, `screen_stocks`, `get_company`,
|
||||||
`analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`,
|
`analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`,
|
||||||
`get_foreign_flow`, `get_shareholders`, `get_investor_holdings`,
|
`get_foreign_flow`, `get_shareholders`, `get_investor_holdings`,
|
||||||
`search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as
|
`search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as
|
||||||
|
|
|
||||||
11
ROADMAP.md
11
ROADMAP.md
|
|
@ -12,11 +12,12 @@ history lives in git.
|
||||||
- `screen presets` / `screen run <PRESET>`: built-in screener presets
|
- `screen presets` / `screen run <PRESET>`: built-in screener presets
|
||||||
(graduate `screen` out of `stocks` only if these grow into a richer workflow)
|
(graduate `screen` out of `stocks` only if these grow into a richer workflow)
|
||||||
- Multi-period financial statements (today `financials` is single-period)
|
- Multi-period financial statements (today `financials` is single-period)
|
||||||
- Order book depth: no free, login-free source exists (IDX sells delayed depth
|
- Order book depth without a login: no free source exists (IDX sells delayed
|
||||||
to vendors). Planned as the Stockbit login follow-on: opt-in with the
|
depth to vendors); `stocks orderbook` needs the user's Stockbit login.
|
||||||
user's own account, the depth ladder, intraday OHLCV and token refresh from
|
- Stockbit login follow-ups: a browser capture for `idx stockbit login`
|
||||||
`exodus.stockbit.com`; fzn0x/idx (MIT) maps the endpoints. The public page
|
(Chrome DevTools, as fzn0x/idx does) instead of pasting the cookie,
|
||||||
already gives the best bid/offer (L1) while the market is open.
|
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
|
- 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;
|
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
|
`open`, `break` and `close` are known, and the bid/offer gate hides the book
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
pub mod ownership;
|
pub mod ownership;
|
||||||
|
pub mod stockbit;
|
||||||
pub mod stocks;
|
pub mod stocks;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand, ValueEnum};
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
|
|
@ -69,6 +70,8 @@ pub enum Commands {
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
#[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")]
|
#[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")]
|
||||||
Ownership(ownership::OwnershipCmd),
|
Ownership(ownership::OwnershipCmd),
|
||||||
|
#[command(about = "Your Stockbit session (opt-in: order book, live quotes)")]
|
||||||
|
Stockbit(stockbit::StockbitCmd),
|
||||||
#[command(about = "Generate shell completions")]
|
#[command(about = "Generate shell completions")]
|
||||||
Completions { shell: Shell },
|
Completions { shell: Shell },
|
||||||
#[command(about = "Show idx-cli version")]
|
#[command(about = "Show idx-cli version")]
|
||||||
|
|
|
||||||
136
crates/idx-cli/src/cli/stockbit.rs
Normal file
136
crates/idx-cli/src/cli/stockbit.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
use std::io::{IsTerminal, Read};
|
||||||
|
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
use crate::output::text::terminal_safe;
|
||||||
|
use crate::output::{OutputFormat, json};
|
||||||
|
use idx_core::api::stockbit::{self, auth::SessionStatus, auth::TokenInfo};
|
||||||
|
use idx_core::error::IdxError;
|
||||||
|
use idx_core::runtime;
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
#[command(
|
||||||
|
about = "Your Stockbit session (opt-in: order book, live quotes)",
|
||||||
|
long_about = "Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` \
|
||||||
|
quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, \
|
||||||
|
stored in the config directory with owner-only permissions, and never logs them. Stockbit \
|
||||||
|
rotates the refresh token on every refresh, so a browser sharing the session is logged out \
|
||||||
|
once idx refreshes: log in through a separate browser profile for idx. Stockbit's terms \
|
||||||
|
allow personal, non-commercial use.",
|
||||||
|
after_help = "Examples:\n idx stockbit login # paste the credentialStorage cookie\n pbpaste | idx stockbit login\n idx stockbit status\n idx stockbit logout"
|
||||||
|
)]
|
||||||
|
pub struct StockbitCmd {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: StockbitSubcommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum StockbitSubcommand {
|
||||||
|
#[command(
|
||||||
|
about = "Store a session pasted on stdin and check it with Stockbit",
|
||||||
|
long_about = "Reads, on stdin, the value of stockbit.com's `credentialStorage` cookie \
|
||||||
|
(in a logged-in browser: DevTools, Application, Cookies, https://stockbit.com) or a refresh \
|
||||||
|
token, stores it, and checks it with one request. Servers can set \
|
||||||
|
IDX_STOCKBIT_REFRESH_TOKEN instead."
|
||||||
|
)]
|
||||||
|
Login,
|
||||||
|
#[command(about = "Show whether a session is stored and when its tokens expire")]
|
||||||
|
Status,
|
||||||
|
#[command(about = "Delete the stored session")]
|
||||||
|
Logout,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> {
|
||||||
|
match &cmd.command {
|
||||||
|
StockbitSubcommand::Login => {
|
||||||
|
let status = stockbit::login(&read_paste()?)?;
|
||||||
|
if format.is_json() {
|
||||||
|
return json::print_json(&status);
|
||||||
|
}
|
||||||
|
println!("Stored and checked your Stockbit session.");
|
||||||
|
print_status(&status);
|
||||||
|
}
|
||||||
|
StockbitSubcommand::Status => {
|
||||||
|
let status = stockbit::session_status()?;
|
||||||
|
if format.is_json() {
|
||||||
|
return json::print_json(&status);
|
||||||
|
}
|
||||||
|
print_status(&status);
|
||||||
|
}
|
||||||
|
StockbitSubcommand::Logout => {
|
||||||
|
let (removed, status) = stockbit::logout()?;
|
||||||
|
if status.logged_in {
|
||||||
|
runtime::warn(
|
||||||
|
"IDX_STOCKBIT_REFRESH_TOKEN or IDX_STOCKBIT_ACCESS_TOKEN still provides a session",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if format.is_json() {
|
||||||
|
return json::print_json(&serde_json::json!({
|
||||||
|
"removed": removed,
|
||||||
|
"logged_in": status.logged_in,
|
||||||
|
"session_file": status.session_file,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if removed {
|
||||||
|
println!("Removed the stored Stockbit session.");
|
||||||
|
} else {
|
||||||
|
println!("No Stockbit session was stored.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pasted session: one line from a terminal, else all of stdin.
|
||||||
|
fn read_paste() -> Result<String, IdxError> {
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut raw = String::new();
|
||||||
|
if stdin.is_terminal() {
|
||||||
|
eprintln!(
|
||||||
|
"Paste the value of stockbit.com's `credentialStorage` cookie (DevTools > \
|
||||||
|
Application > Cookies) or a refresh token, then press Enter:"
|
||||||
|
);
|
||||||
|
stdin
|
||||||
|
.read_line(&mut raw)
|
||||||
|
.map_err(|e| IdxError::Io(e.to_string()))?;
|
||||||
|
} else {
|
||||||
|
stdin
|
||||||
|
.lock()
|
||||||
|
.read_to_string(&mut raw)
|
||||||
|
.map_err(|e| IdxError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
return Err(IdxError::InvalidInput(
|
||||||
|
"nothing pasted: pipe or paste the credentialStorage cookie value or a refresh token"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_status(status: &SessionStatus) {
|
||||||
|
println!(
|
||||||
|
"logged in: {}",
|
||||||
|
if status.logged_in { "yes" } else { "no" }
|
||||||
|
);
|
||||||
|
println!("access token: {}", token_line(&status.access));
|
||||||
|
println!("refresh token: {}", token_line(&status.refresh));
|
||||||
|
println!("session file: {}", terminal_safe(&status.session_file));
|
||||||
|
if !status.logged_in {
|
||||||
|
println!("Run `idx stockbit login` to add one.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_line(token: &TokenInfo) -> String {
|
||||||
|
match (token.present, token.expires_at, token.expires_in_secs) {
|
||||||
|
(false, _, _) => "none".to_string(),
|
||||||
|
(true, Some(at), Some(secs)) if secs > 0 => format!(
|
||||||
|
"expires {} UTC (in {}h {}m)",
|
||||||
|
at.format("%Y-%m-%d %H:%M"),
|
||||||
|
secs / 3600,
|
||||||
|
secs % 3600 / 60
|
||||||
|
),
|
||||||
|
(true, Some(at), _) => format!("expired {} UTC", at.format("%Y-%m-%d %H:%M")),
|
||||||
|
(true, None, _) => "present (expiry unknown)".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,8 @@ use crate::config::{HistoryProviderKind, IdxConfig};
|
||||||
use crate::output::{
|
use crate::output::{
|
||||||
render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
|
render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
|
||||||
render_foreign_flow, render_fundamental, render_growth, render_history, render_insights,
|
render_foreign_flow, render_fundamental, render_growth, render_history, render_insights,
|
||||||
render_intraday, render_news, render_profile, render_quotes, render_risk, render_screener,
|
render_intraday, render_news, render_orderbook, render_profile, render_quotes, render_risk,
|
||||||
render_sentiment, render_technical, render_valuation,
|
render_screener, render_sentiment, render_technical, render_valuation,
|
||||||
};
|
};
|
||||||
use idx_core::analysis::BandarQuery;
|
use idx_core::analysis::BandarQuery;
|
||||||
use idx_core::analysis::bandar::DEFAULT_TOP;
|
use idx_core::analysis::bandar::DEFAULT_TOP;
|
||||||
|
|
@ -23,6 +23,8 @@ use idx_core::{CachePolicy, Idx, runtime};
|
||||||
/// separated by whitespace or commas.
|
/// separated by whitespace or commas.
|
||||||
/// Most symbols one `bandar` screen fetches, one request each.
|
/// Most symbols one `bandar` screen fetches, one request each.
|
||||||
const MAX_BANDAR_SYMBOLS: usize = 100;
|
const MAX_BANDAR_SYMBOLS: usize = 100;
|
||||||
|
/// Most price levels shown per side of an order book.
|
||||||
|
const MAX_BOOK_DEPTH: usize = 50;
|
||||||
|
|
||||||
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
|
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
|
||||||
let mut symbols = Vec::new();
|
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).
|
/// Single ticker symbol (e.g. BBCA).
|
||||||
symbol: String,
|
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(
|
#[command(
|
||||||
about = "Get historical OHLC data",
|
about = "Get historical OHLC data",
|
||||||
after_help = "Examples:\n idx stocks history BBCA --period 3mo\n idx stocks history BBCA --period 1y --interval 1wk"
|
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)?;
|
let day = idx.market().intraday(symbol, policy)?;
|
||||||
render_intraday(&day.data, format, no_color)
|
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 {
|
StocksSubcommand::History {
|
||||||
symbol,
|
symbol,
|
||||||
period,
|
period,
|
||||||
interval,
|
interval,
|
||||||
history_provider,
|
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);
|
announce_history_fallback(&idx, source, config);
|
||||||
let history = idx
|
let history = idx
|
||||||
.market()
|
.market()
|
||||||
|
|
@ -350,7 +380,7 @@ pub fn handle(
|
||||||
symbol,
|
symbol,
|
||||||
history_provider,
|
history_provider,
|
||||||
} => {
|
} => {
|
||||||
let source = idx.market().history_source(*history_provider);
|
let source = idx.analysis().technical_source(*history_provider);
|
||||||
announce_history_fallback(&idx, source, config);
|
announce_history_fallback(&idx, source, config);
|
||||||
let report = idx.analysis().technical(symbol, source, policy)?;
|
let report = idx.analysis().technical(symbol, source, policy)?;
|
||||||
render_technical(&report.data, format, no_color)
|
render_technical(&report.data, format, no_color)
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,12 @@ fn run() -> Result<(), IdxError> {
|
||||||
return Err(err);
|
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")]
|
#[cfg(feature = "ownership")]
|
||||||
Commands::Ownership(cmd) => {
|
Commands::Ownership(cmd) => {
|
||||||
if let Err(err) = cli::ownership::handle(&cmd.command, &config) {
|
if let Err(err) = cli::ownership::handle(&cmd.command, &config) {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use idx_core::analysis::fundamental::{
|
||||||
use idx_core::analysis::report::TechnicalReport;
|
use idx_core::analysis::report::TechnicalReport;
|
||||||
use idx_core::api::types::{
|
use idx_core::api::types::{
|
||||||
CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc,
|
CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc,
|
||||||
Quote, SentimentData,
|
OrderBook, Quote, SentimentData,
|
||||||
};
|
};
|
||||||
use idx_core::broker::types::BrokerSummary;
|
use idx_core::broker::types::BrokerSummary;
|
||||||
use idx_core::config::ProviderKind;
|
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(
|
pub fn render_broker_summary(
|
||||||
summary: &BrokerSummary,
|
summary: &BrokerSummary,
|
||||||
format: &OutputFormat,
|
format: &OutputFormat,
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ use idx_core::analysis::fundamental::{
|
||||||
use idx_core::analysis::report::TechnicalReport;
|
use idx_core::analysis::report::TechnicalReport;
|
||||||
use idx_core::analysis::signals::Signal;
|
use idx_core::analysis::signals::Signal;
|
||||||
use idx_core::api::types::{
|
use idx_core::api::types::{
|
||||||
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, Intraday,
|
BookLevel, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData,
|
||||||
NewsItem, Ohlc, Quote, SentimentData,
|
Intraday, NewsItem, Ohlc, OrderBook, Quote, SentimentData,
|
||||||
};
|
};
|
||||||
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
|
|
@ -128,6 +128,13 @@ fn pct_cell(change_pct: Option<f64>, no_color: bool) -> Cell {
|
||||||
/// Printed under every Stockbit table: what the data is and is not.
|
/// 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 \
|
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.";
|
minutes (Stockbit's terms); bid/offer only while the market is open.";
|
||||||
|
/// Under `--provider stockbit` quotes, which may come from either source.
|
||||||
|
const STOCKBIT_QUOTE_NOTE: &str = "Source: Stockbit, your session when logged in (`idx \
|
||||||
|
stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); \
|
||||||
|
bid/offer only while the market is open.";
|
||||||
|
/// Under the order book, which comes from the user's own session.
|
||||||
|
const STOCKBIT_SESSION_NOTE: &str =
|
||||||
|
"Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).";
|
||||||
|
|
||||||
fn opt_idr(value: Option<i64>) -> String {
|
fn opt_idr(value: Option<i64>) -> String {
|
||||||
value.map(format_idr).unwrap_or_else(|| "-".to_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(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())),
|
||||||
cell(opt_idr(q.bid)),
|
cell(opt_idr(q.bid)),
|
||||||
cell(opt_idr(q.offer)),
|
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())),
|
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()) {
|
for note in stale_quote_notes(quotes, chrono::Utc::now()) {
|
||||||
println!("{}", terminal_safe(¬e));
|
println!("{}", terminal_safe(¬e));
|
||||||
}
|
}
|
||||||
println!("{STOCKBIT_SOURCE_NOTE}");
|
println!("{STOCKBIT_QUOTE_NOTE}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,6 +271,105 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stockbit's market state for people: `STATE_NAME_MARKET_CLOSED` reads
|
||||||
|
/// `closed`.
|
||||||
|
fn market_words(state: &str) -> String {
|
||||||
|
state
|
||||||
|
.strip_prefix("STATE_NAME_MARKET_")
|
||||||
|
.unwrap_or(state)
|
||||||
|
.replace('_', " ")
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_orderbook(book: &OrderBook, no_color: bool) -> Result<(), IdxError> {
|
||||||
|
let change = match (book.change, book.change_pct) {
|
||||||
|
(Some(c), Some(p)) => format!(" {c:+} ({p:+.2}%)"),
|
||||||
|
(Some(c), None) => format!(" {c:+}"),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let change = match book.change {
|
||||||
|
_ if no_color || change.is_empty() => change,
|
||||||
|
Some(c) if c >= 0 => change.green(),
|
||||||
|
_ => change.red(),
|
||||||
|
};
|
||||||
|
let name = book
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.map(|n| format!(" {}", terminal_safe(n)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!(
|
||||||
|
"{}{change}",
|
||||||
|
format!(
|
||||||
|
"{}{name} {}",
|
||||||
|
terminal_safe(&book.symbol),
|
||||||
|
format_idr(book.price)
|
||||||
|
)
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
let count = |v: Option<u64>| v.map(format_u64).unwrap_or_else(|| "-".to_string());
|
||||||
|
println!(
|
||||||
|
"prev close {} · open {} · high {} · low {} · volume {} · value {} · {} trades",
|
||||||
|
opt_idr(book.prev_close),
|
||||||
|
opt_idr(book.open),
|
||||||
|
opt_idr(book.high),
|
||||||
|
opt_idr(book.low),
|
||||||
|
count(book.volume),
|
||||||
|
count(book.value),
|
||||||
|
count(book.frequency)
|
||||||
|
);
|
||||||
|
if book.foreign_buy.is_some() || book.foreign_sell.is_some() {
|
||||||
|
println!(
|
||||||
|
"foreign buy {} · sell {} · net {}",
|
||||||
|
opt_idr(book.foreign_buy),
|
||||||
|
opt_idr(book.foreign_sell),
|
||||||
|
opt_idr(book.foreign_net)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match book.market_status.as_deref() {
|
||||||
|
Some(idx_core::api::stockbit::exodus::MARKET_OPEN) => println!("market open"),
|
||||||
|
Some(state) => println!(
|
||||||
|
"market {}: the book as the market left it",
|
||||||
|
terminal_safe(&market_words(state))
|
||||||
|
),
|
||||||
|
None => println!("market state unknown: the book may not be live"),
|
||||||
|
}
|
||||||
|
if let Some(note) = &book.session_note {
|
||||||
|
println!("Note: {}", terminal_safe(note));
|
||||||
|
}
|
||||||
|
if book.bids.is_empty() && book.offers.is_empty() {
|
||||||
|
println!("No bids or offers.");
|
||||||
|
} else {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec!["ORDERS", "LOTS", "BID", "OFFER", "LOTS", "ORDERS"]);
|
||||||
|
let side = |level: Option<&BookLevel>| match level {
|
||||||
|
Some(l) => [
|
||||||
|
format_u64(l.orders),
|
||||||
|
format_u64(l.shares / 100),
|
||||||
|
format_idr(l.price),
|
||||||
|
],
|
||||||
|
None => ["".to_string(), "".to_string(), "".to_string()],
|
||||||
|
};
|
||||||
|
for i in 0..book.bids.len().max(book.offers.len()) {
|
||||||
|
let [bid_orders, bid_lots, bid] = side(book.bids.get(i));
|
||||||
|
let [offer_orders, offer_lots, offer] = side(book.offers.get(i));
|
||||||
|
table.add_row(vec![
|
||||||
|
cell(bid_orders),
|
||||||
|
cell(bid_lots),
|
||||||
|
cell(bid),
|
||||||
|
cell(offer),
|
||||||
|
cell(offer_lots),
|
||||||
|
cell(offer_orders),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
}
|
||||||
|
println!("{STOCKBIT_SESSION_NOTE}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Whole Rupiah for stocks, two decimals for an index level.
|
/// Whole Rupiah for stocks, two decimals for an index level.
|
||||||
fn format_price(price: f64) -> String {
|
fn format_price(price: f64) -> String {
|
||||||
let cents = (price * 100.0).round() as i64;
|
let cents = (price * 100.0).round() as i64;
|
||||||
|
|
|
||||||
|
|
@ -2137,6 +2137,66 @@ fn provider_flag_overrides_env() {
|
||||||
assert_eq!(quotes[0]["market_cap"], Value::Null);
|
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]
|
#[test]
|
||||||
fn invalid_quote_ttl_env_returns_non_zero() {
|
fn invalid_quote_ttl_env_returns_non_zero() {
|
||||||
test_bin("invalid-quote-ttl")
|
test_bin("invalid-quote-ttl")
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,28 @@ args = ["stocks", "intraday", "MABA"]
|
||||||
name = "stockbit-quote"
|
name = "stockbit-quote"
|
||||||
args = ["--provider", "stockbit", "stocks", "quote", "BBCA,MABA"]
|
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) --------------------
|
# --- stocks: broker summaries (IPOT) and foreign flow (IDX) --------------------
|
||||||
|
|
||||||
[[case]]
|
[[case]]
|
||||||
|
|
@ -366,6 +388,15 @@ args = ["stocks", "intraday", "ZZZZ"]
|
||||||
name = "error-stockbit-fundamental-unsupported"
|
name = "error-stockbit-fundamental-unsupported"
|
||||||
args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"]
|
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 --------------------------------------------------------------------
|
# --- ownership --------------------------------------------------------------------
|
||||||
|
|
||||||
[[case]]
|
[[case]]
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ _idx() {
|
||||||
idx,ownership)
|
idx,ownership)
|
||||||
cmd="idx__ownership"
|
cmd="idx__ownership"
|
||||||
;;
|
;;
|
||||||
|
idx,stockbit)
|
||||||
|
cmd="idx__stockbit"
|
||||||
|
;;
|
||||||
idx,stocks)
|
idx,stocks)
|
||||||
cmd="idx__stocks"
|
cmd="idx__stocks"
|
||||||
;;
|
;;
|
||||||
|
|
@ -102,6 +105,9 @@ _idx() {
|
||||||
idx__help,ownership)
|
idx__help,ownership)
|
||||||
cmd="idx__help__ownership"
|
cmd="idx__help__ownership"
|
||||||
;;
|
;;
|
||||||
|
idx__help,stockbit)
|
||||||
|
cmd="idx__help__stockbit"
|
||||||
|
;;
|
||||||
idx__help,stocks)
|
idx__help,stocks)
|
||||||
cmd="idx__help__stocks"
|
cmd="idx__help__stocks"
|
||||||
;;
|
;;
|
||||||
|
|
@ -174,6 +180,15 @@ _idx() {
|
||||||
idx__help__ownership__resolve,unresolved)
|
idx__help__ownership__resolve,unresolved)
|
||||||
cmd="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)
|
idx__help__stocks,bandar)
|
||||||
cmd="idx__help__stocks__bandar"
|
cmd="idx__help__stocks__bandar"
|
||||||
;;
|
;;
|
||||||
|
|
@ -210,6 +225,9 @@ _idx() {
|
||||||
idx__help__stocks,news)
|
idx__help__stocks,news)
|
||||||
cmd="idx__help__stocks__news"
|
cmd="idx__help__stocks__news"
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks,orderbook)
|
||||||
|
cmd="idx__help__stocks__orderbook"
|
||||||
|
;;
|
||||||
idx__help__stocks,profile)
|
idx__help__stocks,profile)
|
||||||
cmd="idx__help__stocks__profile"
|
cmd="idx__help__stocks__profile"
|
||||||
;;
|
;;
|
||||||
|
|
@ -348,6 +366,30 @@ _idx() {
|
||||||
idx__ownership__resolve__help,unresolved)
|
idx__ownership__resolve__help,unresolved)
|
||||||
cmd="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)
|
idx__stocks,bandar)
|
||||||
cmd="idx__stocks__bandar"
|
cmd="idx__stocks__bandar"
|
||||||
;;
|
;;
|
||||||
|
|
@ -387,6 +429,9 @@ _idx() {
|
||||||
idx__stocks,news)
|
idx__stocks,news)
|
||||||
cmd="idx__stocks__news"
|
cmd="idx__stocks__news"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks,orderbook)
|
||||||
|
cmd="idx__stocks__orderbook"
|
||||||
|
;;
|
||||||
idx__stocks,profile)
|
idx__stocks,profile)
|
||||||
cmd="idx__stocks__profile"
|
cmd="idx__stocks__profile"
|
||||||
;;
|
;;
|
||||||
|
|
@ -447,6 +492,9 @@ _idx() {
|
||||||
idx__stocks__help,news)
|
idx__stocks__help,news)
|
||||||
cmd="idx__stocks__help__news"
|
cmd="idx__stocks__help__news"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help,orderbook)
|
||||||
|
cmd="idx__stocks__help__orderbook"
|
||||||
|
;;
|
||||||
idx__stocks__help,profile)
|
idx__stocks__help,profile)
|
||||||
cmd="idx__stocks__help__profile"
|
cmd="idx__stocks__help__profile"
|
||||||
;;
|
;;
|
||||||
|
|
@ -475,7 +523,7 @@ _idx() {
|
||||||
|
|
||||||
case "${cmd}" in
|
case "${cmd}" in
|
||||||
idx)
|
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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -875,7 +923,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__help)
|
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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1266,8 +1314,64 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
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)
|
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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1448,6 +1552,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
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)
|
idx__help__stocks__profile)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -2392,8 +2510,182 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
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)
|
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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2691,7 +2983,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__stocks__help)
|
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
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2886,6 +3178,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
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)
|
idx__stocks__help__profile)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -3000,7 +3306,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
--history-provider)
|
--history-provider)
|
||||||
COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}"))
|
COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}"))
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
--output)
|
--output)
|
||||||
|
|
@ -3104,6 +3410,36 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__orderbook)
|
||||||
|
opts="-o -q -v -h --depth --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--depth)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--provider)
|
||||||
|
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__stocks__profile)
|
idx__stocks__profile)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
@ -3254,7 +3590,7 @@ _idx() {
|
||||||
fi
|
fi
|
||||||
case "${prev}" in
|
case "${prev}" in
|
||||||
--history-provider)
|
--history-provider)
|
||||||
COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}"))
|
COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}"))
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
--output)
|
--output)
|
||||||
|
|
|
||||||
|
|
@ -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)"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 4
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -10,6 +10,7 @@ Commands:
|
||||||
config Manage configuration
|
config Manage configuration
|
||||||
cache Manage local cache
|
cache Manage local cache
|
||||||
ownership Ownership intelligence (bootstrap with `ownership sync`)
|
ownership Ownership intelligence (bootstrap with `ownership sync`)
|
||||||
|
stockbit Your Stockbit session (opt-in: order book, live quotes)
|
||||||
completions Generate shell completions
|
completions Generate shell completions
|
||||||
version Show idx-cli version
|
version Show idx-cli version
|
||||||
help Print this message or the help of the given subcommand(s)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ Usage: idx stocks [OPTIONS] <COMMAND>
|
||||||
Commands:
|
Commands:
|
||||||
quote Get real-time stock quotes
|
quote Get real-time stock quotes
|
||||||
intraday Today's session price by the minute (Stockbit)
|
intraday Today's session price by the minute (Stockbit)
|
||||||
|
orderbook Order book depth and foreign flow (Stockbit login)
|
||||||
history Get historical OHLC data
|
history Get historical OHLC data
|
||||||
technical Run technical analysis on a stock
|
technical Run technical analysis on a stock
|
||||||
growth Run growth analysis on a stock
|
growth Run growth analysis on a stock
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,5 @@ exit: 0
|
||||||
│ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │
|
│ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │
|
||||||
└─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘
|
└─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘
|
||||||
Note: MABA.JK: Stockbit lists the stock as suspended
|
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
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
{"logged_in":false,"access":{"present":false,"expires_at":null,"expires_in_secs":null},"refresh":{"present":false,"expires_at":null,"expires_in_secs":null},"session_file":"<ROOT>/config/idx/stockbit-session.json"}
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
logged in: no
|
||||||
|
access token: none
|
||||||
|
refresh token: none
|
||||||
|
session file: <ROOT>/config/idx/stockbit-session.json
|
||||||
|
Run `idx stockbit login` to add one.
|
||||||
|
--- stderr
|
||||||
|
|
@ -30,6 +30,8 @@ directories = "5"
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
fastrand = "2"
|
fastrand = "2"
|
||||||
|
# Decodes a Stockbit session JWT's expiry (`exp`); never verifies or signs.
|
||||||
|
base64 = "0.22"
|
||||||
rusqlite = { workspace = true, optional = true }
|
rusqlite = { workspace = true, optional = true }
|
||||||
quick-xml = { version = "0.37", optional = true }
|
quick-xml = { version = "0.37", optional = true }
|
||||||
sha2 = { workspace = true, optional = true }
|
sha2 = { workspace = true, optional = true }
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use crate::api::history_provider;
|
||||||
use crate::api::types::{Fundamentals, Interval, Period};
|
use crate::api::types::{Fundamentals, Interval, Period};
|
||||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
|
use crate::config::HistoryProviderKind;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use crate::market::HistorySource;
|
use crate::market::HistorySource;
|
||||||
use bandar::{BandarReport, analyze_bandar, check_top};
|
use bandar::{BandarReport, analyze_bandar, check_top};
|
||||||
|
|
@ -110,8 +111,16 @@ impl Analysis<'_> {
|
||||||
Ok(BandarScreen { reports, failures })
|
Ok(BandarScreen { reports, failures })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where `technical` reads its year of daily history from, for an
|
||||||
|
/// optional `--history-provider` override.
|
||||||
|
pub fn technical_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
|
||||||
|
self.idx
|
||||||
|
.market()
|
||||||
|
.history_source(mode, &Period::OneYear, &Interval::Day)
|
||||||
|
}
|
||||||
|
|
||||||
/// Technical analysis over one year of daily history from `source`
|
/// Technical analysis over one year of daily history from `source`
|
||||||
/// (see `Market::history_source`).
|
/// (see `technical_source`).
|
||||||
pub fn technical(
|
pub fn technical(
|
||||||
&self,
|
&self,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
|
|
@ -119,9 +128,8 @@ impl Analysis<'_> {
|
||||||
policy: CachePolicy,
|
policy: CachePolicy,
|
||||||
) -> Result<Fetched<TechnicalReport>, IdxError> {
|
) -> Result<Fetched<TechnicalReport>, IdxError> {
|
||||||
let idx = self.idx;
|
let idx = self.idx;
|
||||||
let (provider_kind, provider) =
|
let provider = history_provider(source.provider, idx.config.verbose);
|
||||||
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
|
let bucket = format!("{}-technical", source.provider.as_str());
|
||||||
let bucket = format!("{}-technical", provider_kind.as_str());
|
|
||||||
let resolved = idx.resolve(symbol)?;
|
let resolved = idx.resolve(symbol)?;
|
||||||
cached_fetch(
|
cached_fetch(
|
||||||
&idx.cache,
|
&idx.cache,
|
||||||
|
|
@ -130,7 +138,7 @@ impl Analysis<'_> {
|
||||||
key: &resolved,
|
key: &resolved,
|
||||||
ttl_secs: idx.config.quote_ttl,
|
ttl_secs: idx.config.quote_ttl,
|
||||||
subject: &resolved,
|
subject: &resolved,
|
||||||
source: provider_kind.as_str(),
|
source: source.provider.as_str(),
|
||||||
},
|
},
|
||||||
policy,
|
policy,
|
||||||
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
||||||
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
//! `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::{
|
use super::{
|
||||||
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, QuoteProvider,
|
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
|
||||||
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
OrderBookProvider, QuoteProvider, TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
||||||
};
|
};
|
||||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||||
use crate::config::ProviderKind;
|
use crate::config::ProviderKind;
|
||||||
|
|
@ -135,22 +136,31 @@ impl HistoryProvider for MockProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stockbit symbol pages from `tests/fixtures/stockbit_symbol_<code>.html`;
|
/// Stockbit from fixtures. Without a session (none stored or seeded through
|
||||||
/// a code without a fixture is Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS`
|
/// `IDX_STOCKBIT_REFRESH_TOKEN`): the public page,
|
||||||
/// (e.g. `403`) answers that HTTP status instead, and `IDX_MOCK_ERROR` fails
|
/// `tests/fixtures/stockbit_symbol_<code>.html`. With one: the session API,
|
||||||
/// as for the other providers.
|
/// `tests/fixtures/stockbit_exodus_orderbook_<code>.json` and
|
||||||
|
/// `stockbit_exodus_chart_candle_<code>_<window>.json`, with the market state
|
||||||
|
/// from `stockbit_exodus_market_session.json`. A code without a fixture is
|
||||||
|
/// Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` (e.g. `403`) answers that HTTP
|
||||||
|
/// status instead, and `IDX_MOCK_ERROR` fails as for the other providers.
|
||||||
pub struct MockStockbitProvider;
|
pub struct MockStockbitProvider;
|
||||||
|
|
||||||
impl MockStockbitProvider {
|
impl MockStockbitProvider {
|
||||||
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
|
fn failure(&self, symbol: &str) -> Option<IdxError> {
|
||||||
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
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") {
|
let status = std::env::var("IDX_MOCK_STOCKBIT_STATUS").ok()?;
|
||||||
return Err(stockbit::status_error(
|
Some(stockbit::status_error(
|
||||||
status.parse().unwrap_or(500),
|
status.parse().unwrap_or(500),
|
||||||
symbol,
|
symbol,
|
||||||
));
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
|
||||||
|
if let Some(err) = self.failure(symbol) {
|
||||||
|
return Err(err);
|
||||||
}
|
}
|
||||||
let code = stockbit::stockbit_code(symbol)?;
|
let code = stockbit::stockbit_code(symbol)?;
|
||||||
let path = format!(
|
let path = format!(
|
||||||
|
|
@ -162,10 +172,32 @@ impl MockStockbitProvider {
|
||||||
Err(_) => Err(stockbit::status_error(404, symbol)),
|
Err(_) => Err(stockbit::status_error(404, symbol)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A session API fixture, or the error a request would meet.
|
||||||
|
fn exodus(&self, symbol: &str, name: &str) -> Result<String, IdxError> {
|
||||||
|
if !stockbit::has_session() {
|
||||||
|
return Err(IdxError::AuthError(stockbit::auth::NO_SESSION.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(err) = self.failure(symbol) {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
std::fs::read_to_string(format!("tests/fixtures/stockbit_exodus_{name}"))
|
||||||
|
.map_err(|_| IdxError::SymbolNotFound(symbol.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn book(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||||
|
let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase();
|
||||||
|
let body = self.exodus(symbol, &format!("orderbook_{code}.json"))?;
|
||||||
|
let state = self.exodus(symbol, "market_session.json")?;
|
||||||
|
exodus::parse_orderbook(symbol, &body, exodus::parse_market_status(&state))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QuoteProvider for MockStockbitProvider {
|
impl QuoteProvider for MockStockbitProvider {
|
||||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||||
|
if stockbit::has_session() {
|
||||||
|
return self.book(symbol).map(|book| exodus::quote_from_book(&book));
|
||||||
|
}
|
||||||
let mut quote = self.snapshot(symbol)?.quote;
|
let mut quote = self.snapshot(symbol)?.quote;
|
||||||
// Quotes are judged against the clock (behind the session, a week
|
// Quotes are judged against the clock (behind the session, a week
|
||||||
// old); a fixed trade time would make their output age.
|
// old); a fixed trade time would make their output age.
|
||||||
|
|
@ -186,6 +218,27 @@ impl IntradayProvider for MockStockbitProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl OrderBookProvider for MockStockbitProvider {
|
||||||
|
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||||
|
self.book(symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryProvider for MockStockbitProvider {
|
||||||
|
fn history(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> Result<Vec<Bar>, IdxError> {
|
||||||
|
let window = exodus::history_window(period, interval)
|
||||||
|
.ok_or_else(|| exodus::unsupported_window(period, interval))?;
|
||||||
|
let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase();
|
||||||
|
let body = self.exodus(symbol, &format!("chart_candle_{code}_{window}.json"))?;
|
||||||
|
exodus::daily_for_period(&body, period)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Broker summaries from `tests/fixtures/ipot_brokersummary_<code>_<from>
|
/// Broker summaries from `tests/fixtures/ipot_brokersummary_<code>_<from>
|
||||||
/// [_<to>][_<investor>_<board>].html`; a query without a fixture reads as an
|
/// [_<to>][_<investor>_<board>].html`; a query without a fixture reads as an
|
||||||
/// unknown code.
|
/// unknown code.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use types::{
|
use types::{
|
||||||
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
|
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
|
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
|
||||||
|
|
@ -72,6 +72,12 @@ pub trait IntradayProvider {
|
||||||
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError>;
|
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The order book of one IDX stock: needs the user's Stockbit session.
|
||||||
|
pub trait OrderBookProvider {
|
||||||
|
/// `symbol` is resolved, e.g. `BBCA.JK`.
|
||||||
|
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError>;
|
||||||
|
}
|
||||||
|
|
||||||
pub trait FundamentalsProvider {
|
pub trait FundamentalsProvider {
|
||||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
@ -242,9 +248,12 @@ fn mock_market(_provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
|
||||||
|
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
fn mock_history(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> {
|
fn mock_history(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> {
|
||||||
Some(Box::new(
|
match provider {
|
||||||
|
ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)),
|
||||||
|
_ => Some(Box::new(
|
||||||
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
|
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
|
||||||
))
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "testing"))]
|
#[cfg(not(feature = "testing"))]
|
||||||
|
|
@ -285,7 +294,7 @@ fn build_selected_provider(
|
||||||
news: Some(Box::new(msn::MsnProvider::new(verbose))),
|
news: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||||
screener: 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 {
|
ProviderKind::Stockbit => SelectedProvider {
|
||||||
kind: ProviderKind::Stockbit,
|
kind: ProviderKind::Stockbit,
|
||||||
market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())),
|
market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())),
|
||||||
|
|
@ -328,6 +337,14 @@ pub fn intraday_provider() -> Box<dyn IntradayProvider> {
|
||||||
Box::new(stockbit::StockbitProvider::new())
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn orderbook_provider() -> Box<dyn OrderBookProvider> {
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
if mock::enabled() {
|
||||||
|
return Box::new(mock::MockStockbitProvider);
|
||||||
|
}
|
||||||
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
|
}
|
||||||
|
|
||||||
/// Today in WIB, the date IDX trades on.
|
/// Today in WIB, the date IDX trades on.
|
||||||
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
|
|
@ -338,40 +355,44 @@ pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The provider that serves history for `provider` under `history_mode`.
|
/// 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 {
|
match history_mode {
|
||||||
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
|
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
|
||||||
HistoryProviderKind::Msn => ProviderKind::Msn,
|
HistoryProviderKind::Msn => ProviderKind::Msn,
|
||||||
HistoryProviderKind::Auto => match provider {
|
HistoryProviderKind::Stockbit => ProviderKind::Stockbit,
|
||||||
ProviderKind::Yahoo | ProviderKind::Msn | ProviderKind::Stockbit => ProviderKind::Yahoo,
|
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
|
/// The history provider for a source from `history_source`.
|
||||||
/// history provider strategy.
|
pub fn history_provider(source: ProviderKind, verbose: bool) -> Box<dyn HistoryProvider> {
|
||||||
///
|
|
||||||
/// `history_mode=auto` keeps using Yahoo for IDX history because Yahoo provides
|
|
||||||
/// full OHLCV candles. Explicit `msn` opts into MSN's price-only chart feed.
|
|
||||||
pub fn history_provider(
|
|
||||||
provider: ProviderKind,
|
|
||||||
history_mode: HistoryProviderKind,
|
|
||||||
verbose: bool,
|
|
||||||
) -> Result<(ProviderKind, Box<dyn HistoryProvider>), IdxError> {
|
|
||||||
let resolved = history_source(provider, history_mode);
|
|
||||||
|
|
||||||
if mock_enabled()
|
if mock_enabled()
|
||||||
&& let Some(mock) = mock_history(resolved, verbose)
|
&& let Some(mock) = mock_history(source, verbose)
|
||||||
{
|
{
|
||||||
return Ok((resolved, mock));
|
return mock;
|
||||||
}
|
}
|
||||||
|
match source {
|
||||||
match resolved {
|
ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)),
|
||||||
ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))),
|
ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)),
|
||||||
ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))),
|
ProviderKind::Stockbit => Box::new(stockbit::StockbitProvider::new()),
|
||||||
ProviderKind::Stockbit => Err(IdxError::Unsupported(
|
|
||||||
"Stockbit serves no price history; use --history-provider yahoo or msn".to_string(),
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -472,4 +493,72 @@ mod tests {
|
||||||
"unsupported: screen: command requires --provider msn"
|
"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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
607
crates/idx-core/src/api/stockbit/auth.rs
Normal file
607
crates/idx-core/src/api/stockbit/auth.rs
Normal file
|
|
@ -0,0 +1,607 @@
|
||||||
|
//! The user's own Stockbit session: two bearer JWTs, where they are kept, and
|
||||||
|
//! how they are refreshed. Opt-in; nothing here runs without a session.
|
||||||
|
//!
|
||||||
|
//! - The **access** token (about a day) authorises every
|
||||||
|
//! `exodus.stockbit.com` request.
|
||||||
|
//! - The **refresh** token (about a week) is spent on `POST /login/refresh`,
|
||||||
|
//! which answers a new access token *and a new refresh token*, retiring the
|
||||||
|
//! one presented. So exactly one party may refresh a session: two idx
|
||||||
|
//! processes refresh under a file lock and re-read what the other wrote, and
|
||||||
|
//! a browser sharing the session gets logged out once idx refreshes.
|
||||||
|
//!
|
||||||
|
//! The session lives in `<config dir>/stockbit-session.json` (mode 0600).
|
||||||
|
//! `IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` seed fields the
|
||||||
|
//! file lacks, for servers. Tokens never reach logs, errors or cache keys.
|
||||||
|
//! The token shapes follow fzn0x/idx (MIT).
|
||||||
|
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
pub const ENV_REFRESH: &str = "IDX_STOCKBIT_REFRESH_TOKEN";
|
||||||
|
pub const ENV_ACCESS: &str = "IDX_STOCKBIT_ACCESS_TOKEN";
|
||||||
|
const SESSION_FILE: &str = "stockbit-session.json";
|
||||||
|
const LOCK_FILE: &str = "stockbit-session.lock";
|
||||||
|
/// Refresh this long before the access token expires, so no request races
|
||||||
|
/// the expiry.
|
||||||
|
const REFRESH_BUFFER_SECS: i64 = 60 * 60;
|
||||||
|
|
||||||
|
pub(crate) const NO_SESSION: &str =
|
||||||
|
"no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)";
|
||||||
|
|
||||||
|
/// The persisted token pair. Expiries are read from the tokens themselves.
|
||||||
|
#[derive(Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Session {
|
||||||
|
#[serde(default)]
|
||||||
|
pub access: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub refresh: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Session {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Session")
|
||||||
|
.field("access", &(!self.access.is_empty()).then_some("<redacted>"))
|
||||||
|
.field(
|
||||||
|
"refresh",
|
||||||
|
&(!self.refresh.is_empty()).then_some("<redacted>"),
|
||||||
|
)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.access.is_empty() && self.refresh.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token's presence and expiry, never its value.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TokenInfo {
|
||||||
|
pub present: bool,
|
||||||
|
/// When the token expires; `null` when absent or not a JWT.
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
/// Seconds until expiry, negative once expired.
|
||||||
|
pub expires_in_secs: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenInfo {
|
||||||
|
fn of(token: &str, now: i64) -> Self {
|
||||||
|
let present = !token.is_empty();
|
||||||
|
let exp = present.then(|| token_expiry(token)).flatten();
|
||||||
|
Self {
|
||||||
|
present,
|
||||||
|
expires_at: exp.and_then(|e| DateTime::from_timestamp(e, 0)),
|
||||||
|
expires_in_secs: exp.map(|e| e - now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `idx stockbit status` and the MCP `stockbit_status` tool report.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SessionStatus {
|
||||||
|
/// A refresh or access token is stored or seeded (live or not).
|
||||||
|
pub logged_in: bool,
|
||||||
|
pub access: TokenInfo,
|
||||||
|
pub refresh: TokenInfo,
|
||||||
|
pub session_file: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads, writes and refreshes the session.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
seed: Session,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStore {
|
||||||
|
/// The session beside `config.toml`, seeded from the environment.
|
||||||
|
pub fn open() -> Result<Self, IdxError> {
|
||||||
|
let config = crate::config::config_path()?;
|
||||||
|
let dir = config
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))?;
|
||||||
|
let env = |name| {
|
||||||
|
std::env::var(name)
|
||||||
|
.map(|v| normalize_token(&v))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
Ok(Self::at(
|
||||||
|
dir.to_path_buf(),
|
||||||
|
Session {
|
||||||
|
access: env(ENV_ACCESS),
|
||||||
|
refresh: env(ENV_REFRESH),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn at(dir: PathBuf, seed: Session) -> Self {
|
||||||
|
Self { dir, seed }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> PathBuf {
|
||||||
|
self.dir.join(SESSION_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file's tokens, each missing one taken from the seed.
|
||||||
|
pub fn load(&self) -> Session {
|
||||||
|
let mut session = std::fs::read_to_string(self.path())
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| serde_json::from_str::<Session>(&raw).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
session.access = normalize_token(&session.access);
|
||||||
|
session.refresh = normalize_token(&session.refresh);
|
||||||
|
if session.access.is_empty() {
|
||||||
|
session.access.clone_from(&self.seed.access);
|
||||||
|
}
|
||||||
|
if session.refresh.is_empty() {
|
||||||
|
session.refresh.clone_from(&self.seed.refresh);
|
||||||
|
}
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_session(&self) -> bool {
|
||||||
|
!self.load().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the session atomically, readable by the owner only.
|
||||||
|
pub fn save(&self, session: &Session) -> Result<(), IdxError> {
|
||||||
|
let io = |e: std::io::Error| IdxError::Io(format!("stockbit session: {e}"));
|
||||||
|
std::fs::create_dir_all(&self.dir).map_err(io)?;
|
||||||
|
let tmp = self.dir.join(format!("{SESSION_FILE}.tmp"));
|
||||||
|
let body = serde_json::to_vec_pretty(session)
|
||||||
|
.map_err(|e| IdxError::Io(format!("stockbit session: {e}")))?;
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.write(true).create(true).truncate(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||||
|
let mut file = options.open(&tmp).map_err(io)?;
|
||||||
|
std::io::Write::write_all(&mut file, &body).map_err(io)?;
|
||||||
|
file.sync_all().map_err(io)?;
|
||||||
|
drop(file);
|
||||||
|
std::fs::rename(&tmp, self.path()).map_err(io)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the stored session; false when there was none. Seeds from the
|
||||||
|
/// environment are not affected.
|
||||||
|
pub fn clear(&self) -> Result<bool, IdxError> {
|
||||||
|
match std::fs::remove_file(self.path()) {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||||
|
Err(e) => Err(IdxError::Io(format!("stockbit session: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self, now: i64) -> SessionStatus {
|
||||||
|
let session = self.load();
|
||||||
|
SessionStatus {
|
||||||
|
logged_in: !session.is_empty(),
|
||||||
|
access: TokenInfo::of(&session.access, now),
|
||||||
|
refresh: TokenInfo::of(&session.refresh, now),
|
||||||
|
session_file: self.path().display().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An access token good for at least `REFRESH_BUFFER_SECS`, refreshing
|
||||||
|
/// with `refresher` (refresh token in, response body out) when it is not.
|
||||||
|
pub fn access(
|
||||||
|
&self,
|
||||||
|
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<String, IdxError> {
|
||||||
|
let current = self.load();
|
||||||
|
if current.is_empty() {
|
||||||
|
return Err(IdxError::AuthError(NO_SESSION.to_string()));
|
||||||
|
}
|
||||||
|
if fresh(¤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<String, IdxError>,
|
||||||
|
) -> Result<String, IdxError> {
|
||||||
|
self.locked(|| {
|
||||||
|
let session = self.load();
|
||||||
|
if !session.access.is_empty() && session.access != rejected {
|
||||||
|
return Ok(session.access);
|
||||||
|
}
|
||||||
|
self.rotate(&session, refresher)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spends the refresh token and stores the new pair.
|
||||||
|
fn rotate(
|
||||||
|
&self,
|
||||||
|
session: &Session,
|
||||||
|
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
|
||||||
|
) -> Result<String, IdxError> {
|
||||||
|
if session.refresh.is_empty() {
|
||||||
|
return Err(IdxError::AuthError(
|
||||||
|
"the Stockbit session has no refresh token; run `idx stockbit login`".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let body = refresher(&session.refresh)?;
|
||||||
|
let next = parse_refresh(&body, session)?;
|
||||||
|
self.save(&next)?;
|
||||||
|
Ok(next.access)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `f` holding the session lock, so only one process spends the
|
||||||
|
/// rotating refresh token at a time.
|
||||||
|
fn locked<T>(&self, f: impl FnOnce() -> Result<T, IdxError>) -> Result<T, IdxError> {
|
||||||
|
let io = |e: std::io::Error| IdxError::Io(format!("stockbit session lock: {e}"));
|
||||||
|
std::fs::create_dir_all(&self.dir).map_err(io)?;
|
||||||
|
let lock = lock_file(&self.dir.join(LOCK_FILE)).map_err(io)?;
|
||||||
|
lock.lock().map_err(io)?;
|
||||||
|
let result = f();
|
||||||
|
drop(lock);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_file(path: &Path) -> std::io::Result<File> {
|
||||||
|
OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh(access: &str, now: i64) -> bool {
|
||||||
|
token_expiry(access).is_some_and(|exp| now < exp - REFRESH_BUFFER_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips whitespace and a leading `Bearer `.
|
||||||
|
pub fn normalize_token(raw: &str) -> String {
|
||||||
|
let t = raw.trim();
|
||||||
|
t.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| t.strip_prefix("bearer "))
|
||||||
|
.unwrap_or(t)
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A JWT's `exp` (Unix seconds), read without verifying the signature: idx
|
||||||
|
/// only carries these tokens.
|
||||||
|
pub fn token_expiry(token: &str) -> Option<i64> {
|
||||||
|
let payload = token.split('.').nth(1)?;
|
||||||
|
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(payload.trim_end_matches('='))
|
||||||
|
.ok()?;
|
||||||
|
serde_json::from_slice::<Value>(&bytes)
|
||||||
|
.ok()?
|
||||||
|
.get("exp")?
|
||||||
|
.as_i64()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three dot-separated base64url parts with a JSON header (`eyJ`).
|
||||||
|
fn looks_like_jwt(value: &str) -> bool {
|
||||||
|
let mut parts = value.split('.');
|
||||||
|
matches!(parts.next(), Some(h) if h.starts_with("eyJ"))
|
||||||
|
&& parts.next().is_some_and(|p| !p.is_empty())
|
||||||
|
&& parts.next().is_some_and(|p| !p.is_empty())
|
||||||
|
&& parts.next().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The JWT under key `slot` anywhere in `value`: a bare string or an object
|
||||||
|
/// with `token`. Stockbit's envelopes have moved between versions.
|
||||||
|
fn find_token(value: &Value, slot: &str) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
let direct = map.get(slot).and_then(|hit| {
|
||||||
|
hit.as_str()
|
||||||
|
.or_else(|| hit.get("token").and_then(Value::as_str))
|
||||||
|
.filter(|t| looks_like_jwt(t))
|
||||||
|
.map(str::to_string)
|
||||||
|
});
|
||||||
|
direct.or_else(|| map.values().find_map(|child| find_token(child, slot)))
|
||||||
|
}
|
||||||
|
Value::Array(items) => items.iter().find_map(|child| find_token(child, slot)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The new pair from a `/login/refresh` response; a response that rotates
|
||||||
|
/// only the access token keeps the presented refresh token.
|
||||||
|
pub fn parse_refresh(body: &str, previous: &Session) -> Result<Session, IdxError> {
|
||||||
|
let value: Value = serde_json::from_str(body)
|
||||||
|
.map_err(|e| IdxError::ParseError(format!("stockbit refresh response: {e}")))?;
|
||||||
|
let access = find_token(&value, "access").ok_or_else(|| {
|
||||||
|
IdxError::ParseError("stockbit refresh response carried no access token".to_string())
|
||||||
|
})?;
|
||||||
|
Ok(Session {
|
||||||
|
access,
|
||||||
|
refresh: find_token(&value, "refresh").unwrap_or_else(|| previous.refresh.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session from what a user pastes: the `credentialStorage` cookie of
|
||||||
|
/// stockbit.com (percent-encoded JSON), that JSON decoded, or a bare refresh
|
||||||
|
/// token.
|
||||||
|
pub fn session_from_paste(raw: &str) -> Option<Session> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
let decoded = percent_decode(raw);
|
||||||
|
for candidate in [decoded.as_str(), raw] {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(candidate) {
|
||||||
|
let session = Session {
|
||||||
|
access: find_token(&value, "access").unwrap_or_default(),
|
||||||
|
refresh: find_token(&value, "refresh").unwrap_or_default(),
|
||||||
|
};
|
||||||
|
if !session.is_empty() {
|
||||||
|
return Some(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let token = normalize_token(raw);
|
||||||
|
looks_like_jwt(&token).then(|| Session {
|
||||||
|
access: String::new(),
|
||||||
|
refresh: token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `%XX` escapes decoded; anything else kept as is.
|
||||||
|
fn percent_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%'
|
||||||
|
&& let Some(hex) = s.get(i + 1..i + 3)
|
||||||
|
&& let Ok(byte) = u8::from_str_radix(hex, 16)
|
||||||
|
{
|
||||||
|
out.push(byte);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> String {
|
||||||
|
std::fs::read_to_string(format!(
|
||||||
|
"{}/tests/fixtures/{name}",
|
||||||
|
env!("CARGO_MANIFEST_DIR")
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unsigned JWT expiring at `exp`.
|
||||||
|
fn jwt(exp: i64, tag: &str) -> String {
|
||||||
|
let enc = |v: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v);
|
||||||
|
format!(
|
||||||
|
"{}.{}.{}",
|
||||||
|
enc(r#"{"alg":"none"}"#),
|
||||||
|
enc(&format!(r#"{{"exp":{exp},"tag":"{tag}"}}"#)),
|
||||||
|
enc("sig")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_body(access: &str, refresh: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"data":{{"access":{{"token":"{access}"}},"refresh":{{"token":"{refresh}"}}}}}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
impl TempDir {
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
let dir = std::env::temp_dir()
|
||||||
|
.join(format!("idx-stockbit-auth-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
Self(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW: i64 = 1_800_000_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_the_pair_from_a_refresh_response_and_the_cookie() {
|
||||||
|
let pair = parse_refresh(
|
||||||
|
&fixture("stockbit_exodus_login_refresh_constructed.json"),
|
||||||
|
&Session::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(token_expiry(&pair.access), Some(1_792_385_008));
|
||||||
|
assert!(looks_like_jwt(&pair.refresh));
|
||||||
|
|
||||||
|
let pasted = session_from_paste(&fixture(
|
||||||
|
"stockbit_exodus_credential_storage_cookie_constructed.txt",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(looks_like_jwt(&pasted.access) && looks_like_jwt(&pasted.refresh));
|
||||||
|
|
||||||
|
let bare = jwt(NOW, "r");
|
||||||
|
assert_eq!(
|
||||||
|
session_from_paste(&format!(" Bearer {bare}\n")),
|
||||||
|
Some(Session {
|
||||||
|
access: String::new(),
|
||||||
|
refresh: bare
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(session_from_paste("not a token"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refresh_without_a_new_refresh_token_keeps_the_old_one() {
|
||||||
|
let previous = Session {
|
||||||
|
access: String::new(),
|
||||||
|
refresh: jwt(NOW, "old"),
|
||||||
|
};
|
||||||
|
let access = jwt(NOW + 86_400, "a");
|
||||||
|
let next =
|
||||||
|
parse_refresh(&format!(r#"{{"data":{{"access":"{access}"}}}}"#), &previous).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
next,
|
||||||
|
Session {
|
||||||
|
access,
|
||||||
|
refresh: previous.refresh
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_access_token_is_used_without_refreshing() {
|
||||||
|
let dir = TempDir::new("fresh");
|
||||||
|
let store = SessionStore::at(dir.0.clone(), Session::default());
|
||||||
|
let access = jwt(NOW + 2 * REFRESH_BUFFER_SECS, "a");
|
||||||
|
store
|
||||||
|
.save(&Session {
|
||||||
|
access: access.clone(),
|
||||||
|
refresh: jwt(NOW + 7 * 86_400, "r"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let refresher = |_: &str| -> Result<String, IdxError> { panic!("must not refresh") };
|
||||||
|
assert_eq!(store.access(&refresher, NOW).unwrap(), access);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stale_access_token_rotates_both_tokens_and_persists_them() {
|
||||||
|
let dir = TempDir::new("rotate");
|
||||||
|
let store = SessionStore::at(dir.0.clone(), Session::default());
|
||||||
|
let old_refresh = jwt(NOW + 7 * 86_400, "r1");
|
||||||
|
store
|
||||||
|
.save(&Session {
|
||||||
|
access: jwt(NOW + 60, "a1"),
|
||||||
|
refresh: old_refresh.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let (new_access, new_refresh) = (jwt(NOW + 86_400, "a2"), jwt(NOW + 8 * 86_400, "r2"));
|
||||||
|
let calls = Cell::new(0);
|
||||||
|
let refresher = |presented: &str| {
|
||||||
|
calls.set(calls.get() + 1);
|
||||||
|
assert_eq!(presented, old_refresh);
|
||||||
|
Ok(refresh_body(&new_access, &new_refresh))
|
||||||
|
};
|
||||||
|
assert_eq!(store.access(&refresher, NOW).unwrap(), new_access);
|
||||||
|
assert_eq!(calls.get(), 1);
|
||||||
|
// The rotated pair is what the next process reads.
|
||||||
|
assert_eq!(
|
||||||
|
store.load(),
|
||||||
|
Session {
|
||||||
|
access: new_access,
|
||||||
|
refresh: new_refresh
|
||||||
|
}
|
||||||
|
);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(store.path())
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rejected_token_already_replaced_by_another_process_is_not_refreshed_again() {
|
||||||
|
let dir = TempDir::new("rejected");
|
||||||
|
let store = SessionStore::at(dir.0.clone(), Session::default());
|
||||||
|
let replaced = jwt(NOW + 86_400, "theirs");
|
||||||
|
store
|
||||||
|
.save(&Session {
|
||||||
|
access: replaced.clone(),
|
||||||
|
refresh: jwt(NOW + 86_400, "r"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let refresher = |_: &str| -> Result<String, IdxError> { panic!("must not refresh") };
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.after_rejection(&jwt(NOW, "mine"), &refresher)
|
||||||
|
.unwrap(),
|
||||||
|
replaced
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_session_is_an_auth_error_and_the_file_beats_the_seed() {
|
||||||
|
let dir = TempDir::new("seed");
|
||||||
|
let empty = SessionStore::at(dir.0.clone(), Session::default());
|
||||||
|
let refresher = |_: &str| -> Result<String, IdxError> { unreachable!() };
|
||||||
|
assert!(matches!(
|
||||||
|
empty.access(&refresher, NOW),
|
||||||
|
Err(IdxError::AuthError(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let seeded = SessionStore::at(
|
||||||
|
dir.0.clone(),
|
||||||
|
Session {
|
||||||
|
access: String::new(),
|
||||||
|
refresh: jwt(NOW, "env"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(seeded.has_session());
|
||||||
|
let stored = jwt(NOW, "file");
|
||||||
|
seeded
|
||||||
|
.save(&Session {
|
||||||
|
access: String::new(),
|
||||||
|
refresh: stored.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(seeded.load().refresh, stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_and_debug_never_show_a_token() {
|
||||||
|
let dir = TempDir::new("status");
|
||||||
|
let token = jwt(NOW + 100, "secret");
|
||||||
|
let store = SessionStore::at(
|
||||||
|
dir.0.clone(),
|
||||||
|
Session {
|
||||||
|
access: token.clone(),
|
||||||
|
refresh: String::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let status = store.status(NOW);
|
||||||
|
assert!(status.logged_in);
|
||||||
|
assert_eq!(status.access.expires_in_secs, Some(100));
|
||||||
|
assert!(!status.refresh.present);
|
||||||
|
let shown = format!(
|
||||||
|
"{} {:?}",
|
||||||
|
serde_json::to_string(&status).unwrap(),
|
||||||
|
store.load()
|
||||||
|
);
|
||||||
|
assert!(!shown.contains(&token), "{shown}");
|
||||||
|
}
|
||||||
|
}
|
||||||
543
crates/idx-core/src/api/stockbit/exodus.rs
Normal file
543
crates/idx-core/src/api/stockbit/exodus.rs
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
//! Stockbit's own market-data API, `https://exodus.stockbit.com`, with the
|
||||||
|
//! user's session (see `auth`). Opt-in: used only when a session is stored.
|
||||||
|
//!
|
||||||
|
//! - `GET /company-price-feed/v2/orderbook/companies/{CODE}`: last price,
|
||||||
|
//! OHLC, volume (shares), value, frequency, foreign buy/sell/net (IDR) and
|
||||||
|
//! the depth ladder `bid[]`/`offer[]` of `{price, que_num, volume}` (shares).
|
||||||
|
//! - `GET /company-price-feed/market-time/session`: the market's state,
|
||||||
|
//! `data.detail.regular.state_name` (e.g. `STATE_NAME_MARKET_CLOSED`).
|
||||||
|
//! - `GET /charts/{CODE}/daily?timeframe=1m|3m|1y&chart_type=PRICE_CHART_TYPE_CANDLE`:
|
||||||
|
//! daily candles, `formatted_date` `YYYY-MM-DD`, string OHLC, `value` the
|
||||||
|
//! close and `volume` in lots.
|
||||||
|
//! - `POST /login/refresh` with the refresh token as bearer rotates the pair.
|
||||||
|
//!
|
||||||
|
//! A 401 (or a JSON 403) spends one refresh and retries once. Requests share
|
||||||
|
//! the public page's pacing. Endpoints and units were mapped by fzn0x/idx
|
||||||
|
//! (MIT); `tests/fixtures/stockbit_exodus_*` are its recordings.
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use chrono::{Months, NaiveDate};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::auth::SessionStore;
|
||||||
|
use super::parse::number;
|
||||||
|
use super::{USER_AGENT, pace, stockbit_code};
|
||||||
|
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://exodus.stockbit.com";
|
||||||
|
const REFRESH_PATH: &str = "/login/refresh";
|
||||||
|
/// The state in which the book is live. Assumed from Stockbit's naming
|
||||||
|
/// (only `STATE_NAME_MARKET_CLOSED` has been recorded); any other state hides
|
||||||
|
/// bid/offer in quotes, as on the public page.
|
||||||
|
pub const MARKET_OPEN: &str = "STATE_NAME_MARKET_OPEN";
|
||||||
|
/// The market's state changes a few times a day; one read serves this long.
|
||||||
|
const MARKET_STATE_TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
pub(crate) struct Exodus {
|
||||||
|
agent: ureq::Agent,
|
||||||
|
store: SessionStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Reply {
|
||||||
|
status: u16,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reply {
|
||||||
|
/// The server refused the access token: 401, or a 403 with a JSON body
|
||||||
|
/// (a CDN block is an HTML page).
|
||||||
|
fn rejected(&self) -> bool {
|
||||||
|
self.status == 401
|
||||||
|
|| (self.status == 403 && serde_json::from_str::<Value>(&self.body).is_ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Exodus {
|
||||||
|
pub(crate) fn new(store: SessionStore) -> Self {
|
||||||
|
let agent = ureq::Agent::config_builder()
|
||||||
|
.timeout_connect(Some(Duration::from_secs(5)))
|
||||||
|
.timeout_recv_body(Some(Duration::from_secs(10)))
|
||||||
|
.timeout_global(Some(Duration::from_secs(20)))
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.build()
|
||||||
|
.into();
|
||||||
|
Self { agent, store }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn store(&self) -> &SessionStore {
|
||||||
|
&self.store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One authorised request (the market's state), refreshing the session
|
||||||
|
/// when needed: proves Stockbit accepts it.
|
||||||
|
pub(crate) fn verify(&self) -> Result<(), IdxError> {
|
||||||
|
self.get("/company-price-feed/market-time/session", "market session")
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn orderbook(&self, resolved: &str) -> Result<OrderBook, IdxError> {
|
||||||
|
let code = stockbit_code(resolved)?;
|
||||||
|
let body = self.get(
|
||||||
|
&format!("/company-price-feed/v2/orderbook/companies/{code}"),
|
||||||
|
resolved,
|
||||||
|
)?;
|
||||||
|
parse_orderbook(resolved, &body, self.market_status())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn quote(&self, resolved: &str) -> Result<Quote, IdxError> {
|
||||||
|
self.orderbook(resolved).map(|book| quote_from_book(&book))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Daily candles covering `period`, oldest first.
|
||||||
|
pub(crate) fn daily(
|
||||||
|
&self,
|
||||||
|
resolved: &str,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
let window =
|
||||||
|
history_window(period, interval).ok_or_else(|| unsupported_window(period, interval))?;
|
||||||
|
let code = stockbit_code(resolved)?;
|
||||||
|
let body = self.get(
|
||||||
|
&format!("/charts/{code}/daily?timeframe={window}&chart_type=PRICE_CHART_TYPE_CANDLE"),
|
||||||
|
resolved,
|
||||||
|
)?;
|
||||||
|
daily_for_period(&body, period)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The market's state, read at most every `MARKET_STATE_TTL` per process.
|
||||||
|
/// Unknown on any failure, which hides bid/offer.
|
||||||
|
fn market_status(&self) -> Option<String> {
|
||||||
|
static CACHE: Mutex<Option<(Instant, Option<String>)>> = Mutex::new(None);
|
||||||
|
let mut cached = CACHE.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
if let Some((at, state)) = cached.as_ref()
|
||||||
|
&& at.elapsed() < MARKET_STATE_TTL
|
||||||
|
{
|
||||||
|
return state.clone();
|
||||||
|
}
|
||||||
|
let state = self
|
||||||
|
.get("/company-price-feed/market-time/session", "market session")
|
||||||
|
.ok()
|
||||||
|
.and_then(|body| parse_market_status(&body));
|
||||||
|
*cached = Some((Instant::now(), state.clone()));
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, path: &str, subject: &str) -> Result<String, IdxError> {
|
||||||
|
let refresher = |refresh: &str| self.refresh(refresh);
|
||||||
|
let token = self.store.access(&refresher, unix_now())?;
|
||||||
|
let mut reply = self.send(path, &token)?;
|
||||||
|
if reply.rejected() {
|
||||||
|
let token = self.store.after_rejection(&token, &refresher)?;
|
||||||
|
reply = self.send(path, &token)?;
|
||||||
|
if reply.rejected() {
|
||||||
|
return Err(IdxError::AuthError(
|
||||||
|
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
classify(reply, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&self, path: &str, token: &str) -> Result<Reply, IdxError> {
|
||||||
|
pace();
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.get(format!("{BASE_URL}{path}"))
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Origin", "https://stockbit.com")
|
||||||
|
.header("Referer", "https://stockbit.com/")
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?;
|
||||||
|
read_reply(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /login/refresh`; the response body carries the new pair.
|
||||||
|
fn refresh(&self, refresh: &str) -> Result<String, IdxError> {
|
||||||
|
pace();
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.post(format!("{BASE_URL}{REFRESH_PATH}"))
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Origin", "https://stockbit.com")
|
||||||
|
.header("Referer", "https://stockbit.com/")
|
||||||
|
.header("Authorization", format!("Bearer {refresh}"))
|
||||||
|
.send("{}")
|
||||||
|
.map_err(|e| IdxError::Http(format!("stockbit refresh: {e}")))?;
|
||||||
|
let reply = read_reply(response)?;
|
||||||
|
match reply.status {
|
||||||
|
200..=299 => Ok(reply.body),
|
||||||
|
401 | 403 => Err(IdxError::AuthError(
|
||||||
|
"Stockbit rejected the refresh token (expired or revoked); run `idx stockbit \
|
||||||
|
login` again"
|
||||||
|
.to_string(),
|
||||||
|
)),
|
||||||
|
status => Err(IdxError::Http(format!("stockbit refresh: HTTP {status}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_reply(response: ureq::http::Response<ureq::Body>) -> Result<Reply, IdxError> {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body = response
|
||||||
|
.into_body()
|
||||||
|
.read_to_string()
|
||||||
|
.map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?;
|
||||||
|
Ok(Reply { status, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-rejected reply as data or the error its status means.
|
||||||
|
fn classify(reply: Reply, subject: &str) -> Result<String, IdxError> {
|
||||||
|
match reply.status {
|
||||||
|
200..=299 => Ok(reply.body),
|
||||||
|
404 if error_type(&reply.body).as_deref() == Some("NOT_FOUND") => {
|
||||||
|
Err(IdxError::SymbolNotFound(subject.to_string()))
|
||||||
|
}
|
||||||
|
429 => Err(IdxError::RateLimited),
|
||||||
|
403 => Err(IdxError::UpstreamBlocked(
|
||||||
|
"exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \
|
||||||
|
retry a block. Try again later or from another network"
|
||||||
|
.to_string(),
|
||||||
|
)),
|
||||||
|
status => Err(IdxError::Http(format!(
|
||||||
|
"stockbit api: HTTP {status}{}",
|
||||||
|
message(&reply.body)
|
||||||
|
.map(|m| format!(": {m}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_type(body: &str) -> Option<String> {
|
||||||
|
serde_json::from_str::<Value>(body)
|
||||||
|
.ok()?
|
||||||
|
.get("error_type")?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(body: &str) -> Option<String> {
|
||||||
|
serde_json::from_str::<Value>(body)
|
||||||
|
.ok()?
|
||||||
|
.get("message")?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data(body: &str, what: &str) -> Result<Value, IdxError> {
|
||||||
|
let mut value: Value = serde_json::from_str(body)
|
||||||
|
.map_err(|e| IdxError::ParseError(format!("stockbit {what}: {e}")))?;
|
||||||
|
match value.get_mut("data") {
|
||||||
|
Some(data) => Ok(data.take()),
|
||||||
|
None => Err(IdxError::ParseError(format!("stockbit {what}: no data"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whole(value: Option<&Value>) -> Option<i64> {
|
||||||
|
value.and_then(number).map(|n| n.round() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(value: Option<&Value>) -> Option<u64> {
|
||||||
|
value
|
||||||
|
.and_then(number)
|
||||||
|
.filter(|n| *n >= 0.0)
|
||||||
|
.map(|n| n.round() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The order book and session figures; `market_status` comes from the
|
||||||
|
/// market-time endpoint.
|
||||||
|
pub fn parse_orderbook(
|
||||||
|
resolved: &str,
|
||||||
|
body: &str,
|
||||||
|
market_status: Option<String>,
|
||||||
|
) -> Result<OrderBook, IdxError> {
|
||||||
|
let d = data(body, "order book")?;
|
||||||
|
let price = whole(d.get("lastprice").or_else(|| d.get("close")))
|
||||||
|
.ok_or_else(|| IdxError::ParseError("stockbit order book: no last price".to_string()))?;
|
||||||
|
let levels = |key: &str| -> Vec<BookLevel> {
|
||||||
|
d.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|row| {
|
||||||
|
Some(BookLevel {
|
||||||
|
price: whole(row.get("price")).filter(|p| *p > 0)?,
|
||||||
|
shares: count(row.get("volume")).unwrap_or(0),
|
||||||
|
orders: count(row.get("que_num")).unwrap_or(0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let listing = d.get("status").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
let volume = count(d.get("volume"));
|
||||||
|
let session_note = if !listing.is_empty() && !listing.eq_ignore_ascii_case("active") {
|
||||||
|
Some(format!(
|
||||||
|
"Stockbit lists the stock as {}",
|
||||||
|
listing.to_ascii_lowercase()
|
||||||
|
))
|
||||||
|
} else if volume == Some(0) {
|
||||||
|
Some("no trades this session".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(OrderBook {
|
||||||
|
symbol: resolved.to_string(),
|
||||||
|
name: d.get("name").and_then(Value::as_str).map(str::to_string),
|
||||||
|
price,
|
||||||
|
change: whole(d.get("change")),
|
||||||
|
change_pct: d.get("percentage_change").and_then(number),
|
||||||
|
prev_close: whole(d.get("previous")),
|
||||||
|
open: whole(d.get("open")),
|
||||||
|
high: whole(d.get("high")),
|
||||||
|
low: whole(d.get("low")),
|
||||||
|
volume,
|
||||||
|
value: count(d.get("value")),
|
||||||
|
frequency: count(d.get("frequency")),
|
||||||
|
foreign_buy: whole(d.get("fbuy")),
|
||||||
|
foreign_sell: whole(d.get("fsell")),
|
||||||
|
foreign_net: whole(d.get("fnet")),
|
||||||
|
bids: levels("bid"),
|
||||||
|
offers: levels("offer"),
|
||||||
|
market_status,
|
||||||
|
session_note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quote from the order book; bid/offer only while the market is open.
|
||||||
|
pub fn quote_from_book(book: &OrderBook) -> Quote {
|
||||||
|
let open = book.market_status.as_deref() == Some(MARKET_OPEN);
|
||||||
|
let best = |levels: &[BookLevel]| open.then(|| levels.first().map(|l| l.price)).flatten();
|
||||||
|
Quote {
|
||||||
|
symbol: book.symbol.clone(),
|
||||||
|
price: book.price,
|
||||||
|
change: book.change,
|
||||||
|
change_pct: book.change_pct,
|
||||||
|
volume: book.volume,
|
||||||
|
market_cap: None,
|
||||||
|
week52_high: None,
|
||||||
|
week52_low: None,
|
||||||
|
week52_position: None,
|
||||||
|
range_signal: None,
|
||||||
|
prev_close: book.prev_close,
|
||||||
|
avg_volume: None,
|
||||||
|
as_of: None,
|
||||||
|
behind_session: false,
|
||||||
|
bid: best(&book.bids),
|
||||||
|
offer: best(&book.offers),
|
||||||
|
market_status: book.market_status.clone(),
|
||||||
|
session_note: book.session_note.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_market_status(body: &str) -> Option<String> {
|
||||||
|
data(body, "market session")
|
||||||
|
.ok()?
|
||||||
|
.pointer("/detail/regular/state_name")?
|
||||||
|
.as_str()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chart window holding `period` of daily candles: Stockbit picks the
|
||||||
|
/// bar size from the window (`1w` is hourly, `3y`/`5y` weekly), so only the
|
||||||
|
/// daily windows qualify.
|
||||||
|
pub fn history_window(period: &Period, interval: &Interval) -> Option<&'static str> {
|
||||||
|
match (interval, period) {
|
||||||
|
(Interval::Day, Period::FiveDays | Period::OneMonth) => Some("1m"),
|
||||||
|
(Interval::Day, Period::ThreeMonths) => Some("3m"),
|
||||||
|
(Interval::Day, Period::SixMonths | Period::OneYear) => Some("1y"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unsupported_window(period: &Period, interval: &Interval) -> IdxError {
|
||||||
|
IdxError::Unsupported(format!(
|
||||||
|
"Stockbit history covers 5d to 1y of daily candles, not {} at {}; use \
|
||||||
|
--history-provider yahoo",
|
||||||
|
period.as_str(),
|
||||||
|
interval.as_str()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The candles of `period` from a daily chart response.
|
||||||
|
pub fn daily_for_period(body: &str, period: &Period) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
Ok(trim_to_period(parse_daily(body)?, period))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Daily candles, oldest first. Volume arrives in lots and is returned in
|
||||||
|
/// shares, as from the other providers.
|
||||||
|
pub fn parse_daily(body: &str) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
let d = data(body, "chart")?;
|
||||||
|
let rows = d
|
||||||
|
.get("prices")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| IdxError::ParseError("stockbit chart: no prices".to_string()))?;
|
||||||
|
let mut bars = Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
let Some(close) = whole(row.get("value")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(date) = row
|
||||||
|
.get("formatted_date")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|d| NaiveDate::parse_from_str(d.trim(), "%Y-%m-%d").ok())
|
||||||
|
else {
|
||||||
|
return Err(IdxError::ParseError(format!(
|
||||||
|
"stockbit chart: row without a daily date: {row}"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
bars.push(Ohlc {
|
||||||
|
date,
|
||||||
|
open: whole(row.get("open")).unwrap_or(close),
|
||||||
|
high: whole(row.get("high")).unwrap_or(close),
|
||||||
|
low: whole(row.get("low")).unwrap_or(close),
|
||||||
|
close,
|
||||||
|
volume: count(row.get("volume")).unwrap_or(0).saturating_mul(100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bars.sort_by_key(|b| b.date);
|
||||||
|
Ok(bars)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The candles within `period` of the latest one (5 sessions for `5d`).
|
||||||
|
fn trim_to_period(mut bars: Vec<Ohlc>, period: &Period) -> Vec<Ohlc> {
|
||||||
|
let Some(last) = bars.last().map(|b| b.date) else {
|
||||||
|
return bars;
|
||||||
|
};
|
||||||
|
let months = match period {
|
||||||
|
Period::FiveDays => {
|
||||||
|
let start = bars.len().saturating_sub(5);
|
||||||
|
return bars.split_off(start);
|
||||||
|
}
|
||||||
|
Period::OneMonth => 1,
|
||||||
|
Period::ThreeMonths => 3,
|
||||||
|
Period::SixMonths => 6,
|
||||||
|
_ => 12,
|
||||||
|
};
|
||||||
|
match last.checked_sub_months(Months::new(months)) {
|
||||||
|
Some(start) => bars.into_iter().filter(|b| b.date > start).collect(),
|
||||||
|
None => bars,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> String {
|
||||||
|
std::fs::read_to_string(format!(
|
||||||
|
"{}/tests/fixtures/stockbit_exodus_{name}",
|
||||||
|
env!("CARGO_MANIFEST_DIR")
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_book_carries_depth_in_shares_and_foreign_flow() {
|
||||||
|
let book = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(book.price, book.prev_close, book.change),
|
||||||
|
(5175, Some(6075), Some(-900))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(book.open, book.high, book.low),
|
||||||
|
(Some(5825), Some(5825), Some(5175))
|
||||||
|
);
|
||||||
|
assert_eq!(book.foreign_net, Some(-2_329_493_087_500));
|
||||||
|
// BBCA closed at its lower limit: no bids, offers best price first.
|
||||||
|
assert!(book.bids.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
book.offers[0],
|
||||||
|
BookLevel {
|
||||||
|
price: 5175,
|
||||||
|
shares: 264_462_500,
|
||||||
|
orders: 3284
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(book.offers.windows(2).all(|w| w[0].price < w[1].price));
|
||||||
|
assert_eq!(book.session_note, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quote_shows_the_best_levels_only_while_the_market_is_open() {
|
||||||
|
let mut book = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
|
||||||
|
let closed = quote_from_book(&book);
|
||||||
|
assert_eq!((closed.bid, closed.offer), (None, None));
|
||||||
|
book.market_status = Some(MARKET_OPEN.to_string());
|
||||||
|
let open = quote_from_book(&book);
|
||||||
|
assert_eq!((open.bid, open.offer), (None, Some(5175)));
|
||||||
|
assert_eq!(open.price, 5175);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn market_status_is_read_verbatim() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_market_status(&fixture("market_session.json")).as_deref(),
|
||||||
|
Some("STATE_NAME_MARKET_CLOSED")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_map_to_their_codes() {
|
||||||
|
let reply = |status, body: &str| Reply {
|
||||||
|
status,
|
||||||
|
body: body.to_string(),
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
classify(reply(404, &fixture("orderbook_zzzz.json")), "ZZZZ.JK"),
|
||||||
|
Err(IdxError::SymbolNotFound(s)) if s == "ZZZZ.JK"
|
||||||
|
));
|
||||||
|
assert!(reply(401, &fixture("unauthorized.json")).rejected());
|
||||||
|
assert!(reply(403, &fixture("unauthorized.json")).rejected());
|
||||||
|
let blocked = reply(403, "<HTML>Request blocked.</HTML>");
|
||||||
|
assert!(!blocked.rejected());
|
||||||
|
assert!(matches!(
|
||||||
|
classify(blocked, "BBCA.JK"),
|
||||||
|
Err(IdxError::UpstreamBlocked(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
classify(reply(429, ""), "x"),
|
||||||
|
Err(IdxError::RateLimited)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn daily_candles_convert_lots_to_shares_and_trim_to_the_period() {
|
||||||
|
let year = parse_daily(&fixture("chart_candle_bbca_1y.json")).unwrap();
|
||||||
|
assert_eq!(year.len(), 240);
|
||||||
|
assert!(year.windows(2).all(|w| w[0].date < w[1].date));
|
||||||
|
let month = parse_daily(&fixture("chart_candle_bbca_1m.json")).unwrap();
|
||||||
|
let first = &month[0];
|
||||||
|
assert_eq!(first.date.to_string(), "2026-09-16");
|
||||||
|
assert_eq!(
|
||||||
|
(first.open, first.high, first.low, first.close),
|
||||||
|
(6350, 6425, 6325, 6325)
|
||||||
|
);
|
||||||
|
assert_eq!(first.volume, 72_157_400);
|
||||||
|
|
||||||
|
let six = trim_to_period(year.clone(), &Period::SixMonths);
|
||||||
|
assert_eq!(six.first().unwrap().date.to_string(), "2026-04-17");
|
||||||
|
assert_eq!(trim_to_period(month, &Period::FiveDays).len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_daily_windows_map_to_a_chart() {
|
||||||
|
assert_eq!(history_window(&Period::OneYear, &Interval::Day), Some("1y"));
|
||||||
|
assert_eq!(history_window(&Period::TwoYears, &Interval::Day), None);
|
||||||
|
assert_eq!(history_window(&Period::OneYear, &Interval::Week), None);
|
||||||
|
assert_eq!(history_window(&Period::OneDay, &Interval::Day), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
//! - Stockbit's terms describe its market data as delayed at least 10
|
//! - Stockbit's terms describe its market data as delayed at least 10
|
||||||
//! minutes and for personal use; frontends say so.
|
//! minutes and for personal use; frontends say so.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod exodus;
|
||||||
mod parse;
|
mod parse;
|
||||||
|
|
||||||
use std::fs::{File, OpenOptions};
|
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};
|
pub use parse::{parse_symbol_page, status_error, stockbit_code};
|
||||||
|
|
||||||
use super::types::{Fundamentals, Intraday, Quote};
|
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||||
use super::{FundamentalsProvider, IntradayProvider, QuoteProvider};
|
use super::{
|
||||||
|
FundamentalsProvider, HistoryProvider, IntradayProvider, OrderBookProvider, QuoteProvider,
|
||||||
|
};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use auth::SessionStore;
|
||||||
|
use exodus::Exodus;
|
||||||
|
|
||||||
pub const SOURCE: &str = "stockbit";
|
pub const SOURCE: &str = "stockbit";
|
||||||
/// Identifies idx-cli and where to reach its maintainers.
|
/// 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 MIN_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
const BASE_URL: &str = "https://stockbit.com/symbol/";
|
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 {
|
pub struct StockbitProvider {
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
|
/// `None` when the config directory cannot be resolved.
|
||||||
|
exodus: Option<Exodus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StockbitProvider {
|
impl StockbitProvider {
|
||||||
|
|
@ -59,7 +70,20 @@ impl StockbitProvider {
|
||||||
.timeout_global(Some(Duration::from_secs(20)))
|
.timeout_global(Some(Duration::from_secs(20)))
|
||||||
.build()
|
.build()
|
||||||
.into();
|
.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`).
|
/// 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
|
/// `idx` calls, a CLI beside idx-mcp) share the time of the last request in
|
||||||
/// `<cache>/stockbit.pace` under an exclusive file lock. When that file is
|
/// `<cache>/stockbit.pace` under an exclusive file lock. When that file is
|
||||||
/// unusable, pacing is per process.
|
/// unusable, pacing is per process.
|
||||||
fn pace() {
|
pub(crate) fn pace() {
|
||||||
static LAST: Mutex<Option<Instant>> = Mutex::new(None);
|
static LAST: Mutex<Option<Instant>> = Mutex::new(None);
|
||||||
let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
if let Some(prev) = *last {
|
if let Some(prev) = *last {
|
||||||
|
|
@ -151,7 +175,10 @@ fn now_ms() -> u128 {
|
||||||
|
|
||||||
impl QuoteProvider for StockbitProvider {
|
impl QuoteProvider for StockbitProvider {
|
||||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||||
self.snapshot(symbol).map(|day| day.quote)
|
match self.session() {
|
||||||
|
Some(exodus) => exodus.quote(symbol),
|
||||||
|
None => self.snapshot(symbol).map(|day| day.quote),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,12 +194,71 @@ impl IntradayProvider for StockbitProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl OrderBookProvider for StockbitProvider {
|
||||||
|
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||||
|
self.require_session()?.orderbook(symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryProvider for StockbitProvider {
|
||||||
|
fn history(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> Result<Vec<Bar>, IdxError> {
|
||||||
|
self.require_session()?.daily(symbol, period, interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the user has a Stockbit session stored or seeded.
|
||||||
|
pub fn has_session() -> bool {
|
||||||
|
SessionStore::open().is_ok_and(|store| store.has_session())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError {
|
pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError {
|
||||||
IdxError::Unsupported(format!(
|
IdxError::Unsupported(format!(
|
||||||
"{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo"
|
"{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stores a pasted session (stockbit.com's `credentialStorage` cookie or a
|
||||||
|
/// refresh token) and checks it with one request, which refreshes it when the
|
||||||
|
/// access token is missing or stale. A session Stockbit refuses is removed.
|
||||||
|
pub fn login(pasted: &str) -> Result<auth::SessionStatus, IdxError> {
|
||||||
|
let session = auth::session_from_paste(pasted).ok_or_else(|| {
|
||||||
|
IdxError::InvalidInput(
|
||||||
|
"not a Stockbit session: paste the value of stockbit.com's `credentialStorage` \
|
||||||
|
cookie, or a refresh token"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let store = SessionStore::open()?;
|
||||||
|
store.save(&session)?;
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
let check = !crate::api::mock::enabled();
|
||||||
|
#[cfg(not(feature = "testing"))]
|
||||||
|
let check = true;
|
||||||
|
if check && let Err(err) = Exodus::new(store.clone()).verify() {
|
||||||
|
let _ = store.clear();
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Ok(store.status(chrono::Utc::now().timestamp()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored session's state, without a request and without any token.
|
||||||
|
pub fn session_status() -> Result<auth::SessionStatus, IdxError> {
|
||||||
|
Ok(SessionStore::open()?.status(chrono::Utc::now().timestamp()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the stored session: whether there was one, and the state after
|
||||||
|
/// (still logged in when `IDX_STOCKBIT_*` seeds one).
|
||||||
|
pub fn logout() -> Result<(bool, auth::SessionStatus), IdxError> {
|
||||||
|
let store = SessionStore::open()?;
|
||||||
|
let removed = store.clear()?;
|
||||||
|
Ok((removed, store.status(chrono::Utc::now().timestamp())))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::USER_AGENT;
|
use super::USER_AGENT;
|
||||||
|
|
|
||||||
|
|
@ -271,7 +271,7 @@ fn wib(local: NaiveDateTime) -> Option<DateTime<FixedOffset>> {
|
||||||
|
|
||||||
/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`,
|
/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`,
|
||||||
/// `"NA"` and empty strings are absent.
|
/// `"NA"` and empty strings are absent.
|
||||||
fn number(value: &Value) -> Option<f64> {
|
pub(super) fn number(value: &Value) -> Option<f64> {
|
||||||
let n = match value {
|
let n = match value {
|
||||||
Value::Number(n) => n.as_f64()?,
|
Value::Number(n) => n.as_f64()?,
|
||||||
Value::String(s) => s.trim().parse::<f64>().ok()?,
|
Value::String(s) => s.trim().parse::<f64>().ok()?,
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,52 @@ pub struct Intraday {
|
||||||
pub points: Vec<IntradayPoint>,
|
pub points: Vec<IntradayPoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One price level of an order book.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BookLevel {
|
||||||
|
/// Limit price in IDR (whole Rupiah).
|
||||||
|
pub price: i64,
|
||||||
|
/// Size resting at this price, in shares (1 lot = 100 shares).
|
||||||
|
pub shares: u64,
|
||||||
|
/// Orders queued at this price.
|
||||||
|
pub orders: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The order book of one IDX stock from the user's Stockbit session, with
|
||||||
|
/// the session's figures and foreign flow. Outside trading it is the book as
|
||||||
|
/// the market left it; `market_status` says which.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrderBook {
|
||||||
|
pub symbol: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Last price in IDR (whole Rupiah).
|
||||||
|
pub price: i64,
|
||||||
|
pub change: Option<i64>,
|
||||||
|
pub change_pct: Option<f64>,
|
||||||
|
pub prev_close: Option<i64>,
|
||||||
|
pub open: Option<i64>,
|
||||||
|
pub high: Option<i64>,
|
||||||
|
pub low: Option<i64>,
|
||||||
|
/// Session volume in shares.
|
||||||
|
pub volume: Option<u64>,
|
||||||
|
/// Session value traded in IDR.
|
||||||
|
pub value: Option<u64>,
|
||||||
|
/// Trades in the session.
|
||||||
|
pub frequency: Option<u64>,
|
||||||
|
/// Foreign buy, sell and net value over the session, IDR.
|
||||||
|
pub foreign_buy: Option<i64>,
|
||||||
|
pub foreign_sell: Option<i64>,
|
||||||
|
pub foreign_net: Option<i64>,
|
||||||
|
/// Best price first.
|
||||||
|
pub bids: Vec<BookLevel>,
|
||||||
|
/// Best price first.
|
||||||
|
pub offers: Vec<BookLevel>,
|
||||||
|
/// Stockbit's session state, verbatim (e.g. `STATE_NAME_MARKET_CLOSED`).
|
||||||
|
pub market_status: Option<String>,
|
||||||
|
/// Stockbit's listing status when it is not active (e.g. suspended).
|
||||||
|
pub session_note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Ohlc {
|
pub struct Ohlc {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ pub enum HistoryProviderKind {
|
||||||
Auto,
|
Auto,
|
||||||
Yahoo,
|
Yahoo,
|
||||||
Msn,
|
Msn,
|
||||||
|
// Needs the user's Stockbit session (`idx stockbit login`).
|
||||||
|
Stockbit,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderKind {
|
impl ProviderKind {
|
||||||
|
|
@ -63,6 +65,7 @@ impl HistoryProviderKind {
|
||||||
Self::Auto => "auto",
|
Self::Auto => "auto",
|
||||||
Self::Yahoo => "yahoo",
|
Self::Yahoo => "yahoo",
|
||||||
Self::Msn => "msn",
|
Self::Msn => "msn",
|
||||||
|
Self::Stockbit => "stockbit",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,9 +76,11 @@ impl HistoryProviderKind {
|
||||||
Ok(Self::Yahoo)
|
Ok(Self::Yahoo)
|
||||||
} else if value.eq_ignore_ascii_case("msn") {
|
} else if value.eq_ignore_ascii_case("msn") {
|
||||||
Ok(Self::Msn)
|
Ok(Self::Msn)
|
||||||
|
} else if value.eq_ignore_ascii_case("stockbit") {
|
||||||
|
Ok(Self::Stockbit)
|
||||||
} else {
|
} else {
|
||||||
Err(IdxError::ConfigError(format!(
|
Err(IdxError::ConfigError(format!(
|
||||||
"invalid history provider '{value}' (expected auto, yahoo, or msn)"
|
"invalid history provider '{value}' (expected auto, yahoo, msn, or stockbit)"
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ use std::str::FromStr;
|
||||||
|
|
||||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
|
||||||
|
|
||||||
use crate::api::types::{Interval, Intraday, Ohlc, Period, Quote};
|
use crate::api::types::{Interval, Intraday, Ohlc, OrderBook, Period, Quote};
|
||||||
use crate::api::{history_provider, history_source, idx_offset, intraday_provider, stockbit};
|
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::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
@ -91,11 +93,20 @@ impl Market<'_> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the history source for an optional `--history-provider`
|
/// Resolves the history source for an optional `--history-provider`
|
||||||
/// override, without fetching.
|
/// override and the window asked for, without fetching.
|
||||||
pub fn history_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
|
pub fn history_source(
|
||||||
|
&self,
|
||||||
|
mode: Option<HistoryProviderKind>,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> HistorySource {
|
||||||
let mode = mode.unwrap_or(self.idx.config.history_provider);
|
let mode = mode.unwrap_or(self.idx.config.history_provider);
|
||||||
let selected = self.idx.provider.kind();
|
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 {
|
HistorySource {
|
||||||
provider,
|
provider,
|
||||||
mode,
|
mode,
|
||||||
|
|
@ -112,9 +123,8 @@ impl Market<'_> {
|
||||||
policy: CachePolicy,
|
policy: CachePolicy,
|
||||||
) -> Result<Fetched<Vec<Ohlc>>, IdxError> {
|
) -> Result<Fetched<Vec<Ohlc>>, IdxError> {
|
||||||
let idx = self.idx;
|
let idx = self.idx;
|
||||||
let (provider_kind, provider) =
|
let provider = history_provider(source.provider, idx.config.verbose);
|
||||||
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
|
let bucket = format!("{}-history", source.provider.as_str());
|
||||||
let bucket = format!("{}-history", provider_kind.as_str());
|
|
||||||
let resolved = idx.resolve(symbol)?;
|
let resolved = idx.resolve(symbol)?;
|
||||||
let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str());
|
let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str());
|
||||||
cached_fetch(
|
cached_fetch(
|
||||||
|
|
@ -124,7 +134,7 @@ impl Market<'_> {
|
||||||
key: &key,
|
key: &key,
|
||||||
ttl_secs: idx.config.quote_ttl,
|
ttl_secs: idx.config.quote_ttl,
|
||||||
subject: &resolved,
|
subject: &resolved,
|
||||||
source: provider_kind.as_str(),
|
source: source.provider.as_str(),
|
||||||
},
|
},
|
||||||
policy,
|
policy,
|
||||||
|| provider.history(&resolved, period, interval),
|
|| provider.history(&resolved, period, interval),
|
||||||
|
|
@ -132,6 +142,31 @@ impl Market<'_> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The order book of one IDX stock from the user's Stockbit session,
|
||||||
|
/// whichever market provider is selected.
|
||||||
|
pub fn orderbook(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<OrderBook>, IdxError> {
|
||||||
|
let idx = self.idx;
|
||||||
|
let resolved = idx.resolve(symbol)?;
|
||||||
|
let provider = orderbook_provider();
|
||||||
|
cached_fetch(
|
||||||
|
&idx.cache,
|
||||||
|
CacheSpec {
|
||||||
|
bucket: "stockbit-orderbook",
|
||||||
|
key: &resolved,
|
||||||
|
ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
|
||||||
|
subject: &resolved,
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
|| provider.orderbook(&resolved),
|
||||||
|
Ok,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Screener results, ranked locally and truncated to `query.limit`.
|
/// Screener results, ranked locally and truncated to `query.limit`.
|
||||||
pub fn screen(
|
pub fn screen(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"cagr":"", "change":-1225, "drawdown":"", "markingpoint":"", "percentage":"-19.14", "prices":[{"date":"1789491600000", "formatted_date":"2026-09-16", "xlabel":"", "value":"6325", "percentage":"-1.17", "change":-75, "open":"6350", "high":"6425", "low":"6325", "volume":"721574"}, {"date":"1789578000000", "formatted_date":"2026-09-17", "xlabel":"", "value":"6350", "percentage":"-0.78", "change":-50, "open":"6400", "high":"6400", "low":"6325", "volume":"618120"}, {"date":"1789664400000", "formatted_date":"2026-09-18", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6325", "high":"6350", "low":"6225", "volume":"1767537"}, {"date":"1789923600000", "formatted_date":"2026-09-21", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6300", "high":"6300", "low":"6225", "volume":"699141"}, {"date":"1790010000000", "formatted_date":"2026-09-22", "xlabel":"", "value":"6200", "percentage":"-3.12", "change":-200, "open":"6225", "high":"6250", "low":"6175", "volume":"932112"}, {"date":"1790096400000", "formatted_date":"2026-09-23", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6200", "high":"6300", "low":"6200", "volume":"968261"}, {"date":"1790182800000", "formatted_date":"2026-09-24", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6250", "high":"6275", "low":"6225", "volume":"756486"}, {"date":"1790269200000", "formatted_date":"2026-09-25", "xlabel":"", "value":"6250", "percentage":"-2.34", "change":-150, "open":"6225", "high":"6275", "low":"6200", "volume":"894474"}, {"date":"1790528400000", "formatted_date":"2026-09-28", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6225", "high":"6250", "low":"6175", "volume":"1121076"}, {"date":"1790614800000", "formatted_date":"2026-09-29", "xlabel":"", "value":"6150", "percentage":"-3.91", "change":-250, "open":"6150", "high":"6225", "low":"6125", "volume":"1497834"}, {"date":"1790701200000", "formatted_date":"2026-09-30", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6100", "high":"6150", "low":"6050", "volume":"2213357"}, {"date":"1790787600000", "formatted_date":"2026-10-01", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6050", "high":"6150", "low":"6000", "volume":"1180449"}, {"date":"1790874000000", "formatted_date":"2026-10-02", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6000", "high":"6150", "low":"5975", "volume":"1319203"}, {"date":"1791133200000", "formatted_date":"2026-10-05", "xlabel":"", "value":"6175", "percentage":"-3.52", "change":-225, "open":"6100", "high":"6175", "low":"6075", "volume":"899057"}, {"date":"1791219600000", "formatted_date":"2026-10-06", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6175", "high":"6200", "low":"6100", "volume":"1521734"}, {"date":"1791306000000", "formatted_date":"2026-10-07", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6075", "high":"6100", "low":"5975", "volume":"1626908"}, {"date":"1791392400000", "formatted_date":"2026-10-08", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6100", "high":"6150", "low":"6000", "volume":"1131403"}, {"date":"1791478800000", "formatted_date":"2026-10-09", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6025", "high":"6075", "low":"5975", "volume":"870986"}, {"date":"1791738000000", "formatted_date":"2026-10-12", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"6050", "high":"6100", "low":"5950", "volume":"979651"}, {"date":"1791824400000", "formatted_date":"2026-10-13", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"5925", "high":"5950", "low":"5850", "volume":"1227169"}, {"date":"1791910800000", "formatted_date":"2026-10-14", "xlabel":"", "value":"6125", "percentage":"-4.30", "change":-275, "open":"6000", "high":"6125", "low":"5950", "volume":"1363031"}, {"date":"1791997200000", "formatted_date":"2026-10-15", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6075", "high":"6125", "low":"6025", "volume":"1347632"}, {"date":"1792083600000", "formatted_date":"2026-10-16", "xlabel":"", "value":"5175", "percentage":"-19.14", "change":-1225, "open":"5825", "high":"5825", "low":"5175", "volume":"12410982"}], "timeframe":"1m", "xaxisopt":"", "previous":6400, "line_weight":2, "previous_timeframe_price":{"date":"", "formatted_date":"", "xlabel":"", "value":"", "percentage":"", "change":0, "open":"", "high":"", "low":"", "volume":""}, "chart_type":"PRICE_CHART_TYPE_CANDLE", "interval_in_minutes":0, "allowed_chart_type":["PRICE_CHART_TYPE_LINE", "PRICE_CHART_TYPE_CANDLE"], "max_candles":24}, "message":"Successfully retrieved company daily chart"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
%7B%22state%22%3A%7B%22access%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-19T04%3A43%3A28Z%22%7D%2C%22refresh%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-25T04%3A43%3A28Z%22%7D%2C%22user%22%3A%7B%22username%22%3A%22demo%22%7D%7D%2C%22version%22%3A0%7D
|
||||||
13
crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json
vendored
Normal file
13
crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"message": "Successfully refresh token",
|
||||||
|
"data": {
|
||||||
|
"access": {
|
||||||
|
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU",
|
||||||
|
"expired_at": "2026-10-19T04:43:28Z"
|
||||||
|
},
|
||||||
|
"refresh": {
|
||||||
|
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU",
|
||||||
|
"expired_at": "2026-10-25T04:43:28Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_market_session.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_market_session.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"message":"Successfully get session info data", "data":{"datetime":"2026-10-18 20:15:06", "detail":{"fca":{"session":0, "state_name":"STATE_NAME_MARKET_CLOSED", "is_last_session":false, "is_end_of_day":true, "state_start_time":"", "state_end_time":"", "time_left":{"raw":44994, "formatted":"12 jam 29 menit 54 detik"}, "suspend_info":""}, "regular":{"session":0, "state_name":"STATE_NAME_MARKET_CLOSED", "is_last_session":false, "is_end_of_day":true, "state_start_time":"", "state_end_time":"", "time_left":{"raw":44994, "formatted":"12 jam 29 menit 54 detik"}, "suspend_info":""}}}}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_bbca.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_ihsg.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_ihsg.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"average":0, "bid":[], "change":-678.67, "close":5185.105, "country":"ID", "domestic":"59.01", "down":"110", "exchange":"IDX", "fbuy":9176233289300, "fnet":-3397459968400, "foreign":"40.99", "frequency":2130259, "fsell":12573693257700, "high":5404.508, "id":"IHSG-0", "lastprice":5185.105, "low":5185.105, "offer":[], "open":5393.924, "percentage_change":-11.57, "previous":5863.78, "status":"Active", "symbol":"IHSG", "symbol_2":"IHSG", "symbol_3":"IHSG", "tradable":false, "unchanged":"1118", "up":"265", "value":26532049500300, "volume":66022512700, "corp_action":{"active":false, "icon":"https://assets.stockbit.com/images/corp_action_event_icon.svg", "text":"Perusahaan Memiliki Corporate Action"}, "notation":[], "uma":false, "has_foreign_bs":true, "iepiev":{"symbol":"", "status":"STATUS_UNSPECIFIED", "iep":{"raw":0, "formatted":""}, "iev":{"raw":0, "formatted":""}, "time_left_seconds":0, "best_bid_offer":{"bid":{"price":{"raw":0, "formatted":"0"}, "quantity":{"raw":0, "formatted":"0"}, "change_percentage":""}, "offer":{"price":{"raw":0, "formatted":"0"}, "quantity":{"raw":0, "formatted":"0"}, "change_percentage":""}, "time_left_seconds":11921}, "title":"", "iep_changes":{"price":{"raw":0, "formatted":""}, "percentage":{"raw":0, "formatted":""}}}, "market_data":[{"label":"All Market", "frequency":{"raw":"2133195", "formatted":"2.13 M"}, "volume":{"raw":"68613461946", "formatted":"68.6 B"}, "value":{"raw":"32013813258468", "formatted":"32 T"}}, {"label":"Regular", "frequency":{"raw":"2130259", "formatted":"2.13 M"}, "volume":{"raw":"66022512700", "formatted":"66 B"}, "value":{"raw":"26532049500300", "formatted":"26.5 T"}}, {"label":"Nego", "frequency":{"raw":"511", "formatted":"511"}, "volume":{"raw":"2524183446", "formatted":"2.52 B"}, "value":{"raw":"5475654641968", "formatted":"5.48 T"}}, {"label":"Cash", "frequency":{"raw":"2425", "formatted":"2,425"}, "volume":{"raw":"66765800", "formatted":"66.8 M"}, "value":{"raw":"6109116200", "formatted":"6.11 B"}}], "name":"Index Harga Saham Gabungan", "icon_url":"https://assets.stockbit.com/logos/companies/IHSG.png?version=1784250340348189864", "ara":{"value":"0", "visible":false}, "arb":{"value":"0", "visible":false}, "company_type":"Index", "total_bid_offer":{"bid":{"freq":"0", "lot":"0", "raw_lot":"0", "raw_freq":"0"}, "offer":{"freq":"0", "lot":"0", "raw_lot":"0", "raw_freq":"0"}, "bid_percent":0}, "next_ara":{"value":"", "visible":false}, "next_arb":{"value":"", "visible":false}, "autoreject_time_left_in_sec":0, "auto_reject_estimation":[], "orderbook_active_feature_mobile":"ORDER_BOOK_FEATURE_FOREIGN_BS"}, "message":"Successfully get company orderbook"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_zzzz.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_orderbook_zzzz.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"message":"Company not found","error_type":"NOT_FOUND"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_unauthorized.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_unauthorized.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"error_type":"UNAUTHORIZED","message":"Unauthorized"}
|
||||||
|
|
@ -38,6 +38,15 @@ pub struct IntradayParams {
|
||||||
pub tail: Option<usize>,
|
pub tail: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct OrderBookParams {
|
||||||
|
/// IDX ticker, e.g. "BBCA".
|
||||||
|
pub symbol: String,
|
||||||
|
/// Price levels per side (default 10, at most 50).
|
||||||
|
#[serde(default)]
|
||||||
|
pub depth: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct ScreenParams {
|
pub struct ScreenParams {
|
||||||
/// Screen to run (default "top-performers").
|
/// Screen to run (default "top-performers").
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ use tokio::sync::Semaphore;
|
||||||
|
|
||||||
use crate::params::{
|
use crate::params::{
|
||||||
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
||||||
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, ScreenParams,
|
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, OrderBookParams,
|
||||||
SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
|
ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SYNC_TOOL: &str = "ownership_sync";
|
const SYNC_TOOL: &str = "ownership_sync";
|
||||||
|
|
@ -45,6 +45,8 @@ const MAX_CHANGES_LIMIT: usize = 300;
|
||||||
const CHANGES_TOP_TICKERS: usize = 10;
|
const CHANGES_TOP_TICKERS: usize = 10;
|
||||||
// A session has about 340 minutes, so this is the whole day.
|
// A session has about 340 minutes, so this is the whole day.
|
||||||
const MAX_INTRADAY_POINTS: usize = 400;
|
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.
|
/// Tool calls running at once; the rest wait their turn.
|
||||||
static CALL_SLOTS: Semaphore = Semaphore::const_new(4);
|
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
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Order book of one IDX stock from the user's own Stockbit session: \
|
||||||
|
`bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, \
|
||||||
|
volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is \
|
||||||
|
Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left \
|
||||||
|
it. AUTHERROR means there is no session: call stockbit_status, and ask the user to run \
|
||||||
|
`idx stockbit login` in a terminal."
|
||||||
|
)]
|
||||||
|
async fn get_orderbook(&self, params: Parameters<OrderBookParams>) -> ToolResult {
|
||||||
|
let OrderBookParams { symbol, depth } = params.0;
|
||||||
|
self.live(move |idx| {
|
||||||
|
let depth = bounded_limit("depth", depth, BOOK_DEPTH, MAX_BOOK_DEPTH)?;
|
||||||
|
let mut book = idx.market().orderbook(&symbol, CachePolicy::Default)?;
|
||||||
|
book.data.bids.truncate(depth);
|
||||||
|
book.data.offers.truncate(depth);
|
||||||
|
Ok(book)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(description = "Whether a Stockbit session is stored (or seeded by \
|
||||||
|
IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, \
|
||||||
|
get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the \
|
||||||
|
user adds one with `idx stockbit login` in a terminal.")]
|
||||||
|
async fn stockbit_status(&self) -> ToolResult {
|
||||||
|
blocking(idx_core::api::stockbit::session_status).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tool(description = "OHLCV price history for one IDX ticker.")]
|
#[tool(description = "OHLCV price history for one IDX ticker.")]
|
||||||
async fn get_price_history(&self, params: Parameters<HistoryParams>) -> ToolResult {
|
async fn get_price_history(&self, params: Parameters<HistoryParams>) -> ToolResult {
|
||||||
let HistoryParams {
|
let HistoryParams {
|
||||||
|
|
@ -168,7 +198,7 @@ per-minute volume. Empty `points` means the stock did not trade this session (se
|
||||||
self.live(move |idx| {
|
self.live(move |idx| {
|
||||||
let period = period.as_deref().unwrap_or("3mo").parse()?;
|
let period = period.as_deref().unwrap_or("3mo").parse()?;
|
||||||
let interval = interval.as_deref().unwrap_or("1d").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()
|
idx.market()
|
||||||
.history(&symbol, &period, &interval, source, CachePolicy::Default)
|
.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();
|
let name = kind.as_str();
|
||||||
match kind {
|
match kind {
|
||||||
AnalysisKind::Technical => {
|
AnalysisKind::Technical => {
|
||||||
let source = idx.market().history_source(None);
|
let source = analysis.technical_source(None);
|
||||||
report.add(name, analysis.technical(&symbol, source, policy))
|
report.add(name, analysis.technical(&symbol, source, policy))
|
||||||
}
|
}
|
||||||
AnalysisKind::Growth => report.add(name, analysis.growth(&symbol, policy)),
|
AnalysisKind::Growth => report.add(name, analysis.growth(&symbol, policy)),
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,33 @@
|
||||||
},
|
},
|
||||||
"name": "get_investor_holdings"
|
"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.",
|
"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": {
|
"inputSchema": {
|
||||||
|
|
@ -556,5 +583,13 @@
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"name": "search_investors"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use idx_core::config::CoreConfig;
|
||||||
use idx_core::investor::{ImportSource, Investor};
|
use idx_core::investor::{ImportSource, Investor};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
const TOOLS: [&str; 15] = [
|
const TOOLS: [&str; 17] = [
|
||||||
"analyze_bandar",
|
"analyze_bandar",
|
||||||
"analyze_stock",
|
"analyze_stock",
|
||||||
"compare_stocks",
|
"compare_stocks",
|
||||||
|
|
@ -18,6 +18,7 @@ const TOOLS: [&str; 15] = [
|
||||||
"get_foreign_flow",
|
"get_foreign_flow",
|
||||||
"get_intraday",
|
"get_intraday",
|
||||||
"get_investor_holdings",
|
"get_investor_holdings",
|
||||||
|
"get_orderbook",
|
||||||
"get_ownership_changes",
|
"get_ownership_changes",
|
||||||
"get_price_history",
|
"get_price_history",
|
||||||
"get_quotes",
|
"get_quotes",
|
||||||
|
|
@ -25,8 +26,15 @@ const TOOLS: [&str; 15] = [
|
||||||
"ownership_status",
|
"ownership_status",
|
||||||
"screen_stocks",
|
"screen_stocks",
|
||||||
"search_investors",
|
"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 {
|
struct Client {
|
||||||
child: Child,
|
child: Child,
|
||||||
stdin: ChildStdin,
|
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]
|
#[test]
|
||||||
fn errors_carry_cli_codes() {
|
fn errors_carry_cli_codes() {
|
||||||
let mut client = Client::start(&[], false);
|
let mut client = Client::start(&[], false);
|
||||||
|
|
|
||||||
|
|
@ -164,8 +164,8 @@ WIB as the fallback.
|
||||||
`chart.prices[]` holds one price per minute in WIB with no offset, after a
|
`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
|
synthetic first row at the previous close; per-minute OHLC and volume are
|
||||||
empty. An unknown code is HTTP 404 with the 404 page.
|
empty. An unknown code is HTTP 404 with the 404 page.
|
||||||
- Stockbit's JSON API (`exodus.stockbit.com`: chart, order book) answers 401
|
- Stockbit's JSON API (`exodus.stockbit.com`) answers 401 without a user's
|
||||||
without a user's bearer token; it is not used.
|
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
|
- 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
|
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
|
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
|
personal use; frontends say so. Fixtures (`tests/fixtures/stockbit_*`) are
|
||||||
pages recorded 2026-10-22 after the close, cut to the fields read.
|
pages recorded 2026-10-22 after the close, cut to the fields read.
|
||||||
|
|
||||||
|
### Stockbit session (opt-in login)
|
||||||
|
- `idx stockbit login` stores the user's session from a pasted
|
||||||
|
`credentialStorage` cookie (or a bare refresh token) in
|
||||||
|
`<config dir>/stockbit-session.json`, written atomically with mode 0600;
|
||||||
|
`IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` fill fields the
|
||||||
|
file lacks. `api/stockbit/auth.rs` reads expiries from the JWTs (`exp`,
|
||||||
|
unverified) and refreshes an access token within an hour of expiry with
|
||||||
|
`POST /login/refresh` (bearer = refresh token), which rotates both tokens.
|
||||||
|
Because a refresh token is spent once, refreshes run under an exclusive
|
||||||
|
lock on `stockbit-session.lock` and re-read the file first, so concurrent
|
||||||
|
idx processes never spend the same token. A data request answered 401 (or
|
||||||
|
a JSON 403) refreshes once and retries once; a second refusal is
|
||||||
|
`AUTHERROR`. Tokens never reach output, errors, logs or cache keys
|
||||||
|
(`Session`'s `Debug` redacts them).
|
||||||
|
- `api/stockbit/exodus.rs` (endpoints and units mapped by fzn0x/idx, MIT;
|
||||||
|
its recordings are `tests/fixtures/stockbit_exodus_*`):
|
||||||
|
`/company-price-feed/v2/orderbook/companies/{CODE}` gives the depth ladder
|
||||||
|
(`volume` in shares, `que_num` orders), OHLC, value, frequency and foreign
|
||||||
|
buy/sell/net; `/company-price-feed/market-time/session` the market state
|
||||||
|
(`STATE_NAME_MARKET_CLOSED`, cached 30 s per process); `/charts/{CODE}/daily`
|
||||||
|
daily candles for `1m`/`3m`/`1y` windows (volume in lots, returned in
|
||||||
|
shares). Requests share the public page's cross-process pacing.
|
||||||
|
- With a session, `--provider stockbit` quotes come from the order book (bid
|
||||||
|
and offer only in `STATE_NAME_MARKET_OPEN`, a name assumed until observed
|
||||||
|
in session), `stocks orderbook` works, and `auto` history under the stockbit
|
||||||
|
provider uses Stockbit's daily windows (`5d` to `1y`, trimmed from `1m`,
|
||||||
|
`3m` or `1y`), Yahoo otherwise. Without one, quotes use the public page and
|
||||||
|
the order book is `AUTHERROR`.
|
||||||
|
|
||||||
### Capability Gating
|
### Capability Gating
|
||||||
```
|
```
|
||||||
MarketDataProvider = QuoteProvider + FundamentalsProvider
|
MarketDataProvider = QuoteProvider + FundamentalsProvider
|
||||||
|
|
@ -196,15 +225,17 @@ HistoryProvider = separate trait, not all providers implement
|
||||||
|
|
||||||
Factory functions:
|
Factory functions:
|
||||||
default_provider(kind) → Box<dyn MarketDataProvider>
|
default_provider(kind) → Box<dyn MarketDataProvider>
|
||||||
history_provider(kind, mode, verbose) → Result<(ProviderKind, Box<dyn HistoryProvider>)>
|
history_provider(source, verbose) → Box<dyn HistoryProvider> (source from history_source)
|
||||||
broker_provider() → Box<dyn BrokerSummaryProvider>
|
broker_provider() → Box<dyn BrokerSummaryProvider>
|
||||||
trading_summary_provider() → Box<dyn TradingSummaryProvider>
|
trading_summary_provider() → Box<dyn TradingSummaryProvider>
|
||||||
intraday_provider() → Box<dyn IntradayProvider>
|
intraday_provider() → Box<dyn IntradayProvider>
|
||||||
|
orderbook_provider() → Box<dyn OrderBookProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
`ProviderKind::Stockbit` implements quotes only: fundamentals are
|
`ProviderKind::Stockbit` serves quotes (and, with a session, daily history):
|
||||||
`UNSUPPORTED`, the MSN-only capabilities say "requires --provider msn", and
|
fundamentals are `UNSUPPORTED`, the MSN-only capabilities say "requires
|
||||||
`auto` history falls back to Yahoo.
|
--provider msn", and `auto` history uses Yahoo unless a session covers the
|
||||||
|
window.
|
||||||
|
|
||||||
## Ownership Module (SQLite-backed)
|
## Ownership Module (SQLite-backed)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,12 @@ scripts/live-smoke.sh --dry-run
|
||||||
whose MSN key ratios contain non-finite values.
|
whose MSN key ratios contain non-finite values.
|
||||||
- `stockbit`: `intraday` for the ticker and `^JKSE`, and
|
- `stockbit`: `intraday` for the ticker and `^JKSE`, and
|
||||||
`--provider stockbit stocks quote`, on the new binary only. A run outside the
|
`--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
|
- `ownership`: `discover`, then imports the newest supported report into a temp
|
||||||
DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many
|
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.
|
datacenter IPs, including GitHub-hosted runners; run it from a network IDX accepts.
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@
|
||||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
||||||
sleep 2
|
sleep 2
|
||||||
} | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl
|
} | 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"
|
touch "$out"
|
||||||
'';
|
'';
|
||||||
# The website's generated pages match the CLI help and the MCP
|
# The website's generated pages match the CLI help and the MCP
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ Commands:
|
||||||
config Manage configuration
|
config Manage configuration
|
||||||
cache Manage local cache
|
cache Manage local cache
|
||||||
ownership Ownership intelligence (bootstrap with `ownership sync`)
|
ownership Ownership intelligence (bootstrap with `ownership sync`)
|
||||||
|
stockbit Your Stockbit session (opt-in: order book, live quotes)
|
||||||
completions Generate shell completions
|
completions Generate shell completions
|
||||||
version Show idx-cli version
|
version Show idx-cli version
|
||||||
help Print this message or the help of the given subcommand(s)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
@ -66,6 +67,7 @@ Usage: idx stocks [OPTIONS] <COMMAND>
|
||||||
Commands:
|
Commands:
|
||||||
quote Get real-time stock quotes
|
quote Get real-time stock quotes
|
||||||
intraday Today's session price by the minute (Stockbit)
|
intraday Today's session price by the minute (Stockbit)
|
||||||
|
orderbook Order book depth and foreign flow (Stockbit login)
|
||||||
history Get historical OHLC data
|
history Get historical OHLC data
|
||||||
technical Run technical analysis on a stock
|
technical Run technical analysis on a stock
|
||||||
growth Run growth 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 -o json stocks intraday BBCA | jq '.points[-1]'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `idx stocks orderbook`
|
||||||
|
|
||||||
|
```text
|
||||||
|
The order book of one IDX stock from your own Stockbit session (`idx stockbit login`): bid and offer levels with lots and queued orders, the session's OHLC, volume, value and foreign buy, sell and net. Outside trading it is the book as the market left it, and the output says so.
|
||||||
|
|
||||||
|
Usage: idx stocks orderbook [OPTIONS] <SYMBOL>
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
<SYMBOL>
|
||||||
|
Single ticker symbol (e.g. BBCA)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--depth <DEPTH>
|
||||||
|
Price levels per side (1-50)
|
||||||
|
|
||||||
|
[default: 10]
|
||||||
|
|
||||||
|
-o, --output <OUTPUT>
|
||||||
|
Output format [default: auto = table on a terminal, json when piped]
|
||||||
|
|
||||||
|
[possible values: auto, table, json, jsonl]
|
||||||
|
|
||||||
|
--no-color
|
||||||
|
Disable colored output
|
||||||
|
|
||||||
|
-q, --quiet
|
||||||
|
Suppress non-essential output
|
||||||
|
|
||||||
|
-v, --verbose...
|
||||||
|
Increase verbosity (-v, -vv)
|
||||||
|
|
||||||
|
--provider <PROVIDER>
|
||||||
|
Market data provider [default: general.provider, else msn]
|
||||||
|
|
||||||
|
[possible values: yahoo, msn, stockbit]
|
||||||
|
|
||||||
|
--offline
|
||||||
|
Run without network requests
|
||||||
|
|
||||||
|
--no-cache
|
||||||
|
Bypass local cache
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
idx stocks orderbook BBCA
|
||||||
|
idx stocks orderbook BBRI --depth 5
|
||||||
|
idx -o json stocks orderbook TLKM | jq '.offers[0]'
|
||||||
|
```
|
||||||
|
|
||||||
### `idx stocks history`
|
### `idx stocks history`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -190,7 +243,7 @@ Options:
|
||||||
--no-color
|
--no-color
|
||||||
Disable colored output
|
Disable colored output
|
||||||
--history-provider <HISTORY_PROVIDER>
|
--history-provider <HISTORY_PROVIDER>
|
||||||
[possible values: auto, yahoo, msn]
|
[possible values: auto, yahoo, msn, stockbit]
|
||||||
-q, --quiet
|
-q, --quiet
|
||||||
Suppress non-essential output
|
Suppress non-essential output
|
||||||
-v, --verbose...
|
-v, --verbose...
|
||||||
|
|
@ -221,7 +274,7 @@ Arguments:
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--history-provider <HISTORY_PROVIDER>
|
--history-provider <HISTORY_PROVIDER>
|
||||||
[possible values: auto, yahoo, msn]
|
[possible values: auto, yahoo, msn, stockbit]
|
||||||
-o, --output <OUTPUT>
|
-o, --output <OUTPUT>
|
||||||
Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
|
Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
|
||||||
--no-color
|
--no-color
|
||||||
|
|
@ -1494,6 +1547,128 @@ Options:
|
||||||
-h, --help Print help
|
-h, --help Print help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `idx stockbit`
|
||||||
|
|
||||||
|
```text
|
||||||
|
Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, stored in the config directory with owner-only permissions, and never logs them. Stockbit rotates the refresh token on every refresh, so a browser sharing the session is logged out once idx refreshes: log in through a separate browser profile for idx. Stockbit's terms allow personal, non-commercial use.
|
||||||
|
|
||||||
|
Usage: idx stockbit [OPTIONS] <COMMAND>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
login Store a session pasted on stdin and check it with Stockbit
|
||||||
|
status Show whether a session is stored and when its tokens expire
|
||||||
|
logout Delete the stored session
|
||||||
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-o, --output <OUTPUT>
|
||||||
|
Output format [default: auto = table on a terminal, json when piped]
|
||||||
|
|
||||||
|
[possible values: auto, table, json, jsonl]
|
||||||
|
|
||||||
|
--no-color
|
||||||
|
Disable colored output
|
||||||
|
|
||||||
|
-q, --quiet
|
||||||
|
Suppress non-essential output
|
||||||
|
|
||||||
|
-v, --verbose...
|
||||||
|
Increase verbosity (-v, -vv)
|
||||||
|
|
||||||
|
--provider <PROVIDER>
|
||||||
|
Market data provider [default: general.provider, else msn]
|
||||||
|
|
||||||
|
[possible values: yahoo, msn, stockbit]
|
||||||
|
|
||||||
|
--offline
|
||||||
|
Run without network requests
|
||||||
|
|
||||||
|
--no-cache
|
||||||
|
Bypass local cache
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
idx stockbit login # paste the credentialStorage cookie
|
||||||
|
pbpaste | idx stockbit login
|
||||||
|
idx stockbit status
|
||||||
|
idx stockbit logout
|
||||||
|
```
|
||||||
|
|
||||||
|
### `idx stockbit login`
|
||||||
|
|
||||||
|
```text
|
||||||
|
Reads, on stdin, the value of stockbit.com's `credentialStorage` cookie (in a logged-in browser: DevTools, Application, Cookies, https://stockbit.com) or a refresh token, stores it, and checks it with one request. Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead.
|
||||||
|
|
||||||
|
Usage: idx stockbit login [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-o, --output <OUTPUT>
|
||||||
|
Output format [default: auto = table on a terminal, json when piped]
|
||||||
|
|
||||||
|
[possible values: auto, table, json, jsonl]
|
||||||
|
|
||||||
|
--no-color
|
||||||
|
Disable colored output
|
||||||
|
|
||||||
|
-q, --quiet
|
||||||
|
Suppress non-essential output
|
||||||
|
|
||||||
|
-v, --verbose...
|
||||||
|
Increase verbosity (-v, -vv)
|
||||||
|
|
||||||
|
--provider <PROVIDER>
|
||||||
|
Market data provider [default: general.provider, else msn]
|
||||||
|
|
||||||
|
[possible values: yahoo, msn, stockbit]
|
||||||
|
|
||||||
|
--offline
|
||||||
|
Run without network requests
|
||||||
|
|
||||||
|
--no-cache
|
||||||
|
Bypass local cache
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
```
|
||||||
|
|
||||||
|
### `idx stockbit status`
|
||||||
|
|
||||||
|
```text
|
||||||
|
Show whether a session is stored and when its tokens expire
|
||||||
|
|
||||||
|
Usage: idx stockbit status [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
|
||||||
|
--no-color Disable colored output
|
||||||
|
-q, --quiet Suppress non-essential output
|
||||||
|
-v, --verbose... Increase verbosity (-v, -vv)
|
||||||
|
--provider <PROVIDER> Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit]
|
||||||
|
--offline Run without network requests
|
||||||
|
--no-cache Bypass local cache
|
||||||
|
-h, --help Print help
|
||||||
|
```
|
||||||
|
|
||||||
|
### `idx stockbit logout`
|
||||||
|
|
||||||
|
```text
|
||||||
|
Delete the stored session
|
||||||
|
|
||||||
|
Usage: idx stockbit logout [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-o, --output <OUTPUT> Output format [default: auto = table on a terminal, json when piped] [possible values: auto, table, json, jsonl]
|
||||||
|
--no-color Disable colored output
|
||||||
|
-q, --quiet Suppress non-essential output
|
||||||
|
-v, --verbose... Increase verbosity (-v, -vv)
|
||||||
|
--provider <PROVIDER> Market data provider [default: general.provider, else msn] [possible values: yahoo, msn, stockbit]
|
||||||
|
--offline Run without network requests
|
||||||
|
--no-cache Bypass local cache
|
||||||
|
-h, --help Print help
|
||||||
|
```
|
||||||
|
|
||||||
## `idx completions`
|
## `idx completions`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
---
|
---
|
||||||
title: Stockbit
|
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
|
[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
|
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
|
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
|
```bash
|
||||||
idx stocks intraday BBCA # today's session, by the minute
|
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
|
shows the book as it closed. idx leaves `bid` and `offer` out then, so the
|
||||||
closing book never passes for a live one.
|
closing book never passes for a live one.
|
||||||
- **Top of the book only.** One bid and one offer price, no depth. The full
|
- **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.
|
- **Prices only.** The page has no per-minute volume or OHLC.
|
||||||
- **Empty sessions are data.** A suspended or delisted stock answers with
|
- **Empty sessions are data.** A suspended or delisted stock answers with
|
||||||
`points: []` and a `session_note` saying so (`Stockbit lists the stock as
|
`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
|
`--provider stockbit` (or `IDX_PROVIDER=stockbit`, or
|
||||||
`idx config set general.provider stockbit`) changes only where quotes come
|
`idx config set general.provider stockbit`) changes only where quotes come
|
||||||
from. History still comes from Yahoo. Company data, fundamentals and
|
from. Without a login, history still comes from Yahoo. Company data,
|
||||||
screening answer `UNSUPPORTED` under it, so use MSN for those.
|
fundamentals and screening answer `UNSUPPORTED` under it, so use MSN for
|
||||||
|
those.
|
||||||
|
|
||||||
## How idx talks to Stockbit
|
## 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
|
Wait, or try from another network. `--provider msn` serves quotes without
|
||||||
Stockbit.
|
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
|
## Terms
|
||||||
|
|
||||||
Stockbit's [terms](https://stockbit.com/terms) allow its content for personal,
|
Stockbit's [terms](https://stockbit.com/terms) allow its content for personal,
|
||||||
non-commercial use. They prohibit data mining, robots and redistribution
|
non-commercial use. They prohibit data mining, robots and redistribution
|
||||||
without Stockbit's written consent. idx is not affiliated with or endorsed by
|
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
|
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
|
Over MCP, the same data comes from `get_intraday`, `get_orderbook` and
|
||||||
`get_quotes` when the server runs with `IDX_PROVIDER=stockbit`.
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
is delayed at least 10 minutes. See [Stockbit](/cli/stockbit/) for what it
|
||||||
covers and the terms it comes with.
|
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
|
## Company data
|
||||||
|
|
||||||
| Command | What it shows |
|
| 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
|
MSN is the default for quotes, company data and screening; Yahoo serves
|
||||||
price history. Indo Premier (IPOT) serves broker summaries and foreign flow
|
price history. Indo Premier (IPOT) serves broker summaries and foreign flow
|
||||||
(IDX's own site with `flow --source idx`), and Stockbit serves `intraday`,
|
(IDX's own site with `flow --source idx`), and Stockbit serves `intraday`
|
||||||
whichever provider is selected. Switch with `--provider msn|yahoo|stockbit`
|
and, with your login, `orderbook`, whichever provider is selected. Switch
|
||||||
(or `IDX_PROVIDER`) and `IDX_HISTORY_PROVIDER=auto|yahoo|msn`, or the
|
with `--provider msn|yahoo|stockbit` (or `IDX_PROVIDER`) and
|
||||||
matching [config keys](/start/config/). `--provider stockbit` serves quotes
|
`IDX_HISTORY_PROVIDER=auto|yahoo|msn|stockbit`, or the matching
|
||||||
only, with the best bid/offer; see [Stockbit](/cli/stockbit/).
|
[config keys](/start/config/). `--provider stockbit` serves quotes only, with
|
||||||
|
the best bid/offer, live when you are logged in; see
|
||||||
|
[Stockbit](/cli/stockbit/).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
`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
|
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
|
[17 tools](/mcp/tools/) (plus an opt-in `ownership_sync`): quotes, today's
|
||||||
session by the minute, price history, screening, company data, analysis,
|
session by the minute, the order book (with a Stockbit login), price
|
||||||
comparison, broker summaries, bandarmology, foreign flow, and the KSEI
|
history, screening, company data, analysis, comparison, broker summaries,
|
||||||
ownership queries.
|
bandarmology, foreign flow, and the KSEI ownership queries.
|
||||||
|
|
||||||
It shares idx's [config, cache and ownership database](/start/config/#files-idx-keeps),
|
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.
|
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 |
|
| tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed |
|
||||||
| `get_intraday` `tail` | 400 minutes (default: the whole session) |
|
| `get_intraday` `tail` | 400 minutes (default: the whole session) |
|
||||||
|
| `get_orderbook` `depth` | 50 levels per side (default 10) |
|
||||||
| `screen_stocks` `limit` | 200 |
|
| `screen_stocks` `limit` | 200 |
|
||||||
| `search_investors` `limit` | 100 |
|
| `search_investors` `limit` | 100 |
|
||||||
| `get_ownership_changes` `limit` | 300 per page (default 200); page with `offset` |
|
| `get_ownership_changes` `limit` | 300 per page (default 200); page with `offset` |
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| `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`
|
## `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.
|
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). |
|
| `limit` | integer | no | Maximum results (default 20, at most 100). |
|
||||||
| `query` | string | yes | Full-text query over investor names. |
|
| `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.
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ in scripts. Ask for JSON with `-o json`, `IDX_OUTPUT=json`, or
|
||||||
```toml
|
```toml
|
||||||
[general]
|
[general]
|
||||||
provider = "msn" # msn | yahoo | stockbit
|
provider = "msn" # msn | yahoo | stockbit
|
||||||
history_provider = "auto" # auto | yahoo | msn
|
history_provider = "auto" # auto | yahoo | msn | stockbit
|
||||||
exchange = "JK"
|
exchange = "JK"
|
||||||
output = "auto" # auto | table | json | jsonl
|
output = "auto" # auto | table | json | jsonl
|
||||||
color = true
|
color = true
|
||||||
|
|
@ -56,6 +56,8 @@ fundamental_ttl = 3600
|
||||||
| `IDX_CACHE_FUNDAMENTAL_TTL` | `cache.fundamental_ttl` |
|
| `IDX_CACHE_FUNDAMENTAL_TTL` | `cache.fundamental_ttl` |
|
||||||
| `IDX_OWNERSHIP_SNAPSHOT_MANIFEST` | `ownership.snapshot_manifest` |
|
| `IDX_OWNERSHIP_SNAPSHOT_MANIFEST` | `ownership.snapshot_manifest` |
|
||||||
| `IDX_CURL_IMPERSONATE_BIN` | path to a `curl_chrome*` binary |
|
| `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
|
## Files idx keeps
|
||||||
|
|
||||||
|
|
@ -64,6 +66,7 @@ fundamental_ttl = 3600
|
||||||
| Config | `~/.config/idx/config.toml` |
|
| Config | `~/.config/idx/config.toml` |
|
||||||
| Cache | `~/.cache/idx/` (`idx cache info`, `idx cache clear`) |
|
| Cache | `~/.cache/idx/` (`idx cache info`, `idx cache clear`) |
|
||||||
| Ownership database | `~/.local/share/idx/ownership.db` |
|
| 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.
|
The MCP server reads the same config, cache and database.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ TLKM.JK 2,250 <span class="up">+0.45%</span> 87,159,900
|
||||||
<div>
|
<div>
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="7" width="16" height="12" rx="3"></rect><path d="M12 3v4M9 13h.01M15 13h.01"></path></svg>
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="7" width="16" height="12" rx="3"></rect><path d="M12 3v4M9 13h.01M15 13h.01"></path></svg>
|
||||||
<h2>Agents</h2>
|
<h2>Agents</h2>
|
||||||
<p>JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 15 tools for any MCP client.</p>
|
<p>JSON whenever output is piped, errors as one JSON object, meaningful exit codes. Plus idx-mcp: 17 tools for any MCP client.</p>
|
||||||
<code>npx -y idx-mcp</code>
|
<code>npx -y idx-mcp</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue