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

@ -1,14 +1,16 @@
//! Fixture-backed provider for tests, enabled by the `testing` feature and
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
use super::stockbit::exodus;
use super::stockbit::{corpaction, exodus};
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
use super::{
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
OrderBookProvider, QuoteProvider, TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider,
IntradayProvider, OrderBookProvider, QuoteProvider, TradingSummaryProvider, idx, ipot, msn,
stockbit, yahoo,
};
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
use crate::config::ProviderKind;
use crate::corporate::types::{ActionKind, CorporateAction};
use crate::error::IdxError;
pub(crate) fn enabled() -> bool {
@ -224,6 +226,34 @@ impl OrderBookProvider for MockStockbitProvider {
}
}
/// `stockbit_exodus_corpaction_<type>.json` for the market,
/// `..._<type>_<code>.json` for one stock; a stock without a fixture has none
/// of that kind, as Stockbit answers.
impl CorporateActionProvider for MockStockbitProvider {
fn corporate_actions(
&self,
kind: ActionKind,
symbol: Option<&str>,
) -> Result<Vec<CorporateAction>, IdxError> {
let subject = symbol.unwrap_or("corporate actions");
let name = match symbol {
Some(symbol) => format!(
"corpaction_{}_{}.json",
corpaction::path_type(kind),
stockbit::stockbit_code(symbol)?.to_ascii_lowercase()
),
None => format!("corpaction_{}.json", corpaction::path_type(kind)),
};
let body = match self.exodus(subject, &name) {
Err(IdxError::SymbolNotFound(_)) if symbol.is_some() => {
r#"{"data":{"none":[]}}"#.to_string()
}
other => other?,
};
corpaction::parse_corporate_actions(kind, &body)
}
}
impl HistoryProvider for MockStockbitProvider {
fn history(
&self,

View file

@ -78,6 +78,17 @@ pub trait OrderBookProvider {
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError>;
}
/// Corporate actions of one kind: needs the user's Stockbit session.
pub trait CorporateActionProvider {
/// One stock's history (`symbol` resolved, e.g. `BBCA.JK`), or the
/// market's announced and recent actions with `None`.
fn corporate_actions(
&self,
kind: crate::corporate::types::ActionKind,
symbol: Option<&str>,
) -> Result<Vec<crate::corporate::types::CorporateAction>, IdxError>;
}
pub trait FundamentalsProvider {
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
}
@ -345,6 +356,14 @@ pub fn orderbook_provider() -> Box<dyn OrderBookProvider> {
Box::new(stockbit::StockbitProvider::new())
}
pub fn corporate_action_provider() -> Box<dyn CorporateActionProvider> {
#[cfg(feature = "testing")]
if mock::enabled() {
return Box::new(mock::MockStockbitProvider);
}
Box::new(stockbit::StockbitProvider::new())
}
/// Today in WIB, the date IDX trades on.
pub(crate) fn today_wib() -> chrono::NaiveDate {
#[cfg(feature = "testing")]

View file

@ -0,0 +1,405 @@
//! Corporate actions from Stockbit's session API:
//! `GET /corpaction/{type}?symbol={CODE}` (one stock's history) or without
//! `symbol` (the market, about a year back and the announced ones ahead; the
//! `limit` the web app sends is not honoured by every type).
//!
//! Each type names its fields with its own prefix (`dividend_`,
//! `rightissue_`, `wrant_`, `rups_`, `puexp_`, `tender_`, ...), bonus rows
//! reuse `stocksplit_*`, and the list sits under a key that is not always the
//! path's (`tenderoffer` → `tender`, `reversesplit` → `stock_reverse`). So
//! the entitlement kinds are read by field suffix (`_cumdate`, `_exdate`,
//! ...), which also covers the two kinds no row was recorded for yet
//! (`stock_dividend`, `reversesplit`, both empty on 2026-11-03). Placeholders
//! (`""`, `0`, `0001-01-01 00:00:00`) read as absent; a row without a symbol
//! or a date is dropped.
use chrono::{Datelike, NaiveDate};
use serde_json::{Map, Value};
use crate::corporate::types::{ActionKind, CorporateAction, DateRange, Ratio};
use crate::error::IdxError;
/// Stockbit's path segment for `kind`.
pub fn path_type(kind: ActionKind) -> &'static str {
match kind {
ActionKind::Dividend => "dividend",
ActionKind::StockDividend => "stock_dividend",
ActionKind::Bonus => "bonus",
ActionKind::Rights => "rightissue",
ActionKind::Warrant => "warrant",
ActionKind::Split => "stocksplit",
ActionKind::ReverseSplit => "reversesplit",
ActionKind::Meeting => "rups",
ActionKind::PublicExpose => "pubex",
ActionKind::Ipo => "ipo",
ActionKind::TenderOffer => "tenderoffer",
}
}
/// The actions of one `kind` in a `/corpaction/{type}` response, in the
/// response's order.
pub fn parse_corporate_actions(
kind: ActionKind,
body: &str,
) -> Result<Vec<CorporateAction>, IdxError> {
let bad = |what: String| IdxError::ParseError(format!("stockbit corporate actions: {what}"));
let root: Value = serde_json::from_str(body).map_err(|e| bad(e.to_string()))?;
let rows = match root.get("data") {
Some(Value::Object(data)) => match data.values().find_map(Value::as_array) {
Some(rows) => rows.as_slice(),
None if data.values().all(Value::is_null) => &[],
None => return Err(bad("no list in data".to_string())),
},
Some(Value::Array(rows)) => rows.as_slice(),
Some(Value::Null) => &[],
_ => return Err(bad("no data".to_string())),
};
Ok(rows
.iter()
.filter_map(Value::as_object)
.filter_map(|row| parse_row(kind, row))
.collect())
}
fn parse_row(kind: ActionKind, row: &Map<String, Value>) -> Option<CorporateAction> {
let code = text(row.get("company_symbol"))?.to_ascii_uppercase();
if !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
return None;
}
let symbol = format!("{code}.JK");
let mut action = if kind.is_entitlement() {
entitlement(kind, symbol, row)?
} else {
event(kind, symbol, row)?
};
if matches!(kind, ActionKind::Ipo | ActionKind::TenderOffer) {
action.name = text(row.get("company_name")).map(str::to_string);
}
Some(action)
}
/// Dividends, bonuses, rights and splits: cum/ex/record/pay dates, a ratio
/// and an adjustment factor.
fn entitlement(
kind: ActionKind,
symbol: String,
row: &Map<String, Value>,
) -> Option<CorporateAction> {
let cum = date(suffixed(row, "cumdate"));
let ex = date(suffixed(row, "exdate"));
let mut action = CorporateAction::new(symbol, kind, cum.or(ex)?);
action.cum_date = cum;
action.ex_date = ex;
action.record_date = date(suffixed(row, "recdate"));
action.pay_date = date(suffixed(row, "paydate")).or_else(|| date(suffixed(row, "paymentdate")));
action.ratio = ratio(row);
action.factor = number(suffixed(row, "factor"));
match kind {
ActionKind::Dividend => {
action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, 2));
}
ActionKind::Rights => {
action.price = whole(suffixed(row, "price"));
action.period = range(suffixed(row, "trading_start"), suffixed(row, "trading_end"));
}
_ => {}
}
Some(action)
}
/// Warrants, meetings, exposes, IPOs and tender offers.
fn event(kind: ActionKind, symbol: String, row: &Map<String, Value>) -> Option<CorporateAction> {
let get = |key: &str| row.get(key);
let mut action;
match kind {
ActionKind::Warrant => {
let trading = range(get("wrant_trading_from"), get("wrant_trading_end"));
let exercise = range(get("wrant_exc_from"), get("wrant_exc_end"));
let first = date(get("wrant_trading_from")).or(exercise.map(|r| r.start))?;
action = CorporateAction::new(symbol, kind, first);
action.period = trading;
action.exercise = exercise;
action.price = whole(get("wrant_exc_price"));
}
ActionKind::Meeting | ActionKind::PublicExpose => {
let prefix = if kind == ActionKind::Meeting {
"rups"
} else {
"puexp"
};
let field = |name: &str| row.get(&format!("{prefix}_{name}"));
action = CorporateAction::new(symbol, kind, date(field("date"))?);
action.time = text(field("time")).map(clock);
action.venue = text(field("venue")).map(squeeze);
if kind == ActionKind::Meeting {
action.record_date = date(field("eligible_date"));
}
}
ActionKind::Ipo => {
action = CorporateAction::new(symbol, kind, date(get("ipo_listing_date"))?);
action.price = whole(row.get("ipo_price").and_then(|p| p.get("final")));
let detail = row.get("ipo_data_detail");
let detail = |key: &str| detail.and_then(|d| d.get(key));
action.shares = count(detail("shares"));
action.period = range(detail("offering_start"), detail("offering_end"));
}
ActionKind::TenderOffer => {
let period = range(get("tender_start"), get("tender_end"));
let first = date(get("tender_start")).or(date(get("tender_end")))?;
action = CorporateAction::new(symbol, kind, first);
action.period = period;
action.pay_date = date(get("tender_paydate"));
action.price = whole(get("tender_price"));
action.shares = count(get("tender_shares"));
action.percentage_bps =
number(get("tender_percentage")).map(|p| (p * 100.0).round() as i64);
}
_ => return None,
}
Some(action)
}
/// The value of the one field named `<prefix>_<suffix>`; the shortest name
/// wins should several end so.
fn suffixed<'a>(row: &'a Map<String, Value>, suffix: &str) -> Option<&'a Value> {
let tail = format!("_{suffix}");
row.iter()
.filter(|(key, _)| key.ends_with(&tail))
.min_by_key(|(key, _)| key.len())
.map(|(_, value)| value)
}
/// `old`/`new` fields, else the `"6 : 4"` text.
fn ratio(row: &Map<String, Value>) -> Option<Ratio> {
let pair = |old: Option<u64>, new: Option<u64>| {
Some(Ratio {
old: old?,
new: new?,
})
};
pair(count(suffixed(row, "old")), count(suffixed(row, "new"))).or_else(|| {
let text = text(suffixed(row, "ratio"))?;
let (old, new) = text.split_once(':')?;
pair(
old.trim().replace(',', "").parse().ok().filter(|n| *n > 0),
new.trim().replace(',', "").parse().ok().filter(|n| *n > 0),
)
})
}
fn text(value: Option<&Value>) -> Option<&str> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
}
/// `YYYY-MM-DD`, optionally followed by a time; year 1 is a placeholder.
fn date(value: Option<&Value>) -> Option<NaiveDate> {
let text = text(value)?;
let day = text.get(..10)?;
NaiveDate::parse_from_str(day, "%Y-%m-%d")
.ok()
.filter(|d| d.year() >= 1900)
}
fn range(start: Option<&Value>, end: Option<&Value>) -> Option<DateRange> {
let (start, end) = (date(start)?, date(end)?);
(start <= end).then_some(DateRange { start, end })
}
/// A positive number from a JSON number or a display string (`"1,191.09"`,
/// `"Rp 25"`); zero is a placeholder.
fn number(value: Option<&Value>) -> Option<f64> {
let n = match value? {
Value::Number(n) => n.as_f64()?,
Value::String(s) => {
let cleaned: String = s
.trim()
.trim_start_matches("Rp")
.chars()
.filter(|c| *c != ',' && !c.is_whitespace())
.collect();
cleaned.parse().ok()?
}
_ => return None,
};
(n.is_finite() && n > 0.0).then_some(n)
}
fn whole(value: Option<&Value>) -> Option<i64> {
number(value).map(|n| n.round() as i64)
}
fn count(value: Option<&Value>) -> Option<u64> {
number(value).map(|n| n.round() as u64)
}
fn round_to(value: f64, places: i32) -> f64 {
let scale = 10f64.powi(places);
(value * scale).round() / scale
}
/// `13:00:00` → `13:00`; anything else as given.
fn clock(raw: &str) -> String {
match raw.split(':').collect::<Vec<_>>().as_slice() {
[h, m, s] if s.len() == 2 && h.len() <= 2 && m.len() == 2 => format!("{h}:{m}"),
_ => raw.to_string(),
}
}
fn squeeze(raw: &str) -> String {
raw.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!(
"{}/tests/fixtures/stockbit_exodus_corpaction_{name}.json",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
}
fn day(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
fn find<'a>(actions: &'a [CorporateAction], symbol: &str) -> &'a CorporateAction {
actions.iter().find(|a| a.symbol == symbol).unwrap()
}
#[test]
fn test_parse_dividends_keep_cents_and_dates() {
let bbca =
parse_corporate_actions(ActionKind::Dividend, &fixture("dividend_bbca")).unwrap();
assert_eq!(bbca.len(), 37);
let latest = &bbca[0];
assert_eq!(latest.symbol, "BBCA.JK");
assert_eq!(latest.date, day("2026-08-28"));
assert_eq!(
(latest.ex_date, latest.record_date, latest.pay_date),
(
Some(day("2026-08-31")),
Some(day("2026-09-01")),
Some(day("2026-09-16"))
)
);
assert_eq!(latest.cash_per_share, Some(25.0));
assert_eq!(latest.ratio, None);
let market = parse_corporate_actions(ActionKind::Dividend, &fixture("dividend")).unwrap();
assert_eq!(find(&market, "ITMG.JK").cash_per_share, Some(1191.09));
}
#[test]
fn test_parse_bonus_reads_split_named_fields() {
let bonus = parse_corporate_actions(ActionKind::Bonus, &fixture("bonus")).unwrap();
let ppgl = find(&bonus, "PPGL.JK");
assert_eq!(ppgl.date, day("2026-11-02"));
assert_eq!(ppgl.ex_date, Some(day("2026-11-03")));
assert_eq!(ppgl.pay_date, Some(day("2026-11-25")));
assert_eq!(ppgl.ratio, Some(Ratio { old: 6, new: 4 }));
assert_eq!(ppgl.factor, Some(1.6667));
}
#[test]
fn test_parse_rights_and_split() {
let rights = parse_corporate_actions(ActionKind::Rights, &fixture("rightissue")).unwrap();
let bmas = find(&rights, "BMAS.JK");
assert_eq!(bmas.date, day("2026-11-23"));
assert_eq!(bmas.ratio, Some(Ratio { old: 655, new: 104 }));
assert_eq!(bmas.price, Some(350));
assert_eq!(
bmas.period,
Some(DateRange {
start: day("2026-11-27"),
end: day("2026-12-03")
})
);
let split =
parse_corporate_actions(ActionKind::Split, &fixture("stocksplit_bbca")).unwrap();
assert_eq!(split[0].date, day("2021-10-12"));
assert_eq!(split[0].ratio, Some(Ratio { old: 1, new: 5 }));
assert_eq!(split[0].factor, Some(5.0));
}
#[test]
fn test_parse_events() {
let warrants = parse_corporate_actions(ActionKind::Warrant, &fixture("warrant")).unwrap();
let brna = find(&warrants, "BRNA.JK");
assert_eq!(brna.date, day("2026-10-16"));
assert_eq!(brna.price, Some(800));
assert_eq!(brna.exercise.map(|r| r.end), Some(day("2029-10-15")));
let meetings = parse_corporate_actions(ActionKind::Meeting, &fixture("rups")).unwrap();
let arti = find(&meetings, "ARTI.JK");
assert_eq!(arti.date, day("2026-12-09"));
assert_eq!(arti.time.as_deref(), Some("10:30"));
assert_eq!(arti.record_date, Some(day("2026-11-12")));
let exposes = parse_corporate_actions(ActionKind::PublicExpose, &fixture("pubex")).unwrap();
assert_eq!(find(&exposes, "MEJA.JK").time.as_deref(), Some("13:00"));
let tenders =
parse_corporate_actions(ActionKind::TenderOffer, &fixture("tenderoffer")).unwrap();
let auto = find(&tenders, "AUTO.JK");
assert_eq!(auto.date, day("2026-10-13"));
assert_eq!(auto.price, Some(3600));
assert_eq!(auto.shares, Some(238_442_293));
assert_eq!(auto.percentage_bps, Some(495));
assert_eq!(auto.pay_date, Some(day("2026-11-18")));
assert_eq!(auto.name.as_deref(), Some("Astra Otoparts Tbk."));
}
#[test]
fn test_parse_ipo_drops_placeholders() {
let ipos = parse_corporate_actions(ActionKind::Ipo, &fixture("ipo")).unwrap();
let rans = find(&ipos, "RANS.JK");
assert_eq!(rans.price, Some(170));
assert_eq!(rans.shares, Some(2_525_000_000));
assert_eq!(rans.period.map(|r| r.start), Some(day("2026-07-02")));
// An ETF listed without offer terms: no price, no period.
let xkgs = find(&ipos, "XKGS.JK");
assert_eq!((xkgs.price, xkgs.period, xkgs.shares), (None, None, None));
}
#[test]
fn test_parse_empty_lists_and_odd_keys() {
for (kind, body) in [
(
ActionKind::StockDividend,
r#"{"data":{"stock_dividend":[]}}"#,
),
(ActionKind::ReverseSplit, r#"{"data":{"stock_reverse":[]}}"#),
(ActionKind::Dividend, r#"{"data":null}"#),
] {
assert!(parse_corporate_actions(kind, body).unwrap().is_empty());
}
assert!(parse_corporate_actions(ActionKind::Dividend, r#"{"message":"x"}"#).is_err());
// A reverse split named like a split, read by suffix.
let body = r#"{"data":{"stock_reverse":[{"company_symbol":"ABCD",
"reverse_cumdate":"2026-12-01","reverse_exdate":"2026-12-02",
"reverse_old":"10","reverse_new":"1","reverse_factor":"0.1"}]}}"#;
let rows = parse_corporate_actions(ActionKind::ReverseSplit, body).unwrap();
assert_eq!(rows[0].ratio, Some(Ratio { old: 10, new: 1 }));
assert_eq!(rows[0].factor, Some(0.1));
}
#[test]
fn test_parse_numbers_and_placeholders() {
assert_eq!(number(Some(&Value::from("1,191.09"))), Some(1191.09));
assert_eq!(number(Some(&Value::from("Rp 25"))), Some(25.0));
assert_eq!(number(Some(&Value::from(0))), None);
assert_eq!(date(Some(&Value::from("0001-01-01 00:00:00"))), None);
assert_eq!(
date(Some(&Value::from("2026-07-02 00:00:00"))),
Some(day("2026-07-02"))
);
assert_eq!(clock("09:00:00"), "09:00");
assert_eq!(clock("10:30"), "10:30");
}
}

