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
|
|
@ -2,6 +2,7 @@ pub mod cache;
|
|||
pub mod config;
|
||||
#[cfg(feature = "ownership")]
|
||||
pub mod ownership;
|
||||
pub mod stockbit;
|
||||
pub mod stocks;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
|
|
@ -69,6 +70,8 @@ pub enum Commands {
|
|||
#[cfg(feature = "ownership")]
|
||||
#[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")]
|
||||
Ownership(ownership::OwnershipCmd),
|
||||
#[command(about = "Your Stockbit session (opt-in: order book, live quotes)")]
|
||||
Stockbit(stockbit::StockbitCmd),
|
||||
#[command(about = "Generate shell completions")]
|
||||
Completions { shell: Shell },
|
||||
#[command(about = "Show idx-cli version")]
|
||||
|
|
|
|||
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::{
|
||||
render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
|
||||
render_foreign_flow, render_fundamental, render_growth, render_history, render_insights,
|
||||
render_intraday, render_news, render_profile, render_quotes, render_risk, render_screener,
|
||||
render_sentiment, render_technical, render_valuation,
|
||||
render_intraday, render_news, render_orderbook, render_profile, render_quotes, render_risk,
|
||||
render_screener, render_sentiment, render_technical, render_valuation,
|
||||
};
|
||||
use idx_core::analysis::BandarQuery;
|
||||
use idx_core::analysis::bandar::DEFAULT_TOP;
|
||||
|
|
@ -23,6 +23,8 @@ use idx_core::{CachePolicy, Idx, runtime};
|
|||
/// separated by whitespace or commas.
|
||||
/// Most symbols one `bandar` screen fetches, one request each.
|
||||
const MAX_BANDAR_SYMBOLS: usize = 100;
|
||||
/// Most price levels shown per side of an order book.
|
||||
const MAX_BOOK_DEPTH: usize = 50;
|
||||
|
||||
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
|
||||
let mut symbols = Vec::new();
|
||||
|
|
@ -146,6 +148,21 @@ Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45).",
|
|||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
},
|
||||
#[command(
|
||||
about = "Order book depth and foreign flow (Stockbit login)",
|
||||
long_about = "The order book of one IDX stock from your own Stockbit session (`idx stockbit \
|
||||
login`): bid and offer levels with lots and queued orders, the session's OHLC, volume, value \
|
||||
and foreign buy, sell and net. Outside trading it is the book as the market left it, and \
|
||||
the output says so.",
|
||||
after_help = "Examples:\n idx stocks orderbook BBCA\n idx stocks orderbook BBRI --depth 5\n idx -o json stocks orderbook TLKM | jq '.offers[0]'"
|
||||
)]
|
||||
Orderbook {
|
||||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
/// Price levels per side (1-50).
|
||||
#[arg(long, default_value_t = 10)]
|
||||
depth: usize,
|
||||
},
|
||||
#[command(
|
||||
about = "Get historical OHLC data",
|
||||
after_help = "Examples:\n idx stocks history BBCA --period 3mo\n idx stocks history BBCA --period 1y --interval 1wk"
|
||||
|
|
@ -332,13 +349,26 @@ pub fn handle(
|
|||
let day = idx.market().intraday(symbol, policy)?;
|
||||
render_intraday(&day.data, format, no_color)
|
||||
}
|
||||
StocksSubcommand::Orderbook { symbol, depth } => {
|
||||
if !(1..=MAX_BOOK_DEPTH).contains(depth) {
|
||||
return Err(IdxError::InvalidInput(format!(
|
||||
"--depth must be 1 to {MAX_BOOK_DEPTH} (got {depth})"
|
||||
)));
|
||||
}
|
||||
let mut book = idx.market().orderbook(symbol, policy)?.data;
|
||||
book.bids.truncate(*depth);
|
||||
book.offers.truncate(*depth);
|
||||
render_orderbook(&book, format, no_color)
|
||||
}
|
||||
StocksSubcommand::History {
|
||||
symbol,
|
||||
period,
|
||||
interval,
|
||||
history_provider,
|
||||
} => {
|
||||
let source = idx.market().history_source(*history_provider);
|
||||
let source = idx
|
||||
.market()
|
||||
.history_source(*history_provider, period, interval);
|
||||
announce_history_fallback(&idx, source, config);
|
||||
let history = idx
|
||||
.market()
|
||||
|
|
@ -350,7 +380,7 @@ pub fn handle(
|
|||
symbol,
|
||||
history_provider,
|
||||
} => {
|
||||
let source = idx.market().history_source(*history_provider);
|
||||
let source = idx.analysis().technical_source(*history_provider);
|
||||
announce_history_fallback(&idx, source, config);
|
||||
let report = idx.analysis().technical(symbol, source, policy)?;
|
||||
render_technical(&report.data, format, no_color)
|
||||
|
|
|
|||
|
|
@ -73,6 +73,12 @@ fn run() -> Result<(), IdxError> {
|
|||
return Err(err);
|
||||
}
|
||||
}
|
||||
Commands::Stockbit(cmd) => {
|
||||
if let Err(err) = cli::stockbit::handle(cmd, &config.output) {
|
||||
emit_error(&err, &config.output);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "ownership")]
|
||||
Commands::Ownership(cmd) => {
|
||||
if let Err(err) = cli::ownership::handle(&cmd.command, &config) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use idx_core::analysis::fundamental::{
|
|||
use idx_core::analysis::report::TechnicalReport;
|
||||
use idx_core::api::types::{
|
||||
CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc,
|
||||
Quote, SentimentData,
|
||||
OrderBook, Quote, SentimentData,
|
||||
};
|
||||
use idx_core::broker::types::BrokerSummary;
|
||||
use idx_core::config::ProviderKind;
|
||||
|
|
@ -92,6 +92,17 @@ pub fn render_intraday(
|
|||
}
|
||||
}
|
||||
|
||||
pub fn render_orderbook(
|
||||
book: &OrderBook,
|
||||
format: &OutputFormat,
|
||||
no_color: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
match format {
|
||||
OutputFormat::Table => table::print_orderbook(book, no_color),
|
||||
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(book),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_broker_summary(
|
||||
summary: &BrokerSummary,
|
||||
format: &OutputFormat,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ use idx_core::analysis::fundamental::{
|
|||
use idx_core::analysis::report::TechnicalReport;
|
||||
use idx_core::analysis::signals::Signal;
|
||||
use idx_core::api::types::{
|
||||
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, Intraday,
|
||||
NewsItem, Ohlc, Quote, SentimentData,
|
||||
BookLevel, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData,
|
||||
Intraday, NewsItem, Ohlc, OrderBook, Quote, SentimentData,
|
||||
};
|
||||
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
||||
use idx_core::error::IdxError;
|
||||
|
|
@ -128,6 +128,13 @@ fn pct_cell(change_pct: Option<f64>, no_color: bool) -> Cell {
|
|||
/// Printed under every Stockbit table: what the data is and is not.
|
||||
const STOCKBIT_SOURCE_NOTE: &str = "Source: Stockbit's public page, delayed at least 10 \
|
||||
minutes (Stockbit's terms); bid/offer only while the market is open.";
|
||||
/// Under `--provider stockbit` quotes, which may come from either source.
|
||||
const STOCKBIT_QUOTE_NOTE: &str = "Source: Stockbit, your session when logged in (`idx \
|
||||
stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); \
|
||||
bid/offer only while the market is open.";
|
||||
/// Under the order book, which comes from the user's own session.
|
||||
const STOCKBIT_SESSION_NOTE: &str =
|
||||
"Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).";
|
||||
|
||||
fn opt_idr(value: Option<i64>) -> String {
|
||||
value.map(format_idr).unwrap_or_else(|| "-".to_string())
|
||||
|
|
@ -160,7 +167,12 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx
|
|||
cell(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())),
|
||||
cell(opt_idr(q.bid)),
|
||||
cell(opt_idr(q.offer)),
|
||||
cell(q.market_status.as_deref().unwrap_or("-")),
|
||||
cell(
|
||||
q.market_status
|
||||
.as_deref()
|
||||
.map(market_words)
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
cell(q.as_of.map(wib_minute).unwrap_or_else(|| "-".to_string())),
|
||||
]);
|
||||
}
|
||||
|
|
@ -177,7 +189,7 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx
|
|||
for note in stale_quote_notes(quotes, chrono::Utc::now()) {
|
||||
println!("{}", terminal_safe(¬e));
|
||||
}
|
||||
println!("{STOCKBIT_SOURCE_NOTE}");
|
||||
println!("{STOCKBIT_QUOTE_NOTE}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +271,105 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Stockbit's market state for people: `STATE_NAME_MARKET_CLOSED` reads
|
||||
/// `closed`.
|
||||
fn market_words(state: &str) -> String {
|
||||
state
|
||||
.strip_prefix("STATE_NAME_MARKET_")
|
||||
.unwrap_or(state)
|
||||
.replace('_', " ")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
pub fn print_orderbook(book: &OrderBook, no_color: bool) -> Result<(), IdxError> {
|
||||
let change = match (book.change, book.change_pct) {
|
||||
(Some(c), Some(p)) => format!(" {c:+} ({p:+.2}%)"),
|
||||
(Some(c), None) => format!(" {c:+}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let change = match book.change {
|
||||
_ if no_color || change.is_empty() => change,
|
||||
Some(c) if c >= 0 => change.green(),
|
||||
_ => change.red(),
|
||||
};
|
||||
let name = book
|
||||
.name
|
||||
.as_deref()
|
||||
.map(|n| format!(" {}", terminal_safe(n)))
|
||||
.unwrap_or_default();
|
||||
println!(
|
||||
"{}{change}",
|
||||
format!(
|
||||
"{}{name} {}",
|
||||
terminal_safe(&book.symbol),
|
||||
format_idr(book.price)
|
||||
)
|
||||
.bold()
|
||||
);
|
||||
let count = |v: Option<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.
|
||||
fn format_price(price: f64) -> String {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stockbit_login_stores_a_private_session_and_logout_removes_it() {
|
||||
let root = test_env_dir("stockbit-login");
|
||||
let cookie = fs::read_to_string(fixture_path(
|
||||
"stockbit_exodus_credential_storage_cookie_constructed.txt",
|
||||
))
|
||||
.expect("cookie fixture");
|
||||
let session = root.join("config/idx/stockbit-session.json");
|
||||
|
||||
let status = run_success_json(
|
||||
bin_with_root(&root)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["-o", "json", "stockbit", "login"])
|
||||
.write_stdin(cookie.clone()),
|
||||
);
|
||||
assert_eq!(status["logged_in"], true);
|
||||
assert_eq!(status["refresh"]["present"], true);
|
||||
let stored = fs::read_to_string(&session).expect("session file");
|
||||
assert!(stored.contains("\"refresh\""));
|
||||
// Status shows expiries, never a token.
|
||||
assert!(!serde_json::to_string(&status).unwrap().contains("eyJ"));
|
||||
#[cfg(unix)]
|
||||
assert_eq!(
|
||||
fs::metadata(&session).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
|
||||
let book = run_success_json(
|
||||
bin_with_root(&root)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["-o", "json", "stocks", "orderbook", "BBCA"]),
|
||||
);
|
||||
assert_eq!(book["offers"][0]["shares"], 264_462_500);
|
||||
|
||||
let out = run_success_json(
|
||||
bin_with_root(&root)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["-o", "json", "stockbit", "logout"]),
|
||||
);
|
||||
assert_eq!(out["removed"], true);
|
||||
assert!(!session.exists());
|
||||
run_error_json(
|
||||
bin_with_root(&root)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
// The book fetched above is still cached for a few seconds.
|
||||
.args(["-o", "json", "--no-cache", "stocks", "orderbook", "BBCA"]),
|
||||
"AUTHERROR",
|
||||
);
|
||||
|
||||
// Anything that is not a session is refused and stores nothing.
|
||||
run_error_json(
|
||||
bin_with_root(&root)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["-o", "json", "stockbit", "login"])
|
||||
.write_stdin("not a token"),
|
||||
"INVALIDINPUT",
|
||||
);
|
||||
assert!(!session.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_quote_ttl_env_returns_non_zero() {
|
||||
test_bin("invalid-quote-ttl")
|
||||
|
|
|
|||
|
|
@ -263,6 +263,28 @@ args = ["stocks", "intraday", "MABA"]
|
|||
name = "stockbit-quote"
|
||||
args = ["--provider", "stockbit", "stocks", "quote", "BBCA,MABA"]
|
||||
|
||||
# With a session: IDX_STOCKBIT_REFRESH_TOKEN seeds an unsigned test token that
|
||||
# expires in 2100; the mock serves the recorded exodus responses.
|
||||
|
||||
[[case]]
|
||||
name = "stockbit-session-orderbook"
|
||||
args = ["stocks", "orderbook", "BBCA", "--depth", "3"]
|
||||
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||
|
||||
[[case]]
|
||||
name = "stockbit-session-quote"
|
||||
args = ["--provider", "stockbit", "stocks", "quote", "BBCA"]
|
||||
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||
|
||||
[[case]]
|
||||
name = "stockbit-session-history"
|
||||
args = ["--provider", "stockbit", "stocks", "history", "BBCA", "--period", "1mo"]
|
||||
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||
|
||||
[[case]]
|
||||
name = "stockbit-status-logged-out"
|
||||
args = ["stockbit", "status"]
|
||||
|
||||
# --- stocks: broker summaries (IPOT) and foreign flow (IDX) --------------------
|
||||
|
||||
[[case]]
|
||||
|
|
@ -366,6 +388,15 @@ args = ["stocks", "intraday", "ZZZZ"]
|
|||
name = "error-stockbit-fundamental-unsupported"
|
||||
args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "error-orderbook-no-session"
|
||||
args = ["stocks", "orderbook", "BBCA"]
|
||||
|
||||
[[case]]
|
||||
name = "error-stockbit-history-window"
|
||||
args = ["stocks", "history", "BBCA", "--period", "5y", "--history-provider", "stockbit"]
|
||||
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||
|
||||
# --- ownership --------------------------------------------------------------------
|
||||
|
||||
[[case]]
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ _idx() {
|
|||
idx,ownership)
|
||||
cmd="idx__ownership"
|
||||
;;
|
||||
idx,stockbit)
|
||||
cmd="idx__stockbit"
|
||||
;;
|
||||
idx,stocks)
|
||||
cmd="idx__stocks"
|
||||
;;
|
||||
|
|
@ -102,6 +105,9 @@ _idx() {
|
|||
idx__help,ownership)
|
||||
cmd="idx__help__ownership"
|
||||
;;
|
||||
idx__help,stockbit)
|
||||
cmd="idx__help__stockbit"
|
||||
;;
|
||||
idx__help,stocks)
|
||||
cmd="idx__help__stocks"
|
||||
;;
|
||||
|
|
@ -174,6 +180,15 @@ _idx() {
|
|||
idx__help__ownership__resolve,unresolved)
|
||||
cmd="idx__help__ownership__resolve__unresolved"
|
||||
;;
|
||||
idx__help__stockbit,login)
|
||||
cmd="idx__help__stockbit__login"
|
||||
;;
|
||||
idx__help__stockbit,logout)
|
||||
cmd="idx__help__stockbit__logout"
|
||||
;;
|
||||
idx__help__stockbit,status)
|
||||
cmd="idx__help__stockbit__status"
|
||||
;;
|
||||
idx__help__stocks,bandar)
|
||||
cmd="idx__help__stocks__bandar"
|
||||
;;
|
||||
|
|
@ -210,6 +225,9 @@ _idx() {
|
|||
idx__help__stocks,news)
|
||||
cmd="idx__help__stocks__news"
|
||||
;;
|
||||
idx__help__stocks,orderbook)
|
||||
cmd="idx__help__stocks__orderbook"
|
||||
;;
|
||||
idx__help__stocks,profile)
|
||||
cmd="idx__help__stocks__profile"
|
||||
;;
|
||||
|
|
@ -348,6 +366,30 @@ _idx() {
|
|||
idx__ownership__resolve__help,unresolved)
|
||||
cmd="idx__ownership__resolve__help__unresolved"
|
||||
;;
|
||||
idx__stockbit,help)
|
||||
cmd="idx__stockbit__help"
|
||||
;;
|
||||
idx__stockbit,login)
|
||||
cmd="idx__stockbit__login"
|
||||
;;
|
||||
idx__stockbit,logout)
|
||||
cmd="idx__stockbit__logout"
|
||||
;;
|
||||
idx__stockbit,status)
|
||||
cmd="idx__stockbit__status"
|
||||
;;
|
||||
idx__stockbit__help,help)
|
||||
cmd="idx__stockbit__help__help"
|
||||
;;
|
||||
idx__stockbit__help,login)
|
||||
cmd="idx__stockbit__help__login"
|
||||
;;
|
||||
idx__stockbit__help,logout)
|
||||
cmd="idx__stockbit__help__logout"
|
||||
;;
|
||||
idx__stockbit__help,status)
|
||||
cmd="idx__stockbit__help__status"
|
||||
;;
|
||||
idx__stocks,bandar)
|
||||
cmd="idx__stocks__bandar"
|
||||
;;
|
||||
|
|
@ -387,6 +429,9 @@ _idx() {
|
|||
idx__stocks,news)
|
||||
cmd="idx__stocks__news"
|
||||
;;
|
||||
idx__stocks,orderbook)
|
||||
cmd="idx__stocks__orderbook"
|
||||
;;
|
||||
idx__stocks,profile)
|
||||
cmd="idx__stocks__profile"
|
||||
;;
|
||||
|
|
@ -447,6 +492,9 @@ _idx() {
|
|||
idx__stocks__help,news)
|
||||
cmd="idx__stocks__help__news"
|
||||
;;
|
||||
idx__stocks__help,orderbook)
|
||||
cmd="idx__stocks__help__orderbook"
|
||||
;;
|
||||
idx__stocks__help,profile)
|
||||
cmd="idx__stocks__help__profile"
|
||||
;;
|
||||
|
|
@ -475,7 +523,7 @@ _idx() {
|
|||
|
||||
case "${cmd}" in
|
||||
idx)
|
||||
opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership completions version help"
|
||||
opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership stockbit completions version help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -875,7 +923,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__help)
|
||||
opts="stocks config cache ownership completions version help"
|
||||
opts="stocks config cache ownership stockbit completions version help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -1266,8 +1314,64 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stockbit)
|
||||
opts="login status logout"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stockbit__login)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stockbit__logout)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stockbit__status)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stocks)
|
||||
opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare"
|
||||
opts="quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -1448,6 +1552,20 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stocks__orderbook)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__help__stocks__profile)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
|
|
@ -2392,8 +2510,182 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stockbit)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help login status logout help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
--output)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--provider)
|
||||
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stockbit__help)
|
||||
opts="login status logout help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stockbit__help__help)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stockbit__help__login)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stockbit__help__logout)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stockbit__help__status)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stockbit__login)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
--output)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--provider)
|
||||
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stockbit__logout)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
--output)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--provider)
|
||||
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stockbit__status)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
--output)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
-o)
|
||||
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--provider)
|
||||
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -2691,7 +2983,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
idx__stocks__help)
|
||||
opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
||||
opts="quote intraday orderbook history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
|
|
@ -2886,6 +3178,20 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__help__orderbook)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
case "${prev}" in
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__help__profile)
|
||||
opts=""
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||
|
|
@ -3000,7 +3306,7 @@ _idx() {
|
|||
return 0
|
||||
;;
|
||||
--history-provider)
|
||||
COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}"))
|
||||
COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--output)
|
||||
|
|
@ -3104,6 +3410,36 @@ _idx() {
|
|||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
idx__stocks__orderbook)
|
||||
opts="-o -q -v -h --depth --output --no-color --quiet --verbose --provider --offline --no-cache --help <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)
|
||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
|
||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||
|
|
@ -3254,7 +3590,7 @@ _idx() {
|
|||
fi
|
||||
case "${prev}" in
|
||||
--history-provider)
|
||||
COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}"))
|
||||
COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}"))
|
||||
return 0
|
||||
;;
|
||||
--output)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
cache Manage local cache
|
||||
ownership Ownership intelligence (bootstrap with `ownership sync`)
|
||||
stockbit Your Stockbit session (opt-in: order book, live quotes)
|
||||
completions Generate shell completions
|
||||
version Show idx-cli version
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Usage: idx stocks [OPTIONS] <COMMAND>
|
|||
Commands:
|
||||
quote Get real-time stock quotes
|
||||
intraday Today's session price by the minute (Stockbit)
|
||||
orderbook Order book depth and foreign flow (Stockbit login)
|
||||
history Get historical OHLC data
|
||||
technical Run technical analysis on a stock
|
||||
growth Run growth analysis on a stock
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@ exit: 0
|
|||
│ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │
|
||||
└─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘
|
||||
Note: MABA.JK: Stockbit lists the stock as suspended
|
||||
Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
|
||||
Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
|
||||
--- stderr
|
||||
|
|
|
|||
|
|
@ -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
|
||||
thiserror = "2"
|
||||
fastrand = "2"
|
||||
# Decodes a Stockbit session JWT's expiry (`exp`); never verifies or signs.
|
||||
base64 = "0.22"
|
||||
rusqlite = { workspace = true, optional = true }
|
||||
quick-xml = { version = "0.37", optional = true }
|
||||
sha2 = { workspace = true, optional = true }
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use crate::api::history_provider;
|
|||
use crate::api::types::{Fundamentals, Interval, Period};
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::config::HistoryProviderKind;
|
||||
use crate::error::IdxError;
|
||||
use crate::market::HistorySource;
|
||||
use bandar::{BandarReport, analyze_bandar, check_top};
|
||||
|
|
@ -110,8 +111,16 @@ impl Analysis<'_> {
|
|||
Ok(BandarScreen { reports, failures })
|
||||
}
|
||||
|
||||
/// Where `technical` reads its year of daily history from, for an
|
||||
/// optional `--history-provider` override.
|
||||
pub fn technical_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
|
||||
self.idx
|
||||
.market()
|
||||
.history_source(mode, &Period::OneYear, &Interval::Day)
|
||||
}
|
||||
|
||||
/// Technical analysis over one year of daily history from `source`
|
||||
/// (see `Market::history_source`).
|
||||
/// (see `technical_source`).
|
||||
pub fn technical(
|
||||
&self,
|
||||
symbol: &str,
|
||||
|
|
@ -119,9 +128,8 @@ impl Analysis<'_> {
|
|||
policy: CachePolicy,
|
||||
) -> Result<Fetched<TechnicalReport>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let (provider_kind, provider) =
|
||||
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
|
||||
let bucket = format!("{}-technical", provider_kind.as_str());
|
||||
let provider = history_provider(source.provider, idx.config.verbose);
|
||||
let bucket = format!("{}-technical", source.provider.as_str());
|
||||
let resolved = idx.resolve(symbol)?;
|
||||
cached_fetch(
|
||||
&idx.cache,
|
||||
|
|
@ -130,7 +138,7 @@ impl Analysis<'_> {
|
|||
key: &resolved,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
subject: &resolved,
|
||||
source: provider_kind.as_str(),
|
||||
source: source.provider.as_str(),
|
||||
},
|
||||
policy,
|
||||
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
||||
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
||||
|
||||
use super::types::{Bar, Fundamentals, Interval, Intraday, Period, Quote};
|
||||
use super::stockbit::exodus;
|
||||
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||
use super::{
|
||||
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, QuoteProvider,
|
||||
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
||||
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
|
||||
OrderBookProvider, QuoteProvider, TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
||||
};
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||
use crate::config::ProviderKind;
|
||||
|
|
@ -135,22 +136,31 @@ impl HistoryProvider for MockProvider {
|
|||
}
|
||||
}
|
||||
|
||||
/// Stockbit symbol pages from `tests/fixtures/stockbit_symbol_<code>.html`;
|
||||
/// a code without a fixture is Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS`
|
||||
/// (e.g. `403`) answers that HTTP status instead, and `IDX_MOCK_ERROR` fails
|
||||
/// as for the other providers.
|
||||
/// Stockbit from fixtures. Without a session (none stored or seeded through
|
||||
/// `IDX_STOCKBIT_REFRESH_TOKEN`): the public page,
|
||||
/// `tests/fixtures/stockbit_symbol_<code>.html`. With one: the session API,
|
||||
/// `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;
|
||||
|
||||
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() {
|
||||
return Err(IdxError::ProviderUnavailable);
|
||||
return Some(IdxError::ProviderUnavailable);
|
||||
}
|
||||
if let Ok(status) = std::env::var("IDX_MOCK_STOCKBIT_STATUS") {
|
||||
return Err(stockbit::status_error(
|
||||
status.parse().unwrap_or(500),
|
||||
symbol,
|
||||
));
|
||||
let status = std::env::var("IDX_MOCK_STOCKBIT_STATUS").ok()?;
|
||||
Some(stockbit::status_error(
|
||||
status.parse().unwrap_or(500),
|
||||
symbol,
|
||||
))
|
||||
}
|
||||
|
||||
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
|
||||
if let Some(err) = self.failure(symbol) {
|
||||
return Err(err);
|
||||
}
|
||||
let code = stockbit::stockbit_code(symbol)?;
|
||||
let path = format!(
|
||||
|
|
@ -162,10 +172,32 @@ impl MockStockbitProvider {
|
|||
Err(_) => Err(stockbit::status_error(404, symbol)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A session API fixture, or the error a request would meet.
|
||||
fn exodus(&self, symbol: &str, name: &str) -> Result<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 {
|
||||
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;
|
||||
// Quotes are judged against the clock (behind the session, a week
|
||||
// old); a fixed trade time would make their output age.
|
||||
|
|
@ -186,6 +218,27 @@ impl IntradayProvider for MockStockbitProvider {
|
|||
}
|
||||
}
|
||||
|
||||
impl OrderBookProvider for MockStockbitProvider {
|
||||
fn orderbook(&self, symbol: &str) -> Result<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>
|
||||
/// [_<to>][_<investor>_<board>].html`; a query without a fixture reads as an
|
||||
/// unknown code.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind};
|
|||
use crate::error::IdxError;
|
||||
use types::{
|
||||
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
|
||||
Intraday, NewsItem, Period, Quote, SentimentData,
|
||||
Intraday, NewsItem, OrderBook, Period, Quote, SentimentData,
|
||||
};
|
||||
|
||||
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
|
||||
|
|
@ -72,6 +72,12 @@ pub trait IntradayProvider {
|
|||
fn intraday(&self, symbol: &str) -> Result<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 {
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||
}
|
||||
|
|
@ -242,9 +248,12 @@ fn mock_market(_provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
|
|||
|
||||
#[cfg(feature = "testing")]
|
||||
fn mock_history(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> {
|
||||
Some(Box::new(
|
||||
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
|
||||
))
|
||||
match provider {
|
||||
ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)),
|
||||
_ => Some(Box::new(
|
||||
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "testing"))]
|
||||
|
|
@ -285,7 +294,7 @@ fn build_selected_provider(
|
|||
news: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
screener: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
},
|
||||
// Quotes only; history falls back to Yahoo (see `history_source`).
|
||||
// Quotes only; history needs a session (see `history_source`).
|
||||
ProviderKind::Stockbit => SelectedProvider {
|
||||
kind: ProviderKind::Stockbit,
|
||||
market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())),
|
||||
|
|
@ -328,6 +337,14 @@ pub fn intraday_provider() -> Box<dyn IntradayProvider> {
|
|||
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.
|
||||
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||
#[cfg(feature = "testing")]
|
||||
|
|
@ -338,40 +355,44 @@ pub(crate) fn today_wib() -> chrono::NaiveDate {
|
|||
}
|
||||
|
||||
/// The provider that serves history for `provider` under `history_mode`.
|
||||
pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind {
|
||||
/// `auto` uses Stockbit when it is the selected provider, the user has a
|
||||
/// session and the window is one of its daily charts; otherwise Yahoo, whose
|
||||
/// candles are full OHLCV for any window.
|
||||
pub fn history_source(
|
||||
provider: ProviderKind,
|
||||
history_mode: HistoryProviderKind,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
stockbit_session: bool,
|
||||
) -> ProviderKind {
|
||||
match history_mode {
|
||||
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
|
||||
HistoryProviderKind::Msn => ProviderKind::Msn,
|
||||
HistoryProviderKind::Auto => match provider {
|
||||
ProviderKind::Yahoo | ProviderKind::Msn | ProviderKind::Stockbit => ProviderKind::Yahoo,
|
||||
},
|
||||
HistoryProviderKind::Stockbit => ProviderKind::Stockbit,
|
||||
HistoryProviderKind::Auto => {
|
||||
if provider == ProviderKind::Stockbit
|
||||
&& stockbit_session
|
||||
&& stockbit::exodus::history_window(period, interval).is_some()
|
||||
{
|
||||
ProviderKind::Stockbit
|
||||
} else {
|
||||
ProviderKind::Yahoo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a history provider based on the selected market data provider and
|
||||
/// history provider strategy.
|
||||
///
|
||||
/// `history_mode=auto` keeps using Yahoo for IDX history because Yahoo provides
|
||||
/// full OHLCV candles. Explicit `msn` opts into MSN's price-only chart feed.
|
||||
pub fn history_provider(
|
||||
provider: ProviderKind,
|
||||
history_mode: HistoryProviderKind,
|
||||
verbose: bool,
|
||||
) -> Result<(ProviderKind, Box<dyn HistoryProvider>), IdxError> {
|
||||
let resolved = history_source(provider, history_mode);
|
||||
|
||||
/// The history provider for a source from `history_source`.
|
||||
pub fn history_provider(source: ProviderKind, verbose: bool) -> Box<dyn HistoryProvider> {
|
||||
if mock_enabled()
|
||||
&& let Some(mock) = mock_history(resolved, verbose)
|
||||
&& let Some(mock) = mock_history(source, verbose)
|
||||
{
|
||||
return Ok((resolved, mock));
|
||||
return mock;
|
||||
}
|
||||
|
||||
match resolved {
|
||||
ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))),
|
||||
ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))),
|
||||
ProviderKind::Stockbit => Err(IdxError::Unsupported(
|
||||
"Stockbit serves no price history; use --history-provider yahoo or msn".to_string(),
|
||||
)),
|
||||
match source {
|
||||
ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)),
|
||||
ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)),
|
||||
ProviderKind::Stockbit => Box::new(stockbit::StockbitProvider::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -472,4 +493,72 @@ mod tests {
|
|||
"unsupported: screen: command requires --provider msn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_history_uses_stockbit_only_with_a_session_and_a_daily_window() {
|
||||
use super::history_source;
|
||||
use crate::api::types::{Interval, Period};
|
||||
use crate::config::HistoryProviderKind::{Auto, Stockbit, Yahoo};
|
||||
|
||||
let source = |provider, mode, period, interval, session| {
|
||||
history_source(provider, mode, &period, &interval, session)
|
||||
};
|
||||
let year = (Period::OneYear, Interval::Day);
|
||||
assert_eq!(
|
||||
source(
|
||||
ProviderKind::Stockbit,
|
||||
Auto,
|
||||
year.0.clone(),
|
||||
year.1.clone(),
|
||||
true
|
||||
),
|
||||
ProviderKind::Stockbit
|
||||
);
|
||||
// No session, a window Stockbit charts weekly, or another provider.
|
||||
assert_eq!(
|
||||
source(
|
||||
ProviderKind::Stockbit,
|
||||
Auto,
|
||||
year.0.clone(),
|
||||
year.1.clone(),
|
||||
false
|
||||
),
|
||||
ProviderKind::Yahoo
|
||||
);
|
||||
assert_eq!(
|
||||
source(
|
||||
ProviderKind::Stockbit,
|
||||
Auto,
|
||||
Period::FiveYears,
|
||||
Interval::Day,
|
||||
true
|
||||
),
|
||||
ProviderKind::Yahoo
|
||||
);
|
||||
assert_eq!(
|
||||
source(
|
||||
ProviderKind::Msn,
|
||||
Auto,
|
||||
year.0.clone(),
|
||||
year.1.clone(),
|
||||
true
|
||||
),
|
||||
ProviderKind::Yahoo
|
||||
);
|
||||
// An explicit choice wins either way.
|
||||
assert_eq!(
|
||||
source(
|
||||
ProviderKind::Msn,
|
||||
Stockbit,
|
||||
year.0.clone(),
|
||||
year.1.clone(),
|
||||
false
|
||||
),
|
||||
ProviderKind::Stockbit
|
||||
);
|
||||
assert_eq!(
|
||||
source(ProviderKind::Stockbit, Yahoo, year.0, year.1, true),
|
||||
ProviderKind::Yahoo
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
//! minutes and for personal use; frontends say so.
|
||||
|
||||
pub mod auth;
|
||||
pub mod exodus;
|
||||
mod parse;
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
|
|
@ -24,9 +26,13 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|||
|
||||
pub use parse::{parse_symbol_page, status_error, stockbit_code};
|
||||
|
||||
use super::types::{Fundamentals, Intraday, Quote};
|
||||
use super::{FundamentalsProvider, IntradayProvider, QuoteProvider};
|
||||
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||
use super::{
|
||||
FundamentalsProvider, HistoryProvider, IntradayProvider, OrderBookProvider, QuoteProvider,
|
||||
};
|
||||
use crate::error::IdxError;
|
||||
use auth::SessionStore;
|
||||
use exodus::Exodus;
|
||||
|
||||
pub const SOURCE: &str = "stockbit";
|
||||
/// Identifies idx-cli and where to reach its maintainers.
|
||||
|
|
@ -45,8 +51,13 @@ pub(crate) const CACHE_TTL_SECS: u64 = 5;
|
|||
const MIN_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const BASE_URL: &str = "https://stockbit.com/symbol/";
|
||||
|
||||
/// Quotes come from the user's session when one is stored (live, from
|
||||
/// `exodus`), else from the public page. The order book and history need the
|
||||
/// session; intraday always reads the public page.
|
||||
pub struct StockbitProvider {
|
||||
agent: ureq::Agent,
|
||||
/// `None` when the config directory cannot be resolved.
|
||||
exodus: Option<Exodus>,
|
||||
}
|
||||
|
||||
impl StockbitProvider {
|
||||
|
|
@ -59,7 +70,20 @@ impl StockbitProvider {
|
|||
.timeout_global(Some(Duration::from_secs(20)))
|
||||
.build()
|
||||
.into();
|
||||
Self { agent }
|
||||
Self {
|
||||
agent,
|
||||
exodus: SessionStore::open().ok().map(Exodus::new),
|
||||
}
|
||||
}
|
||||
|
||||
/// The session API when the user has a session.
|
||||
fn session(&self) -> Option<&Exodus> {
|
||||
self.exodus.as_ref().filter(|e| e.store().has_session())
|
||||
}
|
||||
|
||||
fn require_session(&self) -> Result<&Exodus, IdxError> {
|
||||
self.session()
|
||||
.ok_or_else(|| IdxError::AuthError(auth::NO_SESSION.to_string()))
|
||||
}
|
||||
|
||||
/// The quote and today's series for a resolved symbol (`BBCA.JK`).
|
||||
|
|
@ -99,7 +123,7 @@ impl Default for StockbitProvider {
|
|||
/// `idx` calls, a CLI beside idx-mcp) share the time of the last request in
|
||||
/// `<cache>/stockbit.pace` under an exclusive file lock. When that file is
|
||||
/// unusable, pacing is per process.
|
||||
fn pace() {
|
||||
pub(crate) fn pace() {
|
||||
static LAST: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(prev) = *last {
|
||||
|
|
@ -151,7 +175,10 @@ fn now_ms() -> u128 {
|
|||
|
||||
impl QuoteProvider for StockbitProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<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 {
|
||||
IdxError::Unsupported(format!(
|
||||
"{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo"
|
||||
))
|
||||
}
|
||||
|
||||
/// Stores a pasted session (stockbit.com's `credentialStorage` cookie or a
|
||||
/// refresh token) and checks it with one request, which refreshes it when the
|
||||
/// access token is missing or stale. A session Stockbit refuses is removed.
|
||||
pub fn login(pasted: &str) -> Result<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)]
|
||||
mod tests {
|
||||
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"`,
|
||||
/// `"NA"` and empty strings are absent.
|
||||
fn number(value: &Value) -> Option<f64> {
|
||||
pub(super) fn number(value: &Value) -> Option<f64> {
|
||||
let n = match value {
|
||||
Value::Number(n) => n.as_f64()?,
|
||||
Value::String(s) => s.trim().parse::<f64>().ok()?,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,52 @@ pub struct Intraday {
|
|||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Ohlc {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ pub enum HistoryProviderKind {
|
|||
Auto,
|
||||
Yahoo,
|
||||
Msn,
|
||||
// Needs the user's Stockbit session (`idx stockbit login`).
|
||||
Stockbit,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
|
|
@ -63,6 +65,7 @@ impl HistoryProviderKind {
|
|||
Self::Auto => "auto",
|
||||
Self::Yahoo => "yahoo",
|
||||
Self::Msn => "msn",
|
||||
Self::Stockbit => "stockbit",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,9 +76,11 @@ impl HistoryProviderKind {
|
|||
Ok(Self::Yahoo)
|
||||
} else if value.eq_ignore_ascii_case("msn") {
|
||||
Ok(Self::Msn)
|
||||
} else if value.eq_ignore_ascii_case("stockbit") {
|
||||
Ok(Self::Stockbit)
|
||||
} else {
|
||||
Err(IdxError::ConfigError(format!(
|
||||
"invalid history provider '{value}' (expected auto, yahoo, or msn)"
|
||||
"invalid history provider '{value}' (expected auto, yahoo, msn, or stockbit)"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ use std::str::FromStr;
|
|||
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
|
||||
|
||||
use crate::api::types::{Interval, Intraday, Ohlc, Period, Quote};
|
||||
use crate::api::{history_provider, history_source, idx_offset, intraday_provider, stockbit};
|
||||
use crate::api::types::{Interval, Intraday, Ohlc, OrderBook, Period, Quote};
|
||||
use crate::api::{
|
||||
history_provider, history_source, idx_offset, intraday_provider, orderbook_provider, stockbit,
|
||||
};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||
use crate::error::IdxError;
|
||||
|
|
@ -91,11 +93,20 @@ impl Market<'_> {
|
|||
}
|
||||
|
||||
/// Resolves the history source for an optional `--history-provider`
|
||||
/// override, without fetching.
|
||||
pub fn history_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
|
||||
/// override and the window asked for, without fetching.
|
||||
pub fn history_source(
|
||||
&self,
|
||||
mode: Option<HistoryProviderKind>,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> HistorySource {
|
||||
let mode = mode.unwrap_or(self.idx.config.history_provider);
|
||||
let selected = self.idx.provider.kind();
|
||||
let provider = history_source(selected, mode);
|
||||
// Only `auto` under the stockbit provider looks for a session.
|
||||
let session = mode == HistoryProviderKind::Auto
|
||||
&& selected == ProviderKind::Stockbit
|
||||
&& stockbit::has_session();
|
||||
let provider = history_source(selected, mode, period, interval, session);
|
||||
HistorySource {
|
||||
provider,
|
||||
mode,
|
||||
|
|
@ -112,9 +123,8 @@ impl Market<'_> {
|
|||
policy: CachePolicy,
|
||||
) -> Result<Fetched<Vec<Ohlc>>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let (provider_kind, provider) =
|
||||
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
|
||||
let bucket = format!("{}-history", provider_kind.as_str());
|
||||
let provider = history_provider(source.provider, idx.config.verbose);
|
||||
let bucket = format!("{}-history", source.provider.as_str());
|
||||
let resolved = idx.resolve(symbol)?;
|
||||
let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str());
|
||||
cached_fetch(
|
||||
|
|
@ -124,7 +134,7 @@ impl Market<'_> {
|
|||
key: &key,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
subject: &resolved,
|
||||
source: provider_kind.as_str(),
|
||||
source: source.provider.as_str(),
|
||||
},
|
||||
policy,
|
||||
|| provider.history(&resolved, period, interval),
|
||||
|
|
@ -132,6 +142,31 @@ impl Market<'_> {
|
|||
)
|
||||
}
|
||||
|
||||
/// The order book of one IDX stock from the user's Stockbit session,
|
||||
/// whichever market provider is selected.
|
||||
pub fn orderbook(
|
||||
&self,
|
||||
symbol: &str,
|
||||
policy: CachePolicy,
|
||||
) -> Result<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`.
|
||||
pub fn screen(
|
||||
&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>,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub struct ScreenParams {
|
||||
/// Screen to run (default "top-performers").
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ use tokio::sync::Semaphore;
|
|||
|
||||
use crate::params::{
|
||||
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
||||
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, ScreenParams,
|
||||
SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
|
||||
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, OrderBookParams,
|
||||
ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
|
||||
};
|
||||
|
||||
const SYNC_TOOL: &str = "ownership_sync";
|
||||
|
|
@ -45,6 +45,8 @@ const MAX_CHANGES_LIMIT: usize = 300;
|
|||
const CHANGES_TOP_TICKERS: usize = 10;
|
||||
// A session has about 340 minutes, so this is the whole day.
|
||||
const MAX_INTRADAY_POINTS: usize = 400;
|
||||
const BOOK_DEPTH: usize = 10;
|
||||
const MAX_BOOK_DEPTH: usize = 50;
|
||||
|
||||
/// Tool calls running at once; the rest wait their turn.
|
||||
static CALL_SLOTS: Semaphore = Semaphore::const_new(4);
|
||||
|
|
@ -158,6 +160,34 @@ per-minute volume. Empty `points` means the stock did not trade this session (se
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Order book of one IDX stock from the user's own Stockbit session: \
|
||||
`bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, \
|
||||
volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is \
|
||||
Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left \
|
||||
it. AUTHERROR means there is no session: call stockbit_status, and ask the user to run \
|
||||
`idx stockbit login` in a terminal."
|
||||
)]
|
||||
async fn get_orderbook(&self, params: Parameters<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.")]
|
||||
async fn get_price_history(&self, params: Parameters<HistoryParams>) -> ToolResult {
|
||||
let HistoryParams {
|
||||
|
|
@ -168,7 +198,7 @@ per-minute volume. Empty `points` means the stock did not trade this session (se
|
|||
self.live(move |idx| {
|
||||
let period = period.as_deref().unwrap_or("3mo").parse()?;
|
||||
let interval = interval.as_deref().unwrap_or("1d").parse()?;
|
||||
let source = idx.market().history_source(None);
|
||||
let source = idx.market().history_source(None, &period, &interval);
|
||||
idx.market()
|
||||
.history(&symbol, &period, &interval, source, CachePolicy::Default)
|
||||
})
|
||||
|
|
@ -365,7 +395,7 @@ are listed under `errors`; the call fails only if every analysis fails."
|
|||
let name = kind.as_str();
|
||||
match kind {
|
||||
AnalysisKind::Technical => {
|
||||
let source = idx.market().history_source(None);
|
||||
let source = analysis.technical_source(None);
|
||||
report.add(name, analysis.technical(&symbol, source, policy))
|
||||
}
|
||||
AnalysisKind::Growth => report.add(name, analysis.growth(&symbol, policy)),
|
||||
|
|
|
|||
|
|
@ -331,6 +331,33 @@
|
|||
},
|
||||
"name": "get_investor_holdings"
|
||||
},
|
||||
{
|
||||
"description": "Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, and ask the user to run `idx stockbit login` in a terminal.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"depth": {
|
||||
"default": null,
|
||||
"description": "Price levels per side (default 10, at most 50).",
|
||||
"format": "uint",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"symbol": {
|
||||
"description": "IDX ticker, e.g. \"BBCA\".",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"symbol"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_orderbook"
|
||||
},
|
||||
{
|
||||
"description": "Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last.",
|
||||
"inputSchema": {
|
||||
|
|
@ -556,5 +583,13 @@
|
|||
"type": "object"
|
||||
},
|
||||
"name": "search_investors"
|
||||
},
|
||||
{
|
||||
"description": "Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with `idx stockbit login` in a terminal.",
|
||||
"inputSchema": {
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"name": "stockbit_status"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use idx_core::config::CoreConfig;
|
|||
use idx_core::investor::{ImportSource, Investor};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const TOOLS: [&str; 15] = [
|
||||
const TOOLS: [&str; 17] = [
|
||||
"analyze_bandar",
|
||||
"analyze_stock",
|
||||
"compare_stocks",
|
||||
|
|
@ -18,6 +18,7 @@ const TOOLS: [&str; 15] = [
|
|||
"get_foreign_flow",
|
||||
"get_intraday",
|
||||
"get_investor_holdings",
|
||||
"get_orderbook",
|
||||
"get_ownership_changes",
|
||||
"get_price_history",
|
||||
"get_quotes",
|
||||
|
|
@ -25,8 +26,15 @@ const TOOLS: [&str; 15] = [
|
|||
"ownership_status",
|
||||
"screen_stocks",
|
||||
"search_investors",
|
||||
"stockbit_status",
|
||||
];
|
||||
|
||||
/// An unsigned test token expiring in 2100, as a seeded Stockbit session.
|
||||
const TEST_SESSION: (&str, &str) = (
|
||||
"IDX_STOCKBIT_REFRESH_TOKEN",
|
||||
"eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln",
|
||||
);
|
||||
|
||||
struct Client {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
|
|
@ -362,6 +370,39 @@ fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_order_book_needs_a_session_and_status_never_shows_tokens() {
|
||||
let mut client = Client::start(&[], false);
|
||||
let status = client.ok("stockbit_status", json!({}));
|
||||
assert_eq!(status["logged_in"], false);
|
||||
let (is_error, body) = client.call("get_orderbook", json!({"symbol": "BBCA"}));
|
||||
assert!(is_error, "{body}");
|
||||
assert_eq!(body["code"], "AUTHERROR");
|
||||
|
||||
let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]);
|
||||
let status = client.ok("stockbit_status", json!({}));
|
||||
assert_eq!(status["logged_in"], true);
|
||||
assert!(!status.to_string().contains(TEST_SESSION.1));
|
||||
|
||||
let book = client.ok("get_orderbook", json!({"symbol": "BBCA", "depth": 2}));
|
||||
assert_fetched(&book);
|
||||
assert_eq!(book["source"], "stockbit");
|
||||
let offers = book["data"]["offers"].as_array().unwrap();
|
||||
assert_eq!(offers.len(), 2);
|
||||
assert_eq!(
|
||||
offers[0],
|
||||
json!({"price": 5175, "shares": 264_462_500, "orders": 3284})
|
||||
);
|
||||
assert_eq!(book["data"]["foreign_net"], -2_329_493_087_500_i64);
|
||||
assert_eq!(book["data"]["market_status"], "STATE_NAME_MARKET_CLOSED");
|
||||
for depth in [0, 51] {
|
||||
let (is_error, body) =
|
||||
client.call("get_orderbook", json!({"symbol": "BBCA", "depth": depth}));
|
||||
assert!(is_error, "{body}");
|
||||
assert_eq!(body["code"], "INVALIDINPUT");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_carry_cli_codes() {
|
||||
let mut client = Client::start(&[], false);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue