diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 1663578..af37008 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -1708,10 +1708,14 @@ fn action_detail(action: &CorporateAction) -> String { let mut parts: Vec = Vec::new(); match action.kind { ActionKind::Dividend => { + let unit = action + .currency + .as_deref() + .map_or_else(|| "Rp".to_string(), terminal_safe); parts.extend( action .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 => { @@ -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 \ 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) -> 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"]); for action in actions { table.add_row(vec![ - cell(action.date), + action_date_cell(action), cell(action_kind_label(action.kind)), cell(opt_date(action.ex_date)), cell(opt_date(action.record_date)), @@ -1814,7 +1840,7 @@ pub fn print_corporate_actions(symbol: &str, actions: &[CorporateAction]) -> Res ]); } println!("{table}"); - println!("{CORPORATE_DATE_NOTE}"); + print_corporate_notes(actions); } println!("{STOCKBIT_SESSION_NOTE}"); Ok(()) @@ -1844,7 +1870,7 @@ pub fn print_corporate_calendar( .set_header(vec!["DATE", "SYMBOL", "KIND", "EX", "DETAIL"]); for action in actions { table.add_row(vec![ - cell(action.date), + action_date_cell(action), cell(action.symbol.trim_end_matches(".JK")), cell(action_kind_label(action.kind)), cell(opt_date(action.ex_date)), @@ -1852,7 +1878,7 @@ pub fn print_corporate_calendar( ]); } println!("{table}"); - println!("{CORPORATE_DATE_NOTE}"); + print_corporate_notes(actions); } println!("{STOCKBIT_SESSION_NOTE}"); Ok(()) diff --git a/crates/idx-cli/tests/golden/stockbit-actions.json.golden b/crates/idx-cli/tests/golden/stockbit-actions.json.golden index d5f9ad6..a49932d 100644 --- a/crates/idx-cli/tests/golden/stockbit-actions.json.golden +++ b/crates/idx-cli/tests/golden/stockbit-actions.json.golden @@ -1,4 +1,4 @@ exit: 0 --- 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 diff --git a/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden b/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden index 28caa37..0f7f01b 100644 --- a/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden +++ b/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden @@ -1,4 +1,4 @@ exit: 0 --- 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 diff --git a/crates/idx-core/src/api/stockbit/corpaction.rs b/crates/idx-core/src/api/stockbit/corpaction.rs index 7dfb0f1..0848ca8 100644 --- a/crates/idx-core/src/api/stockbit/corpaction.rs +++ b/crates/idx-core/src/api/stockbit/corpaction.rs @@ -47,7 +47,9 @@ pub fn parse_corporate_actions( 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) => &[], + // 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())), }, Some(Value::Array(rows)) => rows.as_slice(), @@ -97,6 +99,9 @@ fn entitlement( match kind { ActionKind::Dividend => { 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 => { action.price = whole(suffixed(row, "price")); @@ -376,10 +381,16 @@ mod tests { ), (ActionKind::ReverseSplit, r#"{"data":{"stock_reverse":[]}}"#), (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(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. let body = r#"{"data":{"stock_reverse":[{"company_symbol":"ABCD", "reverse_cumdate":"2026-12-01","reverse_exdate":"2026-12-02", @@ -389,6 +400,23 @@ mod tests { 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] fn test_parse_numbers_and_placeholders() { assert_eq!(number(Some(&Value::from("1,191.09"))), Some(1191.09)); diff --git a/crates/idx-core/src/corporate/types.rs b/crates/idx-core/src/corporate/types.rs index 97984e3..b6086c8 100644 --- a/crates/idx-core/src/corporate/types.rs +++ b/crates/idx-core/src/corporate/types.rs @@ -3,14 +3,17 @@ use serde::{Deserialize, Serialize}; 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)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "kebab-case")] pub enum ActionKind { /// Cash dividend. Dividend, #[cfg_attr(feature = "clap", value(name = "stock-dividend"))] + #[serde(alias = "stock_dividend")] StockDividend, /// Bonus shares. Bonus, @@ -20,14 +23,17 @@ pub enum ActionKind { /// Stock split. Split, #[cfg_attr(feature = "clap", value(name = "reverse-split"))] + #[serde(alias = "reverse_split")] ReverseSplit, /// Shareholders' meeting (RUPS). Meeting, /// Public expose. #[cfg_attr(feature = "clap", value(name = "expose"))] + #[serde(rename = "expose", alias = "public_expose")] PublicExpose, Ipo, #[cfg_attr(feature = "clap", value(name = "tender"))] + #[serde(rename = "tender", alias = "tender_offer")] TenderOffer, } @@ -96,9 +102,10 @@ impl std::str::FromStr for ActionKind { type Err = IdxError; fn from_str(value: &str) -> Result { + let wanted = value.trim().to_ascii_lowercase().replace('_', "-"); Self::ALL .into_iter() - .find(|kind| kind.as_str() == value) + .find(|kind| kind.as_str() == wanted) .ok_or_else(|| { let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect(); IdxError::InvalidInput(format!( @@ -135,7 +142,8 @@ pub struct CorporateAction { /// 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. + /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub cum_date: Option, @@ -150,6 +158,10 @@ pub struct CorporateAction { /// e.g. 3.8). #[serde(default, skip_serializing_if = "Option::is_none")] pub cash_per_share: Option, + /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub ratio: Option, /// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a @@ -194,6 +206,7 @@ impl CorporateAction { record_date: None, pay_date: None, cash_per_share: None, + currency: None, ratio: None, factor: None, price: None, @@ -221,7 +234,8 @@ pub struct 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( from: Option<&str>, to: Option<&str>, @@ -233,16 +247,18 @@ impl CalendarRange { }) }; 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)); + let to = match to { + Some(v) => parse("to", v)?, + None => from + .checked_add_signed(Duration::days(CALENDAR_DEFAULT_DAYS)) + .ok_or_else(|| IdxError::InvalidInput(format!("from ({from}) is out of range")))?, + }; if to < from { return Err(IdxError::InvalidInput(format!( "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!( "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"))); let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap(); 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 [ (Some("2026-11-10"), Some("2026-11-09")), (Some("2026-01-01"), Some("2027-06-01")), + (Some("2028-01-01"), Some("2029-01-01")), (Some("11/10/2026"), None), ] { 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] fn test_action_kind_names_round_trip() { for kind in ActionKind::ALL { assert_eq!(kind.as_str().parse::().unwrap(), kind); } assert!("rups".parse::().is_err()); + assert_eq!( + "stock_dividend".parse::().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::(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); } } diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 23bbffa..3790249 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -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 \ 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), \ +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 \ diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index bdec564..13ecd5e 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -236,7 +236,7 @@ "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": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 026911f..fd0e292 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -134,8 +134,12 @@ contract() { echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;; "stocks orderbook "*) 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"))' ;; + "stocks calendar"*) + echo 'type == "array" and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;; "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")' ;; "stocks bandar "*) diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index 51c1523..d955873 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -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 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 -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 serves one kind per request, 1 s apart, so a first call with every kind takes diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index a904389..863bbc6 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -65,7 +65,7 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys ## `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 | | --- | --- | --- | --- |