View file

@ -25,9 +25,11 @@ use chrono::{Months, NaiveDate};
use serde_json::Value;
use super::auth::SessionStore;
use super::corpaction;
use super::parse::number;
use super::{USER_AGENT, pace, stockbit_code};
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
use crate::corporate::types::{ActionKind, CorporateAction};
use crate::error::IdxError;
const BASE_URL: &str = "https://exodus.stockbit.com";
@ -120,6 +122,24 @@ impl Exodus {
daily_for_period(&body, period)
}
/// Corporate actions of `kind`: one stock's (`resolved`) or the market's.
pub(crate) fn corporate_actions(
&self,
kind: ActionKind,
resolved: Option<&str>,
) -> Result<Vec<CorporateAction>, IdxError> {
let path = format!("/corpaction/{}", corpaction::path_type(kind));
let (path, subject) = match resolved {
Some(resolved) => (
format!("{path}?symbol={}", stockbit_code(resolved)?),
resolved.to_string(),
),
None => (path, "corporate actions".to_string()),
};
let body = self.get(&path, &subject)?;
corpaction::parse_corporate_actions(kind, &body)
}
/// The market's state, read at most every `MARKET_STATE_TTL` per process.
/// Unknown on any failure, which hides bid/offer.
fn market_status(&self) -> Option<String> {

View file

@ -17,6 +17,7 @@
pub mod auth;
pub mod browser;
pub mod corpaction;
pub mod exodus;
pub mod loopback;
mod parse;
@ -31,8 +32,10 @@ pub use parse::{parse_symbol_page, status_error, stockbit_code};
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
use super::{
FundamentalsProvider, HistoryProvider, IntradayProvider, OrderBookProvider, QuoteProvider,
CorporateActionProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
OrderBookProvider, QuoteProvider,
};
use crate::corporate::types::{ActionKind, CorporateAction};
use crate::error::IdxError;
use auth::SessionStore;
use exodus::Exodus;
@ -203,6 +206,16 @@ impl OrderBookProvider for StockbitProvider {
}
}
impl CorporateActionProvider for StockbitProvider {
fn corporate_actions(
&self,
kind: ActionKind,
symbol: Option<&str>,
) -> Result<Vec<CorporateAction>, IdxError> {
self.require_session()?.corporate_actions(kind, symbol)
}
}
impl HistoryProvider for StockbitProvider {
fn history(
&self,

View file

@ -94,6 +94,10 @@ impl Idx {
crate::flow::Flow { idx: self }
}
pub fn corporate(&self) -> crate::corporate::Corporate<'_> {
crate::corporate::Corporate { idx: self }
}
pub fn financial(&self) -> crate::financial::Financial<'_> {
crate::financial::Financial { idx: self }
}

