Merge branch 'feat/stockbit-movements' into fix/mcp-warnings

Kept both helpers where exodus.rs conflicted: blocked() from this branch and is_read_path() from #78.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-04 20:45:20 +07:00
commit 0582585004
18 changed files with 301 additions and 76 deletions

View file

@ -184,9 +184,10 @@ call takes about a second per kind; answers are cached for 6 hours.",
#[command(
about = "Upcoming and recent corporate actions across the market (Stockbit login)",
long_about = "Corporate actions across IDX from your own Stockbit session (`idx stockbit \
login`), oldest first, dated within --from and --to (default: today and the next 30 days). \
An action's date is its cum date for dividends, bonuses, rights and splits, else the event's \
first day. Stockbit lists about a year back and what is announced ahead. One request per \
login`), oldest first, dated within --from and --to (default: today and the next 30 days), \
plus rights trading, tender offers and IPO offers still open then. An action's date is its \
cum date for dividends, bonuses, rights and splits (its ex date when Stockbit gives no cum \
date), else the event's first day. Stockbit lists about a year back and what is announced ahead. One request per \
kind, 1 s apart; answers are cached for 6 hours.",
after_help = "Examples:\n idx stocks calendar\n idx stocks calendar --kind dividend,rights\n idx stocks calendar --from 2026-12-01 --to 2026-12-31 --kind meeting"
)]

View file

@ -1704,6 +1704,20 @@ fn format_rupiah(value: f64) -> String {
}
}
/// Another currency's amount with up to six decimals, since a dollar
/// dividend can be a fraction of a cent: `0.0012`, `1,250.5`.
fn format_foreign_amount(value: f64) -> String {
let micros = (value.abs() * 1_000_000.0).round() as u64;
let (whole, frac) = (micros / 1_000_000, micros % 1_000_000);
let sign = if value < 0.0 && micros > 0 { "-" } else { "" };
let whole = format_u64(whole);
if frac == 0 {
return format!("{sign}{whole}");
}
let frac = format!("{frac:06}");
format!("{sign}{whole}.{}", frac.trim_end_matches('0'))
}
/// What an action gives or asks, in one line.
fn action_detail(action: &CorporateAction) -> String {
let span = |range: Option<DateRange>| range.map(|r| format!("{} to {}", r.start, r.end));
@ -1711,14 +1725,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);
let (unit, amount): (String, fn(f64) -> String) = match action.currency.as_deref() {
Some(currency) => (terminal_safe(currency), format_foreign_amount),
None => ("Rp".to_string(), format_rupiah),
};
parts.extend(
action
.cash_per_share
.map(|c| format!("{unit} {} per share", format_rupiah(c))),
.map(|c| format!("{unit} {} per share", amount(c))),
);
}
ActionKind::StockDividend | ActionKind::Bonus => {
@ -2015,12 +2029,22 @@ pub fn print_shareholder_movements(
#[cfg(test)]
mod tests {
use super::{
format_earnings_period, format_idr, format_signal, format_table_date, format_u64,
humanize_metric_key, stale_quote_notes,
format_earnings_period, format_foreign_amount, format_idr, format_rupiah, format_signal,
format_table_date, format_u64, humanize_metric_key, stale_quote_notes,
};
use idx_core::analysis::signals::Signal;
use idx_core::api::types::Quote;
/// Rupiah to the sen; another currency keeps a fraction of a cent.
#[test]
fn dividend_amounts_keep_their_decimals() {
assert_eq!(format_rupiah(1191.09), "1,191.09");
assert_eq!(format_rupiah(3.8), "3.8");
assert_eq!(format_foreign_amount(0.0012), "0.0012");
assert_eq!(format_foreign_amount(1250.5), "1,250.5");
assert_eq!(format_foreign_amount(2.0), "2");
}
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
Quote {
symbol: symbol.into(),

View file

@ -3293,3 +3293,24 @@ fn bandar_never_reads_a_broker_summary_cached_in_the_old_shape() {
assert!(!table.contains("No trades."), "{table}");
assert!(table.contains("big distribution"), "{table}");
}
/// `--kind` takes the names JSON carried before they matched it, so a kind
/// copied from an older answer works here as it does over MCP.
#[test]
fn actions_kind_takes_the_old_json_names() {
let root = test_env_dir("actions-old-kinds");
let rows = run_success_json(
mock_bin_with_root(&root)
.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
.args(["-o", "json", "stocks", "actions", "BBCA", "--kind"])
.arg("stock_dividend,reverse_split,public_expose,tender_offer"),
);
let kinds: Vec<&str> = rows
.as_array()
.unwrap()
.iter()
.map(|row| row["kind"].as_str().unwrap())
.collect();
assert!(kinds.contains(&"expose"), "{kinds:?}");
let _ = fs::remove_dir_all(root);
}

View file

@ -1,4 +1,4 @@
exit: 0
--- stdout
[{"symbol":"MGLV.JK","kind":"rights","date":"2026-11-04","cum_date":"2026-11-04","ex_date":"2026-11-05","record_date":"2026-11-06","ratio":{"old":100,"new":15},"factor":1.15,"price":8880,"period":{"start":"2026-11-10","end":"2026-11-23"}},{"symbol":"JPFA.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-12","cash_per_share":90.0},{"symbol":"PTPS.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-18","cash_per_share":2.0},{"symbol":"UNTR.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-23","cash_per_share":430.0},{"symbol":"CNMA.JK","kind":"dividend","date":"2026-11-06","cum_date":"2026-11-06","ex_date":"2026-11-09","record_date":"2026-11-10","pay_date":"2026-11-19","cash_per_share":4.0},{"symbol":"SMSM.JK","kind":"dividend","date":"2026-11-09","cum_date":"2026-11-09","ex_date":"2026-11-10","record_date":"2026-11-11","pay_date":"2026-11-26","cash_per_share":45.0},{"symbol":"AVIA.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-19","cash_per_share":12.0},{"symbol":"BWPT.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-25","cash_per_share":1.0},{"symbol":"ITMG.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-18","cash_per_share":1191.09},{"symbol":"MEDC.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-12-02","cash_per_share":21.65},{"symbol":"SSMS.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-12-02","cash_per_share":52.49},{"symbol":"TAPG.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-27","cash_per_share":70.0},{"symbol":"IFII.JK","kind":"dividend","date":"2026-11-11","cum_date":"2026-11-11","ex_date":"2026-11-12","record_date":"2026-11-13","pay_date":"2026-11-26","cash_per_share":5.0},{"symbol":"MIDI.JK","kind":"dividend","date":"2026-11-11","cum_date":"2026-11-11","ex_date":"2026-11-12","record_date":"2026-11-13","pay_date":"2026-12-01","cash_per_share":3.8}]
[{"symbol":"DOOH.JK","kind":"tender","date":"2026-10-05","pay_date":"2026-11-09","price":148,"period":{"start":"2026-10-05","end":"2026-11-03"},"shares":2624548624,"percentage_bps":3391,"name":"Era Media Sejahtera Tbk."},{"symbol":"NAYZ.JK","kind":"tender","date":"2026-10-05","pay_date":"2026-11-10","price":75,"period":{"start":"2026-10-05","end":"2026-11-03"},"shares":1169011472,"percentage_bps":4584,"name":"Hassana Boga Sejahtera Tbk."},{"symbol":"AUTO.JK","kind":"tender","date":"2026-10-13","pay_date":"2026-11-18","price":3600,"period":{"start":"2026-10-13","end":"2026-11-11"},"shares":238442293,"percentage_bps":495,"name":"Astra Otoparts Tbk."},{"symbol":"MGLV.JK","kind":"rights","date":"2026-11-04","cum_date":"2026-11-04","ex_date":"2026-11-05","record_date":"2026-11-06","ratio":{"old":100,"new":15},"factor":1.15,"price":8880,"period":{"start":"2026-11-10","end":"2026-11-23"}},{"symbol":"JPFA.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-12","cash_per_share":90.0},{"symbol":"PTPS.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-18","cash_per_share":2.0},{"symbol":"UNTR.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-23","cash_per_share":430.0},{"symbol":"CNMA.JK","kind":"dividend","date":"2026-11-06","cum_date":"2026-11-06","ex_date":"2026-11-09","record_date":"2026-11-10","pay_date":"2026-11-19","cash_per_share":4.0},{"symbol":"SMSM.JK","kind":"dividend","date":"2026-11-09","cum_date":"2026-11-09","ex_date":"2026-11-10","record_date":"2026-11-11","pay_date":"2026-11-26","cash_per_share":45.0},{"symbol":"AVIA.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-19","cash_per_share":12.0},{"symbol":"BWPT.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-25","cash_per_share":1.0},{"symbol":"ITMG.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-18","cash_per_share":1191.09},{"symbol":"MEDC.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-12-02","cash_per_share":21.65},{"symbol":"SSMS.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-12-02","cash_per_share":52.49},{"symbol":"TAPG.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-27","cash_per_share":70.0},{"symbol":"IFII.JK","kind":"dividend","date":"2026-11-11","cum_date":"2026-11-11","ex_date":"2026-11-12","record_date":"2026-11-13","pay_date":"2026-11-26","cash_per_share":5.0},{"symbol":"MIDI.JK","kind":"dividend","date":"2026-11-11","cum_date":"2026-11-11","ex_date":"2026-11-12","record_date":"2026-11-13","pay_date":"2026-12-01","cash_per_share":3.8}]
--- stderr

View file

@ -1,37 +1,43 @@
exit: 0
--- stdout
Corporate actions from 2026-11-03 to 2026-11-12 (14)
┌────────────┬────────┬──────────┬────────────┬───────────────────────────────────────────────────────────────────────┐
│ DATE ┆ SYMBOL ┆ KIND ┆ EX ┆ DETAIL │
╞════════════╪════════╪══════════╪════════════╪═══════════════════════════════════════════════════════════════════════╡
│ 2026-11-04 ┆ MGLV ┆ rights ┆ 2026-11-05 ┆ 15 rights per 100 held, at Rp 8,880, trading 2026-11-10 to 2026-11-23 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-05 ┆ JPFA ┆ dividend ┆ 2026-11-06 ┆ Rp 90 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-05 ┆ PTPS ┆ dividend ┆ 2026-11-06 ┆ Rp 2 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-05 ┆ UNTR ┆ dividend ┆ 2026-11-06 ┆ Rp 430 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-06 ┆ CNMA ┆ dividend ┆ 2026-11-09 ┆ Rp 4 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-09 ┆ SMSM ┆ dividend ┆ 2026-11-10 ┆ Rp 45 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ AVIA ┆ dividend ┆ 2026-11-11 ┆ Rp 12 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ BWPT ┆ dividend ┆ 2026-11-11 ┆ Rp 1 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ ITMG ┆ dividend ┆ 2026-11-11 ┆ Rp 1,191.09 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ MEDC ┆ dividend ┆ 2026-11-11 ┆ Rp 21.65 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ SSMS ┆ dividend ┆ 2026-11-11 ┆ Rp 52.49 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ TAPG ┆ dividend ┆ 2026-11-11 ┆ Rp 70 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-11 ┆ IFII ┆ dividend ┆ 2026-11-12 ┆ Rp 5 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-11 ┆ MIDI ┆ dividend ┆ 2026-11-12 ┆ Rp 3.8 per share │
└────────────┴────────┴──────────┴────────────┴───────────────────────────────────────────────────────────────────────┘
Corporate actions from 2026-11-03 to 2026-11-12 (17)
┌────────────┬────────┬──────────────┬────────────┬──────────────────────────────────────────────────────────────────────────┐
│ DATE ┆ SYMBOL ┆ KIND ┆ EX ┆ DETAIL │
╞════════════╪════════╪══════════════╪════════════╪══════════════════════════════════════════════════════════════════════════╡
│ 2026-10-05 ┆ DOOH ┆ tender offer ┆ - ┆ Rp 148, for 2,624,548,624 shares (33.91%), open 2026-10-05 to 2026-11-03 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-05 ┆ NAYZ ┆ tender offer ┆ - ┆ Rp 75, for 1,169,011,472 shares (45.84%), open 2026-10-05 to 2026-11-03 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-10-13 ┆ AUTO ┆ tender offer ┆ - ┆ Rp 3,600, for 238,442,293 shares (4.95%), open 2026-10-13 to 2026-11-11 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-04 ┆ MGLV ┆ rights ┆ 2026-11-05 ┆ 15 rights per 100 held, at Rp 8,880, trading 2026-11-10 to 2026-11-23 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-05 ┆ JPFA ┆ dividend ┆ 2026-11-06 ┆ Rp 90 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-05 ┆ PTPS ┆ dividend ┆ 2026-11-06 ┆ Rp 2 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-05 ┆ UNTR ┆ dividend ┆ 2026-11-06 ┆ Rp 430 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-06 ┆ CNMA ┆ dividend ┆ 2026-11-09 ┆ Rp 4 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-09 ┆ SMSM ┆ dividend ┆ 2026-11-10 ┆ Rp 45 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ AVIA ┆ dividend ┆ 2026-11-11 ┆ Rp 12 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ BWPT ┆ dividend ┆ 2026-11-11 ┆ Rp 1 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ ITMG ┆ dividend ┆ 2026-11-11 ┆ Rp 1,191.09 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ MEDC ┆ dividend ┆ 2026-11-11 ┆ Rp 21.65 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ SSMS ┆ dividend ┆ 2026-11-11 ┆ Rp 52.49 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-10 ┆ TAPG ┆ dividend ┆ 2026-11-11 ┆ Rp 70 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-11 ┆ IFII ┆ dividend ┆ 2026-11-12 ┆ Rp 5 per share │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 2026-11-11 ┆ MIDI ┆ dividend ┆ 2026-11-12 ┆ Rp 3.8 per share │
└────────────┴────────┴──────────────┴────────────┴──────────────────────────────────────────────────────────────────────────┘
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.
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
--- stderr

View file

@ -271,6 +271,12 @@ fn wait_for_session(
));
retry_at = Some(Instant::now() + VERIFY_RETRY);
}
// A block holds for every session from this network, so
// the wait ends with it rather than at the timeout.
Err(err @ IdxError::UpstreamBlocked(_)) => {
devtools.close();
return Err(err);
}
Err(err) => {
crate::runtime::warn(format!(
"Stockbit refused the session in idx's browser ({err}); sign in \
@ -1041,6 +1047,35 @@ mod tests {
let _ = std::fs::remove_dir_all(&profile);
}
/// A CDN block is never retried: the wait ends with it at once, not at
/// the timeout.
#[test]
fn a_blocked_check_ends_the_wait() {
let profile = temp_dir("blocked");
let browser = FakeBrowser::start(Fake::default());
write_endpoint(&profile, browser.port);
let mut devtools = connect(&profile, &|| true).unwrap();
let checks = std::cell::Cell::new(0);
let started = Instant::now();
let err = wait_for_session(
&mut devtools,
Duration::from_secs(30),
&|_| {
checks.set(checks.get() + 1);
Err(IdxError::UpstreamBlocked(
"HTTP 403 from its CDN".to_string(),
))
},
&|| true,
)
.unwrap_err();
assert!(matches!(err, IdxError::UpstreamBlocked(_)), "{err}");
assert_eq!(checks.get(), 1);
assert!(started.elapsed() < Duration::from_secs(5));
browser.finish();
let _ = std::fs::remove_dir_all(&profile);
}
/// Closing the window ends the wait at once, not at the timeout.
#[test]
fn closing_the_window_ends_the_wait() {

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

@ -35,6 +35,16 @@ use crate::movements::types::{MovementPage, MovementQuery};
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/",
"/insider/company/majorholder?",
];
/// 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`,
@ -179,6 +189,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)?;
@ -503,10 +518,40 @@ pub(crate) fn blocked() -> IdxError {
)
}
/// 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",
"/insider/company/majorholder?page=1&symbol=BBCA",
] {
assert!(is_read_path(path), "{path}");
}
for path in [
"/order/buy",
"/login/refresh",
"/corpaction/../order/buy",
"/insider/company/majorholder/../../order",
"/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

@ -320,12 +320,10 @@ fn keep_after_failed_check(err: &IdxError, rotated: bool) -> bool {
}
/// Whether a session check failed for a reason that says nothing about the
/// session itself, so the same session may be checked again later.
/// session itself, so the same session may be checked again later. A CDN
/// block (`UpstreamBlocked`) is not: idx never retries a block.
pub(crate) fn check_is_transient(err: &IdxError) -> bool {
matches!(
err,
IdxError::Http(_) | IdxError::RateLimited | IdxError::UpstreamBlocked(_)
)
matches!(err, IdxError::Http(_) | IdxError::RateLimited)
}
fn restore(store: &SessionStore, previous: Option<Vec<u8>>) {
@ -368,6 +366,9 @@ mod tests {
assert!(check_is_transient(&IdxError::RateLimited));
assert!(check_is_transient(&network));
assert!(!check_is_transient(&refused));
assert!(!check_is_transient(&IdxError::UpstreamBlocked(
"403".to_string()
)));
assert!(!check_is_transient(&IdxError::InvalidInput(
"x".to_string()
)));

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

View file

@ -389,8 +389,10 @@ fields are omitted. One Stockbit request per kind (about 1 s each on a cold cach
#[tool(
description = "Corporate actions across IDX from the user's own Stockbit session, \
oldest first, dated from `from` to `to` (default today and the next 30 days): upcoming cum \
dates, meetings, exposes, rights, warrants, IPOs and tender offers, and the past year. Each \
oldest first, dated from `from` to `to` (default today and the next 30 days), plus rights \
trading, tender offers and IPO offers still open in that range (their `period` overlaps it, \
though `date` may be earlier): upcoming cum dates, meetings, exposes, rights, warrants, IPOs \
and tender offers, and the past year. Each \
item has the same fields as get_corporate_actions, with `symbol`. One Stockbit request per \
kind (about 1 s each on a cold cache, then cached 6 hours); pass `kinds` to ask for less. \
AUTHERROR means there is no session: call stockbit_status, then stockbit_login."

View file

@ -274,7 +274,7 @@
"name": "get_corporate_actions"
},
{
"description": "Corporate actions across IDX from the user's own Stockbit session, oldest first, dated from `from` to `to` (default today and the next 30 days): upcoming cum dates, meetings, exposes, rights, warrants, IPOs and tender offers, and the past year. Each item has the same fields as get_corporate_actions, with `symbol`. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours); pass `kinds` to ask for less. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.",
"description": "Corporate actions across IDX from the user's own Stockbit session, oldest first, dated from `from` to `to` (default today and the next 30 days), plus rights trading, tender offers and IPO offers still open in that range (their `period` overlaps it, though `date` may be earlier): upcoming cum dates, meetings, exposes, rights, warrants, IPOs and tender offers, and the past year. Each item has the same fields as get_corporate_actions, with `symbol`. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours); pass `kinds` to ask for less. AUTHERROR means there is no session: call stockbit_status, then stockbit_login.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {