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

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

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

View file

@ -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));

View file

@ -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);
}
}