View file

@ -0,0 +1,108 @@
//! Corporate actions (cash and stock dividends, bonus shares, rights issues,
//! warrants, splits, shareholders' meetings, public exposes, IPOs, tender
//! offers) from the user's Stockbit session: one stock's history, or the
//! market's calendar. Stockbit serves one kind per request, so a query costs
//! one request per kind (paced 1 s apart), each cached for 6 hours.
pub mod types;
use chrono::{NaiveDate, Utc};
use crate::api::{corporate_action_provider, stockbit, today_wib};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::error::IdxError;
use types::{ActionKind, CalendarRange, CorporateAction};
/// Announcements change a few times a day at most.
const TTL_SECS: u64 = 6 * 3600;
const BUCKET: &str = "stockbit-corpaction";
pub struct Corporate<'a> {
pub(crate) idx: &'a Idx,
}
impl Corporate<'_> {
/// Today in WIB.
pub fn today(&self) -> NaiveDate {
today_wib()
}
/// One IDX stock's corporate actions of `kinds`, newest first.
pub fn actions(
&self,
symbol: &str,
kinds: &[ActionKind],
policy: CachePolicy,
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
let (resolved, _) = self.idx.idx_code(symbol, "corporate actions")?;
let mut fetched = self.fetch(kinds, Some(&resolved), policy)?;
fetched
.data
.sort_by(|a, b| b.date.cmp(&a.date).then(a.kind.cmp(&b.kind)));
Ok(fetched)
}
/// The market's corporate actions of `kinds` dated within `range` (see
/// `CorporateAction::date`), oldest first.
pub fn calendar(
&self,
range: CalendarRange,
kinds: &[ActionKind],
policy: CachePolicy,
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
let fetched = self.fetch(kinds, None, policy)?;
Ok(fetched.map(|mut actions| {
actions.retain(|a| range.contains(a.date));
actions.sort_by(|a, b| (a.date, &a.symbol, a.kind).cmp(&(b.date, &b.symbol, b.kind)));
actions
}))
}
/// Every kind in turn, through the cache; the result is as old as its
/// oldest part and stale if any part is.
fn fetch(
&self,
kinds: &[ActionKind],
resolved: Option<&str>,
policy: CachePolicy,
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
if kinds.is_empty() {
return Err(IdxError::InvalidInput(
"name at least one kind of corporate action".to_string(),
));
}
let provider = corporate_action_provider();
let subject = resolved.unwrap_or("corporate actions");
let mut merged = Fetched {
data: Vec::new(),
source: stockbit::SOURCE,
fetched_at: Utc::now(),
stale: false,
};
let mut seen = Vec::with_capacity(kinds.len());
for &kind in kinds {
if seen.contains(&kind) {
continue;
}
seen.push(kind);
let key = format!("{}:{}", kind.as_str(), resolved.unwrap_or("market"));
let part = cached_fetch(
&self.idx.cache,
CacheSpec {
bucket: BUCKET,
key: &key,
ttl_secs: TTL_SECS,
subject,
source: stockbit::SOURCE,
},
policy,
|| provider.corporate_actions(kind, resolved),
Ok,
)?;
merged.fetched_at = merged.fetched_at.min(part.fetched_at);
merged.stale |= part.stale;
merged.data.extend(part.data);
}
Ok(merged)
}
}

