mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
feat: login-free Stockbit intraday and quote provider
Read Stockbit's public symbol page (__NEXT_DATA__) with a plain ureq request and an honest user agent: `stocks intraday` (today by the minute, any provider), `--provider stockbit` quotes with the best bid/offer only while the market is open, and MCP `get_intraday`. - UPSTREAMBLOCKED error (exit 4) for a CDN 403; never retried - requests paced 1 s apart across idx processes, 5 s snapshot cache - global --provider flag (yahoo, msn, stockbit) - roadmap: L1 ships from Stockbit; depth stays a login follow-on
This commit is contained in:
parent
018d9ba936
commit
74e5bb0536
65 changed files with 2320 additions and 381 deletions
|
|
@ -287,6 +287,33 @@
|
|||
},
|
||||
"name": "get_foreign_flow"
|
||||
},
|
||||
{
|
||||
"description": "Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"symbol": {
|
||||
"description": "IDX ticker, e.g. \"BBCA\"; indices as \"^JKSE\" or \"IHSG\".",
|
||||
"type": "string"
|
||||
},
|
||||
"tail": {
|
||||
"default": null,
|
||||
"description": "Only the latest N minutes (default: the whole session, at most 400).",
|
||||
"format": "uint",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"symbol"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_intraday"
|
||||
},
|
||||
{
|
||||
"description": "Every IDX holding (above 1%) of one investor, matched by name.",
|
||||
"inputSchema": {
|
||||
|
|
@ -397,7 +424,7 @@
|
|||
"name": "get_price_history"
|
||||
},
|
||||
{
|
||||
"description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday.",
|
||||
"description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. \"open\", \"close\"), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ use idx_core::config::CoreConfig;
|
|||
use idx_core::investor::{ImportSource, Investor};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const TOOLS: [&str; 14] = [
|
||||
const TOOLS: [&str; 15] = [
|
||||
"analyze_bandar",
|
||||
"analyze_stock",
|
||||
"compare_stocks",
|
||||
"get_broker_summary",
|
||||
"get_company",
|
||||
"get_foreign_flow",
|
||||
"get_intraday",
|
||||
"get_investor_holdings",
|
||||
"get_ownership_changes",
|
||||
"get_price_history",
|
||||
|
|
@ -36,6 +37,10 @@ struct Client {
|
|||
|
||||
impl Client {
|
||||
fn start(args: &[&str], seed: bool) -> Self {
|
||||
Self::start_with_env(args, seed, &[])
|
||||
}
|
||||
|
||||
fn start_with_env(args: &[&str], seed: bool, env: &[(&str, &str)]) -> Self {
|
||||
let home = tempdir::Dir::new();
|
||||
if seed {
|
||||
seed_ownership_db(&home.path().join("data/idx/ownership.db"));
|
||||
|
|
@ -50,6 +55,7 @@ impl Client {
|
|||
.env("XDG_CACHE_HOME", home.path().join("cache"))
|
||||
.env("XDG_DATA_HOME", home.path().join("data"))
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.envs(env.iter().copied())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
|
|
@ -299,6 +305,63 @@ fn live_tools_wrap_data_with_freshness() {
|
|||
assert_eq!(compared["failures"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intraday_is_stockbit_whichever_provider_and_empty_sessions_are_data() {
|
||||
let mut client = Client::start(&[], false);
|
||||
|
||||
let day = client.ok("get_intraday", json!({"symbol": "BBCA"}));
|
||||
assert_fetched(&day);
|
||||
assert_eq!(day["source"], "stockbit");
|
||||
let quote = &day["data"]["quote"];
|
||||
assert_eq!(quote["symbol"], "BBCA.JK");
|
||||
assert_eq!(quote["market_status"], "close");
|
||||
// The closing book is not reported as a live one.
|
||||
assert!(
|
||||
quote.get("bid").is_none() && quote.get("offer").is_none(),
|
||||
"{quote}"
|
||||
);
|
||||
assert_eq!(day["data"]["points"].as_array().unwrap().len(), 335);
|
||||
|
||||
let tail = client.ok("get_intraday", json!({"symbol": "BBCA", "tail": 2}));
|
||||
let points = tail["data"]["points"].as_array().unwrap();
|
||||
assert_eq!(points.len(), 2);
|
||||
assert_eq!(points[1]["time"], "2026-10-22T16:14:00+07:00");
|
||||
|
||||
let suspended = client.ok("get_intraday", json!({"symbol": "MABA"}));
|
||||
assert_eq!(suspended["data"]["points"], json!([]));
|
||||
assert_eq!(
|
||||
suspended["data"]["quote"]["session_note"],
|
||||
"Stockbit lists the stock as suspended"
|
||||
);
|
||||
|
||||
for (arguments, code) in [
|
||||
(json!({"symbol": "ZZZZ"}), "SYMBOLNOTFOUND"),
|
||||
(json!({"symbol": "BBCA", "tail": 0}), "INVALIDINPUT"),
|
||||
(json!({"symbol": "BBCA", "tail": 401}), "INVALIDINPUT"),
|
||||
] {
|
||||
let (is_error, body) = client.call("get_intraday", arguments.clone());
|
||||
assert!(is_error, "{arguments}: {body}");
|
||||
assert_eq!(body["code"], code, "{arguments}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() {
|
||||
let mut client = Client::start_with_env(&[], false, &[("IDX_PROVIDER", "stockbit")]);
|
||||
let quotes = client.ok("get_quotes", json!({"symbols": ["BBCA"]}));
|
||||
assert_eq!(quotes["quotes"][0]["source"], "stockbit");
|
||||
assert_eq!(quotes["quotes"][0]["data"]["market_status"], "close");
|
||||
|
||||
let mut blocked = Client::start_with_env(&[], false, &[("IDX_MOCK_STOCKBIT_STATUS", "403")]);
|
||||
let (is_error, body) = blocked.call("get_intraday", json!({"symbol": "BBCA"}));
|
||||
assert!(is_error, "{body}");
|
||||
assert_eq!(body["code"], "UPSTREAMBLOCKED");
|
||||
assert!(
|
||||
body["message"].as_str().unwrap().contains("does not retry"),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_carry_cli_codes() {
|
||||
let mut client = Client::start(&[], false);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue