diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index 718d7bb..0982248 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -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" )] diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 216fce0..e47e3fd 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -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| range.map(|r| format!("{} to {}", r.start, r.end)); @@ -1711,14 +1725,14 @@ fn action_detail(action: &CorporateAction) -> String { let mut parts: Vec = 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(), diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index b1e5cbe..ee02057 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -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); +} diff --git a/crates/idx-cli/tests/golden/stockbit-calendar.json.golden b/crates/idx-cli/tests/golden/stockbit-calendar.json.golden index 7455de0..505d3ad 100644 --- a/crates/idx-cli/tests/golden/stockbit-calendar.json.golden +++ b/crates/idx-cli/tests/golden/stockbit-calendar.json.golden @@ -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 diff --git a/crates/idx-cli/tests/golden/stockbit-calendar.table.golden b/crates/idx-cli/tests/golden/stockbit-calendar.table.golden index 7eb5aba..9ddb54e 100644 --- a/crates/idx-cli/tests/golden/stockbit-calendar.table.golden +++ b/crates/idx-cli/tests/golden/stockbit-calendar.table.golden @@ -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 diff --git a/crates/idx-core/src/api/stockbit/browser.rs b/crates/idx-core/src/api/stockbit/browser.rs index 3e94ce5..edf4f36 100644 --- a/crates/idx-core/src/api/stockbit/browser.rs +++ b/crates/idx-core/src/api/stockbit/browser.rs @@ -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() { diff --git a/crates/idx-core/src/api/stockbit/corpaction.rs b/crates/idx-core/src/api/stockbit/corpaction.rs index 0848ca8..6679c8d 100644 --- a/crates/idx-core/src/api/stockbit/corpaction.rs +++ b/crates/idx-core/src/api/stockbit/corpaction.rs @@ -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)); } diff --git a/crates/idx-core/src/api/stockbit/exodus.rs b/crates/idx-core/src/api/stockbit/exodus.rs index d594ffe..3d832b9 100644 --- a/crates/idx-core/src/api/stockbit/exodus.rs +++ b/crates/idx-core/src/api/stockbit/exodus.rs @@ -35,6 +35,11 @@ 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/"]; /// 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 +184,11 @@ impl Exodus { } fn get(&self, path: &str, subject: &str) -> Result { + 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)?; @@ -497,10 +507,38 @@ fn trim_to_period(mut bars: Vec, period: &Period) -> Vec { } } +/// 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}", diff --git a/crates/idx-core/src/api/stockbit/mod.rs b/crates/idx-core/src/api/stockbit/mod.rs index a0e6f45..990467b 100644 --- a/crates/idx-core/src/api/stockbit/mod.rs +++ b/crates/idx-core/src/api/stockbit/mod.rs @@ -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>) { @@ -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() ))); diff --git a/crates/idx-core/src/corporate/mod.rs b/crates/idx-core/src/corporate/mod.rs index 4ddfe01..58ec161 100644 --- a/crates/idx-core/src/corporate/mod.rs +++ b/crates/idx-core/src/corporate/mod.rs @@ -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>, 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 })) diff --git a/crates/idx-core/src/corporate/types.rs b/crates/idx-core/src/corporate/types.rs index 4c67369..c03fa90 100644 --- a/crates/idx-core/src/corporate/types.rs +++ b/crates/idx-core/src/corporate/types.rs @@ -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, #[serde(default, skip_serializing_if = "Option::is_none")] pub pay_date: Option, - /// 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, /// 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"); diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 37b52a5..2a08cb4 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -385,8 +385,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." diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index d218ecb..6506f18 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -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": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0c49cd0..e724e59 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -296,8 +296,10 @@ WIB as the fallback. request per kind, cached 6 hours under `stockbit-corpaction` (`:` or `:market`); `stocks actions` / `get_corporate_actions` sort newest first, `stocks calendar` / - `get_corporate_calendar` filter by `CorporateAction::date` (the cum date - for entitlements) and sort oldest first. Endpoint notes and the wider + `get_corporate_calendar` keep what `CalendarRange::includes`: a + `CorporateAction::date` (the cum date for entitlements) in the range, or a + rights, tender or IPO `period` that overlaps it (not a warrant's, which + runs for years), sorted oldest first. Endpoint notes and the wider design: `docs/STOCKBIT.md`. - Shareholder movements (`movements`, `api/stockbit/movement.rs`) read `/insider/company/majorholder`: IDX insider disclosures and KSEI 5% holder diff --git a/docs/STOCKBIT.md b/docs/STOCKBIT.md index 4a0b507..6895228 100644 --- a/docs/STOCKBIT.md +++ b/docs/STOCKBIT.md @@ -10,7 +10,8 @@ one GET at a time at least 1 s apart. Nothing here ships yet unless marked. ## Rules for every session feature 1. **Read-only by construction.** The session client (`api/stockbit/exodus.rs`) - issues GET on an allowlist of path prefixes, plus `POST /login/refresh`. + issues GET only on an allowlist of path prefixes (`READ_PATHS`), plus + `POST /login/refresh`. The bundle also holds trading (orders, day trade, PIN, cash withdrawal, portfolio), chat, posting and account endpoints; idx never calls them, and the allowlist makes a stray call fail before it leaves the process. @@ -134,18 +135,32 @@ Examples from 2026-11-03: PPGL bonus 6:4 ex 2026-11-03; PRDA split 1:5 cum ### Types +As shipped (`corporate/types.rs`), one flat record with a calendar `date` and +optional per-kind fields; JSON leaves absent ones out: + ```rust pub struct CorporateAction { - pub symbol: String, // BBCA.JK - pub kind: ActionKind, // Dividend, StockDividend, Bonus, Rights, Warrant, - // Split, ReverseSplit, Meeting, PublicExpose, Ipo, TenderOffer + pub symbol: String, // BBCA.JK + pub kind: ActionKind, // JSON: dividend, stock-dividend, bonus, rights, warrant, + // split, reverse-split, meeting, expose, ipo, tender + pub date: NaiveDate, // cum date for entitlements (ex date without one), + // else the event's first day pub cum_date: Option, pub ex_date: Option, pub record_date: Option, pub pay_date: Option, - pub event_date: Option, // meeting, expose, listing, tender end - pub detail: ActionDetail, // kind-specific: amount + currency; old:new ratio - // and factor; price; trading/exercise windows; venue + pub cash_per_share: Option, // dividends; IDR to the sen, else up to 6 decimals + pub currency: Option, // dividends not paid in IDR + pub ratio: Option, // old : new + pub factor: Option, // Stockbit's price adjustment + pub price: Option, // rights, warrant, tender or IPO price (IDR) + pub period: Option, // rights or warrant trading, tender, IPO offer + pub exercise: Option, // warrant exercise + pub shares: Option, // offered (IPO) or sought (tender) + pub percentage_bps: Option, // tender + pub time: Option, // meeting or expose + pub venue: Option, + pub name: Option, // IPO or tender issuer } ``` @@ -157,8 +172,9 @@ lists are not errors. A row whose dates are all placeholders is dropped. - `idx stocks actions SYM [--kind dividend,rights,...]`: one stock's history, newest first; every kind but `ipo` by default. - `idx stocks calendar [--kind ...] [--from DATE] [--to DATE]`: market-wide, - default today to +30 days, oldest first by `date`. -- MCP: `get_corporate_actions {symbol, kinds?, since?}`, + default today to +30 days, oldest first by `date`; rights trading, tender + offers and IPO offers still open in the range are included too. +- MCP: `get_corporate_actions {symbol, kinds?}`, `get_corporate_calendar {kinds?, from?, to?}`. - Later: `stocks history` notes ex-dates of splits, bonuses and rights in the window, so a gap is not read as a crash. diff --git a/web/src/content/docs/cli/reference.md b/web/src/content/docs/cli/reference.md index 6e35899..9f816dd 100644 --- a/web/src/content/docs/cli/reference.md +++ b/web/src/content/docs/cli/reference.md @@ -290,7 +290,7 @@ Examples: ### `idx stocks calendar` ```text -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 kind, 1 s apart; answers are cached for 6 hours. +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), 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. Usage: idx stocks calendar [OPTIONS] diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index d955873..71b1810 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -60,7 +60,9 @@ idx stocks calendar --kind dividend,rights --to 2026-12-31 issues, warrants, splits and reverse splits, shareholders' meetings, public exposes and tender offers (`--kind ipo` adds its IPO). `calendar` lists them across the market between `--from` and `--to` (at most 366 days), including -what is announced ahead. An action's date is its cum date for dividends, +what is announced ahead, plus rights trading, tender offers and IPO offers +still open in that range, so an offer that began earlier still shows while +you can act on it. An action's date is its cum date for dividends, bonuses, rights and splits: buy by then to be entitled. For the others it is the event's first day. When Stockbit gives no cum date, the action sits on its ex date, marked `(ex)` in the table, and JSON leaves `cum_date` out. diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index d785cb0..43ec637 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -74,7 +74,7 @@ Corporate actions of one IDX stock from the user's own Stockbit session, newest ## `get_corporate_calendar` -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. +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. | Input | Type | Required | Description | | --- | --- | --- | --- |