View file

@ -0,0 +1,292 @@
use chrono::{Duration, NaiveDate};
use serde::{Deserialize, Serialize};
use crate::error::IdxError;
/// The kinds of corporate action Stockbit lists, one request each.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[serde(rename_all = "snake_case")]
pub enum ActionKind {
/// Cash dividend.
Dividend,
#[cfg_attr(feature = "clap", value(name = "stock-dividend"))]
StockDividend,
/// Bonus shares.
Bonus,
/// Rights issue (HMETD).
Rights,
Warrant,
/// Stock split.
Split,
#[cfg_attr(feature = "clap", value(name = "reverse-split"))]
ReverseSplit,
/// Shareholders' meeting (RUPS).
Meeting,
/// Public expose.
#[cfg_attr(feature = "clap", value(name = "expose"))]
PublicExpose,
Ipo,
#[cfg_attr(feature = "clap", value(name = "tender"))]
TenderOffer,
}
impl ActionKind {
pub const ALL: [Self; 11] = [
Self::Dividend,
Self::StockDividend,
Self::Bonus,
Self::Rights,
Self::Warrant,
Self::Split,
Self::ReverseSplit,
Self::Meeting,
Self::PublicExpose,
Self::Ipo,
Self::TenderOffer,
];
/// What one stock's history covers by default: everything but its IPO.
pub const STOCK_DEFAULT: [Self; 10] = [
Self::Dividend,
Self::StockDividend,
Self::Bonus,
Self::Rights,
Self::Warrant,
Self::Split,
Self::ReverseSplit,
Self::Meeting,
Self::PublicExpose,
Self::TenderOffer,
];
/// The CLI's name (`--kind`), also used in MCP inputs.
pub fn as_str(self) -> &'static str {
match self {
Self::Dividend => "dividend",
Self::StockDividend => "stock-dividend",
Self::Bonus => "bonus",
Self::Rights => "rights",
Self::Warrant => "warrant",
Self::Split => "split",
Self::ReverseSplit => "reverse-split",
Self::Meeting => "meeting",
Self::PublicExpose => "expose",
Self::Ipo => "ipo",
Self::TenderOffer => "tender",
}
}
/// Whether the action entitles holders on a cum date (and so moves the
/// price on the ex date).
pub fn is_entitlement(self) -> bool {
matches!(
self,
Self::Dividend
| Self::StockDividend
| Self::Bonus
| Self::Rights
| Self::Split
| Self::ReverseSplit
)
}
}
impl std::str::FromStr for ActionKind {
type Err = IdxError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::ALL
.into_iter()
.find(|kind| kind.as_str() == value)
.ok_or_else(|| {
let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect();
IdxError::InvalidInput(format!(
"invalid kind '{value}' (expected one of: {})",
names.join(", ")
))
})
}
}
/// `old : new` as Stockbit states it: a 1:5 split turns 1 share into 5; a
/// 655:104 rights issue gives 104 rights per 655 shares held; a 6:4 bonus
/// gives 4 bonus shares per 6 held.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ratio {
pub old: u64,
pub new: u64,
}
/// Inclusive dates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct DateRange {
pub start: NaiveDate,
pub end: NaiveDate,
}
/// One corporate action of one stock. Which fields are present depends on
/// `kind`; absent ones are left out of JSON.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CorporateAction {
/// `BBCA.JK`.
pub symbol: String,
pub kind: ActionKind,
/// Where the action sits on a calendar: the cum date for entitlements
/// (the last day to buy and be entitled), else the meeting or expose
/// day, the listing day, a warrant's first trading day, or a tender
/// offer's first day.
pub date: NaiveDate,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cum_date: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ex_date: Option<NaiveDate>,
/// Recording date; for a meeting, the date that decides who may attend.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub record_date: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pay_date: Option<NaiveDate>,
/// Cash dividend per share, IDR as Stockbit gives it (up to 2 decimals,
/// e.g. 3.8).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cash_per_share: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratio: Option<Ratio>,
/// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a
/// 6:4 bonus.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub factor: Option<f64>,
/// Whole IDR: the rights exercise price, a warrant's exercise price, the
/// tender offer price or the final IPO price.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub price: Option<i64>,
/// When rights or warrants trade, a tender offer runs, or an IPO is
/// offered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period: Option<DateRange>,
/// When warrants can be exercised.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exercise: Option<DateRange>,
/// Shares offered (IPO) or sought (tender offer).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shares: Option<u64>,
/// Share of the company a tender offer seeks, basis points.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub percentage_bps: Option<i64>,
/// Meeting or expose time, as Stockbit writes it (WIB).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub venue: Option<String>,
/// The issuer's name, when Stockbit gives it (IPO, tender offer).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl CorporateAction {
pub fn new(symbol: String, kind: ActionKind, date: NaiveDate) -> Self {
Self {
symbol,
kind,
date,
cum_date: None,
ex_date: None,
record_date: None,
pay_date: None,
cash_per_share: None,
ratio: None,
factor: None,
price: None,
period: None,
exercise: None,
shares: None,
percentage_bps: None,
time: None,
venue: None,
name: None,
}
}
}
/// Default calendar span, days from today.
pub const CALENDAR_DEFAULT_DAYS: i64 = 30;
/// Longest calendar range, days.
pub const CALENDAR_MAX_DAYS: i64 = 366;
/// The dates a market-wide calendar covers, inclusive.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CalendarRange {
pub from: NaiveDate,
pub to: NaiveDate,
}
impl CalendarRange {
/// `from` defaults to today, `to` to 30 days after `from`.
pub fn from_args(
from: Option<&str>,
to: Option<&str>,
today: NaiveDate,
) -> Result<Self, IdxError> {
let parse = |name: &str, value: &str| {
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| {
IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD"))
})
};
let from = from.map(|v| parse("from", v)).transpose()?.unwrap_or(today);
let to = to
.map(|v| parse("to", v))
.transpose()?
.unwrap_or(from + Duration::days(CALENDAR_DEFAULT_DAYS));
if to < from {
return Err(IdxError::InvalidInput(format!(
"to ({to}) is before from ({from})"
)));
}
if (to - from).num_days() > CALENDAR_MAX_DAYS {
return Err(IdxError::InvalidInput(format!(
"a calendar covers at most {CALENDAR_MAX_DAYS} days"
)));
}
Ok(Self { from, to })
}
pub fn contains(&self, date: NaiveDate) -> bool {
(self.from..=self.to).contains(&date)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn day(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
#[test]
fn test_calendar_range_defaults_and_bounds() {
let today = day("2026-11-03");
let range = CalendarRange::from_args(None, None, today).unwrap();
assert_eq!((range.from, range.to), (today, day("2026-12-03")));
let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap();
assert_eq!(range.to, day("2026-12-10"));
for (from, to) in [
(Some("2026-11-10"), Some("2026-11-09")),
(Some("2026-01-01"), Some("2027-06-01")),
(Some("11/10/2026"), None),
] {
assert!(matches!(
CalendarRange::from_args(from, to, today),
Err(IdxError::InvalidInput(_))
));
}
}
#[test]
fn test_action_kind_names_round_trip() {
for kind in ActionKind::ALL {
assert_eq!(kind.as_str().parse::<ActionKind>().unwrap(), kind);
}
assert!("rups".parse::<ActionKind>().is_err());
}
}

View file

@ -8,6 +8,7 @@ pub mod broker;
pub mod cache;
mod client;
pub mod config;
pub mod corporate;
pub mod curl_impersonate;
pub mod error;
pub mod financial;