fix(actions): keep small foreign dividends, allow only read paths, show open offers

From Pullfrog's review of #78:

- A dividend in another currency keeps 6 decimals (USD 0.0012 was rounded
  to 0.0 in JSON and "USD 0" in the table); rupiah stays to the sen.
- `--kind stock_dividend` and `--kind reverse_split` work on the CLI too,
  as they already did over MCP.
- The session client GETs only the prefixes in READ_PATHS, as
  docs/STOCKBIT.md's rule 1 already said: any other path (orders, PIN,
  withdrawals) fails before a request or token refresh leaves the process.
- `stocks calendar` and get_corporate_calendar also list rights trading,
  tender offers and IPO offers still open in the range, so an offer that
  began earlier shows while it can be acted on. A warrant's years-long
  windows do not count. The 2026-11-03..12 golden gains DOOH, NAYZ and AUTO.
- `stocks calendar --help` names the ex-date fallback, and docs/STOCKBIT.md
  shows the record and surface as shipped.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-04 20:44:07 +07:00
commit 884c6459e4
16 changed files with 247 additions and 67 deletions

View file

@ -98,10 +98,12 @@ fn entitlement(
action.factor = number(suffixed(row, "factor"));
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");
// Rupiah to the sen; a dollar dividend can be a fraction of a cent.
let places = if action.currency.is_some() { 6 } else { 2 };
action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, places));
}
ActionKind::Rights => {
action.price = whole(suffixed(row, "price"));
@ -413,7 +415,9 @@ mod tests {
"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[0].cash_per_share, Some(0.0012), "not rounded to cents");
assert_eq!(rows[1].currency, None);
assert_eq!(rows[1].cash_per_share, Some(5.0));
assert_eq!((rows[1].date, rows[1].cum_date), (day("2026-11-12"), None));
}

View file

@ -34,6 +34,11 @@ use crate::error::IdxError;
const BASE_URL: &str = "https://exodus.stockbit.com";
const REFRESH_PATH: &str = "/login/refresh";
/// The only paths idx GETs from the session API. The same API places orders,
/// withdraws cash and changes the PIN; a path outside these fails before any
/// request (or token refresh) leaves the process. A feature that reads a new
/// endpoint adds its prefix here.
const READ_PATHS: &[&str] = &["/company-price-feed/", "/charts/", "/corpaction/"];
/// The prefix of the states in which the regular market trades:
/// `STATE_NAME_SESSION_2` was recorded on 2026-11-03 from 13:30; session 1 is
/// assumed to follow the same naming. Every other state (`STATE_NAME_BREAK`,
@ -159,6 +164,11 @@ impl Exodus {
}
fn get(&self, path: &str, subject: &str) -> Result<String, IdxError> {
if !is_read_path(path) {
return Err(IdxError::Unsupported(format!(
"idx does not call {path} on Stockbit's session API"
)));
}
let refresher = |refresh: &str| self.refresh(refresh);
let token = self.store.access(&refresher, unix_now())?;
let mut reply = self.send(path, &token)?;
@ -477,10 +487,38 @@ fn trim_to_period(mut bars: Vec<Ohlc>, period: &Period) -> Vec<Ohlc> {
}
}
/// Whether `path` is one idx reads; `..` never climbs out of a prefix.
fn is_read_path(path: &str) -> bool {
READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..")
}
#[cfg(test)]
mod tests {
use super::*;
/// Only the endpoints idx reads pass; trading and account paths, and a
/// path that climbs out of a prefix, never leave the process.
#[test]
fn only_read_paths_are_called() {
for path in [
"/company-price-feed/market-time/session",
"/company-price-feed/v2/orderbook/companies/BBCA",
"/charts/BBCA/daily?timeframe=1y&chart_type=PRICE_CHART_TYPE_CANDLE",
"/corpaction/dividend?symbol=BBCA",
] {
assert!(is_read_path(path), "{path}");
}
for path in [
"/order/buy",
"/login/refresh",
"/corpaction/../order/buy",
"/charts",
"",
] {
assert!(!is_read_path(path), "{path}");
}
}
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!(
"{}/tests/fixtures/stockbit_exodus_{name}",

View file

@ -43,7 +43,8 @@ impl Corporate<'_> {
}
/// The market's corporate actions of `kinds` dated within `range` (see
/// `CorporateAction::date`), oldest first.
/// `CorporateAction::date`), plus offers still open in it (see
/// `CalendarRange::includes`), oldest first.
pub fn calendar(
&self,
range: CalendarRange,
@ -52,7 +53,7 @@ impl Corporate<'_> {
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
let fetched = self.fetch(kinds, None, policy)?;
Ok(fetched.map(|mut actions| {
actions.retain(|a| range.contains(a.date));
actions.retain(|a| range.includes(a));
actions.sort_by(|a, b| (a.date, &a.symbol, a.kind).cmp(&(b.date, &b.symbol, b.kind)));
actions
}))

View file

@ -12,7 +12,10 @@ use crate::error::IdxError;
pub enum ActionKind {
/// Cash dividend.
Dividend,
#[cfg_attr(feature = "clap", value(name = "stock-dividend"))]
#[cfg_attr(
feature = "clap",
value(name = "stock-dividend", alias = "stock_dividend")
)]
#[serde(alias = "stock_dividend")]
StockDividend,
/// Bonus shares.
@ -22,7 +25,10 @@ pub enum ActionKind {
Warrant,
/// Stock split.
Split,
#[cfg_attr(feature = "clap", value(name = "reverse-split"))]
#[cfg_attr(
feature = "clap",
value(name = "reverse-split", alias = "reverse_split")
)]
#[serde(alias = "reverse_split")]
ReverseSplit,
/// Shareholders' meeting (RUPS).
@ -167,8 +173,8 @@ pub struct CorporateAction {
pub record_date: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pay_date: Option<NaiveDate>,
/// Cash dividend per share, IDR as Stockbit gives it (up to 2 decimals,
/// e.g. 3.8).
/// Cash dividend per share as Stockbit gives it: IDR to the sen (e.g.
/// 3.8), another `currency` to 6 decimals (e.g. USD 0.0012).
#[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;
@ -282,6 +288,20 @@ impl CalendarRange {
pub fn contains(&self, date: NaiveDate) -> bool {
(self.from..=self.to).contains(&date)
}
/// Whether `action` belongs on this calendar: dated in it, or an offer
/// still open in it (rights trading, a tender offer, an IPO offer), so a
/// calendar shows what can still be acted on. A warrant's windows run
/// for years, so a warrant counts by its date alone.
pub fn includes(&self, action: &CorporateAction) -> bool {
let open_offer = matches!(
action.kind,
ActionKind::Rights | ActionKind::TenderOffer | ActionKind::Ipo
) && action
.period
.is_some_and(|period| period.start <= self.to && period.end >= self.from);
self.contains(action.date) || open_offer
}
}
#[cfg(test)]
@ -292,6 +312,49 @@ mod tests {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
/// A calendar shows what is dated in it and the offers still open in it;
/// a warrant's years-long windows do not put it on every calendar.
#[test]
fn test_calendar_includes_offers_still_open() {
let range = CalendarRange {
from: day("2026-11-03"),
to: day("2026-11-12"),
};
let with_period = |kind, date: &str, start: &str, end: &str| {
let mut action = CorporateAction::new("AUTO.JK".into(), kind, day(date));
action.period = Some(DateRange {
start: day(start),
end: day(end),
});
action
};
let tender = with_period(
ActionKind::TenderOffer,
"2026-10-13",
"2026-10-13",
"2026-11-11",
);
assert!(range.includes(&tender), "open until 11-11");
let rights = with_period(ActionKind::Rights, "2026-10-20", "2026-10-28", "2026-11-03");
assert!(range.includes(&rights), "trades on the first day");
let closed = with_period(
ActionKind::TenderOffer,
"2026-09-01",
"2026-09-01",
"2026-11-02",
);
assert!(!range.includes(&closed), "closed the day before");
let warrant = with_period(
ActionKind::Warrant,
"2025-01-10",
"2025-01-10",
"2028-01-10",
);
assert!(!range.includes(&warrant), "a warrant counts by its date");
let dated = CorporateAction::new("BBCA.JK".into(), ActionKind::Dividend, day("2026-11-12"));
assert!(range.includes(&dated));
}
#[test]
fn test_calendar_range_defaults_and_bounds() {
let today = day("2026-11-03");