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();
|
||||
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<chrono::NaiveDate>) -> 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(())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<Self, Self::Err> {
|
||||
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<NaiveDate>,
|
||||
|
|
@ -150,6 +158,10 @@ pub struct CorporateAction {
|
|||
/// e.g. 3.8).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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")]
|
||||
pub ratio: Option<Ratio>,
|
||||
/// 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::<ActionKind>().unwrap(), kind);
|
||||
}
|
||||
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 \
|
||||
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 \
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue