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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue