feat: login-free Stockbit intraday and quote provider

Read Stockbit's public symbol page (__NEXT_DATA__) with a plain ureq
request and an honest user agent: `stocks intraday` (today by the
minute, any provider), `--provider stockbit` quotes with the best
bid/offer only while the market is open, and MCP `get_intraday`.

- UPSTREAMBLOCKED error (exit 4) for a CDN 403; never retried
- requests paced 1 s apart across idx processes, 5 s snapshot cache
- global --provider flag (yahoo, msn, stockbit)
- roadmap: L1 ships from Stockbit; depth stays a login follow-on
This commit is contained in:
hermes 2026-10-23 04:58:32 +00:00
commit 74e5bb0536
65 changed files with 2320 additions and 381 deletions

View file

@ -287,6 +287,33 @@
},
"name": "get_foreign_flow"
},
{
"description": "Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"symbol": {
"description": "IDX ticker, e.g. \"BBCA\"; indices as \"^JKSE\" or \"IHSG\".",
"type": "string"
},
"tail": {
"default": null,
"description": "Only the latest N minutes (default: the whole session, at most 400).",
"format": "uint",
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"required": [
"symbol"
],
"type": "object"
},
"name": "get_intraday"
},
{
"description": "Every IDX holding (above 1%) of one investor, matched by name.",
"inputSchema": {
@ -397,7 +424,7 @@
"name": "get_price_history"
},
{
"description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday.",
"description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. \"open\", \"close\"), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {

View file

@ -9,13 +9,14 @@ use idx_core::config::CoreConfig;
use idx_core::investor::{ImportSource, Investor};
use serde_json::{Value, json};
const TOOLS: [&str; 14] = [
const TOOLS: [&str; 15] = [
"analyze_bandar",
"analyze_stock",
"compare_stocks",
"get_broker_summary",
"get_company",
"get_foreign_flow",
"get_intraday",
"get_investor_holdings",
"get_ownership_changes",
"get_price_history",
@ -36,6 +37,10 @@ struct Client {
impl Client {
fn start(args: &[&str], seed: bool) -> Self {
Self::start_with_env(args, seed, &[])
}
fn start_with_env(args: &[&str], seed: bool, env: &[(&str, &str)]) -> Self {
let home = tempdir::Dir::new();
if seed {
seed_ownership_db(&home.path().join("data/idx/ownership.db"));
@ -50,6 +55,7 @@ impl Client {
.env("XDG_CACHE_HOME", home.path().join("cache"))
.env("XDG_DATA_HOME", home.path().join("data"))
.env("IDX_USE_MOCK_PROVIDER", "1")
.envs(env.iter().copied())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
@ -299,6 +305,63 @@ fn live_tools_wrap_data_with_freshness() {
assert_eq!(compared["failures"], json!([]));
}
#[test]
fn intraday_is_stockbit_whichever_provider_and_empty_sessions_are_data() {
let mut client = Client::start(&[], false);
let day = client.ok("get_intraday", json!({"symbol": "BBCA"}));
assert_fetched(&day);
assert_eq!(day["source"], "stockbit");
let quote = &day["data"]["quote"];
assert_eq!(quote["symbol"], "BBCA.JK");
assert_eq!(quote["market_status"], "close");
// The closing book is not reported as a live one.
assert!(
quote.get("bid").is_none() && quote.get("offer").is_none(),
"{quote}"
);
assert_eq!(day["data"]["points"].as_array().unwrap().len(), 335);
let tail = client.ok("get_intraday", json!({"symbol": "BBCA", "tail": 2}));
let points = tail["data"]["points"].as_array().unwrap();
assert_eq!(points.len(), 2);
assert_eq!(points[1]["time"], "2026-10-22T16:14:00+07:00");
let suspended = client.ok("get_intraday", json!({"symbol": "MABA"}));
assert_eq!(suspended["data"]["points"], json!([]));
assert_eq!(
suspended["data"]["quote"]["session_note"],
"Stockbit lists the stock as suspended"
);
for (arguments, code) in [
(json!({"symbol": "ZZZZ"}), "SYMBOLNOTFOUND"),
(json!({"symbol": "BBCA", "tail": 0}), "INVALIDINPUT"),
(json!({"symbol": "BBCA", "tail": 401}), "INVALIDINPUT"),
] {
let (is_error, body) = client.call("get_intraday", arguments.clone());
assert!(is_error, "{arguments}: {body}");
assert_eq!(body["code"], code, "{arguments}: {body}");
}
}
#[test]
fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() {
let mut client = Client::start_with_env(&[], false, &[("IDX_PROVIDER", "stockbit")]);
let quotes = client.ok("get_quotes", json!({"symbols": ["BBCA"]}));
assert_eq!(quotes["quotes"][0]["source"], "stockbit");
assert_eq!(quotes["quotes"][0]["data"]["market_status"], "close");
let mut blocked = Client::start_with_env(&[], false, &[("IDX_MOCK_STOCKBIT_STATUS", "403")]);
let (is_error, body) = blocked.call("get_intraday", json!({"symbol": "BBCA"}));
assert!(is_error, "{body}");
assert_eq!(body["code"], "UPSTREAMBLOCKED");
assert!(
body["message"].as_str().unwrap().contains("does not retry"),
"{body}"
);
}
#[test]
fn errors_carry_cli_codes() {
let mut client = Client::start(&[], false);