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:
hermes 2026-10-23 08:35:57 +00:00
commit b5ae9b98de
61 changed files with 2882 additions and 129 deletions

View file

@ -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"
}
]

View file

@ -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);