mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
feat: shareholder movements from the Stockbit session (ownership movements)
`idx ownership movements [SYMBOL]` and the MCP tool
`get_shareholder_movements` list reported changes in insiders' and major
holders' stakes, newest first, from Stockbit's
/insider/company/majorholder: IDX insider disclosures (any size, with the
trade price) and KSEI changes of holders at 5% and above (Stockbit's
estimated price). Stockbit filters by date range, holder id, action and
source; each page is 50 rows and one request, up to 10 pages, cached an
hour per page under every filter.
Rows keep exact signed share counts and the stake in basis points as
Stockbit rounds it; unknown action labels are kept as `other` rather than
dropped. JSON is {movements, more}; JSONL is one movement per line. The
command lives under `ownership` but needs no import, and `ownership`
commands now take --offline / --no-cache for it.
The per-symbol summary score is the next PR; docs/STOCKBIT.md records the
endpoint's parameters and action types as checked on 2026-11-03.
Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
parent
1b72c3ed6b
commit
ae0ca0c109
43 changed files with 3050 additions and 29 deletions
|
|
@ -555,6 +555,76 @@
|
|||
},
|
||||
"name": "get_quotes"
|
||||
},
|
||||
{
|
||||
"description": "Reported changes in insiders' and major holders' stakes in IDX stocks, from the user's own Stockbit session, newest first: IDX insider disclosures (directors, commissioners and other insiders, any size, with the trade price) and KSEI changes of holders at 5% and above (price estimated: `price_estimated`). One stock's, or the market's without `symbol`. Shares are exact and signed (`shares_change` < 0 is a sale); percentages are basis points as Stockbit rounds them, so a small insider can read 0. `more` is true when rows remain past `pages`. These are filings, not signals: many insiders buying at one price on one day is usually a share plan, and rows appear days after the change. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.",
|
||||
"inputSchema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"action": {
|
||||
"default": null,
|
||||
"description": "Only this kind of movement; \"corporate-action\" covers rights issues, private placements and the like.",
|
||||
"enum": [
|
||||
"buy",
|
||||
"sell",
|
||||
"transfer",
|
||||
"cross",
|
||||
"corporate-action"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"from": {
|
||||
"default": null,
|
||||
"description": "First day, YYYY-MM-DD.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"holder_id": {
|
||||
"default": null,
|
||||
"description": "One holder's movements: the `holder_id` of a row.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pages": {
|
||||
"default": null,
|
||||
"description": "Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit.",
|
||||
"maximum": 10,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"source": {
|
||||
"default": null,
|
||||
"description": "\"idx\" (insider disclosures, any size) or \"ksei\" (holders at 5% and above).",
|
||||
"enum": [
|
||||
"idx",
|
||||
"ksei"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"default": null,
|
||||
"description": "IDX ticker, e.g. \"BBCA\"; omit for the whole market.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"default": null,
|
||||
"description": "Last day, YYYY-MM-DD.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_shareholder_movements"
|
||||
},
|
||||
{
|
||||
"description": "Shareholders above 1% of an IDX ticker from the latest KSEI release, with concentration metrics (top1, top3, HHI, free float).",
|
||||
"inputSchema": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use idx_core::config::CoreConfig;
|
|||
use idx_core::investor::{ImportSource, Investor};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const TOOLS: [&str; 20] = [
|
||||
const TOOLS: [&str; 21] = [
|
||||
"analyze_bandar",
|
||||
"analyze_stock",
|
||||
"compare_stocks",
|
||||
|
|
@ -24,6 +24,7 @@ const TOOLS: [&str; 20] = [
|
|||
"get_ownership_changes",
|
||||
"get_price_history",
|
||||
"get_quotes",
|
||||
"get_shareholder_movements",
|
||||
"get_shareholders",
|
||||
"ownership_status",
|
||||
"screen_stocks",
|
||||
|
|
@ -551,6 +552,54 @@ fn corporate_actions_need_a_session_and_keep_kinds_apart() {
|
|||
assert_eq!(body["code"], "INVALIDINPUT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shareholder_movements_page_and_filter() {
|
||||
let mut client = Client::start(&[], false);
|
||||
let (is_error, body) = client.call("get_shareholder_movements", json!({}));
|
||||
assert!(is_error, "{body}");
|
||||
assert_eq!(body["code"], "AUTHERROR");
|
||||
|
||||
let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]);
|
||||
let market = client.ok("get_shareholder_movements", json!({"pages": 2}));
|
||||
assert_fetched(&market);
|
||||
assert_eq!(market["source"], "stockbit");
|
||||
let rows = market["data"]["movements"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 14, "8 rows on page 1, 6 on the last page");
|
||||
assert_eq!(market["data"]["more"], false);
|
||||
assert!(
|
||||
rows.windows(2)
|
||||
.all(|w| w[0]["date"].as_str() >= w[1]["date"].as_str()),
|
||||
"newest first"
|
||||
);
|
||||
|
||||
let sells = client.ok(
|
||||
"get_shareholder_movements",
|
||||
json!({"action": "sell", "source": "ksei", "holder_id": "1000004481", "pages": 3}),
|
||||
);
|
||||
let rows = sells["data"]["movements"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows.iter().all(|r| r["symbol"] == "ATLA"
|
||||
&& r["action"] == "sell"
|
||||
&& r["shares_change"].as_i64() < Some(0)
|
||||
&& r["price_estimated"] == true));
|
||||
|
||||
let bbca = client.ok("get_shareholder_movements", json!({"symbol": "bbca"}));
|
||||
assert_eq!(bbca["data"]["more"], true, "BBCA has a second page");
|
||||
assert_eq!(bbca["data"]["movements"][0]["roles"], json!(["director"]));
|
||||
|
||||
for bad in [
|
||||
json!({"action": "dividend"}),
|
||||
json!({"source": "bing"}),
|
||||
json!({"holder_id": "RUDI"}),
|
||||
json!({"pages": 11}),
|
||||
json!({"from": "2026-11-02", "to": "2026-11-01"}),
|
||||
] {
|
||||
let (is_error, body) = client.call("get_shareholder_movements", bad.clone());
|
||||
assert!(is_error, "{bad} -> {body}");
|
||||
assert_eq!(body["code"], "INVALIDINPUT", "{bad} -> {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_carry_cli_codes() {
|
||||
let mut client = Client::start(&[], false);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue