Merge branch 'feat/stockbit-corpaction' into feat/stockbit-movements

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-04 17:36:04 +07:00
commit 5a6b5d1e2e
26 changed files with 430 additions and 81 deletions

View file

@ -239,6 +239,9 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> {
if let Some(note) = &q.session_note { if let Some(note) = &q.session_note {
println!("Note: {}", terminal_safe(note)); println!("Note: {}", terminal_safe(note));
} }
for note in stale_quote_notes(std::slice::from_ref(q), chrono::Utc::now()) {
println!("{}", terminal_safe(&note));
}
let prices: Vec<f64> = day.points.iter().map(|p| p.price).collect(); let prices: Vec<f64> = day.points.iter().map(|p| p.price).collect();
if let (Some(first), Some(last)) = (day.points.first(), day.points.last()) { if let (Some(first), Some(last)) = (day.points.first(), day.points.last()) {
@ -1708,10 +1711,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
.currency
.as_deref()
.map_or_else(|| "Rp".to_string(), terminal_safe);
parts.extend( parts.extend(
action action
.cash_per_share .cash_per_share
.map(|c| format!("Rp {} per share", format_rupiah(c))), .map(|c| format!("{unit} {} per share", format_rupiah(c))),
); );
} }
ActionKind::StockDividend | ActionKind::Bonus => { ActionKind::StockDividend | ActionKind::Bonus => {
@ -1784,6 +1791,28 @@ fn action_detail(action: &CorporateAction) -> String {
const CORPORATE_DATE_NOTE: &str = "DATE is the cum date for dividends, bonuses, rights and \ const CORPORATE_DATE_NOTE: &str = "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."; splits (buy by that day to be entitled); for other actions, the event's first day.";
const CORPORATE_EX_NOTE: &str = "A DATE marked (ex) is the ex date: Stockbit gives no cum \
date for it, and buying that day is too late to be entitled.";
/// An entitlement Stockbit gave no cum date for sits on its ex date.
fn on_ex_date(action: &CorporateAction) -> bool {
action.kind.is_entitlement() && action.cum_date.is_none()
}
fn action_date_cell(action: &CorporateAction) -> Cell {
if on_ex_date(action) {
cell(format!("{} (ex)", action.date))
} else {
cell(action.date)
}
}
fn print_corporate_notes(actions: &[CorporateAction]) {
println!("{CORPORATE_DATE_NOTE}");
if actions.iter().any(on_ex_date) {
println!("{CORPORATE_EX_NOTE}");
}
}
fn opt_date(date: Option<chrono::NaiveDate>) -> String { fn opt_date(date: Option<chrono::NaiveDate>) -> String {
date.map(|d| d.to_string()) date.map(|d| d.to_string())
@ -1805,7 +1834,7 @@ pub fn print_corporate_actions(symbol: &str, actions: &[CorporateAction]) -> Res
.set_header(vec!["DATE", "KIND", "EX", "RECORD", "PAY", "DETAIL"]); .set_header(vec!["DATE", "KIND", "EX", "RECORD", "PAY", "DETAIL"]);
for action in actions { for action in actions {
table.add_row(vec![ table.add_row(vec![
cell(action.date), action_date_cell(action),
cell(action_kind_label(action.kind)), cell(action_kind_label(action.kind)),
cell(opt_date(action.ex_date)), cell(opt_date(action.ex_date)),
cell(opt_date(action.record_date)), cell(opt_date(action.record_date)),
@ -1814,7 +1843,7 @@ pub fn print_corporate_actions(symbol: &str, actions: &[CorporateAction]) -> Res
]); ]);
} }
println!("{table}"); println!("{table}");
println!("{CORPORATE_DATE_NOTE}"); print_corporate_notes(actions);
} }
println!("{STOCKBIT_SESSION_NOTE}"); println!("{STOCKBIT_SESSION_NOTE}");
Ok(()) Ok(())
@ -1844,7 +1873,7 @@ pub fn print_corporate_calendar(
.set_header(vec!["DATE", "SYMBOL", "KIND", "EX", "DETAIL"]); .set_header(vec!["DATE", "SYMBOL", "KIND", "EX", "DETAIL"]);
for action in actions { for action in actions {
table.add_row(vec![ table.add_row(vec![
cell(action.date), action_date_cell(action),
cell(action.symbol.trim_end_matches(".JK")), cell(action.symbol.trim_end_matches(".JK")),
cell(action_kind_label(action.kind)), cell(action_kind_label(action.kind)),
cell(opt_date(action.ex_date)), cell(opt_date(action.ex_date)),
@ -1852,7 +1881,7 @@ pub fn print_corporate_calendar(
]); ]);
} }
println!("{table}"); println!("{table}");
println!("{CORPORATE_DATE_NOTE}"); print_corporate_notes(actions);
} }
println!("{STOCKBIT_SESSION_NOTE}"); println!("{STOCKBIT_SESSION_NOTE}");
Ok(()) Ok(())

View file

@ -2197,6 +2197,51 @@ fn stockbit_login_stores_a_private_session_and_logout_removes_it() {
assert!(!session.exists()); assert!(!session.exists());
} }
/// A session file Stockbit no longer accepts does not break `auto` history:
/// it falls back to Yahoo with a warning. An explicit Stockbit choice fails.
#[test]
fn auto_history_falls_back_to_yahoo_when_the_session_is_refused() {
for args in [
&["stocks", "history", "BBCA", "--period", "1mo"][..],
&["stocks", "technical", "BBCA"][..],
] {
let root = test_env_dir(&format!("stockbit-refused-{}", args[1]));
let output = mock_bin_with_root(&root)
.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
.env("IDX_MOCK_STOCKBIT_STATUS", "401")
.args(["-o", "json", "--provider", "stockbit"])
.args(args)
.output()
.expect("run history");
assert!(output.status.success(), "{args:?}: {output:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("falls back to yahoo"), "{stderr}");
serde_json::from_slice::<Value>(&output.stdout).expect("JSON on stdout");
let _ = fs::remove_dir_all(root);
}
let root = test_env_dir("stockbit-refused-explicit");
run_error_json(
mock_bin_with_root(&root)
.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
.env("IDX_MOCK_STOCKBIT_STATUS", "401")
.args([
"-o",
"json",
"--provider",
"stockbit",
"stocks",
"history",
"BBCA",
])
.args(["--period", "1mo", "--history-provider", "stockbit"]),
"AUTHERROR",
);
let _ = fs::remove_dir_all(root);
}
/// A test session, signed by nobody, that expires in 2100.
const FAKE_STOCKBIT_TOKEN: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln";
/// An agent on a headless box gets told how to log in, not "nothing pasted". /// An agent on a headless box gets told how to log in, not "nothing pasted".
#[test] #[test]
fn stockbit_login_with_empty_stdin_says_how_to_log_in() { fn stockbit_login_with_empty_stdin_says_how_to_log_in() {

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- stdout
[{"symbol":"BBCA.JK","kind":"public_expose","date":"2026-09-09","time":"16:00","venue":"Public Expose Live 2026 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2026-03-12","record_date":"2026-02-09","time":"14:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 10310"},{"symbol":"BBCA.JK","kind":"public_expose","date":"2025-09-11","time":"16:00","venue":"Public Expose Live 2025 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2025-03-12","record_date":"2025-02-07","time":"09:30","venue":"Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta, 10310"},{"symbol":"BBCA.JK","kind":"public_expose","date":"2024-08-28","time":"15:00","venue":"Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan pendaftaran peserta di website www.idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2024-03-14","record_date":"2024-02-07","time":"09:30","venue":"Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta Selatan"},{"symbol":"BBCA.JK","kind":"public_expose","date":"2023-11-29","time":"10:00","venue":"Public Expose Live 2023"},{"symbol":"BBCA.JK","kind":"meeting","date":"2023-03-16","record_date":"2023-02-13","time":"09:30","venue":"Menara BCA"},{"symbol":"BBCA.JK","kind":"public_expose","date":"2022-09-14","time":"10:00","venue":"Public Expose Live 2022"},{"symbol":"BBCA.JK","kind":"meeting","date":"2022-03-17","record_date":"2022-02-14","time":"09:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2021-10-12","cum_date":"2021-10-12","ex_date":"2021-10-13","record_date":"2021-10-14","ratio":{"old":1,"new":5},"factor":5.0},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-09-23","record_date":"2021-08-27","time":"10:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"public_expose","date":"2021-09-08","time":"15:00","venue":"Public Expose Live 2021"},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-03-29","record_date":"2021-02-24","time":"09:00","venue":"Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Jakarta"},{"symbol":"BBCA.JK","kind":"meeting","date":"2020-07-30","record_date":"2020-07-03","time":"10:00","venue":"Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2008-01-25","cum_date":"2008-01-25","ex_date":"2008-01-28","record_date":"2008-01-30","ratio":{"old":1,"new":2},"factor":2.0},{"symbol":"BBCA.JK","kind":"split","date":"2004-06-07","cum_date":"2004-06-07","ex_date":"2004-06-08","record_date":"2004-06-10","ratio":{"old":1,"new":2},"factor":2.0}] [{"symbol":"BBCA.JK","kind":"expose","date":"2026-09-09","time":"16:00","venue":"Public Expose Live 2026 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2026-03-12","record_date":"2026-02-09","time":"14:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 10310"},{"symbol":"BBCA.JK","kind":"expose","date":"2025-09-11","time":"16:00","venue":"Public Expose Live 2025 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2025-03-12","record_date":"2025-02-07","time":"09:30","venue":"Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta, 10310"},{"symbol":"BBCA.JK","kind":"expose","date":"2024-08-28","time":"15:00","venue":"Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan pendaftaran peserta di website www.idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2024-03-14","record_date":"2024-02-07","time":"09:30","venue":"Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta Selatan"},{"symbol":"BBCA.JK","kind":"expose","date":"2023-11-29","time":"10:00","venue":"Public Expose Live 2023"},{"symbol":"BBCA.JK","kind":"meeting","date":"2023-03-16","record_date":"2023-02-13","time":"09:30","venue":"Menara BCA"},{"symbol":"BBCA.JK","kind":"expose","date":"2022-09-14","time":"10:00","venue":"Public Expose Live 2022"},{"symbol":"BBCA.JK","kind":"meeting","date":"2022-03-17","record_date":"2022-02-14","time":"09:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2021-10-12","cum_date":"2021-10-12","ex_date":"2021-10-13","record_date":"2021-10-14","ratio":{"old":1,"new":5},"factor":5.0},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-09-23","record_date":"2021-08-27","time":"10:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"expose","date":"2021-09-08","time":"15:00","venue":"Public Expose Live 2021"},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-03-29","record_date":"2021-02-24","time":"09:00","venue":"Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Jakarta"},{"symbol":"BBCA.JK","kind":"meeting","date":"2020-07-30","record_date":"2020-07-03","time":"10:00","venue":"Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2008-01-25","cum_date":"2008-01-25","ex_date":"2008-01-28","record_date":"2008-01-30","ratio":{"old":1,"new":2},"factor":2.0},{"symbol":"BBCA.JK","kind":"split","date":"2004-06-07","cum_date":"2004-06-07","ex_date":"2004-06-08","record_date":"2004-06-10","ratio":{"old":1,"new":2},"factor":2.0}]
--- stderr --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- stdout
[{"symbol":"AUTO.JK","kind":"public_expose","date":"2026-11-05","time":"10:00","venue":"Zoom Webinar"},{"symbol":"BATA.JK","kind":"meeting","date":"2026-11-05","record_date":"2026-10-09","time":"14:00","venue":"Gedung Ventura, lantai 7. Jl. Raden Ajeng Kartini No.26, Cilandak Barat, Jakarta Selatan"},{"symbol":"AMAG.JK","kind":"public_expose","date":"2026-11-06","time":"15:00","venue":"Virtual Zoom Webinar, di PT Asuransi Multi Artha Guna Tbk, Gedung TCC Batavia Tower One, Jalan KH Mas Mansyur Kav 126, Jakarta Pusat"},{"symbol":"BRIS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Jakarta Pusat, melalui fasilitas Electronic General Meeting System KSEI (eASY.KSEI) dalam tautan https//akses.ksei.co.id"},{"symbol":"GIAA.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"Secara elektronik melalui sistem eASY.KSEI (https//akses.ksei.co.id/)"},{"symbol":"INPS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-07","time":"14:00","venue":"Gedung Inprase, Jl. Sunter Garden Raya Blok D8 No. 3G-3H, Sunter Agung, Tanjung Priok, Jakarta Utara, DKI Jakarta 14350"},{"symbol":"KDTN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"PT Puri Sentul Permai Tbk diumumkan dan sesuai iklan)"},{"symbol":"MTFN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5, Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"MTFN.JK","kind":"public_expose","date":"2026-11-06","time":"11:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5 Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"ULTJ.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-08-31","time":"10:00","venue":"Kantor Perseroan, Jalan Raya Cimareme No 131 Bandung Barat, Jawa Barat Indonesia"}] [{"symbol":"AUTO.JK","kind":"expose","date":"2026-11-05","time":"10:00","venue":"Zoom Webinar"},{"symbol":"BATA.JK","kind":"meeting","date":"2026-11-05","record_date":"2026-10-09","time":"14:00","venue":"Gedung Ventura, lantai 7. Jl. Raden Ajeng Kartini No.26, Cilandak Barat, Jakarta Selatan"},{"symbol":"AMAG.JK","kind":"expose","date":"2026-11-06","time":"15:00","venue":"Virtual Zoom Webinar, di PT Asuransi Multi Artha Guna Tbk, Gedung TCC Batavia Tower One, Jalan KH Mas Mansyur Kav 126, Jakarta Pusat"},{"symbol":"BRIS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Jakarta Pusat, melalui fasilitas Electronic General Meeting System KSEI (eASY.KSEI) dalam tautan https//akses.ksei.co.id"},{"symbol":"GIAA.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"Secara elektronik melalui sistem eASY.KSEI (https//akses.ksei.co.id/)"},{"symbol":"INPS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-07","time":"14:00","venue":"Gedung Inprase, Jl. Sunter Garden Raya Blok D8 No. 3G-3H, Sunter Agung, Tanjung Priok, Jakarta Utara, DKI Jakarta 14350"},{"symbol":"KDTN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"PT Puri Sentul Permai Tbk diumumkan dan sesuai iklan)"},{"symbol":"MTFN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5, Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"MTFN.JK","kind":"expose","date":"2026-11-06","time":"11:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5 Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"ULTJ.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-08-31","time":"10:00","venue":"Kantor Perseroan, Jalan Raya Cimareme No 131 Bandung Barat, Jawa Barat Indonesia"}]
--- stderr --- stderr

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- stdout
[{"symbol":"BBCA.JK","price":5175,"change":-900,"change_pct":-14.81,"volume":1241098200,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":6075,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"STATE_NAME_MARKET_CLOSED"}] [{"symbol":"BBCA.JK","price":5175,"change":-900,"change_pct":-14.81,"volume":1241098200,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":6075,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close"}]
--- stderr --- stderr

View file

@ -3,7 +3,7 @@ exit: 0
┌─────────┬───────┬──────┬─────────┬───────────────┬─────┬───────┬────────┬───────┐ ┌─────────┬───────┬──────┬─────────┬───────────────┬─────┬───────┬────────┬───────┐
│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ BID ┆ OFFER ┆ MARKET ┆ AS OF │ │ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ BID ┆ OFFER ┆ MARKET ┆ AS OF │
╞═════════╪═══════╪══════╪═════════╪═══════════════╪═════╪═══════╪════════╪═══════╡ ╞═════════╪═══════╪══════╪═════════╪═══════════════╪═════╪═══════╪════════╪═══════╡
│ BBCA.JK ┆ 5,175 ┆ -900 ┆ -14.81% ┆ 1,241,098,200 ┆ - ┆ - ┆ closed ┆ - │ │ BBCA.JK ┆ 5,175 ┆ -900 ┆ -14.81% ┆ 1,241,098,200 ┆ - ┆ - ┆ close ┆ - │
└─────────┴───────┴──────┴─────────┴───────────────┴─────┴───────┴────────┴───────┘ └─────────┴───────┴──────┴─────────┴───────────────┴─────┴───────┴────────┴───────┘
Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open. Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
--- stderr --- stderr

View file

@ -2,6 +2,7 @@ exit: 0
--- stdout --- stdout
^JKSE.JK 5,388 +60 (+1.12%) ^JKSE.JK 5,388 +60 (+1.12%)
prev close 5,328 · volume 36,446,806,700 · market close · last trade 2026-10-22 16:14 WIB prev close 5,328 · volume 36,446,806,700 · market close · last trade 2026-10-22 16:14 WIB
Note: ^JKSE.JK last traded on 2026-10-22; this price is not current.
▃▄▄▆▆▆▆▆▆▇▇▇▇▇████▇▇▇▇███▇▇▇▇███▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ ▃▄▄▆▆▆▆▆▆▇▇▇▇▇████▇▇▇▇███▇▇▇▇███▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
08:58 to 16:14 WIB · low 5,192.41 · high 5,419.34 · 335 minutes 08:58 to 16:14 WIB · low 5,192.41 · high 5,419.34 · 335 minutes

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,7 @@ exit: 0
--- stdout --- stdout
BBCA.JK 5,175 -100 (-1.90%) BBCA.JK 5,175 -100 (-1.90%)
prev close 5,275 · volume 286,928,500 · market close · last trade 2026-10-22 16:14 WIB prev close 5,275 · volume 286,928,500 · market close · last trade 2026-10-22 16:14 WIB
Note: BBCA.JK last traded on 2026-10-22; this price is not current.
▄▅▇█▅▇▇▇▅▇▇▇▇▇▇██▇▅▇▅▅███▇▅▇▅▇▇▇▅▇▅▅▄▅▄▅▄▄▅▅▅▅▅▇▅▄▄▄▄▄▄▂▄▄▄▄ ▄▅▇█▅▇▇▇▅▇▇▇▇▇▇██▇▅▇▅▅███▇▅▇▅▇▇▇▅▇▅▅▄▅▄▅▄▄▅▅▅▅▅▇▅▄▄▄▄▄▄▂▄▄▄▄
08:58 to 16:14 WIB · low 5,125 · high 5,250 · 335 minutes 08:58 to 16:14 WIB · low 5,125 · high 5,250 · 335 minutes

View file

@ -128,9 +128,10 @@ impl Analysis<'_> {
policy: CachePolicy, policy: CachePolicy,
) -> Result<Fetched<TechnicalReport>, IdxError> { ) -> Result<Fetched<TechnicalReport>, IdxError> {
let idx = self.idx; let idx = self.idx;
let resolved = idx.resolve(symbol)?;
idx.market().with_history_fallback(source, |source| {
let provider = history_provider(source.provider, idx.config.verbose); let provider = history_provider(source.provider, idx.config.verbose);
let bucket = format!("{}-technical", source.provider.as_str()); let bucket = format!("{}-technical", source.provider.as_str());
let resolved = idx.resolve(symbol)?;
cached_fetch( cached_fetch(
&idx.cache, &idx.cache,
CacheSpec { CacheSpec {
@ -144,6 +145,7 @@ impl Analysis<'_> {
|| provider.history(&resolved, &Period::OneYear, &Interval::Day), || provider.history(&resolved, &Period::OneYear, &Interval::Day),
|history| build_technical_report(&resolved, &history), |history| build_technical_report(&resolved, &history),
) )
})
} }
pub fn growth( pub fn growth(

View file

@ -146,7 +146,8 @@ impl HistoryProvider for MockProvider {
/// `stockbit_exodus_chart_candle_<code>_<window>.json`, with the market state /// `stockbit_exodus_chart_candle_<code>_<window>.json`, with the market state
/// from `stockbit_exodus_market_session.json`. A code without a fixture is /// from `stockbit_exodus_market_session.json`. A code without a fixture is
/// Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` (e.g. `403`) answers that HTTP /// Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` (e.g. `403`) answers that HTTP
/// status instead, and `IDX_MOCK_ERROR` fails as for the other providers. /// status instead (`401` on the session API: Stockbit refused the session),
/// and `IDX_MOCK_ERROR` fails as for the other providers.
pub struct MockStockbitProvider; pub struct MockStockbitProvider;
impl MockStockbitProvider { impl MockStockbitProvider {
@ -181,6 +182,11 @@ impl MockStockbitProvider {
if !stockbit::has_session() { if !stockbit::has_session() {
return Err(IdxError::AuthError(stockbit::auth::NO_SESSION.to_string())); return Err(IdxError::AuthError(stockbit::auth::NO_SESSION.to_string()));
} }
if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("401") {
return Err(IdxError::AuthError(
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
));
}
if let Some(err) = self.failure(symbol) { if let Some(err) = self.failure(symbol) {
return Err(err); return Err(err);
} }

View file

@ -55,6 +55,12 @@ pub const PROFILE_DIR: &str = "stockbit-browser";
const START_TIMEOUT: Duration = Duration::from_secs(20); const START_TIMEOUT: Duration = Duration::from_secs(20);
/// Pause between cookie reads. /// Pause between cookie reads.
const POLL: Duration = Duration::from_millis(500); const POLL: Duration = Duration::from_millis(500);
/// How long a session check that failed for a passing reason (network, rate
/// limit) waits before the same cookie is checked again.
#[cfg(not(test))]
const VERIFY_RETRY: Duration = Duration::from_secs(5);
#[cfg(test)]
const VERIFY_RETRY: Duration = Duration::from_millis(50);
/// Socket timeout for one DevTools message. /// Socket timeout for one DevTools message.
const IO_TIMEOUT: Duration = Duration::from_secs(10); const IO_TIMEOUT: Duration = Duration::from_secs(10);
/// How long the browser gets to quit after `Browser.close`. /// How long the browser gets to quit after `Browser.close`.
@ -228,6 +234,8 @@ fn wait_for_session(
) -> Result<(), IdxError> { ) -> Result<(), IdxError> {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
let mut rejected: Option<String> = None; let mut rejected: Option<String> = None;
// After a check fails for a passing reason, the next is held back.
let mut retry_at: Option<Instant> = None;
let mut last_error: Option<String> = None; let mut last_error: Option<String> = None;
// The window is watched so closing it ends the wait instead of running // The window is watched so closing it ends the wait instead of running
// out the clock; the first polls may not see a page yet. // out the clock; the first polls may not see a page yet.
@ -235,7 +243,10 @@ fn wait_for_session(
let mut misses = 0; let mut misses = 0;
loop { loop {
match devtools.session_cookie() { match devtools.session_cookie() {
Ok(Some(cookie)) if rejected.as_deref() != Some(cookie.value.as_str()) => { Ok(Some(cookie))
if rejected.as_deref() != Some(cookie.value.as_str())
&& retry_at.is_none_or(|at| Instant::now() >= at) =>
{
match verify(&cookie.value) { match verify(&cookie.value) {
Ok(()) => { Ok(()) => {
// The session now lives in idx's own file. Take the // The session now lives in idx's own file. Take the
@ -250,12 +261,20 @@ fn wait_for_session(
devtools.close(); devtools.close();
return Ok(()); return Ok(());
} }
Err(err) if super::check_is_transient(&err) => {
crate::runtime::warn(format!(
"could not check the session in idx's browser yet ({err}); \
retrying"
));
retry_at = Some(Instant::now() + VERIFY_RETRY);
}
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 \
again in that window" again in that window"
)); ));
rejected = Some(cookie.value); rejected = Some(cookie.value);
retry_at = None;
} }
} }
} }
@ -979,6 +998,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&profile); let _ = std::fs::remove_dir_all(&profile);
} }
/// A check that fails for a passing reason (a timeout, a 429) is not a
/// refusal: the same cookie is checked again and can still sign in.
#[test]
fn a_check_that_fails_in_passing_is_retried() {
let profile = temp_dir("transient");
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);
wait_for_session(
&mut devtools,
Duration::from_secs(5),
&|_| {
checks.set(checks.get() + 1);
match checks.get() {
1 => Err(IdxError::RateLimited),
_ => Ok(()),
}
},
&|| true,
)
.unwrap();
assert_eq!(checks.get(), 2);
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() {

View file

@ -47,7 +47,9 @@ pub fn parse_corporate_actions(
let rows = match root.get("data") { let rows = match root.get("data") {
Some(Value::Object(data)) => match data.values().find_map(Value::as_array) { Some(Value::Object(data)) => match data.values().find_map(Value::as_array) {
Some(rows) => rows.as_slice(), Some(rows) => rows.as_slice(),
None if data.values().all(Value::is_null) => &[], // No rows reads `{"dividend": null, "today": "..."}`: a null list
// beside scalars. An object in their place is a new layout.
None if !data.values().any(Value::is_object) => &[],
None => return Err(bad("no list in data".to_string())), None => return Err(bad("no list in data".to_string())),
}, },
Some(Value::Array(rows)) => rows.as_slice(), Some(Value::Array(rows)) => rows.as_slice(),
@ -97,6 +99,9 @@ fn entitlement(
match kind { match kind {
ActionKind::Dividend => { ActionKind::Dividend => {
action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, 2)); action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, 2));
action.currency = text(suffixed(row, "currency"))
.map(|c| c.trim_start_matches("CURRENCY_").to_ascii_uppercase())
.filter(|c| c != "IDR");
} }
ActionKind::Rights => { ActionKind::Rights => {
action.price = whole(suffixed(row, "price")); action.price = whole(suffixed(row, "price"));
@ -376,10 +381,16 @@ mod tests {
), ),
(ActionKind::ReverseSplit, r#"{"data":{"stock_reverse":[]}}"#), (ActionKind::ReverseSplit, r#"{"data":{"stock_reverse":[]}}"#),
(ActionKind::Dividend, r#"{"data":null}"#), (ActionKind::Dividend, r#"{"data":null}"#),
(
ActionKind::Dividend,
r#"{"data":{"dividend":null,"today":"2026-11-04"}}"#,
),
] { ] {
assert!(parse_corporate_actions(kind, body).unwrap().is_empty()); assert!(parse_corporate_actions(kind, body).unwrap().is_empty());
} }
assert!(parse_corporate_actions(ActionKind::Dividend, r#"{"message":"x"}"#).is_err()); assert!(parse_corporate_actions(ActionKind::Dividend, r#"{"message":"x"}"#).is_err());
let moved = r#"{"data":{"dividend":{"rows":[]},"today":"2026-11-04"}}"#;
assert!(parse_corporate_actions(ActionKind::Dividend, moved).is_err());
// A reverse split named like a split, read by suffix. // A reverse split named like a split, read by suffix.
let body = r#"{"data":{"stock_reverse":[{"company_symbol":"ABCD", let body = r#"{"data":{"stock_reverse":[{"company_symbol":"ABCD",
"reverse_cumdate":"2026-12-01","reverse_exdate":"2026-12-02", "reverse_cumdate":"2026-12-01","reverse_exdate":"2026-12-02",
@ -389,6 +400,23 @@ mod tests {
assert_eq!(rows[0].factor, Some(0.1)); assert_eq!(rows[0].factor, Some(0.1));
} }
/// A dividend not in IDR names its currency; one in IDR leaves it out.
/// Without a cum date, the action sits on its ex date.
#[test]
fn test_parse_dividend_currency_and_missing_cum_date() {
let body = r#"{"data":{"dividend":[
{"company_symbol":"ABCD","dividend_cumdate":"2026-11-11",
"dividend_exdate":"2026-11-12","dividend_value":"0.0012",
"dividend_currency":"CURRENCY_USD"},
{"company_symbol":"EFGH","dividend_cumdate":"0001-01-01 00:00:00",
"dividend_exdate":"2026-11-12","dividend_value":"5",
"dividend_currency":"CURRENCY_IDR"}]}}"#;
let rows = parse_corporate_actions(ActionKind::Dividend, body).unwrap();
assert_eq!(rows[0].currency.as_deref(), Some("USD"));
assert_eq!(rows[1].currency, None);
assert_eq!((rows[1].date, rows[1].cum_date), (day("2026-11-12"), None));
}
#[test] #[test]
fn test_parse_numbers_and_placeholders() { fn test_parse_numbers_and_placeholders() {
assert_eq!(number(Some(&Value::from("1,191.09"))), Some(1191.09)); assert_eq!(number(Some(&Value::from("1,191.09"))), Some(1191.09));

View file

@ -375,6 +375,9 @@ pub fn parse_orderbook(
} }
/// A quote from the order book; bid/offer only while the market is open. /// A quote from the order book; bid/offer only while the market is open.
/// Its `market_status` is the public page's vocabulary, `open` or `close`,
/// so a quote reads the same with or without a login; the order book keeps
/// Stockbit's state verbatim.
pub fn quote_from_book(book: &OrderBook) -> Quote { pub fn quote_from_book(book: &OrderBook) -> Quote {
let open = book.market_status.as_deref().is_some_and(is_trading); let open = book.market_status.as_deref().is_some_and(is_trading);
let best = |levels: &[BookLevel]| open.then(|| levels.first().map(|l| l.price)).flatten(); let best = |levels: &[BookLevel]| open.then(|| levels.first().map(|l| l.price)).flatten();
@ -395,7 +398,10 @@ pub fn quote_from_book(book: &OrderBook) -> Quote {
behind_session: false, behind_session: false,
bid: best(&book.bids), bid: best(&book.bids),
offer: best(&book.offers), offer: best(&book.offers),
market_status: book.market_status.clone(), market_status: book
.market_status
.as_ref()
.map(|_| if open { "open" } else { "close" }.to_string()),
session_note: book.session_note.clone(), session_note: book.session_note.clone(),
} }
} }
@ -538,11 +544,14 @@ mod tests {
let open = quote_from_book(&book(trading)); let open = quote_from_book(&book(trading));
assert_eq!((open.bid, open.offer), (Some(4850), Some(4860))); assert_eq!((open.bid, open.offer), (Some(4850), Some(4860)));
assert_eq!(open.price, 4860); assert_eq!(open.price, 4860);
assert_eq!(open.market_status.as_deref(), Some("open"));
for state in ["market_session_break.json", "market_session.json"] { for state in ["market_session_break.json", "market_session.json"] {
let shut = quote_from_book(&book(parse_market_status(&fixture(state)))); let shut = quote_from_book(&book(parse_market_status(&fixture(state))));
assert_eq!((shut.bid, shut.offer), (None, None), "{state}"); assert_eq!((shut.bid, shut.offer), (None, None), "{state}");
assert_eq!(shut.market_status.as_deref(), Some("close"), "{state}");
} }
assert_eq!(quote_from_book(&book(None)).bid, None); let unknown = quote_from_book(&book(None));
assert_eq!((unknown.bid, unknown.market_status), (None, None));
} }
#[test] #[test]

View file

@ -72,13 +72,24 @@ impl LoginPage {
/// ///
/// Each connection gets its own thread: browsers open idle preconnects, /// Each connection gets its own thread: browsers open idle preconnects,
/// and answering them one at a time used to hold the page back for the /// and answering them one at a time used to hold the page back for the
/// read timeout on every one. /// read timeout on every one. Pastes are checked one at a time, and none
/// after one is accepted: two at once (a double click, the bookmarklet
/// and the clipboard button) would each store a session, and the later
/// could overwrite the one Stockbit had just rotated.
pub fn wait<F>(&self, timeout: Duration, verify: F) -> Result<(), IdxError> pub fn wait<F>(&self, timeout: Duration, verify: F) -> Result<(), IdxError>
where where
F: Fn(&str) -> Result<(), IdxError> + Send + Sync + 'static, F: Fn(&str) -> Result<(), IdxError> + Send + Sync + 'static,
{ {
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let verify = std::sync::Arc::new(verify); let accepted = std::sync::Mutex::new(false);
let verify = std::sync::Arc::new(move |paste: &str| {
let mut accepted = accepted.lock().unwrap_or_else(|e| e.into_inner());
if !*accepted {
verify(paste)?;
*accepted = true;
}
Ok(())
});
let page = std::sync::Arc::new(self.page()); let page = std::sync::Arc::new(self.page());
let path = std::sync::Arc::new(format!("/login/{}", self.secret)); let path = std::sync::Arc::new(format!("/login/{}", self.secret));
let busy = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let busy = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
@ -575,6 +586,37 @@ mod tests {
(status, body) (status, body)
} }
/// Two pastes at once are checked one at a time, and the second is not
/// checked at all once the first is accepted.
#[test]
fn concurrent_pastes_are_checked_once() {
let page = LoginPage::bind(0).unwrap();
let url = page.url();
let checks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = checks.clone();
let handle = std::thread::spawn(move || {
page.wait(Duration::from_secs(10), move |_| {
counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(300));
Ok(())
})
});
std::thread::sleep(Duration::from_millis(100));
let clients: Vec<_> = (0..2)
.map(|_| {
let url = url.clone();
std::thread::spawn(move || request(&url, "POST", "good"))
})
.collect();
for client in clients {
let (status, body) = client.join().unwrap();
assert_eq!(status, 200);
assert!(body.contains("\"ok\":true"), "{body}");
}
handle.join().unwrap().unwrap();
assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test] #[test]
fn serves_the_page_only_with_the_secret_and_accepts_a_good_paste() { fn serves_the_page_only_with_the_secret_and_accepts_a_good_paste() {
let page = LoginPage::bind(0).unwrap(); let page = LoginPage::bind(0).unwrap();

View file

@ -277,6 +277,11 @@ pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError {
/// Stores a pasted session (stockbit.com's `credentialStorage` cookie or a /// Stores a pasted session (stockbit.com's `credentialStorage` cookie or a
/// refresh token) and checks it with one request, which refreshes it when the /// refresh token) and checks it with one request, which refreshes it when the
/// access token is missing or stale. A session Stockbit refuses is removed. /// access token is missing or stale. A session Stockbit refuses is removed.
///
/// A check that fails another way (network, rate limit) puts the previous
/// session back too, unless Stockbit already rotated the pasted one: the
/// refresh spent the pasted token, so the stored pair is then the only
/// working copy, and a refresh Stockbit answered is proof enough.
pub fn login(pasted: &str) -> Result<auth::SessionStatus, IdxError> { pub fn login(pasted: &str) -> Result<auth::SessionStatus, IdxError> {
let session = auth::session_from_paste(pasted).ok_or_else(|| { let session = auth::session_from_paste(pasted).ok_or_else(|| {
IdxError::InvalidInput( IdxError::InvalidInput(
@ -289,17 +294,40 @@ pub fn login(pasted: &str) -> Result<auth::SessionStatus, IdxError> {
// A refused paste must not lose a session that already worked. // A refused paste must not lose a session that already worked.
let previous = std::fs::read(store.path()).ok(); let previous = std::fs::read(store.path()).ok();
store.save(&session)?; store.save(&session)?;
let saved = std::fs::read(store.path()).ok();
#[cfg(feature = "testing")] #[cfg(feature = "testing")]
let check = !crate::api::mock::enabled(); let check = !crate::api::mock::enabled();
#[cfg(not(feature = "testing"))] #[cfg(not(feature = "testing"))]
let check = true; let check = true;
if check && let Err(err) = Exodus::new(store.clone()).verify() { if check && let Err(err) = Exodus::new(store.clone()).verify() {
let rotated = std::fs::read(store.path()).ok() != saved;
if !keep_after_failed_check(&err, rotated) {
restore(&store, previous); restore(&store, previous);
return Err(err); return Err(err);
} }
crate::runtime::warn(format!(
"Stockbit accepted the session and rotated it, but the check after that failed \
({err}); the new session is stored"
));
}
Ok(store.status(chrono::Utc::now().timestamp())) Ok(store.status(chrono::Utc::now().timestamp()))
} }
/// Whether `login` keeps the stored session after its check failed with
/// `err`: only when the check rotated it and Stockbit did not then refuse it.
fn keep_after_failed_check(err: &IdxError, rotated: bool) -> bool {
rotated && !matches!(err, IdxError::AuthError(_))
}
/// Whether a session check failed for a reason that says nothing about the
/// session itself, so the same session may be checked again later.
pub(crate) fn check_is_transient(err: &IdxError) -> bool {
matches!(
err,
IdxError::Http(_) | IdxError::RateLimited | IdxError::UpstreamBlocked(_)
)
}
fn restore(store: &SessionStore, previous: Option<Vec<u8>>) { fn restore(store: &SessionStore, previous: Option<Vec<u8>>) {
match previous { match previous {
Some(bytes) => { Some(bytes) => {
@ -326,7 +354,24 @@ pub fn logout() -> Result<(bool, auth::SessionStatus), IdxError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::USER_AGENT; use super::{USER_AGENT, check_is_transient, keep_after_failed_check};
use crate::error::IdxError;
#[test]
fn login_keeps_a_rotated_session_only_when_stockbit_did_not_refuse_it() {
let network = IdxError::Http("timeout".to_string());
let refused = IdxError::AuthError("expired".to_string());
assert!(keep_after_failed_check(&network, true));
assert!(!keep_after_failed_check(&network, false));
assert!(!keep_after_failed_check(&refused, true));
assert!(!keep_after_failed_check(&refused, false));
assert!(check_is_transient(&IdxError::RateLimited));
assert!(check_is_transient(&network));
assert!(!check_is_transient(&refused));
assert!(!check_is_transient(&IdxError::InvalidInput(
"x".to_string()
)));
}
/// CloudFront answers HTTP 403 to agents containing these (probed /// CloudFront answers HTTP 403 to agents containing these (probed
/// 2026-10-22); the honest agent must never match one. /// 2026-10-22); the honest agent must never match one.

View file

@ -65,8 +65,10 @@ pub struct Quote {
/// Best offer in IDR (whole Rupiah), gated like `bid`. /// Best offer in IDR (whole Rupiah), gated like `bid`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub offer: Option<i64>, pub offer: Option<i64>,
/// The provider's own session state, verbatim (Stockbit /// Stockbit's market state as its public page words it: `open` while
/// `market_hour.status`, e.g. `close`). Absent for providers without one. /// the regular market trades, `close` otherwise (also the lunch break
/// and auctions). A logged-in quote maps its session state to the same
/// two words. Absent for other providers, or when the state is unknown.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub market_status: Option<String>, pub market_status: Option<String>,
/// What the provider says about this symbol's session: suspended, /// What the provider says about this symbol's session: suspended,

View file

@ -3,14 +3,17 @@ use serde::{Deserialize, Serialize};
use crate::error::IdxError; use crate::error::IdxError;
/// The kinds of corporate action Stockbit lists, one request each. /// The kinds of corporate action Stockbit lists, one request each. JSON
/// names them as `--kind` does (`as_str`), so an output kind can be passed
/// back in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "kebab-case")]
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"))]
#[serde(alias = "stock_dividend")]
StockDividend, StockDividend,
/// Bonus shares. /// Bonus shares.
Bonus, Bonus,
@ -20,14 +23,17 @@ pub enum ActionKind {
/// Stock split. /// Stock split.
Split, Split,
#[cfg_attr(feature = "clap", value(name = "reverse-split"))] #[cfg_attr(feature = "clap", value(name = "reverse-split"))]
#[serde(alias = "reverse_split")]
ReverseSplit, ReverseSplit,
/// Shareholders' meeting (RUPS). /// Shareholders' meeting (RUPS).
Meeting, Meeting,
/// Public expose. /// Public expose.
#[cfg_attr(feature = "clap", value(name = "expose"))] #[cfg_attr(feature = "clap", value(name = "expose"))]
#[serde(rename = "expose", alias = "public_expose")]
PublicExpose, PublicExpose,
Ipo, Ipo,
#[cfg_attr(feature = "clap", value(name = "tender"))] #[cfg_attr(feature = "clap", value(name = "tender"))]
#[serde(rename = "tender", alias = "tender_offer")]
TenderOffer, TenderOffer,
} }
@ -96,9 +102,10 @@ impl std::str::FromStr for ActionKind {
type Err = IdxError; type Err = IdxError;
fn from_str(value: &str) -> Result<Self, Self::Err> { fn from_str(value: &str) -> Result<Self, Self::Err> {
let wanted = value.trim().to_ascii_lowercase().replace('_', "-");
Self::ALL Self::ALL
.into_iter() .into_iter()
.find(|kind| kind.as_str() == value) .find(|kind| kind.as_str() == wanted)
.ok_or_else(|| { .ok_or_else(|| {
let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect(); let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect();
IdxError::InvalidInput(format!( IdxError::InvalidInput(format!(
@ -135,7 +142,8 @@ pub struct CorporateAction {
/// Where the action sits on a calendar: the cum date for entitlements /// Where the action sits on a calendar: the cum date for entitlements
/// (the last day to buy and be entitled), else the meeting or expose /// (the last day to buy and be entitled), else the meeting or expose
/// day, the listing day, a warrant's first trading day, or a tender /// day, the listing day, a warrant's first trading day, or a tender
/// offer's first day. /// offer's first day. An entitlement Stockbit gives no cum date for is
/// placed on its ex date, and `cum_date` is then absent.
pub date: NaiveDate, pub date: NaiveDate,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cum_date: Option<NaiveDate>, pub cum_date: Option<NaiveDate>,
@ -150,6 +158,10 @@ pub struct CorporateAction {
/// e.g. 3.8). /// e.g. 3.8).
#[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;
/// absent for IDR, which every recorded dividend was.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ratio: Option<Ratio>, pub ratio: Option<Ratio>,
/// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a /// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a
@ -194,6 +206,7 @@ impl CorporateAction {
record_date: None, record_date: None,
pay_date: None, pay_date: None,
cash_per_share: None, cash_per_share: None,
currency: None,
ratio: None, ratio: None,
factor: None, factor: None,
price: None, price: None,
@ -221,7 +234,8 @@ pub struct CalendarRange {
} }
impl CalendarRange { impl CalendarRange {
/// `from` defaults to today, `to` to 30 days after `from`. /// `from` defaults to today, `to` to 30 days after `from`; at most
/// `CALENDAR_MAX_DAYS` days, counting both ends.
pub fn from_args( pub fn from_args(
from: Option<&str>, from: Option<&str>,
to: Option<&str>, to: Option<&str>,
@ -233,16 +247,18 @@ impl CalendarRange {
}) })
}; };
let from = from.map(|v| parse("from", v)).transpose()?.unwrap_or(today); let from = from.map(|v| parse("from", v)).transpose()?.unwrap_or(today);
let to = to let to = match to {
.map(|v| parse("to", v)) Some(v) => parse("to", v)?,
.transpose()? None => from
.unwrap_or(from + Duration::days(CALENDAR_DEFAULT_DAYS)); .checked_add_signed(Duration::days(CALENDAR_DEFAULT_DAYS))
.ok_or_else(|| IdxError::InvalidInput(format!("from ({from}) is out of range")))?,
};
if to < from { if to < from {
return Err(IdxError::InvalidInput(format!( return Err(IdxError::InvalidInput(format!(
"to ({to}) is before from ({from})" "to ({to}) is before from ({from})"
))); )));
} }
if (to - from).num_days() > CALENDAR_MAX_DAYS { if (to - from).num_days() >= CALENDAR_MAX_DAYS {
return Err(IdxError::InvalidInput(format!( return Err(IdxError::InvalidInput(format!(
"a calendar covers at most {CALENDAR_MAX_DAYS} days" "a calendar covers at most {CALENDAR_MAX_DAYS} days"
))); )));
@ -270,9 +286,13 @@ mod tests {
assert_eq!((range.from, range.to), (today, day("2026-12-03"))); assert_eq!((range.from, range.to), (today, day("2026-12-03")));
let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap(); let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap();
assert_eq!(range.to, day("2026-12-10")); assert_eq!(range.to, day("2026-12-10"));
// 366 days counting both ends is the most.
let range = CalendarRange::from_args(Some("2028-01-01"), Some("2028-12-31"), today);
assert!(range.is_ok());
for (from, to) in [ for (from, to) in [
(Some("2026-11-10"), Some("2026-11-09")), (Some("2026-11-10"), Some("2026-11-09")),
(Some("2026-01-01"), Some("2027-06-01")), (Some("2026-01-01"), Some("2027-06-01")),
(Some("2028-01-01"), Some("2029-01-01")),
(Some("11/10/2026"), None), (Some("11/10/2026"), None),
] { ] {
assert!(matches!( assert!(matches!(
@ -282,11 +302,36 @@ mod tests {
} }
} }
/// The last date there is: 30 days on is an error, not a panic.
#[test]
fn test_calendar_range_at_the_end_of_time() {
let last = NaiveDate::MAX.format("%Y-%m-%d").to_string();
let err = CalendarRange::from_args(Some(&last), None, day("2026-11-03")).unwrap_err();
assert!(err.to_string().contains("out of range"), "{err}");
}
#[test] #[test]
fn test_action_kind_names_round_trip() { fn test_action_kind_names_round_trip() {
for kind in ActionKind::ALL { for kind in ActionKind::ALL {
assert_eq!(kind.as_str().parse::<ActionKind>().unwrap(), kind); assert_eq!(kind.as_str().parse::<ActionKind>().unwrap(), kind);
} }
assert!("rups".parse::<ActionKind>().is_err()); assert!("rups".parse::<ActionKind>().is_err());
assert_eq!(
"stock_dividend".parse::<ActionKind>().unwrap(),
ActionKind::StockDividend
);
}
/// JSON's `kind` is the name `--kind` and MCP take.
#[test]
fn test_action_kind_json_is_the_input_name() {
for kind in ActionKind::ALL {
let json = serde_json::to_value(kind).unwrap();
assert_eq!(json, kind.as_str(), "{kind:?}");
assert_eq!(serde_json::from_value::<ActionKind>(json).unwrap(), kind);
}
// Entries cached before the names changed still read.
let old: ActionKind = serde_json::from_str("\"public_expose\"").unwrap();
assert_eq!(old, ActionKind::PublicExpose);
} }
} }

View file

@ -77,7 +77,7 @@ impl Market<'_> {
let idx = self.idx; let idx = self.idx;
let resolved = idx.resolve(symbol)?; let resolved = idx.resolve(symbol)?;
let provider = intraday_provider(); let provider = intraday_provider();
cached_fetch( let mut day = cached_fetch(
&idx.cache, &idx.cache,
CacheSpec { CacheSpec {
bucket: "stockbit-intraday", bucket: "stockbit-intraday",
@ -89,7 +89,11 @@ impl Market<'_> {
policy, policy,
|| provider.intraday(&resolved), || provider.intraday(&resolved),
Ok, Ok,
) )?;
// Stockbit's page keeps the last session's chart up after the close,
// so a cached or lagging day reads as current unless it is marked.
mark_behind_session(&mut day.data.quote, latest_session(Utc::now()));
Ok(day)
} }
/// Resolves the history source for an optional `--history-provider` /// Resolves the history source for an optional `--history-provider`
@ -123,10 +127,11 @@ impl Market<'_> {
policy: CachePolicy, policy: CachePolicy,
) -> Result<Fetched<Vec<Ohlc>>, IdxError> { ) -> Result<Fetched<Vec<Ohlc>>, IdxError> {
let idx = self.idx; let idx = self.idx;
let provider = history_provider(source.provider, idx.config.verbose);
let bucket = format!("{}-history", source.provider.as_str());
let resolved = idx.resolve(symbol)?; let resolved = idx.resolve(symbol)?;
let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str()); let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str());
self.with_history_fallback(source, |source| {
let provider = history_provider(source.provider, idx.config.verbose);
let bucket = format!("{}-history", source.provider.as_str());
cached_fetch( cached_fetch(
&idx.cache, &idx.cache,
CacheSpec { CacheSpec {
@ -140,6 +145,31 @@ impl Market<'_> {
|| provider.history(&resolved, period, interval), || provider.history(&resolved, period, interval),
Ok, Ok,
) )
})
}
/// Runs `fetch` on `source`; when `auto` picked Stockbit because a
/// session file exists and Stockbit then refuses that session, runs it
/// again on Yahoo instead of failing (`has_session` sees only the file).
pub(crate) fn with_history_fallback<T>(
&self,
source: HistorySource,
fetch: impl Fn(HistorySource) -> Result<Fetched<T>, IdxError>,
) -> Result<Fetched<T>, IdxError> {
match fetch(source) {
Err(IdxError::AuthError(reason))
if source.mode == HistoryProviderKind::Auto
&& source.provider == ProviderKind::Stockbit =>
{
crate::runtime::warn(format!("{reason}; history falls back to yahoo meanwhile"));
fetch(HistorySource {
provider: ProviderKind::Yahoo,
is_fallback: self.idx.provider.kind() != ProviderKind::Yahoo,
..source
})
}
other => other,
}
} }
/// The order book of one IDX stock from the user's Stockbit session, /// The order book of one IDX stock from the user's Stockbit session,

View file

@ -126,8 +126,8 @@ impl IdxServer {
for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true \ for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true \
when that is before the latest IDX session, so the price is not that session's close: the \ when that is before the latest IDX session, so the price is not that session's close: the \
provider lags, the stock did not trade, or the day was an exchange holiday. When the server \ provider lags, the stock did not trade, or the day was an exchange holiday. When the server \
runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. \ runs with the stockbit provider, quotes also carry `market_status` (\"open\" while the \
\"open\", \"close\"), `bid`/`offer` (best price, only while the market is open) and \ regular market trades, else \"close\", with or without a login), `bid`/`offer` (best price, only while the market is open) and \
`session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 \ `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 \
minutes." minutes."
)] )]
@ -149,8 +149,9 @@ minutes."
`market_status`, `bid`/`offer` only while the market is open, `session_note`) and \ `market_status`, `bid`/`offer` only while the market is open, `session_note`) and \
`points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no \ `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no \
per-minute volume. Empty `points` means the stock did not trade this session (see \ per-minute volume. Empty `points` means the stock did not trade this session (see \
`session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine \ `session_note`), not an error. `quote.behind_session` is true when the chart is from \
(HTTP 403): do not retry at once." before the latest IDX session (a holiday, or Stockbit lagging), as in get_quotes. \
UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once."
)] )]
async fn get_intraday(&self, params: Parameters<IntradayParams>) -> ToolResult { async fn get_intraday(&self, params: Parameters<IntradayParams>) -> ToolResult {
let IntradayParams { symbol, tail } = params.0; let IntradayParams { symbol, tail } = params.0;
@ -358,8 +359,9 @@ average price, the close, `change_bps` and `non_regular_bps`."
description = "Corporate actions of one IDX stock from the user's own Stockbit \ description = "Corporate actions of one IDX stock from the user's own Stockbit \
session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, \ session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, \
reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, \ reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, \
rights and splits, i.e. the last day to buy and be entitled; else the event's first day), \ rights and splits, i.e. the last day to buy and be entitled, or the ex date when `cum_date` \
`ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals), \ is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind \
`cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), \
`ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), \ `ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), \
`factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), \ `factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), \
`period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent \ `period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent \

View file

@ -236,7 +236,7 @@
"name": "get_company" "name": "get_company"
}, },
{ {
"description": "Corporate actions of one IDX stock from the user's own Stockbit session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, rights and splits, i.e. the last day to buy and be entitled; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals), `ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), `factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), `period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours). AUTHERROR means there is no session: call stockbit_status, then stockbit_login.", "description": "Corporate actions of one IDX stock from the user's own Stockbit session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, rights and splits, i.e. the last day to buy and be entitled, or the ex date when `cum_date` is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), `ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), `factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), `period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours). 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": {
@ -373,7 +373,7 @@
"name": "get_foreign_flow" "name": "get_foreign_flow"
}, },
{ {
"description": "Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.", "description": "Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. `quote.behind_session` is true when the chart is from before the latest IDX session (a holiday, or Stockbit lagging), as in get_quotes. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.",
"inputSchema": { "inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {
@ -536,7 +536,7 @@
"name": "get_price_history" "name": "get_price_history"
}, },
{ {
"description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. \"open\", \"close\"), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.", "description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (\"open\" while the regular market trades, else \"close\", with or without a login), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.",
"inputSchema": { "inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {

View file

@ -82,7 +82,7 @@ WIB as the fallback.
`behind_session` when `as_of` is before the latest weekday whose session has `behind_session` when `as_of` is before the latest weekday whose session has
ended (16:15 WIB). It is computed on return, not cached; MSN can lag the close ended (16:15 WIB). It is computed on return, not cached; MSN can lag the close
by hours, and on an exchange holiday every quote reads as behind. Only `.JK` by hours, and on an exchange holiday every quote reads as behind. Only `.JK`
quotes and `--region id` screens are marked; other exchanges keep other hours. quotes, `--region id` screens and `stocks intraday` are marked; other exchanges keep other hours.
Table output notes behind quotes, and calls those over seven days old not Table output notes behind quotes, and calls those over seven days old not
current. current.
- `change`, `change_pct`, and `volume` are nullable when the provider omits them; - `change`, `change_pct`, and `volume` are nullable when the provider omits them;

View file

@ -134,8 +134,12 @@ contract() {
echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;; echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;;
"stocks orderbook "*) "stocks orderbook "*)
echo '(.symbol | type == "string") and (.price | type == "number") and (.market_status | type == "string") and (.bids | type == "array") and (.offers | type == "array")' ;; echo '(.symbol | type == "string") and (.price | type == "number") and (.market_status | type == "string") and (.bids | type == "array") and (.offers | type == "array")' ;;
"stocks actions "*|"stocks calendar"*) # The smoke symbol always has dividends on record; a quiet month can
# leave the calendar empty.
"stocks actions "*)
echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;; echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;;
"stocks calendar"*)
echo 'type == "array" and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;;
"ownership movements"*) "ownership movements"*)
echo '(.more | type == "boolean") and (.movements | length > 0) and all(.movements[]; (.symbol | type == "string") and (.date | type == "string") and (.holder_id | type == "string") and (.action | type == "string") and (.shares_change | type == "number"))' ;; echo '(.more | type == "boolean") and (.movements | length > 0) and all(.movements[]; (.symbol | type == "string") and (.date | type == "string") and (.holder_id | type == "string") and (.action | type == "string") and (.shares_change | type == "number"))' ;;
"stocks broker "*) "stocks broker "*)

View file

@ -198,6 +198,7 @@ What the login adds:
| --- | --- | --- | | --- | --- | --- |
| `--provider stockbit` quotes | public page, delayed | Stockbit's API, live | | `--provider stockbit` quotes | public page, delayed | Stockbit's API, live |
| Bid and offer in quotes | best price, while open | best price, while open | | Bid and offer in quotes | best price, while open | best price, while open |
| `market_status` in quotes | `open` or `close` | `open` or `close` |
| `stocks orderbook` | `AUTHERROR` | every level with lots and queued orders, OHLC, value, trades; foreign buy/sell/net once Stockbit marks them current | | `stocks orderbook` | `AUTHERROR` | every level with lots and queued orders, OHLC, value, trades; foreign buy/sell/net once Stockbit marks them current |
| `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows | | `stocks history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows |
| `stocks intraday` | public page | public page | | `stocks intraday` | public page | public page |
@ -211,7 +212,14 @@ the previous session's, so idx leaves them out (`stocks flow` has foreign
flow session by session). Sizes are lots in the flow session by session). Sizes are lots in the
table and shares in JSON. `--history-provider stockbit` asks for Stockbit's table and shares in JSON. `--history-provider stockbit` asks for Stockbit's
candles explicitly; windows it charts only weekly or hourly (`2y`, `5y`, candles explicitly; windows it charts only weekly or hourly (`2y`, `5y`,
`1wk`) answer `UNSUPPORTED`. `1wk`) answer `UNSUPPORTED`. If Stockbit refuses a stored session, `auto`
history falls back to Yahoo with a warning; an explicit
`--history-provider stockbit` fails with `AUTHERROR`.
`market_status` in quotes is `open` while the regular market trades and
`close` otherwise, including the lunch break and auctions, with or without a
login. `stocks orderbook` keeps Stockbit's state verbatim
(`STATE_NAME_SESSION_2`, `STATE_NAME_BREAK`, ...).
## Terms ## Terms

View file

@ -30,7 +30,8 @@ idx -o json stocks intraday BBCA | jq '.points[-1]'
`intraday` shows today's session from Stockbit's public page, without a `intraday` shows today's session from Stockbit's public page, without a
login: the quote, the best bid and offer while the market is open, a login: the quote, the best bid and offer while the market is open, a
sparkline and the last five minutes. JSON gives every minute in `points`. It sparkline and the last five minutes. JSON gives every minute in `points`. It
is delayed at least 10 minutes. See [Stockbit](/cli/stockbit/) for what it is delayed at least 10 minutes. A chart from before the latest session is
noted under the header and sets `quote.behind_session`, as with quotes. See [Stockbit](/cli/stockbit/) for what it
covers and the terms it comes with. covers and the terms it comes with.
## Order book (Stockbit login) ## Order book (Stockbit login)
@ -61,7 +62,10 @@ 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. 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. Dividends per share keep their decimals (`Rp 3.8`). 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.
Dividends per share keep their decimals (`Rp 3.8`); one not paid in Rupiah
carries `currency` (e.g. `USD`). JSON's `kind` is the name `--kind` takes.
Both need your Stockbit session and answer `AUTHERROR` without one. Stockbit Both need your Stockbit session and answer `AUTHERROR` without one. Stockbit
serves one kind per request, 1 s apart, so a first call with every kind takes serves one kind per request, 1 s apart, so a first call with every kind takes

View file

@ -65,7 +65,7 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys
## `get_corporate_actions` ## `get_corporate_actions`
Corporate actions of one IDX stock from the user's own Stockbit session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, rights and splits, i.e. the last day to buy and be entitled; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals), `ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), `factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), `period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours). AUTHERROR means there is no session: call stockbit_status, then stockbit_login. Corporate actions of one IDX stock from the user's own Stockbit session, newest first: `kind` (dividend, stock-dividend, bonus, rights, warrant, split, reverse-split, meeting, expose, ipo, tender), `date` (the cum date for dividends, bonuses, rights and splits, i.e. the last day to buy and be entitled, or the ex date when `cum_date` is absent; else the event's first day), `ex_date`, `record_date`, `pay_date`, and per kind `cash_per_share` (IDR, may have decimals; in `currency`, an ISO code, when that is present), `ratio` {old, new} (1:5 split = 1 share becomes 5; 655:104 rights = 104 rights per 655 held), `factor` (price adjustment), `price` (rights, warrant exercise, tender or IPO price, IDR), `period` and `exercise` {start, end}, `shares`, `percentage_bps`, `time`, `venue`. Absent fields are omitted. One Stockbit request per kind (about 1 s each on a cold cache, then cached 6 hours). AUTHERROR means there is no session: call stockbit_status, then stockbit_login.
| Input | Type | Required | Description | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -96,7 +96,7 @@ Foreign investors' buying and selling of one IDX ticker, session by session (old
## `get_intraday` ## `get_intraday`
Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once. Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. `quote.behind_session` is true when the chart is from before the latest IDX session (a holiday, or Stockbit lagging), as in get_quotes. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.
| Input | Type | Required | Description | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -144,7 +144,7 @@ OHLCV price history for one IDX ticker.
## `get_quotes` ## `get_quotes`
Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. "open", "close"), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes. Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` ("open" while the regular market trades, else "close", with or without a login), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.
| Input | Type | Required | Description | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |