mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
feat(mcp): page get_ownership_changes and summarize all changes
Between the two latest releases there are 1,568 holder changes. With only `limit` (up to 2000, ~200 KB), agents that wanted them all overflowed their tool output: Hermes then paged ticker by ticker (32 calls), and opencode 1 and 2 did the same and timed out. - `offset` pages through the rows; responses carry `offset` and `next_offset` (null after the last page). Pages cap at 300 rows (~40 KB); the over-limit error points to `offset` and `summary`. - Rows come largest |delta| first, so the first page holds the moves that matter rather than tickers starting with A. - `summary` covers every row: counts by change type, the number of tickers, and the ten tickers with the most changes. The CLI's `ownership changes` is unchanged. tools.json and the website's MCP pages are regenerated for the new description and parameter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
2d0bceade2
commit
db7c90861c
6 changed files with 124 additions and 12 deletions
|
|
@ -144,9 +144,12 @@ pub struct ChangesParams {
|
||||||
/// Only this ticker's changes.
|
/// Only this ticker's changes.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticker: Option<String>,
|
pub ticker: Option<String>,
|
||||||
/// Maximum rows returned (default 200, at most 2000); `total` reports the full count.
|
/// Rows per page (default 200, at most 300); `total` reports the full count.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
|
/// Rows to skip, for the next page: the previous response's `next_offset`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@
|
||||||
//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk
|
//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk
|
||||||
//! cache and ownership database.
|
//! cache and ownership database.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use idx_core::analysis::fundamental::FundamentalReport;
|
use idx_core::analysis::fundamental::FundamentalReport;
|
||||||
use idx_core::config::CoreConfig;
|
use idx_core::config::CoreConfig;
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::financial::{EarningsFilter, StatementFilter};
|
use idx_core::financial::{EarningsFilter, StatementFilter};
|
||||||
use idx_core::investor::{HolderSource, Investor};
|
use idx_core::investor::{HolderSource, Investor};
|
||||||
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
||||||
|
use idx_core::ownership::types::ChangeRow;
|
||||||
use idx_core::{CachePolicy, Fetched, Idx};
|
use idx_core::{CachePolicy, Fetched, Idx};
|
||||||
use rmcp::handler::server::router::tool::ToolRouter;
|
use rmcp::handler::server::router::tool::ToolRouter;
|
||||||
use rmcp::handler::server::wrapper::Parameters;
|
use rmcp::handler::server::wrapper::Parameters;
|
||||||
|
|
@ -32,7 +35,9 @@ const CHANGES_LIMIT: usize = 200;
|
||||||
const MAX_SYMBOLS: usize = 20;
|
const MAX_SYMBOLS: usize = 20;
|
||||||
const MAX_SCREEN_LIMIT: usize = 200;
|
const MAX_SCREEN_LIMIT: usize = 200;
|
||||||
const MAX_SEARCH_LIMIT: usize = 100;
|
const MAX_SEARCH_LIMIT: usize = 100;
|
||||||
const MAX_CHANGES_LIMIT: usize = 2000;
|
// Pages above a few hundred rows (~40 KB) overflow some agents' tool output.
|
||||||
|
const MAX_CHANGES_LIMIT: usize = 300;
|
||||||
|
const CHANGES_TOP_TICKERS: usize = 10;
|
||||||
|
|
||||||
/// Tool calls running at once; the rest wait their turn.
|
/// Tool calls running at once; the rest wait their turn.
|
||||||
static CALL_SLOTS: Semaphore = Semaphore::const_new(4);
|
static CALL_SLOTS: Semaphore = Semaphore::const_new(4);
|
||||||
|
|
@ -294,8 +299,10 @@ with concentration metrics (top1, top3, HHI, free float)."
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "Holder changes (new, exit, increase, decrease) between two KSEI \
|
description = "Holder changes (new, exited, increased, decreased) between two KSEI \
|
||||||
release dates, optionally for one ticker."
|
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."
|
||||||
)]
|
)]
|
||||||
async fn get_ownership_changes(&self, params: Parameters<ChangesParams>) -> ToolResult {
|
async fn get_ownership_changes(&self, params: Parameters<ChangesParams>) -> ToolResult {
|
||||||
let ChangesParams {
|
let ChangesParams {
|
||||||
|
|
@ -303,13 +310,39 @@ release dates, optionally for one ticker."
|
||||||
to,
|
to,
|
||||||
ticker,
|
ticker,
|
||||||
limit,
|
limit,
|
||||||
|
offset,
|
||||||
} = params.0;
|
} = params.0;
|
||||||
self.local(move |investor| {
|
self.local(move |investor| {
|
||||||
let limit = bounded_limit("limit", limit, CHANGES_LIMIT, MAX_CHANGES_LIMIT)?;
|
let limit = bounded_limit("limit", limit, CHANGES_LIMIT, MAX_CHANGES_LIMIT).map_err(
|
||||||
|
|error| match error {
|
||||||
|
IdxError::InvalidInput(message) => IdxError::InvalidInput(format!(
|
||||||
|
"{message}; page with `offset`, or use `summary` for an overview"
|
||||||
|
)),
|
||||||
|
other => other,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let offset = offset.unwrap_or(0);
|
||||||
let mut changes = investor.changes(&from, &to, ticker.as_deref())?;
|
let mut changes = investor.changes(&from, &to, ticker.as_deref())?;
|
||||||
|
changes.sort_by(|a, b| {
|
||||||
|
b.delta_bps
|
||||||
|
.abs()
|
||||||
|
.cmp(&a.delta_bps.abs())
|
||||||
|
.then_with(|| a.ticker_code.cmp(&b.ticker_code))
|
||||||
|
.then_with(|| a.entity_name.cmp(&b.entity_name))
|
||||||
|
});
|
||||||
let total = changes.len();
|
let total = changes.len();
|
||||||
changes.truncate(limit);
|
let summary = changes_summary(&changes);
|
||||||
Ok(json!({ "from": from, "to": to, "total": total, "changes": changes }))
|
let page: Vec<_> = changes.into_iter().skip(offset).take(limit).collect();
|
||||||
|
let next_offset = (offset + page.len() < total).then_some(offset + page.len());
|
||||||
|
Ok(json!({
|
||||||
|
"from": from,
|
||||||
|
"to": to,
|
||||||
|
"total": total,
|
||||||
|
"summary": summary,
|
||||||
|
"offset": offset,
|
||||||
|
"next_offset": next_offset,
|
||||||
|
"changes": page,
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -455,6 +488,33 @@ fn dedup_in_order<T: PartialEq + Copy>(values: &mut Vec<T>) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Counts by change type and the tickers with the most changes, so an agent
|
||||||
|
/// can answer an overview question without paging through every row.
|
||||||
|
fn changes_summary(changes: &[ChangeRow]) -> Value {
|
||||||
|
let mut by_type: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
let mut by_ticker: BTreeMap<&str, usize> = BTreeMap::new();
|
||||||
|
for row in changes {
|
||||||
|
let kind = serde_json::to_value(row.change_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.as_str().map(str::to_string))
|
||||||
|
.unwrap_or_default();
|
||||||
|
*by_type.entry(kind).or_default() += 1;
|
||||||
|
*by_ticker.entry(row.ticker_code.as_str()).or_default() += 1;
|
||||||
|
}
|
||||||
|
let tickers = by_ticker.len();
|
||||||
|
let mut top: Vec<_> = by_ticker.into_iter().collect();
|
||||||
|
top.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
|
||||||
|
top.truncate(CHANGES_TOP_TICKERS);
|
||||||
|
json!({
|
||||||
|
"by_type": by_type,
|
||||||
|
"tickers": tickers,
|
||||||
|
"top_tickers": top
|
||||||
|
.into_iter()
|
||||||
|
.map(|(ticker, changes)| json!({ "ticker": ticker, "changes": changes }))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn bounded_limit(
|
fn bounded_limit(
|
||||||
field: &str,
|
field: &str,
|
||||||
value: Option<usize>,
|
value: Option<usize>,
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
"name": "get_investor_holdings"
|
"name": "get_investor_holdings"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Holder changes (new, exit, increase, decrease) between two KSEI release dates, optionally for one ticker.",
|
"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": {
|
"inputSchema": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -125,7 +125,17 @@
|
||||||
},
|
},
|
||||||
"limit": {
|
"limit": {
|
||||||
"default": null,
|
"default": null,
|
||||||
"description": "Maximum rows returned (default 200, at most 2000); `total` reports the full count.",
|
"description": "Rows per page (default 200, at most 300); `total` reports the full count.",
|
||||||
|
"format": "uint",
|
||||||
|
"minimum": 0,
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"default": null,
|
||||||
|
"description": "Rows to skip, for the next page: the previous response's `next_offset`.",
|
||||||
"format": "uint",
|
"format": "uint",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": [
|
"type": [
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,44 @@ fn ownership_tools_read_the_local_database() {
|
||||||
);
|
);
|
||||||
assert_eq!(limited["total"], total);
|
assert_eq!(limited["total"], total);
|
||||||
assert_eq!(limited["changes"].as_array().unwrap().len(), 1);
|
assert_eq!(limited["changes"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(limited["next_offset"], 1);
|
||||||
|
|
||||||
|
// Pages cover every row exactly once, largest change first.
|
||||||
|
let mut paged = Vec::new();
|
||||||
|
let mut offset = Some(0);
|
||||||
|
while let Some(next) = offset {
|
||||||
|
let page = client.ok(
|
||||||
|
"get_ownership_changes",
|
||||||
|
json!({"from": "2026-01-30", "to": "2026-02-27", "limit": 2, "offset": next}),
|
||||||
|
);
|
||||||
|
paged.extend(page["changes"].as_array().unwrap().iter().cloned());
|
||||||
|
offset = page["next_offset"].as_u64();
|
||||||
|
}
|
||||||
|
assert_eq!(paged.len() as u64, total);
|
||||||
|
assert_eq!(&paged, all["changes"].as_array().unwrap());
|
||||||
|
let deltas: Vec<_> = paged
|
||||||
|
.iter()
|
||||||
|
.map(|row| row["delta_bps"].as_i64().unwrap().abs())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
deltas.windows(2).all(|pair| pair[0] >= pair[1]),
|
||||||
|
"{deltas:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let by_type = all["summary"]["by_type"].as_object().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
by_type.values().map(|n| n.as_u64().unwrap()).sum::<u64>(),
|
||||||
|
total
|
||||||
|
);
|
||||||
|
assert!(all["summary"]["tickers"].as_u64().unwrap() >= 1, "{all}");
|
||||||
|
|
||||||
|
let (is_error, body) = client.call(
|
||||||
|
"get_ownership_changes",
|
||||||
|
json!({"from": "2026-01-30", "to": "2026-02-27", "limit": 301}),
|
||||||
|
);
|
||||||
|
assert!(is_error, "{body}");
|
||||||
|
assert_eq!(body["code"], "INVALIDINPUT");
|
||||||
|
assert!(body.to_string().contains("offset"), "{body}");
|
||||||
|
|
||||||
let one = client.ok(
|
let one = client.ok(
|
||||||
"get_ownership_changes",
|
"get_ownership_changes",
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ body, so the agent can decide whether to retry:
|
||||||
| tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed |
|
| tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed |
|
||||||
| `screen_stocks` `limit` | 200 |
|
| `screen_stocks` `limit` | 200 |
|
||||||
| `search_investors` `limit` | 100 |
|
| `search_investors` `limit` | 100 |
|
||||||
| `get_ownership_changes` `limit` | 2000 (default 200) |
|
| `get_ownership_changes` `limit` | 300 per page (default 200); page with `offset` |
|
||||||
| concurrent tool calls | 4 |
|
| concurrent tool calls | 4 |
|
||||||
|
|
||||||
## Ownership data
|
## Ownership data
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,13 @@ Every IDX holding (above 1%) of one investor, matched by name.
|
||||||
|
|
||||||
## `get_ownership_changes`
|
## `get_ownership_changes`
|
||||||
|
|
||||||
Holder changes (new, exit, increase, decrease) between two KSEI release dates, optionally for one ticker.
|
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.
|
||||||
|
|
||||||
| Input | Type | Required | Description |
|
| Input | Type | Required | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `from` | string | yes | Earlier release date, YYYY-MM-DD (see ownership_status). |
|
| `from` | string | yes | Earlier release date, YYYY-MM-DD (see ownership_status). |
|
||||||
| `limit` | integer | no | Maximum rows returned (default 200, at most 2000); `total` reports the full count. |
|
| `limit` | integer | no | Rows per page (default 200, at most 300); `total` reports the full count. |
|
||||||
|
| `offset` | integer | no | Rows to skip, for the next page: the previous response's `next_offset`. |
|
||||||
| `ticker` | string | no | Only this ticker's changes. |
|
| `ticker` | string | no | Only this ticker's changes. |
|
||||||
| `to` | string | yes | Later release date, YYYY-MM-DD. |
|
| `to` | string | yes | Later release date, YYYY-MM-DD. |
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue