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