mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
commit
0582585004
18 changed files with 301 additions and 76 deletions
|
|
@ -184,9 +184,10 @@ call takes about a second per kind; answers are cached for 6 hours.",
|
||||||
#[command(
|
#[command(
|
||||||
about = "Upcoming and recent corporate actions across the market (Stockbit login)",
|
about = "Upcoming and recent corporate actions across the market (Stockbit login)",
|
||||||
long_about = "Corporate actions across IDX from your own Stockbit session (`idx stockbit \
|
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). \
|
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 \
|
plus rights trading, tender offers and IPO offers still open then. An action's date is its \
|
||||||
first day. Stockbit lists about a year back and what is announced ahead. One request per \
|
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.",
|
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"
|
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"
|
||||||
)]
|
)]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// What an action gives or asks, in one line.
|
||||||
fn action_detail(action: &CorporateAction) -> String {
|
fn action_detail(action: &CorporateAction) -> String {
|
||||||
let span = |range: Option<DateRange>| range.map(|r| format!("{} to {}", r.start, r.end));
|
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();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
match action.kind {
|
match action.kind {
|
||||||
ActionKind::Dividend => {
|
ActionKind::Dividend => {
|
||||||
let unit = action
|
let (unit, amount): (String, fn(f64) -> String) = match action.currency.as_deref() {
|
||||||
.currency
|
Some(currency) => (terminal_safe(currency), format_foreign_amount),
|
||||||
.as_deref()
|
None => ("Rp".to_string(), format_rupiah),
|
||||||
.map_or_else(|| "Rp".to_string(), terminal_safe);
|
};
|
||||||
parts.extend(
|
parts.extend(
|
||||||
action
|
action
|
||||||
.cash_per_share
|
.cash_per_share
|
||||||
.map(|c| format!("{unit} {} per share", format_rupiah(c))),
|
.map(|c| format!("{unit} {} per share", amount(c))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ActionKind::StockDividend | ActionKind::Bonus => {
|
ActionKind::StockDividend | ActionKind::Bonus => {
|
||||||
|
|
@ -2015,12 +2029,22 @@ pub fn print_shareholder_movements(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
format_earnings_period, format_idr, format_signal, format_table_date, format_u64,
|
format_earnings_period, format_foreign_amount, format_idr, format_rupiah, format_signal,
|
||||||
humanize_metric_key, stale_quote_notes,
|
format_table_date, format_u64, humanize_metric_key, stale_quote_notes,
|
||||||
};
|
};
|
||||||
use idx_core::analysis::signals::Signal;
|
use idx_core::analysis::signals::Signal;
|
||||||
use idx_core::api::types::Quote;
|
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 {
|
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
|
||||||
Quote {
|
Quote {
|
||||||
symbol: symbol.into(),
|
symbol: symbol.into(),
|
||||||
|
|
|
||||||
|
|
@ -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("No trades."), "{table}");
|
||||||
assert!(table.contains("big distribution"), "{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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- 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
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,43 @@
|
||||||
exit: 0
|
exit: 0
|
||||||
--- stdout
|
--- stdout
|
||||||
Corporate actions from 2026-11-03 to 2026-11-12 (14)
|
Corporate actions from 2026-11-03 to 2026-11-12 (17)
|
||||||
┌────────────┬────────┬──────────┬────────────┬───────────────────────────────────────────────────────────────────────┐
|
┌────────────┬────────┬──────────────┬────────────┬──────────────────────────────────────────────────────────────────────────┐
|
||||||
│ DATE ┆ SYMBOL ┆ KIND ┆ EX ┆ DETAIL │
|
│ 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-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 ┆ JPFA ┆ dividend ┆ 2026-11-06 ┆ Rp 90 per share │
|
||||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
│ 2026-11-05 ┆ PTPS ┆ dividend ┆ 2026-11-06 ┆ Rp 2 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-05 ┆ UNTR ┆ dividend ┆ 2026-11-06 ┆ Rp 430 per share │
|
||||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
│ 2026-11-06 ┆ CNMA ┆ dividend ┆ 2026-11-09 ┆ Rp 4 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-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 ┆ AVIA ┆ dividend ┆ 2026-11-11 ┆ Rp 12 per share │
|
||||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
│ 2026-11-10 ┆ BWPT ┆ dividend ┆ 2026-11-11 ┆ Rp 1 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 ┆ 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 ┆ 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 ┆ SSMS ┆ dividend ┆ 2026-11-11 ┆ Rp 52.49 per share │
|
||||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
│ 2026-11-10 ┆ TAPG ┆ dividend ┆ 2026-11-11 ┆ Rp 70 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 ┆ IFII ┆ dividend ┆ 2026-11-12 ┆ Rp 5 per share │
|
||||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
│ 2026-11-11 ┆ MIDI ┆ dividend ┆ 2026-11-12 ┆ Rp 3.8 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.
|
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).
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,12 @@ fn wait_for_session(
|
||||||
));
|
));
|
||||||
retry_at = Some(Instant::now() + VERIFY_RETRY);
|
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) => {
|
Err(err) => {
|
||||||
crate::runtime::warn(format!(
|
crate::runtime::warn(format!(
|
||||||
"Stockbit refused the session in idx's browser ({err}); sign in \
|
"Stockbit refused the session in idx's browser ({err}); sign in \
|
||||||
|
|
@ -1041,6 +1047,35 @@ mod tests {
|
||||||
let _ = std::fs::remove_dir_all(&profile);
|
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.
|
/// Closing the window ends the wait at once, not at the timeout.
|
||||||
#[test]
|
#[test]
|
||||||
fn closing_the_window_ends_the_wait() {
|
fn closing_the_window_ends_the_wait() {
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,12 @@ fn entitlement(
|
||||||
action.factor = number(suffixed(row, "factor"));
|
action.factor = number(suffixed(row, "factor"));
|
||||||
match kind {
|
match kind {
|
||||||
ActionKind::Dividend => {
|
ActionKind::Dividend => {
|
||||||
action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, 2));
|
|
||||||
action.currency = text(suffixed(row, "currency"))
|
action.currency = text(suffixed(row, "currency"))
|
||||||
.map(|c| c.trim_start_matches("CURRENCY_").to_ascii_uppercase())
|
.map(|c| c.trim_start_matches("CURRENCY_").to_ascii_uppercase())
|
||||||
.filter(|c| c != "IDR");
|
.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 => {
|
ActionKind::Rights => {
|
||||||
action.price = whole(suffixed(row, "price"));
|
action.price = whole(suffixed(row, "price"));
|
||||||
|
|
@ -413,7 +415,9 @@ mod tests {
|
||||||
"dividend_currency":"CURRENCY_IDR"}]}}"#;
|
"dividend_currency":"CURRENCY_IDR"}]}}"#;
|
||||||
let rows = parse_corporate_actions(ActionKind::Dividend, body).unwrap();
|
let rows = parse_corporate_actions(ActionKind::Dividend, body).unwrap();
|
||||||
assert_eq!(rows[0].currency.as_deref(), Some("USD"));
|
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].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));
|
assert_eq!((rows[1].date, rows[1].cum_date), (day("2026-11-12"), None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,16 @@ use crate::movements::types::{MovementPage, MovementQuery};
|
||||||
|
|
||||||
const BASE_URL: &str = "https://exodus.stockbit.com";
|
const BASE_URL: &str = "https://exodus.stockbit.com";
|
||||||
const REFRESH_PATH: &str = "/login/refresh";
|
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:
|
/// 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
|
/// `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`,
|
/// 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> {
|
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 refresher = |refresh: &str| self.refresh(refresh);
|
||||||
let token = self.store.access(&refresher, unix_now())?;
|
let token = self.store.access(&refresher, unix_now())?;
|
||||||
let mut reply = self.send(path, &token)?;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn fixture(name: &str) -> String {
|
||||||
std::fs::read_to_string(format!(
|
std::fs::read_to_string(format!(
|
||||||
"{}/tests/fixtures/stockbit_exodus_{name}",
|
"{}/tests/fixtures/stockbit_exodus_{name}",
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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 {
|
pub(crate) fn check_is_transient(err: &IdxError) -> bool {
|
||||||
matches!(
|
matches!(err, IdxError::Http(_) | IdxError::RateLimited)
|
||||||
err,
|
|
||||||
IdxError::Http(_) | IdxError::RateLimited | IdxError::UpstreamBlocked(_)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore(store: &SessionStore, previous: Option<Vec<u8>>) {
|
fn restore(store: &SessionStore, previous: Option<Vec<u8>>) {
|
||||||
|
|
@ -368,6 +366,9 @@ mod tests {
|
||||||
assert!(check_is_transient(&IdxError::RateLimited));
|
assert!(check_is_transient(&IdxError::RateLimited));
|
||||||
assert!(check_is_transient(&network));
|
assert!(check_is_transient(&network));
|
||||||
assert!(!check_is_transient(&refused));
|
assert!(!check_is_transient(&refused));
|
||||||
|
assert!(!check_is_transient(&IdxError::UpstreamBlocked(
|
||||||
|
"403".to_string()
|
||||||
|
)));
|
||||||
assert!(!check_is_transient(&IdxError::InvalidInput(
|
assert!(!check_is_transient(&IdxError::InvalidInput(
|
||||||
"x".to_string()
|
"x".to_string()
|
||||||
)));
|
)));
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ impl Corporate<'_> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The market's corporate actions of `kinds` dated within `range` (see
|
/// 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(
|
pub fn calendar(
|
||||||
&self,
|
&self,
|
||||||
range: CalendarRange,
|
range: CalendarRange,
|
||||||
|
|
@ -52,7 +53,7 @@ impl Corporate<'_> {
|
||||||
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||||
let fetched = self.fetch(kinds, None, policy)?;
|
let fetched = self.fetch(kinds, None, policy)?;
|
||||||
Ok(fetched.map(|mut actions| {
|
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.sort_by(|a, b| (a.date, &a.symbol, a.kind).cmp(&(b.date, &b.symbol, b.kind)));
|
||||||
actions
|
actions
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ use crate::error::IdxError;
|
||||||
pub enum ActionKind {
|
pub enum ActionKind {
|
||||||
/// Cash dividend.
|
/// Cash dividend.
|
||||||
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")]
|
#[serde(alias = "stock_dividend")]
|
||||||
StockDividend,
|
StockDividend,
|
||||||
/// Bonus shares.
|
/// Bonus shares.
|
||||||
|
|
@ -22,7 +25,10 @@ pub enum ActionKind {
|
||||||
Warrant,
|
Warrant,
|
||||||
/// Stock split.
|
/// Stock split.
|
||||||
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")]
|
#[serde(alias = "reverse_split")]
|
||||||
ReverseSplit,
|
ReverseSplit,
|
||||||
/// Shareholders' meeting (RUPS).
|
/// Shareholders' meeting (RUPS).
|
||||||
|
|
@ -167,8 +173,8 @@ pub struct CorporateAction {
|
||||||
pub record_date: Option<NaiveDate>,
|
pub record_date: Option<NaiveDate>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub pay_date: Option<NaiveDate>,
|
pub pay_date: Option<NaiveDate>,
|
||||||
/// Cash dividend per share, IDR as Stockbit gives it (up to 2 decimals,
|
/// Cash dividend per share as Stockbit gives it: IDR to the sen (e.g.
|
||||||
/// e.g. 3.8).
|
/// 3.8), another `currency` to 6 decimals (e.g. USD 0.0012).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cash_per_share: Option<f64>,
|
pub cash_per_share: Option<f64>,
|
||||||
/// The dividend's currency (ISO code, e.g. `USD`) when it is not IDR;
|
/// 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 {
|
pub fn contains(&self, date: NaiveDate) -> bool {
|
||||||
(self.from..=self.to).contains(&date)
|
(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)]
|
#[cfg(test)]
|
||||||
|
|
@ -292,6 +312,49 @@ mod tests {
|
||||||
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
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]
|
#[test]
|
||||||
fn test_calendar_range_defaults_and_bounds() {
|
fn test_calendar_range_defaults_and_bounds() {
|
||||||
let today = day("2026-11-03");
|
let today = day("2026-11-03");
|
||||||
|
|
|
||||||
|
|
@ -389,8 +389,10 @@ fields are omitted. One Stockbit request per kind (about 1 s each on a cold cach
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "Corporate actions across IDX from the user's own Stockbit session, \
|
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 \
|
oldest first, dated from `from` to `to` (default today and the next 30 days), plus rights \
|
||||||
dates, meetings, exposes, rights, warrants, IPOs and tender offers, and the past year. Each \
|
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 \
|
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. \
|
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."
|
AUTHERROR means there is no session: call stockbit_status, then stockbit_login."
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@
|
||||||
"name": "get_corporate_actions"
|
"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": {
|
"inputSchema": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
|
|
@ -296,16 +296,20 @@ WIB as the fallback.
|
||||||
request per kind, cached 6 hours under `stockbit-corpaction`
|
request per kind, cached 6 hours under `stockbit-corpaction`
|
||||||
(`<kind>:<SYMBOL.JK>` or `<kind>:market`); `stocks actions` /
|
(`<kind>:<SYMBOL.JK>` or `<kind>:market`); `stocks actions` /
|
||||||
`get_corporate_actions` sort newest first, `stocks calendar` /
|
`get_corporate_actions` sort newest first, `stocks calendar` /
|
||||||
`get_corporate_calendar` filter by `CorporateAction::date` (the cum date
|
`get_corporate_calendar` keep what `CalendarRange::includes`: a
|
||||||
for entitlements) and sort oldest first. Endpoint notes and the wider
|
`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`.
|
design: `docs/STOCKBIT.md`.
|
||||||
- Shareholder movements (`movements`, `api/stockbit/movement.rs`) read
|
- Shareholder movements (`movements`, `api/stockbit/movement.rs`) read
|
||||||
`/insider/company/majorholder`: IDX insider disclosures and KSEI 5% holder
|
`/insider/company/majorholder`: IDX insider disclosures and KSEI 5% holder
|
||||||
changes, 50 rows a page, filtered by Stockbit (`symbol`, `date_start`,
|
changes, 50 rows a page, filtered by Stockbit (`symbol`, `date_start`,
|
||||||
`date_end`, `insider` (holder id), `action_type`, `source_type`). Each page
|
`date_end`, `insider` (holder id), `action_type`, `source_type`).
|
||||||
is cached an hour under `stockbit-movements`, keyed by every filter and the
|
`ownership movements` and `get_shareholder_movements` read up to 10 pages
|
||||||
page. `ownership movements` and `get_shareholder_movements` read up to 10
|
as one answer, cached an hour under `stockbit-movements`, keyed by every
|
||||||
pages; the KSEI register in SQLite is not involved.
|
filter and the page count. If a page after the first fails, the rows read
|
||||||
|
before it come back with `incomplete` set and nothing is cached. The KSEI
|
||||||
|
register in SQLite is not involved.
|
||||||
|
|
||||||
### Capability Gating
|
### Capability Gating
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Rules for every session feature
|
||||||
|
|
||||||
1. **Read-only by construction.** The session client (`api/stockbit/exodus.rs`)
|
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,
|
The bundle also holds trading (orders, day trade, PIN, cash withdrawal,
|
||||||
portfolio), chat, posting and account endpoints; idx never calls them, and
|
portfolio), chat, posting and account endpoints; idx never calls them, and
|
||||||
the allowlist makes a stray call fail before it leaves the process.
|
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
|
### Types
|
||||||
|
|
||||||
|
As shipped (`corporate/types.rs`), one flat record with a calendar `date` and
|
||||||
|
optional per-kind fields; JSON leaves absent ones out:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct CorporateAction {
|
pub struct CorporateAction {
|
||||||
pub symbol: String, // BBCA.JK
|
pub symbol: String, // BBCA.JK
|
||||||
pub kind: ActionKind, // Dividend, StockDividend, Bonus, Rights, Warrant,
|
pub kind: ActionKind, // JSON: dividend, stock-dividend, bonus, rights, warrant,
|
||||||
// Split, ReverseSplit, Meeting, PublicExpose, Ipo, TenderOffer
|
// 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<NaiveDate>,
|
pub cum_date: Option<NaiveDate>,
|
||||||
pub ex_date: Option<NaiveDate>,
|
pub ex_date: Option<NaiveDate>,
|
||||||
pub record_date: Option<NaiveDate>,
|
pub record_date: Option<NaiveDate>,
|
||||||
pub pay_date: Option<NaiveDate>,
|
pub pay_date: Option<NaiveDate>,
|
||||||
pub event_date: Option<NaiveDate>, // meeting, expose, listing, tender end
|
pub cash_per_share: Option<f64>, // dividends; IDR to the sen, else up to 6 decimals
|
||||||
pub detail: ActionDetail, // kind-specific: amount + currency; old:new ratio
|
pub currency: Option<String>, // dividends not paid in IDR
|
||||||
// and factor; price; trading/exercise windows; venue
|
pub ratio: Option<Ratio>, // old : new
|
||||||
|
pub factor: Option<f64>, // Stockbit's price adjustment
|
||||||
|
pub price: Option<i64>, // rights, warrant, tender or IPO price (IDR)
|
||||||
|
pub period: Option<DateRange>, // rights or warrant trading, tender, IPO offer
|
||||||
|
pub exercise: Option<DateRange>, // warrant exercise
|
||||||
|
pub shares: Option<u64>, // offered (IPO) or sought (tender)
|
||||||
|
pub percentage_bps: Option<i64>, // tender
|
||||||
|
pub time: Option<String>, // meeting or expose
|
||||||
|
pub venue: Option<String>,
|
||||||
|
pub name: Option<String>, // 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
|
- `idx stocks actions SYM [--kind dividend,rights,...]`: one stock's
|
||||||
history, newest first; every kind but `ipo` by default.
|
history, newest first; every kind but `ipo` by default.
|
||||||
- `idx stocks calendar [--kind ...] [--from DATE] [--to DATE]`: market-wide,
|
- `idx stocks calendar [--kind ...] [--from DATE] [--to DATE]`: market-wide,
|
||||||
default today to +30 days, oldest first by `date`.
|
default today to +30 days, oldest first by `date`; rights trading, tender
|
||||||
- MCP: `get_corporate_actions {symbol, kinds?, since?}`,
|
offers and IPO offers still open in the range are included too.
|
||||||
|
- MCP: `get_corporate_actions {symbol, kinds?}`,
|
||||||
`get_corporate_calendar {kinds?, from?, to?}`.
|
`get_corporate_calendar {kinds?, from?, to?}`.
|
||||||
- Later: `stocks history` notes ex-dates of splits, bonuses and rights in the
|
- Later: `stocks history` notes ex-dates of splits, bonuses and rights in the
|
||||||
window, so a gap is not read as a crash.
|
window, so a gap is not read as a crash.
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,7 @@ Examples:
|
||||||
### `idx stocks calendar`
|
### `idx stocks calendar`
|
||||||
|
|
||||||
```text
|
```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]
|
Usage: idx stocks calendar [OPTIONS]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,9 @@ idx stocks calendar --kind dividend,rights --to 2026-12-31
|
||||||
issues, warrants, splits and reverse splits, shareholders' meetings, public
|
issues, warrants, splits and reverse splits, shareholders' meetings, public
|
||||||
exposes and tender offers (`--kind ipo` adds its IPO). `calendar` lists them
|
exposes and tender offers (`--kind ipo` adds its IPO). `calendar` lists them
|
||||||
across the market between `--from` and `--to` (at most 366 days), including
|
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
|
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
|
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.
|
its ex date, marked `(ex)` in the table, and JSON leaves `cum_date` out.
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ Corporate actions of one IDX stock from the user's own Stockbit session, newest
|
||||||
|
|
||||||
## `get_corporate_calendar`
|
## `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 |
|
| Input | Type | Required | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue