feat: corporate actions from the Stockbit session (stocks actions, stocks calendar)

`idx stocks actions SYM [--kind ...]`: one stock's cash and stock
dividends, bonus shares, rights, warrants, splits and reverse splits,
meetings, public exposes and tender offers (`--kind ipo` adds its IPO),
newest first. `idx stocks calendar [--from] [--to] [--kind ...]`: the
market's actions dated in the range (default today and 30 days ahead),
oldest first. MCP: get_corporate_actions, get_corporate_calendar.

Stockbit's `/corpaction/{type}` serves one kind per request; each type has
its own field prefix and list key, so entitlements are read by field
suffix. An action's `date` is the cum date for entitlements, else the
event's first day. Dividends keep their decimals. One request per kind,
cached 6 hours. AUTHERROR without a session.

Fixtures are the 2026-11-03 recordings (market-wide ones trimmed to
October-December). Goldens added; help-stocks and completions-bash gain
the two subcommands, MCP tools.json the two tools (regenerated with
UPDATE_GOLDEN=1). The MCP docs counted 17 tools without stockbit_login;
now 20.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-03 14:35:20 +07:00
commit 43ff4aa433
55 changed files with 2026 additions and 34 deletions

View file

@ -4,6 +4,7 @@
use idx_core::analysis::bandar::TIERS;
use idx_core::api::types::{Interval, Period};
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 schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
@ -228,6 +229,41 @@ pub struct FlowParams {
pub source: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CorporateActionsParams {
/// IDX ticker, e.g. "BBCA".
pub symbol: String,
/// Kinds to list (default: every kind but "ipo"). Each kind is one request to Stockbit.
#[serde(default)]
#[schemars(schema_with = "action_kinds_schema")]
pub kinds: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CorporateCalendarParams {
/// First day, YYYY-MM-DD (default today).
#[serde(default)]
pub from: Option<String>,
/// Last day, YYYY-MM-DD (default 30 days after `from`; at most 366 days).
#[serde(default)]
pub to: Option<String>,
/// Kinds to list (default: every kind). Each kind is one request to Stockbit.
#[serde(default)]
#[schemars(schema_with = "action_kinds_schema")]
pub kinds: Option<Vec<String>>,
}
/// Parses `kinds`, or `default` when absent.
pub fn action_kinds(
kinds: Option<Vec<String>>,
default: &[ActionKind],
) -> Result<Vec<ActionKind>, idx_core::error::IdxError> {
match kinds {
None => Ok(default.to_vec()),
Some(kinds) => kinds.iter().map(|k| k.parse()).collect(),
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ShareholdersParams {
/// IDX ticker, e.g. "BBCA".
@ -305,6 +341,11 @@ fn flow_source_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(FlowSource::ALL.map(FlowSource::as_str).to_vec())
}
fn action_kinds_schema(_: &mut SchemaGenerator) -> Schema {
let kinds: Vec<&str> = ActionKind::ALL.iter().map(|k| k.as_str()).collect();
json_schema!({ "type": "array", "items": { "type": "string", "enum": kinds } })
}
fn login_method_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(LoginMethod::ALL.map(LoginMethod::as_str).to_vec())
}

View file

@ -10,6 +10,7 @@ use idx_core::analysis::fundamental::FundamentalReport;
use idx_core::api::stockbit::{self, signin};
use idx_core::broker::types::BrokerRange;
use idx_core::config::CoreConfig;
use idx_core::corporate::types::{ActionKind, CalendarRange};
use idx_core::error::IdxError;
use idx_core::financial::{EarningsFilter, StatementFilter};
use idx_core::flow::types::FlowRange;
@ -27,9 +28,9 @@ use tokio::sync::Semaphore;
use crate::params::{
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, LoginMethod,
LoginParams, OrderBookParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams,
SyncParams,
CompanySection, CorporateActionsParams, CorporateCalendarParams, EntityParams, FlowParams,
HistoryParams, IntradayParams, LoginMethod, LoginParams, OrderBookParams, ScreenParams,
SearchParams, ShareholdersParams, SymbolsParams, SyncParams, action_kinds,
};
const SYNC_TOOL: &str = "ownership_sync";
@ -352,6 +353,54 @@ average price, the close, `change_bps` and `non_regular_bps`."
.await
}
#[tool(
description = "Corporate actions of one IDX stock from the user's own Stockbit \
session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, \
reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, \
rights and splits, i.e. the last day to buy and be entitled; else the event's first day), \
`ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals), \
`ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), \
`factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), \
`period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent \
fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached \
6 hours). AUTHERROR means there is no session: call stockbit_status, then stockbit_login."
)]
async fn get_corporate_actions(
&self,
params: Parameters<CorporateActionsParams>,
) -> ToolResult {
let CorporateActionsParams { symbol, kinds } = params.0;
self.live(move |idx| {
let kinds = action_kinds(kinds, &ActionKind::STOCK_DEFAULT)?;
idx.corporate()
.actions(&symbol, &kinds, CachePolicy::Default)
})
.await
}
#[tool(
description = "Corporate actions across IDX from the user's own Stockbit session, \
oldest first, dated from `from` to `to` (default today and the next 30 days): upcoming cum \
dates, meetings, exposes, rights, warrants, IPOs and tender offers, and the past year. Each \
item has the same fields as get_corporate_actions, with `symbol`. One Stockbit request per \
kind (about 1 s each on a cold cache, then cached 6 hours); pass `kinds` to ask for less. \
AUTHERROR means there is no session: call stockbit_status, then stockbit_login."
)]
async fn get_corporate_calendar(
&self,
params: Parameters<CorporateCalendarParams>,
) -> ToolResult {
let CorporateCalendarParams { from, to, kinds } = params.0;
self.live(move |idx| {
let corporate = idx.corporate();
let range =
CalendarRange::from_args(from.as_deref(), to.as_deref(), corporate.today())?;
let kinds = action_kinds(kinds, &ActionKind::ALL)?;
corporate.calendar(range, &kinds, CachePolicy::Default)
})
.await
}
#[tool(
description = "Company data for one IDX ticker: profile, financial statements, \
earnings, analyst sentiment, insights, and news. News items carry `relevance`: \