mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
6ad57dd997
commit
1e5561e990
10 changed files with 131 additions and 24 deletions
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 "*)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue