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
|
|
@ -7,6 +7,7 @@ use idx_core::broker::types::{BrokerBoard, BrokerInvestor};
|
|||
use idx_core::corporate::types::ActionKind;
|
||||
use idx_core::flow::types::FlowSource;
|
||||
use idx_core::market::{ScreenFilter, ScreenRegion};
|
||||
use idx_core::movements::types::{ActionFilter, MAX_PAGES};
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -253,6 +254,34 @@ pub struct CorporateCalendarParams {
|
|||
pub kinds: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct MovementsParams {
|
||||
/// IDX ticker, e.g. "BBCA"; omit for the whole market.
|
||||
#[serde(default)]
|
||||
pub symbol: Option<String>,
|
||||
/// First day, YYYY-MM-DD.
|
||||
#[serde(default)]
|
||||
pub from: Option<String>,
|
||||
/// Last day, YYYY-MM-DD.
|
||||
#[serde(default)]
|
||||
pub to: Option<String>,
|
||||
/// One holder's movements: the `holder_id` of a row.
|
||||
#[serde(default)]
|
||||
pub holder_id: Option<String>,
|
||||
/// Only this kind of movement; "corporate-action" covers rights issues, private placements and the like.
|
||||
#[serde(default)]
|
||||
#[schemars(schema_with = "movement_action_schema")]
|
||||
pub action: Option<String>,
|
||||
/// "idx" (insider disclosures, any size) or "ksei" (holders at 5% and above).
|
||||
#[serde(default)]
|
||||
#[schemars(schema_with = "movement_source_schema")]
|
||||
pub source: Option<String>,
|
||||
/// Pages of 50 rows (default 1, at most 10); each page is one request to Stockbit.
|
||||
#[serde(default)]
|
||||
#[schemars(schema_with = "movement_pages_schema")]
|
||||
pub pages: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parses `kinds`, or `default` when absent.
|
||||
pub fn action_kinds(
|
||||
kinds: Option<Vec<String>>,
|
||||
|
|
@ -346,6 +375,18 @@ fn action_kinds_schema(_: &mut SchemaGenerator) -> Schema {
|
|||
json_schema!({ "type": "array", "items": { "type": "string", "enum": kinds } })
|
||||
}
|
||||
|
||||
fn movement_action_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(ActionFilter::ALL.map(ActionFilter::as_str).to_vec())
|
||||
}
|
||||
|
||||
fn movement_source_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(vec!["idx", "ksei"])
|
||||
}
|
||||
|
||||
fn movement_pages_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({ "type": "integer", "minimum": 1, "maximum": MAX_PAGES })
|
||||
}
|
||||
|
||||
fn login_method_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
string_enum(LoginMethod::ALL.map(LoginMethod::as_str).to_vec())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use idx_core::financial::{EarningsFilter, StatementFilter};
|
|||
use idx_core::flow::types::FlowRange;
|
||||
use idx_core::investor::{HolderSource, Investor};
|
||||
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
||||
use idx_core::movements::types::MovementQuery;
|
||||
use idx_core::ownership::types::ChangeRow;
|
||||
use idx_core::{CachePolicy, Fetched, Idx};
|
||||
use rmcp::handler::server::router::tool::ToolRouter;
|
||||
|
|
@ -29,8 +30,8 @@ use tokio::sync::Semaphore;
|
|||
use crate::params::{
|
||||
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
|
||||
CompanySection, CorporateActionsParams, CorporateCalendarParams, EntityParams, FlowParams,
|
||||
HistoryParams, IntradayParams, LoginMethod, LoginParams, OrderBookParams, ScreenParams,
|
||||
SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds,
|
||||
HistoryParams, IntradayParams, LoginMethod, LoginParams, MovementsParams, OrderBookParams,
|
||||
ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds,
|
||||
};
|
||||
|
||||
const SYNC_TOOL: &str = "ownership_sync";
|
||||
|
|
@ -401,6 +402,42 @@ AUTHERROR means there is no session: call stockbit_status, then stockbit_login."
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
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."
|
||||
)]
|
||||
async fn get_shareholder_movements(&self, params: Parameters<MovementsParams>) -> ToolResult {
|
||||
let MovementsParams {
|
||||
symbol,
|
||||
from,
|
||||
to,
|
||||
holder_id,
|
||||
action,
|
||||
source,
|
||||
pages,
|
||||
} = params.0;
|
||||
self.live(move |idx| {
|
||||
let mut query = MovementQuery::from_args(
|
||||
symbol.as_deref(),
|
||||
from.as_deref(),
|
||||
to.as_deref(),
|
||||
holder_id.as_deref(),
|
||||
pages.unwrap_or(1),
|
||||
)?;
|
||||
query.action = action.as_deref().map(str::parse).transpose()?;
|
||||
query.source = source.as_deref().map(str::parse).transpose()?;
|
||||
idx.movements().list(&query, CachePolicy::Default)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Company data for one IDX ticker: profile, financial statements, \
|
||||
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
|
||||
|
|
|
|||
|
|
@ -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