fix(actions): JSON kinds match --kind, and harden parsing and ranges

- JSON named kinds by their Rust names (public_expose, tender_offer,
  stock_dividend, reverse_split) while --kind and MCP take expose, tender,
  stock-dividend, reverse-split, so an output kind could not be passed
  back. JSON now uses the input names; old cache entries still read, and
  --kind also accepts underscores.
- {"dividend": null, "today": "..."} failed to parse and aborted the
  whole command; a null list beside scalars now reads as none.
- An extreme --from panicked on the default 30 days; it is an error now.
  The 366-day limit let 367 days through; it counts both ends now.
- An entitlement without a cum date sat on its ex date under a note that
  calls DATE the cum date. The table marks it (ex) and says what that
  means; JSON leaves cum_date out, as before, and the docs say so.
- A dividend's currency was ignored; one not in IDR now carries currency
  and the table shows it instead of Rp.
- The live check no longer fails a quiet month's empty calendar.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-04 17:35:51 +07:00
commit 1e5561e990
10 changed files with 131 additions and 24 deletions

View file

@ -1708,10 +1708,14 @@ fn action_detail(action: &CorporateAction) -> String {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
match action.kind { match action.kind {
ActionKind::Dividend => { ActionKind::Dividend => {
let unit = action
.currency
.as_deref()
.map_or_else(|| "Rp".to_string(), terminal_safe);
parts.extend( parts.extend(
action action
.cash_per_share .cash_per_share
.map(|c| format!("Rp {} per share", format_rupiah(c))), .map(|c| format!("{unit} {} per share", format_rupiah(c))),
); );
} }
ActionKind::StockDividend | ActionKind::Bonus => { ActionKind::StockDividend | ActionKind::Bonus => {
@ -1784,6 +1788,28 @@ fn action_detail(action: &CorporateAction) -> String {
const CORPORATE_DATE_NOTE: &str = "DATE is the cum date for dividends, bonuses, rights and \ const CORPORATE_DATE_NOTE: &str = "DATE is the cum date for dividends, bonuses, rights and \
splits (buy by that day to be entitled); for other actions, the event's first day."; splits (buy by that day to be entitled); for other actions, the event's first day.";
const CORPORATE_EX_NOTE: &str = "A DATE marked (ex) is the ex date: Stockbit gives no cum \
date for it, and buying that day is too late to be entitled.";
/// An entitlement Stockbit gave no cum date for sits on its ex date.
fn on_ex_date(action: &CorporateAction) -> bool {
action.kind.is_entitlement() && action.cum_date.is_none()
}
fn action_date_cell(action: &CorporateAction) -> Cell {
if on_ex_date(action) {
cell(format!("{} (ex)", action.date))
} else {
cell(action.date)
}
}
fn print_corporate_notes(actions: &[CorporateAction]) {
println!("{CORPORATE_DATE_NOTE}");
if actions.iter().any(on_ex_date) {
println!("{CORPORATE_EX_NOTE}");
}
}
fn opt_date(date: Option<chrono::NaiveDate>) -> String { fn opt_date(date: Option<chrono::NaiveDate>) -> String {
date.map(|d| d.to_string()) date.map(|d| d.to_string())
@ -1805,7 +1831,7 @@ pub fn print_corporate_actions(symbol: &str, actions: &[CorporateAction]) -> Res
.set_header(vec!["DATE", "KIND", "EX", "RECORD", "PAY", "DETAIL"]); .set_header(vec!["DATE", "KIND", "EX", "RECORD", "PAY", "DETAIL"]);
for action in actions { for action in actions {
table.add_row(vec![ table.add_row(vec![
cell(action.date), action_date_cell(action),
cell(action_kind_label(action.kind)), cell(action_kind_label(action.kind)),
cell(opt_date(action.ex_date)), cell(opt_date(action.ex_date)),
cell(opt_date(action.record_date)), cell(opt_date(action.record_date)),
@ -1814,7 +1840,7 @@ pub fn print_corporate_actions(symbol: &str, actions: &[CorporateAction]) -> Res
]); ]);
} }
println!("{table}"); println!("{table}");
println!("{CORPORATE_DATE_NOTE}"); print_corporate_notes(actions);
} }
println!("{STOCKBIT_SESSION_NOTE}"); println!("{STOCKBIT_SESSION_NOTE}");
Ok(()) Ok(())
@ -1844,7 +1870,7 @@ pub fn print_corporate_calendar(
.set_header(vec!["DATE", "SYMBOL", "KIND", "EX", "DETAIL"]); .set_header(vec!["DATE", "SYMBOL", "KIND", "EX", "DETAIL"]);
for action in actions { for action in actions {
table.add_row(vec![ table.add_row(vec![
cell(action.date), action_date_cell(action),
cell(action.symbol.trim_end_matches(".JK")), cell(action.symbol.trim_end_matches(".JK")),
cell(action_kind_label(action.kind)), cell(action_kind_label(action.kind)),
cell(opt_date(action.ex_date)), cell(opt_date(action.ex_date)),
@ -1852,7 +1878,7 @@ pub fn print_corporate_calendar(
]); ]);
} }
println!("{table}"); println!("{table}");
println!("{CORPORATE_DATE_NOTE}"); print_corporate_notes(actions);
} }
println!("{STOCKBIT_SESSION_NOTE}"); println!("{STOCKBIT_SESSION_NOTE}");
Ok(()) Ok(())

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- stdout
[{"symbol":"BBCA.JK","kind":"public_expose","date":"2026-09-09","time":"16:00","venue":"Public Expose Live 2026 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2026-03-12","record_date":"2026-02-09","time":"14:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 10310"},{"symbol":"BBCA.JK","kind":"public_expose","date":"2025-09-11","time":"16:00","venue":"Public Expose Live 2025 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2025-03-12","record_date":"2025-02-07","time":"09:30","venue":"Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta, 10310"},{"symbol":"BBCA.JK","kind":"public_expose","date":"2024-08-28","time":"15:00","venue":"Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan pendaftaran peserta di website www.idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2024-03-14","record_date":"2024-02-07","time":"09:30","venue":"Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta Selatan"},{"symbol":"BBCA.JK","kind":"public_expose","date":"2023-11-29","time":"10:00","venue":"Public Expose Live 2023"},{"symbol":"BBCA.JK","kind":"meeting","date":"2023-03-16","record_date":"2023-02-13","time":"09:30","venue":"Menara BCA"},{"symbol":"BBCA.JK","kind":"public_expose","date":"2022-09-14","time":"10:00","venue":"Public Expose Live 2022"},{"symbol":"BBCA.JK","kind":"meeting","date":"2022-03-17","record_date":"2022-02-14","time":"09:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2021-10-12","cum_date":"2021-10-12","ex_date":"2021-10-13","record_date":"2021-10-14","ratio":{"old":1,"new":5},"factor":5.0},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-09-23","record_date":"2021-08-27","time":"10:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"public_expose","date":"2021-09-08","time":"15:00","venue":"Public Expose Live 2021"},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-03-29","record_date":"2021-02-24","time":"09:00","venue":"Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Jakarta"},{"symbol":"BBCA.JK","kind":"meeting","date":"2020-07-30","record_date":"2020-07-03","time":"10:00","venue":"Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2008-01-25","cum_date":"2008-01-25","ex_date":"2008-01-28","record_date":"2008-01-30","ratio":{"old":1,"new":2},"factor":2.0},{"symbol":"BBCA.JK","kind":"split","date":"2004-06-07","cum_date":"2004-06-07","ex_date":"2004-06-08","record_date":"2004-06-10","ratio":{"old":1,"new":2},"factor":2.0}] [{"symbol":"BBCA.JK","kind":"expose","date":"2026-09-09","time":"16:00","venue":"Public Expose Live 2026 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2026-03-12","record_date":"2026-02-09","time":"14:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 10310"},{"symbol":"BBCA.JK","kind":"expose","date":"2025-09-11","time":"16:00","venue":"Public Expose Live 2025 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2025-03-12","record_date":"2025-02-07","time":"09:30","venue":"Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta, 10310"},{"symbol":"BBCA.JK","kind":"expose","date":"2024-08-28","time":"15:00","venue":"Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan pendaftaran peserta di website www.idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2024-03-14","record_date":"2024-02-07","time":"09:30","venue":"Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta Selatan"},{"symbol":"BBCA.JK","kind":"expose","date":"2023-11-29","time":"10:00","venue":"Public Expose Live 2023"},{"symbol":"BBCA.JK","kind":"meeting","date":"2023-03-16","record_date":"2023-02-13","time":"09:30","venue":"Menara BCA"},{"symbol":"BBCA.JK","kind":"expose","date":"2022-09-14","time":"10:00","venue":"Public Expose Live 2022"},{"symbol":"BBCA.JK","kind":"meeting","date":"2022-03-17","record_date":"2022-02-14","time":"09:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2021-10-12","cum_date":"2021-10-12","ex_date":"2021-10-13","record_date":"2021-10-14","ratio":{"old":1,"new":5},"factor":5.0},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-09-23","record_date":"2021-08-27","time":"10:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"expose","date":"2021-09-08","time":"15:00","venue":"Public Expose Live 2021"},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-03-29","record_date":"2021-02-24","time":"09:00","venue":"Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Jakarta"},{"symbol":"BBCA.JK","kind":"meeting","date":"2020-07-30","record_date":"2020-07-03","time":"10:00","venue":"Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2008-01-25","cum_date":"2008-01-25","ex_date":"2008-01-28","record_date":"2008-01-30","ratio":{"old":1,"new":2},"factor":2.0},{"symbol":"BBCA.JK","kind":"split","date":"2004-06-07","cum_date":"2004-06-07","ex_date":"2004-06-08","record_date":"2004-06-10","ratio":{"old":1,"new":2},"factor":2.0}]
--- stderr --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- stdout
[{"symbol":"AUTO.JK","kind":"public_expose","date":"2026-11-05","time":"10:00","venue":"Zoom Webinar"},{"symbol":"BATA.JK","kind":"meeting","date":"2026-11-05","record_date":"2026-10-09","time":"14:00","venue":"Gedung Ventura, lantai 7. Jl. Raden Ajeng Kartini No.26, Cilandak Barat, Jakarta Selatan"},{"symbol":"AMAG.JK","kind":"public_expose","date":"2026-11-06","time":"15:00","venue":"Virtual Zoom Webinar, di PT Asuransi Multi Artha Guna Tbk, Gedung TCC Batavia Tower One, Jalan KH Mas Mansyur Kav 126, Jakarta Pusat"},{"symbol":"BRIS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Jakarta Pusat, melalui fasilitas Electronic General Meeting System KSEI (eASY.KSEI) dalam tautan https//akses.ksei.co.id"},{"symbol":"GIAA.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"Secara elektronik melalui sistem eASY.KSEI (https//akses.ksei.co.id/)"},{"symbol":"INPS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-07","time":"14:00","venue":"Gedung Inprase, Jl. Sunter Garden Raya Blok D8 No. 3G-3H, Sunter Agung, Tanjung Priok, Jakarta Utara, DKI Jakarta 14350"},{"symbol":"KDTN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"PT Puri Sentul Permai Tbk diumumkan dan sesuai iklan)"},{"symbol":"MTFN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5, Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"MTFN.JK","kind":"public_expose","date":"2026-11-06","time":"11:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5 Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"ULTJ.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-08-31","time":"10:00","venue":"Kantor Perseroan, Jalan Raya Cimareme No 131 Bandung Barat, Jawa Barat Indonesia"}] [{"symbol":"AUTO.JK","kind":"expose","date":"2026-11-05","time":"10:00","venue":"Zoom Webinar"},{"symbol":"BATA.JK","kind":"meeting","date":"2026-11-05","record_date":"2026-10-09","time":"14:00","venue":"Gedung Ventura, lantai 7. Jl. Raden Ajeng Kartini No.26, Cilandak Barat, Jakarta Selatan"},{"symbol":"AMAG.JK","kind":"expose","date":"2026-11-06","time":"15:00","venue":"Virtual Zoom Webinar, di PT Asuransi Multi Artha Guna Tbk, Gedung TCC Batavia Tower One, Jalan KH Mas Mansyur Kav 126, Jakarta Pusat"},{"symbol":"BRIS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Jakarta Pusat, melalui fasilitas Electronic General Meeting System KSEI (eASY.KSEI) dalam tautan https//akses.ksei.co.id"},{"symbol":"GIAA.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"Secara elektronik melalui sistem eASY.KSEI (https//akses.ksei.co.id/)"},{"symbol":"INPS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-07","time":"14:00","venue":"Gedung Inprase, Jl. Sunter Garden Raya Blok D8 No. 3G-3H, Sunter Agung, Tanjung Priok, Jakarta Utara, DKI Jakarta 14350"},{"symbol":"KDTN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"PT Puri Sentul Permai Tbk diumumkan dan sesuai iklan)"},{"symbol":"MTFN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5, Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"MTFN.JK","kind":"expose","date":"2026-11-06","time":"11:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5 Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"ULTJ.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-08-31","time":"10:00","venue":"Kantor Perseroan, Jalan Raya Cimareme No 131 Bandung Barat, Jawa Barat Indonesia"}]
--- stderr --- stderr

View file

@ -47,7 +47,9 @@ pub fn parse_corporate_actions(
let rows = match root.get("data") { let rows = match root.get("data") {
Some(Value::Object(data)) => match data.values().find_map(Value::as_array) { Some(Value::Object(data)) => match data.values().find_map(Value::as_array) {
Some(rows) => rows.as_slice(), Some(rows) => rows.as_slice(),
None if data.values().all(Value::is_null) => &[], // No rows reads `{"dividend": null, "today": "..."}`: a null list
// beside scalars. An object in their place is a new layout.
None if !data.values().any(Value::is_object) => &[],
None => return Err(bad("no list in data".to_string())), None => return Err(bad("no list in data".to_string())),
}, },
Some(Value::Array(rows)) => rows.as_slice(), Some(Value::Array(rows)) => rows.as_slice(),
@ -97,6 +99,9 @@ fn entitlement(
match kind { match kind {
ActionKind::Dividend => { ActionKind::Dividend => {
action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, 2)); action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, 2));
action.currency = text(suffixed(row, "currency"))
.map(|c| c.trim_start_matches("CURRENCY_").to_ascii_uppercase())
.filter(|c| c != "IDR");
} }
ActionKind::Rights => { ActionKind::Rights => {
action.price = whole(suffixed(row, "price")); action.price = whole(suffixed(row, "price"));
@ -376,10 +381,16 @@ mod tests {
), ),
(ActionKind::ReverseSplit, r#"{"data":{"stock_reverse":[]}}"#), (ActionKind::ReverseSplit, r#"{"data":{"stock_reverse":[]}}"#),
(ActionKind::Dividend, r#"{"data":null}"#), (ActionKind::Dividend, r#"{"data":null}"#),
(
ActionKind::Dividend,
r#"{"data":{"dividend":null,"today":"2026-11-04"}}"#,
),
] { ] {
assert!(parse_corporate_actions(kind, body).unwrap().is_empty()); assert!(parse_corporate_actions(kind, body).unwrap().is_empty());
} }
assert!(parse_corporate_actions(ActionKind::Dividend, r#"{"message":"x"}"#).is_err()); assert!(parse_corporate_actions(ActionKind::Dividend, r#"{"message":"x"}"#).is_err());
let moved = r#"{"data":{"dividend":{"rows":[]},"today":"2026-11-04"}}"#;
assert!(parse_corporate_actions(ActionKind::Dividend, moved).is_err());
// A reverse split named like a split, read by suffix. // A reverse split named like a split, read by suffix.
let body = r#"{"data":{"stock_reverse":[{"company_symbol":"ABCD", let body = r#"{"data":{"stock_reverse":[{"company_symbol":"ABCD",
"reverse_cumdate":"2026-12-01","reverse_exdate":"2026-12-02", "reverse_cumdate":"2026-12-01","reverse_exdate":"2026-12-02",
@ -389,6 +400,23 @@ mod tests {
assert_eq!(rows[0].factor, Some(0.1)); assert_eq!(rows[0].factor, Some(0.1));
} }
/// A dividend not in IDR names its currency; one in IDR leaves it out.
/// Without a cum date, the action sits on its ex date.
#[test]
fn test_parse_dividend_currency_and_missing_cum_date() {
let body = r#"{"data":{"dividend":[
{"company_symbol":"ABCD","dividend_cumdate":"2026-11-11",
"dividend_exdate":"2026-11-12","dividend_value":"0.0012",
"dividend_currency":"CURRENCY_USD"},
{"company_symbol":"EFGH","dividend_cumdate":"0001-01-01 00:00:00",
"dividend_exdate":"2026-11-12","dividend_value":"5",
"dividend_currency":"CURRENCY_IDR"}]}}"#;
let rows = parse_corporate_actions(ActionKind::Dividend, body).unwrap();
assert_eq!(rows[0].currency.as_deref(), Some("USD"));
assert_eq!(rows[1].currency, None);
assert_eq!((rows[1].date, rows[1].cum_date), (day("2026-11-12"), None));
}
#[test] #[test]
fn test_parse_numbers_and_placeholders() { fn test_parse_numbers_and_placeholders() {
assert_eq!(number(Some(&Value::from("1,191.09"))), Some(1191.09)); assert_eq!(number(Some(&Value::from("1,191.09"))), Some(1191.09));

View file

@ -3,14 +3,17 @@ use serde::{Deserialize, Serialize};
use crate::error::IdxError; use crate::error::IdxError;
/// The kinds of corporate action Stockbit lists, one request each. /// The kinds of corporate action Stockbit lists, one request each. JSON
/// names them as `--kind` does (`as_str`), so an output kind can be passed
/// back in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "kebab-case")]
pub enum ActionKind { pub enum ActionKind {
/// Cash dividend. /// Cash dividend.
Dividend, Dividend,
#[cfg_attr(feature = "clap", value(name = "stock-dividend"))] #[cfg_attr(feature = "clap", value(name = "stock-dividend"))]
#[serde(alias = "stock_dividend")]
StockDividend, StockDividend,
/// Bonus shares. /// Bonus shares.
Bonus, Bonus,
@ -20,14 +23,17 @@ pub enum ActionKind {
/// Stock split. /// Stock split.
Split, Split,
#[cfg_attr(feature = "clap", value(name = "reverse-split"))] #[cfg_attr(feature = "clap", value(name = "reverse-split"))]
#[serde(alias = "reverse_split")]
ReverseSplit, ReverseSplit,
/// Shareholders' meeting (RUPS). /// Shareholders' meeting (RUPS).
Meeting, Meeting,
/// Public expose. /// Public expose.
#[cfg_attr(feature = "clap", value(name = "expose"))] #[cfg_attr(feature = "clap", value(name = "expose"))]
#[serde(rename = "expose", alias = "public_expose")]
PublicExpose, PublicExpose,
Ipo, Ipo,
#[cfg_attr(feature = "clap", value(name = "tender"))] #[cfg_attr(feature = "clap", value(name = "tender"))]
#[serde(rename = "tender", alias = "tender_offer")]
TenderOffer, TenderOffer,
} }
@ -96,9 +102,10 @@ impl std::str::FromStr for ActionKind {
type Err = IdxError; type Err = IdxError;
fn from_str(value: &str) -> Result<Self, Self::Err> { fn from_str(value: &str) -> Result<Self, Self::Err> {
let wanted = value.trim().to_ascii_lowercase().replace('_', "-");
Self::ALL Self::ALL
.into_iter() .into_iter()
.find(|kind| kind.as_str() == value) .find(|kind| kind.as_str() == wanted)
.ok_or_else(|| { .ok_or_else(|| {
let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect(); let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect();
IdxError::InvalidInput(format!( IdxError::InvalidInput(format!(
@ -135,7 +142,8 @@ pub struct CorporateAction {
/// Where the action sits on a calendar: the cum date for entitlements /// 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 /// (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 /// day, the listing day, a warrant's first trading day, or a tender
/// offer's first day. /// offer's first day. An entitlement Stockbit gives no cum date for is
/// placed on its ex date, and `cum_date` is then absent.
pub date: NaiveDate, pub date: NaiveDate,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cum_date: Option<NaiveDate>, pub cum_date: Option<NaiveDate>,
@ -150,6 +158,10 @@ pub struct CorporateAction {
/// e.g. 3.8). /// e.g. 3.8).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cash_per_share: Option<f64>, pub cash_per_share: Option<f64>,
/// The dividend's currency (ISO code, e.g. `USD`) when it is not IDR;
/// absent for IDR, which every recorded dividend was.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ratio: Option<Ratio>, pub ratio: Option<Ratio>,
/// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a /// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a
@ -194,6 +206,7 @@ impl CorporateAction {
record_date: None, record_date: None,
pay_date: None, pay_date: None,
cash_per_share: None, cash_per_share: None,
currency: None,
ratio: None, ratio: None,
factor: None, factor: None,
price: None, price: None,
@ -221,7 +234,8 @@ pub struct CalendarRange {
} }
impl CalendarRange { impl CalendarRange {
/// `from` defaults to today, `to` to 30 days after `from`. /// `from` defaults to today, `to` to 30 days after `from`; at most
/// `CALENDAR_MAX_DAYS` days, counting both ends.
pub fn from_args( pub fn from_args(
from: Option<&str>, from: Option<&str>,
to: Option<&str>, to: Option<&str>,
@ -233,16 +247,18 @@ impl CalendarRange {
}) })
}; };
let from = from.map(|v| parse("from", v)).transpose()?.unwrap_or(today); let from = from.map(|v| parse("from", v)).transpose()?.unwrap_or(today);
let to = to let to = match to {
.map(|v| parse("to", v)) Some(v) => parse("to", v)?,
.transpose()? None => from
.unwrap_or(from + Duration::days(CALENDAR_DEFAULT_DAYS)); .checked_add_signed(Duration::days(CALENDAR_DEFAULT_DAYS))
.ok_or_else(|| IdxError::InvalidInput(format!("from ({from}) is out of range")))?,
};
if to < from { if to < from {
return Err(IdxError::InvalidInput(format!( return Err(IdxError::InvalidInput(format!(
"to ({to}) is before from ({from})" "to ({to}) is before from ({from})"
))); )));
} }
if (to - from).num_days() > CALENDAR_MAX_DAYS { if (to - from).num_days() >= CALENDAR_MAX_DAYS {
return Err(IdxError::InvalidInput(format!( return Err(IdxError::InvalidInput(format!(
"a calendar covers at most {CALENDAR_MAX_DAYS} days" "a calendar covers at most {CALENDAR_MAX_DAYS} days"
))); )));
@ -270,9 +286,13 @@ mod tests {
assert_eq!((range.from, range.to), (today, day("2026-12-03"))); assert_eq!((range.from, range.to), (today, day("2026-12-03")));
let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap(); let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap();
assert_eq!(range.to, day("2026-12-10")); assert_eq!(range.to, day("2026-12-10"));
// 366 days counting both ends is the most.
let range = CalendarRange::from_args(Some("2028-01-01"), Some("2028-12-31"), today);
assert!(range.is_ok());
for (from, to) in [ for (from, to) in [
(Some("2026-11-10"), Some("2026-11-09")), (Some("2026-11-10"), Some("2026-11-09")),
(Some("2026-01-01"), Some("2027-06-01")), (Some("2026-01-01"), Some("2027-06-01")),
(Some("2028-01-01"), Some("2029-01-01")),
(Some("11/10/2026"), None), (Some("11/10/2026"), None),
] { ] {
assert!(matches!( assert!(matches!(
@ -282,11 +302,36 @@ mod tests {
} }
} }
/// The last date there is: 30 days on is an error, not a panic.
#[test]
fn test_calendar_range_at_the_end_of_time() {
let last = NaiveDate::MAX.format("%Y-%m-%d").to_string();
let err = CalendarRange::from_args(Some(&last), None, day("2026-11-03")).unwrap_err();
assert!(err.to_string().contains("out of range"), "{err}");
}
#[test] #[test]
fn test_action_kind_names_round_trip() { fn test_action_kind_names_round_trip() {
for kind in ActionKind::ALL { for kind in ActionKind::ALL {
assert_eq!(kind.as_str().parse::<ActionKind>().unwrap(), kind); assert_eq!(kind.as_str().parse::<ActionKind>().unwrap(), kind);
} }
assert!("rups".parse::<ActionKind>().is_err()); assert!("rups".parse::<ActionKind>().is_err());
assert_eq!(
"stock_dividend".parse::<ActionKind>().unwrap(),
ActionKind::StockDividend
);
}
/// JSON's `kind` is the name `--kind` and MCP take.
#[test]
fn test_action_kind_json_is_the_input_name() {
for kind in ActionKind::ALL {
let json = serde_json::to_value(kind).unwrap();
assert_eq!(json, kind.as_str(), "{kind:?}");
assert_eq!(serde_json::from_value::<ActionKind>(json).unwrap(), kind);
}
// Entries cached before the names changed still read.
let old: ActionKind = serde_json::from_str("\"public_expose\"").unwrap();
assert_eq!(old, ActionKind::PublicExpose);
} }
} }

View file

@ -358,8 +358,9 @@ average price, the close, `change_bps` and `non_regular_bps`."
description = "Corporate actions of one IDX stock from the user's own Stockbit \ description = "Corporate actions of one IDX stock from the user's own Stockbit \
session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, \ session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, \
reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, \ 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), \ rights and splits, i.e. the last day to buy and be entitled, or the ex date when `cum_date` \
`ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals), \ is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind \
`cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), \
`ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), \ `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), \ `factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), \
`period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent \ `period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent \

View file

@ -236,7 +236,7 @@
"name": "get_company" "name": "get_company"
}, },
{ {
"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.", "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, or the ex date when `cum_date` is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), `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.",
"inputSchema": { "inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {

View file

@ -134,8 +134,12 @@ contract() {
echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;; echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;;
"stocks orderbook "*) "stocks orderbook "*)
echo '(.symbol | type == "string") and (.price | type == "number") and (.market_status | type == "string") and (.bids | type == "array") and (.offers | type == "array")' ;; echo '(.symbol | type == "string") and (.price | type == "number") and (.market_status | type == "string") and (.bids | type == "array") and (.offers | type == "array")' ;;
"stocks actions "*|"stocks calendar"*) # The smoke symbol always has dividends on record; a quiet month can
# leave the calendar empty.
"stocks actions "*)
echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;; echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;;
"stocks calendar"*)
echo 'type == "array" and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;;
"stocks broker "*) "stocks broker "*)
echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;; echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;;
"stocks bandar "*) "stocks bandar "*)

View file

@ -62,7 +62,10 @@ exposes and tender offers (`--kind ipo` adds its IPO). `calendar` lists them
across the market between `--from` and `--to` (at most 366 days), including across the market between `--from` and `--to` (at most 366 days), including
what is announced ahead. An action's date is its cum date for dividends, what is announced ahead. An action's date is its cum date for dividends,
bonuses, rights and splits: buy by then to be entitled. For the others it is bonuses, rights and splits: buy by then to be entitled. For the others it is
the event's first day. Dividends per share keep their decimals (`Rp 3.8`). the event's first day. When Stockbit gives no cum date, the action sits on
its ex date, marked `(ex)` in the table, and JSON leaves `cum_date` out.
Dividends per share keep their decimals (`Rp 3.8`); one not paid in Rupiah
carries `currency` (e.g. `USD`). JSON's `kind` is the name `--kind` takes.
Both need your Stockbit session and answer `AUTHERROR` without one. Stockbit Both need your Stockbit session and answer `AUTHERROR` without one. Stockbit
serves one kind per request, 1 s apart, so a first call with every kind takes serves one kind per request, 1 s apart, so a first call with every kind takes

View file

@ -65,7 +65,7 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys
## `get_corporate_actions` ## `get_corporate_actions`
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. 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, or the ex date when `cum_date` is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), `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.
| Input | Type | Required | Description | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |