diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 35cb88d..d3afec8 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -239,6 +239,9 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> { if let Some(note) = &q.session_note { println!("Note: {}", terminal_safe(note)); } + for note in stale_quote_notes(std::slice::from_ref(q), chrono::Utc::now()) { + println!("{}", terminal_safe(¬e)); + } let prices: Vec = day.points.iter().map(|p| p.price).collect(); 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 = Vec::new(); match action.kind { ActionKind::Dividend => { + let unit = action + .currency + .as_deref() + .map_or_else(|| "Rp".to_string(), terminal_safe); parts.extend( action .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 => { @@ -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 \ 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) -> 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"]); for action in actions { table.add_row(vec![ - cell(action.date), + action_date_cell(action), cell(action_kind_label(action.kind)), cell(opt_date(action.ex_date)), cell(opt_date(action.record_date)), @@ -1814,7 +1843,7 @@ pub fn print_corporate_actions(symbol: &str, actions: &[CorporateAction]) -> Res ]); } println!("{table}"); - println!("{CORPORATE_DATE_NOTE}"); + print_corporate_notes(actions); } println!("{STOCKBIT_SESSION_NOTE}"); Ok(()) @@ -1844,7 +1873,7 @@ pub fn print_corporate_calendar( .set_header(vec!["DATE", "SYMBOL", "KIND", "EX", "DETAIL"]); for action in actions { table.add_row(vec![ - cell(action.date), + action_date_cell(action), cell(action.symbol.trim_end_matches(".JK")), cell(action_kind_label(action.kind)), cell(opt_date(action.ex_date)), @@ -1852,7 +1881,7 @@ pub fn print_corporate_calendar( ]); } println!("{table}"); - println!("{CORPORATE_DATE_NOTE}"); + print_corporate_notes(actions); } println!("{STOCKBIT_SESSION_NOTE}"); Ok(()) diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index c086316..490e254 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2197,6 +2197,51 @@ fn stockbit_login_stores_a_private_session_and_logout_removes_it() { 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::(&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". #[test] fn stockbit_login_with_empty_stdin_says_how_to_log_in() { diff --git a/crates/idx-cli/tests/golden/stockbit-actions.json.golden b/crates/idx-cli/tests/golden/stockbit-actions.json.golden index d5f9ad6..a49932d 100644 --- a/crates/idx-cli/tests/golden/stockbit-actions.json.golden +++ b/crates/idx-cli/tests/golden/stockbit-actions.json.golden @@ -1,4 +1,4 @@ exit: 0 --- 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 diff --git a/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden b/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden index 28caa37..0f7f01b 100644 --- a/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden +++ b/crates/idx-cli/tests/golden/stockbit-calendar-meetings.json.golden @@ -1,4 +1,4 @@ exit: 0 --- 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 diff --git a/crates/idx-cli/tests/golden/stockbit-session-quote.json.golden b/crates/idx-cli/tests/golden/stockbit-session-quote.json.golden index 57399e3..7d27f04 100644 --- a/crates/idx-cli/tests/golden/stockbit-session-quote.json.golden +++ b/crates/idx-cli/tests/golden/stockbit-session-quote.json.golden @@ -1,4 +1,4 @@ exit: 0 --- 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 diff --git a/crates/idx-cli/tests/golden/stockbit-session-quote.table.golden b/crates/idx-cli/tests/golden/stockbit-session-quote.table.golden index 4863e68..70fae5d 100644 --- a/crates/idx-cli/tests/golden/stockbit-session-quote.table.golden +++ b/crates/idx-cli/tests/golden/stockbit-session-quote.table.golden @@ -3,7 +3,7 @@ exit: 0 ┌─────────┬───────┬──────┬─────────┬───────────────┬─────┬───────┬────────┬───────┐ │ 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. --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-intraday-index.table.golden b/crates/idx-cli/tests/golden/stocks-intraday-index.table.golden index b284ebc..173b7c5 100644 --- a/crates/idx-cli/tests/golden/stocks-intraday-index.table.golden +++ b/crates/idx-cli/tests/golden/stocks-intraday-index.table.golden @@ -2,6 +2,7 @@ exit: 0 --- stdout ^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 +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 diff --git a/crates/idx-cli/tests/golden/stocks-intraday.json.golden b/crates/idx-cli/tests/golden/stocks-intraday.json.golden index 54882c9..40d7b72 100644 --- a/crates/idx-cli/tests/golden/stocks-intraday.json.golden +++ b/crates/idx-cli/tests/golden/stocks-intraday.json.golden @@ -1,4 +1,4 @@ exit: 0 --- stdout -{"quote":{"symbol":"BBCA.JK","price":5175,"change":-100,"change_pct":-1.9,"volume":286928500,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":5275,"avg_volume":null,"as_of":"2026-10-22T16:14:00+07:00","behind_session":false,"market_status":"close"},"points":[{"time":"2026-10-22T08:58:00+07:00","price":5125.0},{"time":"2026-10-22T09:00:00+07:00","price":5125.0},{"time":"2026-10-22T09:01:00+07:00","price":5175.0},{"time":"2026-10-22T09:02:00+07:00","price":5150.0},{"time":"2026-10-22T09:03:00+07:00","price":5175.0},{"time":"2026-10-22T09:04:00+07:00","price":5175.0},{"time":"2026-10-22T09:05:00+07:00","price":5175.0},{"time":"2026-10-22T09:06:00+07:00","price":5175.0},{"time":"2026-10-22T09:07:00+07:00","price":5200.0},{"time":"2026-10-22T09:08:00+07:00","price":5175.0},{"time":"2026-10-22T09:09:00+07:00","price":5200.0},{"time":"2026-10-22T09:10:00+07:00","price":5200.0},{"time":"2026-10-22T09:11:00+07:00","price":5200.0},{"time":"2026-10-22T09:12:00+07:00","price":5200.0},{"time":"2026-10-22T09:13:00+07:00","price":5200.0},{"time":"2026-10-22T09:14:00+07:00","price":5225.0},{"time":"2026-10-22T09:15:00+07:00","price":5200.0},{"time":"2026-10-22T09:16:00+07:00","price":5225.0},{"time":"2026-10-22T09:17:00+07:00","price":5225.0},{"time":"2026-10-22T09:18:00+07:00","price":5200.0},{"time":"2026-10-22T09:19:00+07:00","price":5225.0},{"time":"2026-10-22T09:20:00+07:00","price":5250.0},{"time":"2026-10-22T09:21:00+07:00","price":5225.0},{"time":"2026-10-22T09:22:00+07:00","price":5225.0},{"time":"2026-10-22T09:23:00+07:00","price":5225.0},{"time":"2026-10-22T09:24:00+07:00","price":5225.0},{"time":"2026-10-22T09:25:00+07:00","price":5200.0},{"time":"2026-10-22T09:26:00+07:00","price":5200.0},{"time":"2026-10-22T09:27:00+07:00","price":5225.0},{"time":"2026-10-22T09:28:00+07:00","price":5225.0},{"time":"2026-10-22T09:29:00+07:00","price":5250.0},{"time":"2026-10-22T09:30:00+07:00","price":5225.0},{"time":"2026-10-22T09:31:00+07:00","price":5225.0},{"time":"2026-10-22T09:32:00+07:00","price":5200.0},{"time":"2026-10-22T09:33:00+07:00","price":5225.0},{"time":"2026-10-22T09:34:00+07:00","price":5225.0},{"time":"2026-10-22T09:35:00+07:00","price":5225.0},{"time":"2026-10-22T09:36:00+07:00","price":5250.0},{"time":"2026-10-22T09:37:00+07:00","price":5225.0},{"time":"2026-10-22T09:38:00+07:00","price":5225.0},{"time":"2026-10-22T09:39:00+07:00","price":5225.0},{"time":"2026-10-22T09:40:00+07:00","price":5225.0},{"time":"2026-10-22T09:41:00+07:00","price":5225.0},{"time":"2026-10-22T09:42:00+07:00","price":5225.0},{"time":"2026-10-22T09:43:00+07:00","price":5225.0},{"time":"2026-10-22T09:44:00+07:00","price":5225.0},{"time":"2026-10-22T09:45:00+07:00","price":5200.0},{"time":"2026-10-22T09:46:00+07:00","price":5200.0},{"time":"2026-10-22T09:47:00+07:00","price":5225.0},{"time":"2026-10-22T09:48:00+07:00","price":5200.0},{"time":"2026-10-22T09:49:00+07:00","price":5225.0},{"time":"2026-10-22T09:50:00+07:00","price":5225.0},{"time":"2026-10-22T09:51:00+07:00","price":5225.0},{"time":"2026-10-22T09:52:00+07:00","price":5225.0},{"time":"2026-10-22T09:53:00+07:00","price":5225.0},{"time":"2026-10-22T09:54:00+07:00","price":5200.0},{"time":"2026-10-22T09:55:00+07:00","price":5225.0},{"time":"2026-10-22T09:56:00+07:00","price":5225.0},{"time":"2026-10-22T09:57:00+07:00","price":5225.0},{"time":"2026-10-22T09:58:00+07:00","price":5225.0},{"time":"2026-10-22T09:59:00+07:00","price":5225.0},{"time":"2026-10-22T10:00:00+07:00","price":5250.0},{"time":"2026-10-22T10:01:00+07:00","price":5250.0},{"time":"2026-10-22T10:02:00+07:00","price":5250.0},{"time":"2026-10-22T10:03:00+07:00","price":5250.0},{"time":"2026-10-22T10:04:00+07:00","price":5250.0},{"time":"2026-10-22T10:05:00+07:00","price":5225.0},{"time":"2026-10-22T10:06:00+07:00","price":5225.0},{"time":"2026-10-22T10:07:00+07:00","price":5225.0},{"time":"2026-10-22T10:08:00+07:00","price":5225.0},{"time":"2026-10-22T10:09:00+07:00","price":5225.0},{"time":"2026-10-22T10:10:00+07:00","price":5225.0},{"time":"2026-10-22T10:11:00+07:00","price":5225.0},{"time":"2026-10-22T10:12:00+07:00","price":5225.0},{"time":"2026-10-22T10:13:00+07:00","price":5225.0},{"time":"2026-10-22T10:14:00+07:00","price":5225.0},{"time":"2026-10-22T10:15:00+07:00","price":5225.0},{"time":"2026-10-22T10:16:00+07:00","price":5225.0},{"time":"2026-10-22T10:17:00+07:00","price":5225.0},{"time":"2026-10-22T10:18:00+07:00","price":5225.0},{"time":"2026-10-22T10:19:00+07:00","price":5225.0},{"time":"2026-10-22T10:20:00+07:00","price":5225.0},{"time":"2026-10-22T10:21:00+07:00","price":5225.0},{"time":"2026-10-22T10:22:00+07:00","price":5250.0},{"time":"2026-10-22T10:23:00+07:00","price":5250.0},{"time":"2026-10-22T10:24:00+07:00","price":5250.0},{"time":"2026-10-22T10:25:00+07:00","price":5250.0},{"time":"2026-10-22T10:26:00+07:00","price":5250.0},{"time":"2026-10-22T10:27:00+07:00","price":5250.0},{"time":"2026-10-22T10:28:00+07:00","price":5250.0},{"time":"2026-10-22T10:29:00+07:00","price":5250.0},{"time":"2026-10-22T10:30:00+07:00","price":5225.0},{"time":"2026-10-22T10:31:00+07:00","price":5250.0},{"time":"2026-10-22T10:32:00+07:00","price":5250.0},{"time":"2026-10-22T10:33:00+07:00","price":5250.0},{"time":"2026-10-22T10:34:00+07:00","price":5225.0},{"time":"2026-10-22T10:35:00+07:00","price":5250.0},{"time":"2026-10-22T10:36:00+07:00","price":5250.0},{"time":"2026-10-22T10:37:00+07:00","price":5250.0},{"time":"2026-10-22T10:38:00+07:00","price":5225.0},{"time":"2026-10-22T10:39:00+07:00","price":5250.0},{"time":"2026-10-22T10:40:00+07:00","price":5250.0},{"time":"2026-10-22T10:41:00+07:00","price":5250.0},{"time":"2026-10-22T10:42:00+07:00","price":5225.0},{"time":"2026-10-22T10:43:00+07:00","price":5225.0},{"time":"2026-10-22T10:44:00+07:00","price":5200.0},{"time":"2026-10-22T10:45:00+07:00","price":5200.0},{"time":"2026-10-22T10:46:00+07:00","price":5200.0},{"time":"2026-10-22T10:47:00+07:00","price":5200.0},{"time":"2026-10-22T10:48:00+07:00","price":5225.0},{"time":"2026-10-22T10:49:00+07:00","price":5225.0},{"time":"2026-10-22T10:50:00+07:00","price":5225.0},{"time":"2026-10-22T10:51:00+07:00","price":5200.0},{"time":"2026-10-22T10:52:00+07:00","price":5225.0},{"time":"2026-10-22T10:53:00+07:00","price":5225.0},{"time":"2026-10-22T10:54:00+07:00","price":5200.0},{"time":"2026-10-22T10:55:00+07:00","price":5200.0},{"time":"2026-10-22T10:56:00+07:00","price":5225.0},{"time":"2026-10-22T10:57:00+07:00","price":5200.0},{"time":"2026-10-22T10:58:00+07:00","price":5200.0},{"time":"2026-10-22T10:59:00+07:00","price":5225.0},{"time":"2026-10-22T11:00:00+07:00","price":5200.0},{"time":"2026-10-22T11:01:00+07:00","price":5200.0},{"time":"2026-10-22T11:02:00+07:00","price":5225.0},{"time":"2026-10-22T11:03:00+07:00","price":5225.0},{"time":"2026-10-22T11:04:00+07:00","price":5200.0},{"time":"2026-10-22T11:05:00+07:00","price":5225.0},{"time":"2026-10-22T11:06:00+07:00","price":5250.0},{"time":"2026-10-22T11:07:00+07:00","price":5225.0},{"time":"2026-10-22T11:08:00+07:00","price":5225.0},{"time":"2026-10-22T11:09:00+07:00","price":5250.0},{"time":"2026-10-22T11:10:00+07:00","price":5250.0},{"time":"2026-10-22T11:11:00+07:00","price":5225.0},{"time":"2026-10-22T11:12:00+07:00","price":5250.0},{"time":"2026-10-22T11:13:00+07:00","price":5250.0},{"time":"2026-10-22T11:14:00+07:00","price":5250.0},{"time":"2026-10-22T11:15:00+07:00","price":5250.0},{"time":"2026-10-22T11:16:00+07:00","price":5250.0},{"time":"2026-10-22T11:17:00+07:00","price":5250.0},{"time":"2026-10-22T11:18:00+07:00","price":5225.0},{"time":"2026-10-22T11:19:00+07:00","price":5225.0},{"time":"2026-10-22T11:20:00+07:00","price":5225.0},{"time":"2026-10-22T11:21:00+07:00","price":5225.0},{"time":"2026-10-22T11:22:00+07:00","price":5200.0},{"time":"2026-10-22T11:23:00+07:00","price":5225.0},{"time":"2026-10-22T11:24:00+07:00","price":5200.0},{"time":"2026-10-22T11:25:00+07:00","price":5200.0},{"time":"2026-10-22T11:26:00+07:00","price":5225.0},{"time":"2026-10-22T11:27:00+07:00","price":5225.0},{"time":"2026-10-22T11:28:00+07:00","price":5200.0},{"time":"2026-10-22T11:29:00+07:00","price":5225.0},{"time":"2026-10-22T11:30:00+07:00","price":5225.0},{"time":"2026-10-22T11:31:00+07:00","price":5200.0},{"time":"2026-10-22T11:32:00+07:00","price":5200.0},{"time":"2026-10-22T11:33:00+07:00","price":5225.0},{"time":"2026-10-22T11:34:00+07:00","price":5225.0},{"time":"2026-10-22T11:35:00+07:00","price":5200.0},{"time":"2026-10-22T11:36:00+07:00","price":5225.0},{"time":"2026-10-22T11:37:00+07:00","price":5225.0},{"time":"2026-10-22T11:38:00+07:00","price":5225.0},{"time":"2026-10-22T11:39:00+07:00","price":5200.0},{"time":"2026-10-22T11:40:00+07:00","price":5225.0},{"time":"2026-10-22T11:41:00+07:00","price":5225.0},{"time":"2026-10-22T11:42:00+07:00","price":5200.0},{"time":"2026-10-22T11:43:00+07:00","price":5225.0},{"time":"2026-10-22T11:44:00+07:00","price":5200.0},{"time":"2026-10-22T11:45:00+07:00","price":5225.0},{"time":"2026-10-22T11:46:00+07:00","price":5225.0},{"time":"2026-10-22T11:47:00+07:00","price":5225.0},{"time":"2026-10-22T11:48:00+07:00","price":5250.0},{"time":"2026-10-22T11:49:00+07:00","price":5250.0},{"time":"2026-10-22T11:50:00+07:00","price":5225.0},{"time":"2026-10-22T11:51:00+07:00","price":5225.0},{"time":"2026-10-22T11:52:00+07:00","price":5225.0},{"time":"2026-10-22T11:53:00+07:00","price":5225.0},{"time":"2026-10-22T11:54:00+07:00","price":5225.0},{"time":"2026-10-22T11:55:00+07:00","price":5225.0},{"time":"2026-10-22T11:56:00+07:00","price":5225.0},{"time":"2026-10-22T11:57:00+07:00","price":5225.0},{"time":"2026-10-22T11:58:00+07:00","price":5250.0},{"time":"2026-10-22T11:59:00+07:00","price":5250.0},{"time":"2026-10-22T13:30:00+07:00","price":5225.0},{"time":"2026-10-22T13:31:00+07:00","price":5200.0},{"time":"2026-10-22T13:32:00+07:00","price":5200.0},{"time":"2026-10-22T13:33:00+07:00","price":5200.0},{"time":"2026-10-22T13:34:00+07:00","price":5200.0},{"time":"2026-10-22T13:35:00+07:00","price":5225.0},{"time":"2026-10-22T13:36:00+07:00","price":5225.0},{"time":"2026-10-22T13:37:00+07:00","price":5225.0},{"time":"2026-10-22T13:38:00+07:00","price":5200.0},{"time":"2026-10-22T13:39:00+07:00","price":5225.0},{"time":"2026-10-22T13:40:00+07:00","price":5200.0},{"time":"2026-10-22T13:41:00+07:00","price":5225.0},{"time":"2026-10-22T13:42:00+07:00","price":5225.0},{"time":"2026-10-22T13:43:00+07:00","price":5200.0},{"time":"2026-10-22T13:44:00+07:00","price":5200.0},{"time":"2026-10-22T13:45:00+07:00","price":5175.0},{"time":"2026-10-22T13:46:00+07:00","price":5175.0},{"time":"2026-10-22T13:47:00+07:00","price":5175.0},{"time":"2026-10-22T13:48:00+07:00","price":5175.0},{"time":"2026-10-22T13:49:00+07:00","price":5200.0},{"time":"2026-10-22T13:50:00+07:00","price":5200.0},{"time":"2026-10-22T13:51:00+07:00","price":5175.0},{"time":"2026-10-22T13:52:00+07:00","price":5200.0},{"time":"2026-10-22T13:53:00+07:00","price":5200.0},{"time":"2026-10-22T13:54:00+07:00","price":5175.0},{"time":"2026-10-22T13:55:00+07:00","price":5175.0},{"time":"2026-10-22T13:56:00+07:00","price":5175.0},{"time":"2026-10-22T13:57:00+07:00","price":5200.0},{"time":"2026-10-22T13:58:00+07:00","price":5200.0},{"time":"2026-10-22T13:59:00+07:00","price":5200.0},{"time":"2026-10-22T14:00:00+07:00","price":5200.0},{"time":"2026-10-22T14:01:00+07:00","price":5175.0},{"time":"2026-10-22T14:02:00+07:00","price":5200.0},{"time":"2026-10-22T14:03:00+07:00","price":5200.0},{"time":"2026-10-22T14:04:00+07:00","price":5175.0},{"time":"2026-10-22T14:05:00+07:00","price":5175.0},{"time":"2026-10-22T14:06:00+07:00","price":5175.0},{"time":"2026-10-22T14:07:00+07:00","price":5175.0},{"time":"2026-10-22T14:08:00+07:00","price":5175.0},{"time":"2026-10-22T14:09:00+07:00","price":5175.0},{"time":"2026-10-22T14:10:00+07:00","price":5175.0},{"time":"2026-10-22T14:11:00+07:00","price":5200.0},{"time":"2026-10-22T14:12:00+07:00","price":5200.0},{"time":"2026-10-22T14:13:00+07:00","price":5200.0},{"time":"2026-10-22T14:14:00+07:00","price":5175.0},{"time":"2026-10-22T14:15:00+07:00","price":5200.0},{"time":"2026-10-22T14:16:00+07:00","price":5175.0},{"time":"2026-10-22T14:17:00+07:00","price":5175.0},{"time":"2026-10-22T14:18:00+07:00","price":5175.0},{"time":"2026-10-22T14:19:00+07:00","price":5175.0},{"time":"2026-10-22T14:20:00+07:00","price":5200.0},{"time":"2026-10-22T14:21:00+07:00","price":5200.0},{"time":"2026-10-22T14:22:00+07:00","price":5175.0},{"time":"2026-10-22T14:23:00+07:00","price":5175.0},{"time":"2026-10-22T14:24:00+07:00","price":5200.0},{"time":"2026-10-22T14:25:00+07:00","price":5175.0},{"time":"2026-10-22T14:26:00+07:00","price":5200.0},{"time":"2026-10-22T14:27:00+07:00","price":5200.0},{"time":"2026-10-22T14:28:00+07:00","price":5200.0},{"time":"2026-10-22T14:29:00+07:00","price":5200.0},{"time":"2026-10-22T14:30:00+07:00","price":5175.0},{"time":"2026-10-22T14:31:00+07:00","price":5200.0},{"time":"2026-10-22T14:32:00+07:00","price":5175.0},{"time":"2026-10-22T14:33:00+07:00","price":5200.0},{"time":"2026-10-22T14:34:00+07:00","price":5200.0},{"time":"2026-10-22T14:35:00+07:00","price":5200.0},{"time":"2026-10-22T14:36:00+07:00","price":5225.0},{"time":"2026-10-22T14:37:00+07:00","price":5200.0},{"time":"2026-10-22T14:38:00+07:00","price":5200.0},{"time":"2026-10-22T14:39:00+07:00","price":5200.0},{"time":"2026-10-22T14:40:00+07:00","price":5200.0},{"time":"2026-10-22T14:41:00+07:00","price":5200.0},{"time":"2026-10-22T14:42:00+07:00","price":5200.0},{"time":"2026-10-22T14:43:00+07:00","price":5200.0},{"time":"2026-10-22T14:44:00+07:00","price":5200.0},{"time":"2026-10-22T14:45:00+07:00","price":5200.0},{"time":"2026-10-22T14:46:00+07:00","price":5200.0},{"time":"2026-10-22T14:47:00+07:00","price":5175.0},{"time":"2026-10-22T14:48:00+07:00","price":5175.0},{"time":"2026-10-22T14:49:00+07:00","price":5200.0},{"time":"2026-10-22T14:50:00+07:00","price":5200.0},{"time":"2026-10-22T14:51:00+07:00","price":5200.0},{"time":"2026-10-22T14:52:00+07:00","price":5175.0},{"time":"2026-10-22T14:53:00+07:00","price":5200.0},{"time":"2026-10-22T14:54:00+07:00","price":5200.0},{"time":"2026-10-22T14:55:00+07:00","price":5225.0},{"time":"2026-10-22T14:56:00+07:00","price":5225.0},{"time":"2026-10-22T14:57:00+07:00","price":5225.0},{"time":"2026-10-22T14:58:00+07:00","price":5200.0},{"time":"2026-10-22T14:59:00+07:00","price":5200.0},{"time":"2026-10-22T15:00:00+07:00","price":5175.0},{"time":"2026-10-22T15:01:00+07:00","price":5200.0},{"time":"2026-10-22T15:02:00+07:00","price":5200.0},{"time":"2026-10-22T15:03:00+07:00","price":5200.0},{"time":"2026-10-22T15:04:00+07:00","price":5200.0},{"time":"2026-10-22T15:05:00+07:00","price":5200.0},{"time":"2026-10-22T15:06:00+07:00","price":5200.0},{"time":"2026-10-22T15:07:00+07:00","price":5175.0},{"time":"2026-10-22T15:08:00+07:00","price":5200.0},{"time":"2026-10-22T15:09:00+07:00","price":5200.0},{"time":"2026-10-22T15:10:00+07:00","price":5175.0},{"time":"2026-10-22T15:11:00+07:00","price":5175.0},{"time":"2026-10-22T15:12:00+07:00","price":5175.0},{"time":"2026-10-22T15:13:00+07:00","price":5200.0},{"time":"2026-10-22T15:14:00+07:00","price":5175.0},{"time":"2026-10-22T15:15:00+07:00","price":5175.0},{"time":"2026-10-22T15:16:00+07:00","price":5175.0},{"time":"2026-10-22T15:17:00+07:00","price":5200.0},{"time":"2026-10-22T15:18:00+07:00","price":5175.0},{"time":"2026-10-22T15:19:00+07:00","price":5175.0},{"time":"2026-10-22T15:20:00+07:00","price":5150.0},{"time":"2026-10-22T15:21:00+07:00","price":5175.0},{"time":"2026-10-22T15:22:00+07:00","price":5175.0},{"time":"2026-10-22T15:23:00+07:00","price":5175.0},{"time":"2026-10-22T15:24:00+07:00","price":5175.0},{"time":"2026-10-22T15:25:00+07:00","price":5150.0},{"time":"2026-10-22T15:26:00+07:00","price":5175.0},{"time":"2026-10-22T15:27:00+07:00","price":5175.0},{"time":"2026-10-22T15:28:00+07:00","price":5175.0},{"time":"2026-10-22T15:29:00+07:00","price":5175.0},{"time":"2026-10-22T15:30:00+07:00","price":5200.0},{"time":"2026-10-22T15:31:00+07:00","price":5200.0},{"time":"2026-10-22T15:32:00+07:00","price":5200.0},{"time":"2026-10-22T15:33:00+07:00","price":5200.0},{"time":"2026-10-22T15:34:00+07:00","price":5200.0},{"time":"2026-10-22T15:35:00+07:00","price":5175.0},{"time":"2026-10-22T15:36:00+07:00","price":5200.0},{"time":"2026-10-22T15:37:00+07:00","price":5175.0},{"time":"2026-10-22T15:38:00+07:00","price":5175.0},{"time":"2026-10-22T15:39:00+07:00","price":5175.0},{"time":"2026-10-22T15:40:00+07:00","price":5150.0},{"time":"2026-10-22T15:41:00+07:00","price":5150.0},{"time":"2026-10-22T15:42:00+07:00","price":5175.0},{"time":"2026-10-22T15:43:00+07:00","price":5175.0},{"time":"2026-10-22T15:44:00+07:00","price":5200.0},{"time":"2026-10-22T15:45:00+07:00","price":5200.0},{"time":"2026-10-22T15:46:00+07:00","price":5175.0},{"time":"2026-10-22T15:47:00+07:00","price":5200.0},{"time":"2026-10-22T15:48:00+07:00","price":5175.0},{"time":"2026-10-22T15:49:00+07:00","price":5175.0},{"time":"2026-10-22T16:00:00+07:00","price":5175.0},{"time":"2026-10-22T16:02:00+07:00","price":5175.0},{"time":"2026-10-22T16:03:00+07:00","price":5175.0},{"time":"2026-10-22T16:04:00+07:00","price":5175.0},{"time":"2026-10-22T16:05:00+07:00","price":5175.0},{"time":"2026-10-22T16:06:00+07:00","price":5175.0},{"time":"2026-10-22T16:07:00+07:00","price":5175.0},{"time":"2026-10-22T16:08:00+07:00","price":5175.0},{"time":"2026-10-22T16:09:00+07:00","price":5175.0},{"time":"2026-10-22T16:10:00+07:00","price":5175.0},{"time":"2026-10-22T16:11:00+07:00","price":5175.0},{"time":"2026-10-22T16:12:00+07:00","price":5175.0},{"time":"2026-10-22T16:13:00+07:00","price":5175.0},{"time":"2026-10-22T16:14:00+07:00","price":5175.0}]} +{"quote":{"symbol":"BBCA.JK","price":5175,"change":-100,"change_pct":-1.9,"volume":286928500,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":5275,"avg_volume":null,"as_of":"2026-10-22T16:14:00+07:00","behind_session":true,"market_status":"close"},"points":[{"time":"2026-10-22T08:58:00+07:00","price":5125.0},{"time":"2026-10-22T09:00:00+07:00","price":5125.0},{"time":"2026-10-22T09:01:00+07:00","price":5175.0},{"time":"2026-10-22T09:02:00+07:00","price":5150.0},{"time":"2026-10-22T09:03:00+07:00","price":5175.0},{"time":"2026-10-22T09:04:00+07:00","price":5175.0},{"time":"2026-10-22T09:05:00+07:00","price":5175.0},{"time":"2026-10-22T09:06:00+07:00","price":5175.0},{"time":"2026-10-22T09:07:00+07:00","price":5200.0},{"time":"2026-10-22T09:08:00+07:00","price":5175.0},{"time":"2026-10-22T09:09:00+07:00","price":5200.0},{"time":"2026-10-22T09:10:00+07:00","price":5200.0},{"time":"2026-10-22T09:11:00+07:00","price":5200.0},{"time":"2026-10-22T09:12:00+07:00","price":5200.0},{"time":"2026-10-22T09:13:00+07:00","price":5200.0},{"time":"2026-10-22T09:14:00+07:00","price":5225.0},{"time":"2026-10-22T09:15:00+07:00","price":5200.0},{"time":"2026-10-22T09:16:00+07:00","price":5225.0},{"time":"2026-10-22T09:17:00+07:00","price":5225.0},{"time":"2026-10-22T09:18:00+07:00","price":5200.0},{"time":"2026-10-22T09:19:00+07:00","price":5225.0},{"time":"2026-10-22T09:20:00+07:00","price":5250.0},{"time":"2026-10-22T09:21:00+07:00","price":5225.0},{"time":"2026-10-22T09:22:00+07:00","price":5225.0},{"time":"2026-10-22T09:23:00+07:00","price":5225.0},{"time":"2026-10-22T09:24:00+07:00","price":5225.0},{"time":"2026-10-22T09:25:00+07:00","price":5200.0},{"time":"2026-10-22T09:26:00+07:00","price":5200.0},{"time":"2026-10-22T09:27:00+07:00","price":5225.0},{"time":"2026-10-22T09:28:00+07:00","price":5225.0},{"time":"2026-10-22T09:29:00+07:00","price":5250.0},{"time":"2026-10-22T09:30:00+07:00","price":5225.0},{"time":"2026-10-22T09:31:00+07:00","price":5225.0},{"time":"2026-10-22T09:32:00+07:00","price":5200.0},{"time":"2026-10-22T09:33:00+07:00","price":5225.0},{"time":"2026-10-22T09:34:00+07:00","price":5225.0},{"time":"2026-10-22T09:35:00+07:00","price":5225.0},{"time":"2026-10-22T09:36:00+07:00","price":5250.0},{"time":"2026-10-22T09:37:00+07:00","price":5225.0},{"time":"2026-10-22T09:38:00+07:00","price":5225.0},{"time":"2026-10-22T09:39:00+07:00","price":5225.0},{"time":"2026-10-22T09:40:00+07:00","price":5225.0},{"time":"2026-10-22T09:41:00+07:00","price":5225.0},{"time":"2026-10-22T09:42:00+07:00","price":5225.0},{"time":"2026-10-22T09:43:00+07:00","price":5225.0},{"time":"2026-10-22T09:44:00+07:00","price":5225.0},{"time":"2026-10-22T09:45:00+07:00","price":5200.0},{"time":"2026-10-22T09:46:00+07:00","price":5200.0},{"time":"2026-10-22T09:47:00+07:00","price":5225.0},{"time":"2026-10-22T09:48:00+07:00","price":5200.0},{"time":"2026-10-22T09:49:00+07:00","price":5225.0},{"time":"2026-10-22T09:50:00+07:00","price":5225.0},{"time":"2026-10-22T09:51:00+07:00","price":5225.0},{"time":"2026-10-22T09:52:00+07:00","price":5225.0},{"time":"2026-10-22T09:53:00+07:00","price":5225.0},{"time":"2026-10-22T09:54:00+07:00","price":5200.0},{"time":"2026-10-22T09:55:00+07:00","price":5225.0},{"time":"2026-10-22T09:56:00+07:00","price":5225.0},{"time":"2026-10-22T09:57:00+07:00","price":5225.0},{"time":"2026-10-22T09:58:00+07:00","price":5225.0},{"time":"2026-10-22T09:59:00+07:00","price":5225.0},{"time":"2026-10-22T10:00:00+07:00","price":5250.0},{"time":"2026-10-22T10:01:00+07:00","price":5250.0},{"time":"2026-10-22T10:02:00+07:00","price":5250.0},{"time":"2026-10-22T10:03:00+07:00","price":5250.0},{"time":"2026-10-22T10:04:00+07:00","price":5250.0},{"time":"2026-10-22T10:05:00+07:00","price":5225.0},{"time":"2026-10-22T10:06:00+07:00","price":5225.0},{"time":"2026-10-22T10:07:00+07:00","price":5225.0},{"time":"2026-10-22T10:08:00+07:00","price":5225.0},{"time":"2026-10-22T10:09:00+07:00","price":5225.0},{"time":"2026-10-22T10:10:00+07:00","price":5225.0},{"time":"2026-10-22T10:11:00+07:00","price":5225.0},{"time":"2026-10-22T10:12:00+07:00","price":5225.0},{"time":"2026-10-22T10:13:00+07:00","price":5225.0},{"time":"2026-10-22T10:14:00+07:00","price":5225.0},{"time":"2026-10-22T10:15:00+07:00","price":5225.0},{"time":"2026-10-22T10:16:00+07:00","price":5225.0},{"time":"2026-10-22T10:17:00+07:00","price":5225.0},{"time":"2026-10-22T10:18:00+07:00","price":5225.0},{"time":"2026-10-22T10:19:00+07:00","price":5225.0},{"time":"2026-10-22T10:20:00+07:00","price":5225.0},{"time":"2026-10-22T10:21:00+07:00","price":5225.0},{"time":"2026-10-22T10:22:00+07:00","price":5250.0},{"time":"2026-10-22T10:23:00+07:00","price":5250.0},{"time":"2026-10-22T10:24:00+07:00","price":5250.0},{"time":"2026-10-22T10:25:00+07:00","price":5250.0},{"time":"2026-10-22T10:26:00+07:00","price":5250.0},{"time":"2026-10-22T10:27:00+07:00","price":5250.0},{"time":"2026-10-22T10:28:00+07:00","price":5250.0},{"time":"2026-10-22T10:29:00+07:00","price":5250.0},{"time":"2026-10-22T10:30:00+07:00","price":5225.0},{"time":"2026-10-22T10:31:00+07:00","price":5250.0},{"time":"2026-10-22T10:32:00+07:00","price":5250.0},{"time":"2026-10-22T10:33:00+07:00","price":5250.0},{"time":"2026-10-22T10:34:00+07:00","price":5225.0},{"time":"2026-10-22T10:35:00+07:00","price":5250.0},{"time":"2026-10-22T10:36:00+07:00","price":5250.0},{"time":"2026-10-22T10:37:00+07:00","price":5250.0},{"time":"2026-10-22T10:38:00+07:00","price":5225.0},{"time":"2026-10-22T10:39:00+07:00","price":5250.0},{"time":"2026-10-22T10:40:00+07:00","price":5250.0},{"time":"2026-10-22T10:41:00+07:00","price":5250.0},{"time":"2026-10-22T10:42:00+07:00","price":5225.0},{"time":"2026-10-22T10:43:00+07:00","price":5225.0},{"time":"2026-10-22T10:44:00+07:00","price":5200.0},{"time":"2026-10-22T10:45:00+07:00","price":5200.0},{"time":"2026-10-22T10:46:00+07:00","price":5200.0},{"time":"2026-10-22T10:47:00+07:00","price":5200.0},{"time":"2026-10-22T10:48:00+07:00","price":5225.0},{"time":"2026-10-22T10:49:00+07:00","price":5225.0},{"time":"2026-10-22T10:50:00+07:00","price":5225.0},{"time":"2026-10-22T10:51:00+07:00","price":5200.0},{"time":"2026-10-22T10:52:00+07:00","price":5225.0},{"time":"2026-10-22T10:53:00+07:00","price":5225.0},{"time":"2026-10-22T10:54:00+07:00","price":5200.0},{"time":"2026-10-22T10:55:00+07:00","price":5200.0},{"time":"2026-10-22T10:56:00+07:00","price":5225.0},{"time":"2026-10-22T10:57:00+07:00","price":5200.0},{"time":"2026-10-22T10:58:00+07:00","price":5200.0},{"time":"2026-10-22T10:59:00+07:00","price":5225.0},{"time":"2026-10-22T11:00:00+07:00","price":5200.0},{"time":"2026-10-22T11:01:00+07:00","price":5200.0},{"time":"2026-10-22T11:02:00+07:00","price":5225.0},{"time":"2026-10-22T11:03:00+07:00","price":5225.0},{"time":"2026-10-22T11:04:00+07:00","price":5200.0},{"time":"2026-10-22T11:05:00+07:00","price":5225.0},{"time":"2026-10-22T11:06:00+07:00","price":5250.0},{"time":"2026-10-22T11:07:00+07:00","price":5225.0},{"time":"2026-10-22T11:08:00+07:00","price":5225.0},{"time":"2026-10-22T11:09:00+07:00","price":5250.0},{"time":"2026-10-22T11:10:00+07:00","price":5250.0},{"time":"2026-10-22T11:11:00+07:00","price":5225.0},{"time":"2026-10-22T11:12:00+07:00","price":5250.0},{"time":"2026-10-22T11:13:00+07:00","price":5250.0},{"time":"2026-10-22T11:14:00+07:00","price":5250.0},{"time":"2026-10-22T11:15:00+07:00","price":5250.0},{"time":"2026-10-22T11:16:00+07:00","price":5250.0},{"time":"2026-10-22T11:17:00+07:00","price":5250.0},{"time":"2026-10-22T11:18:00+07:00","price":5225.0},{"time":"2026-10-22T11:19:00+07:00","price":5225.0},{"time":"2026-10-22T11:20:00+07:00","price":5225.0},{"time":"2026-10-22T11:21:00+07:00","price":5225.0},{"time":"2026-10-22T11:22:00+07:00","price":5200.0},{"time":"2026-10-22T11:23:00+07:00","price":5225.0},{"time":"2026-10-22T11:24:00+07:00","price":5200.0},{"time":"2026-10-22T11:25:00+07:00","price":5200.0},{"time":"2026-10-22T11:26:00+07:00","price":5225.0},{"time":"2026-10-22T11:27:00+07:00","price":5225.0},{"time":"2026-10-22T11:28:00+07:00","price":5200.0},{"time":"2026-10-22T11:29:00+07:00","price":5225.0},{"time":"2026-10-22T11:30:00+07:00","price":5225.0},{"time":"2026-10-22T11:31:00+07:00","price":5200.0},{"time":"2026-10-22T11:32:00+07:00","price":5200.0},{"time":"2026-10-22T11:33:00+07:00","price":5225.0},{"time":"2026-10-22T11:34:00+07:00","price":5225.0},{"time":"2026-10-22T11:35:00+07:00","price":5200.0},{"time":"2026-10-22T11:36:00+07:00","price":5225.0},{"time":"2026-10-22T11:37:00+07:00","price":5225.0},{"time":"2026-10-22T11:38:00+07:00","price":5225.0},{"time":"2026-10-22T11:39:00+07:00","price":5200.0},{"time":"2026-10-22T11:40:00+07:00","price":5225.0},{"time":"2026-10-22T11:41:00+07:00","price":5225.0},{"time":"2026-10-22T11:42:00+07:00","price":5200.0},{"time":"2026-10-22T11:43:00+07:00","price":5225.0},{"time":"2026-10-22T11:44:00+07:00","price":5200.0},{"time":"2026-10-22T11:45:00+07:00","price":5225.0},{"time":"2026-10-22T11:46:00+07:00","price":5225.0},{"time":"2026-10-22T11:47:00+07:00","price":5225.0},{"time":"2026-10-22T11:48:00+07:00","price":5250.0},{"time":"2026-10-22T11:49:00+07:00","price":5250.0},{"time":"2026-10-22T11:50:00+07:00","price":5225.0},{"time":"2026-10-22T11:51:00+07:00","price":5225.0},{"time":"2026-10-22T11:52:00+07:00","price":5225.0},{"time":"2026-10-22T11:53:00+07:00","price":5225.0},{"time":"2026-10-22T11:54:00+07:00","price":5225.0},{"time":"2026-10-22T11:55:00+07:00","price":5225.0},{"time":"2026-10-22T11:56:00+07:00","price":5225.0},{"time":"2026-10-22T11:57:00+07:00","price":5225.0},{"time":"2026-10-22T11:58:00+07:00","price":5250.0},{"time":"2026-10-22T11:59:00+07:00","price":5250.0},{"time":"2026-10-22T13:30:00+07:00","price":5225.0},{"time":"2026-10-22T13:31:00+07:00","price":5200.0},{"time":"2026-10-22T13:32:00+07:00","price":5200.0},{"time":"2026-10-22T13:33:00+07:00","price":5200.0},{"time":"2026-10-22T13:34:00+07:00","price":5200.0},{"time":"2026-10-22T13:35:00+07:00","price":5225.0},{"time":"2026-10-22T13:36:00+07:00","price":5225.0},{"time":"2026-10-22T13:37:00+07:00","price":5225.0},{"time":"2026-10-22T13:38:00+07:00","price":5200.0},{"time":"2026-10-22T13:39:00+07:00","price":5225.0},{"time":"2026-10-22T13:40:00+07:00","price":5200.0},{"time":"2026-10-22T13:41:00+07:00","price":5225.0},{"time":"2026-10-22T13:42:00+07:00","price":5225.0},{"time":"2026-10-22T13:43:00+07:00","price":5200.0},{"time":"2026-10-22T13:44:00+07:00","price":5200.0},{"time":"2026-10-22T13:45:00+07:00","price":5175.0},{"time":"2026-10-22T13:46:00+07:00","price":5175.0},{"time":"2026-10-22T13:47:00+07:00","price":5175.0},{"time":"2026-10-22T13:48:00+07:00","price":5175.0},{"time":"2026-10-22T13:49:00+07:00","price":5200.0},{"time":"2026-10-22T13:50:00+07:00","price":5200.0},{"time":"2026-10-22T13:51:00+07:00","price":5175.0},{"time":"2026-10-22T13:52:00+07:00","price":5200.0},{"time":"2026-10-22T13:53:00+07:00","price":5200.0},{"time":"2026-10-22T13:54:00+07:00","price":5175.0},{"time":"2026-10-22T13:55:00+07:00","price":5175.0},{"time":"2026-10-22T13:56:00+07:00","price":5175.0},{"time":"2026-10-22T13:57:00+07:00","price":5200.0},{"time":"2026-10-22T13:58:00+07:00","price":5200.0},{"time":"2026-10-22T13:59:00+07:00","price":5200.0},{"time":"2026-10-22T14:00:00+07:00","price":5200.0},{"time":"2026-10-22T14:01:00+07:00","price":5175.0},{"time":"2026-10-22T14:02:00+07:00","price":5200.0},{"time":"2026-10-22T14:03:00+07:00","price":5200.0},{"time":"2026-10-22T14:04:00+07:00","price":5175.0},{"time":"2026-10-22T14:05:00+07:00","price":5175.0},{"time":"2026-10-22T14:06:00+07:00","price":5175.0},{"time":"2026-10-22T14:07:00+07:00","price":5175.0},{"time":"2026-10-22T14:08:00+07:00","price":5175.0},{"time":"2026-10-22T14:09:00+07:00","price":5175.0},{"time":"2026-10-22T14:10:00+07:00","price":5175.0},{"time":"2026-10-22T14:11:00+07:00","price":5200.0},{"time":"2026-10-22T14:12:00+07:00","price":5200.0},{"time":"2026-10-22T14:13:00+07:00","price":5200.0},{"time":"2026-10-22T14:14:00+07:00","price":5175.0},{"time":"2026-10-22T14:15:00+07:00","price":5200.0},{"time":"2026-10-22T14:16:00+07:00","price":5175.0},{"time":"2026-10-22T14:17:00+07:00","price":5175.0},{"time":"2026-10-22T14:18:00+07:00","price":5175.0},{"time":"2026-10-22T14:19:00+07:00","price":5175.0},{"time":"2026-10-22T14:20:00+07:00","price":5200.0},{"time":"2026-10-22T14:21:00+07:00","price":5200.0},{"time":"2026-10-22T14:22:00+07:00","price":5175.0},{"time":"2026-10-22T14:23:00+07:00","price":5175.0},{"time":"2026-10-22T14:24:00+07:00","price":5200.0},{"time":"2026-10-22T14:25:00+07:00","price":5175.0},{"time":"2026-10-22T14:26:00+07:00","price":5200.0},{"time":"2026-10-22T14:27:00+07:00","price":5200.0},{"time":"2026-10-22T14:28:00+07:00","price":5200.0},{"time":"2026-10-22T14:29:00+07:00","price":5200.0},{"time":"2026-10-22T14:30:00+07:00","price":5175.0},{"time":"2026-10-22T14:31:00+07:00","price":5200.0},{"time":"2026-10-22T14:32:00+07:00","price":5175.0},{"time":"2026-10-22T14:33:00+07:00","price":5200.0},{"time":"2026-10-22T14:34:00+07:00","price":5200.0},{"time":"2026-10-22T14:35:00+07:00","price":5200.0},{"time":"2026-10-22T14:36:00+07:00","price":5225.0},{"time":"2026-10-22T14:37:00+07:00","price":5200.0},{"time":"2026-10-22T14:38:00+07:00","price":5200.0},{"time":"2026-10-22T14:39:00+07:00","price":5200.0},{"time":"2026-10-22T14:40:00+07:00","price":5200.0},{"time":"2026-10-22T14:41:00+07:00","price":5200.0},{"time":"2026-10-22T14:42:00+07:00","price":5200.0},{"time":"2026-10-22T14:43:00+07:00","price":5200.0},{"time":"2026-10-22T14:44:00+07:00","price":5200.0},{"time":"2026-10-22T14:45:00+07:00","price":5200.0},{"time":"2026-10-22T14:46:00+07:00","price":5200.0},{"time":"2026-10-22T14:47:00+07:00","price":5175.0},{"time":"2026-10-22T14:48:00+07:00","price":5175.0},{"time":"2026-10-22T14:49:00+07:00","price":5200.0},{"time":"2026-10-22T14:50:00+07:00","price":5200.0},{"time":"2026-10-22T14:51:00+07:00","price":5200.0},{"time":"2026-10-22T14:52:00+07:00","price":5175.0},{"time":"2026-10-22T14:53:00+07:00","price":5200.0},{"time":"2026-10-22T14:54:00+07:00","price":5200.0},{"time":"2026-10-22T14:55:00+07:00","price":5225.0},{"time":"2026-10-22T14:56:00+07:00","price":5225.0},{"time":"2026-10-22T14:57:00+07:00","price":5225.0},{"time":"2026-10-22T14:58:00+07:00","price":5200.0},{"time":"2026-10-22T14:59:00+07:00","price":5200.0},{"time":"2026-10-22T15:00:00+07:00","price":5175.0},{"time":"2026-10-22T15:01:00+07:00","price":5200.0},{"time":"2026-10-22T15:02:00+07:00","price":5200.0},{"time":"2026-10-22T15:03:00+07:00","price":5200.0},{"time":"2026-10-22T15:04:00+07:00","price":5200.0},{"time":"2026-10-22T15:05:00+07:00","price":5200.0},{"time":"2026-10-22T15:06:00+07:00","price":5200.0},{"time":"2026-10-22T15:07:00+07:00","price":5175.0},{"time":"2026-10-22T15:08:00+07:00","price":5200.0},{"time":"2026-10-22T15:09:00+07:00","price":5200.0},{"time":"2026-10-22T15:10:00+07:00","price":5175.0},{"time":"2026-10-22T15:11:00+07:00","price":5175.0},{"time":"2026-10-22T15:12:00+07:00","price":5175.0},{"time":"2026-10-22T15:13:00+07:00","price":5200.0},{"time":"2026-10-22T15:14:00+07:00","price":5175.0},{"time":"2026-10-22T15:15:00+07:00","price":5175.0},{"time":"2026-10-22T15:16:00+07:00","price":5175.0},{"time":"2026-10-22T15:17:00+07:00","price":5200.0},{"time":"2026-10-22T15:18:00+07:00","price":5175.0},{"time":"2026-10-22T15:19:00+07:00","price":5175.0},{"time":"2026-10-22T15:20:00+07:00","price":5150.0},{"time":"2026-10-22T15:21:00+07:00","price":5175.0},{"time":"2026-10-22T15:22:00+07:00","price":5175.0},{"time":"2026-10-22T15:23:00+07:00","price":5175.0},{"time":"2026-10-22T15:24:00+07:00","price":5175.0},{"time":"2026-10-22T15:25:00+07:00","price":5150.0},{"time":"2026-10-22T15:26:00+07:00","price":5175.0},{"time":"2026-10-22T15:27:00+07:00","price":5175.0},{"time":"2026-10-22T15:28:00+07:00","price":5175.0},{"time":"2026-10-22T15:29:00+07:00","price":5175.0},{"time":"2026-10-22T15:30:00+07:00","price":5200.0},{"time":"2026-10-22T15:31:00+07:00","price":5200.0},{"time":"2026-10-22T15:32:00+07:00","price":5200.0},{"time":"2026-10-22T15:33:00+07:00","price":5200.0},{"time":"2026-10-22T15:34:00+07:00","price":5200.0},{"time":"2026-10-22T15:35:00+07:00","price":5175.0},{"time":"2026-10-22T15:36:00+07:00","price":5200.0},{"time":"2026-10-22T15:37:00+07:00","price":5175.0},{"time":"2026-10-22T15:38:00+07:00","price":5175.0},{"time":"2026-10-22T15:39:00+07:00","price":5175.0},{"time":"2026-10-22T15:40:00+07:00","price":5150.0},{"time":"2026-10-22T15:41:00+07:00","price":5150.0},{"time":"2026-10-22T15:42:00+07:00","price":5175.0},{"time":"2026-10-22T15:43:00+07:00","price":5175.0},{"time":"2026-10-22T15:44:00+07:00","price":5200.0},{"time":"2026-10-22T15:45:00+07:00","price":5200.0},{"time":"2026-10-22T15:46:00+07:00","price":5175.0},{"time":"2026-10-22T15:47:00+07:00","price":5200.0},{"time":"2026-10-22T15:48:00+07:00","price":5175.0},{"time":"2026-10-22T15:49:00+07:00","price":5175.0},{"time":"2026-10-22T16:00:00+07:00","price":5175.0},{"time":"2026-10-22T16:02:00+07:00","price":5175.0},{"time":"2026-10-22T16:03:00+07:00","price":5175.0},{"time":"2026-10-22T16:04:00+07:00","price":5175.0},{"time":"2026-10-22T16:05:00+07:00","price":5175.0},{"time":"2026-10-22T16:06:00+07:00","price":5175.0},{"time":"2026-10-22T16:07:00+07:00","price":5175.0},{"time":"2026-10-22T16:08:00+07:00","price":5175.0},{"time":"2026-10-22T16:09:00+07:00","price":5175.0},{"time":"2026-10-22T16:10:00+07:00","price":5175.0},{"time":"2026-10-22T16:11:00+07:00","price":5175.0},{"time":"2026-10-22T16:12:00+07:00","price":5175.0},{"time":"2026-10-22T16:13:00+07:00","price":5175.0},{"time":"2026-10-22T16:14:00+07:00","price":5175.0}]} --- stderr diff --git a/crates/idx-cli/tests/golden/stocks-intraday.table.golden b/crates/idx-cli/tests/golden/stocks-intraday.table.golden index 35c6cf8..303612a 100644 --- a/crates/idx-cli/tests/golden/stocks-intraday.table.golden +++ b/crates/idx-cli/tests/golden/stocks-intraday.table.golden @@ -2,6 +2,7 @@ exit: 0 --- stdout 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 +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 diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index c20672c..6b985c0 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -128,22 +128,24 @@ impl Analysis<'_> { policy: CachePolicy, ) -> Result, IdxError> { let idx = self.idx; - let provider = history_provider(source.provider, idx.config.verbose); - let bucket = format!("{}-technical", source.provider.as_str()); let resolved = idx.resolve(symbol)?; - cached_fetch( - &idx.cache, - CacheSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: idx.config.quote_ttl, - subject: &resolved, - source: source.provider.as_str(), - }, - policy, - || provider.history(&resolved, &Period::OneYear, &Interval::Day), - |history| build_technical_report(&resolved, &history), - ) + idx.market().with_history_fallback(source, |source| { + let provider = history_provider(source.provider, idx.config.verbose); + let bucket = format!("{}-technical", source.provider.as_str()); + cached_fetch( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &resolved, + ttl_secs: idx.config.quote_ttl, + subject: &resolved, + source: source.provider.as_str(), + }, + policy, + || provider.history(&resolved, &Period::OneYear, &Interval::Day), + |history| build_technical_report(&resolved, &history), + ) + }) } pub fn growth( diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index d21b03a..3e13721 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -146,7 +146,8 @@ impl HistoryProvider for MockProvider { /// `stockbit_exodus_chart_candle__.json`, with the market state /// 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 -/// 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; impl MockStockbitProvider { @@ -181,6 +182,11 @@ impl MockStockbitProvider { if !stockbit::has_session() { 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) { return Err(err); } diff --git a/crates/idx-core/src/api/stockbit/browser.rs b/crates/idx-core/src/api/stockbit/browser.rs index 51f7a9c..8db593e 100644 --- a/crates/idx-core/src/api/stockbit/browser.rs +++ b/crates/idx-core/src/api/stockbit/browser.rs @@ -55,6 +55,12 @@ pub const PROFILE_DIR: &str = "stockbit-browser"; const START_TIMEOUT: Duration = Duration::from_secs(20); /// Pause between cookie reads. 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. const IO_TIMEOUT: Duration = Duration::from_secs(10); /// How long the browser gets to quit after `Browser.close`. @@ -228,6 +234,8 @@ fn wait_for_session( ) -> Result<(), IdxError> { let deadline = Instant::now() + timeout; let mut rejected: Option = None; + // After a check fails for a passing reason, the next is held back. + let mut retry_at: Option = None; let mut last_error: Option = None; // 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. @@ -235,7 +243,10 @@ fn wait_for_session( let mut misses = 0; loop { 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) { Ok(()) => { // The session now lives in idx's own file. Take the @@ -250,12 +261,20 @@ fn wait_for_session( devtools.close(); 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) => { crate::runtime::warn(format!( "Stockbit refused the session in idx's browser ({err}); sign in \ again in that window" )); rejected = Some(cookie.value); + retry_at = None; } } } @@ -979,6 +998,33 @@ mod tests { 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. #[test] fn closing_the_window_ends_the_wait() { diff --git a/crates/idx-core/src/api/stockbit/corpaction.rs b/crates/idx-core/src/api/stockbit/corpaction.rs index 7dfb0f1..0848ca8 100644 --- a/crates/idx-core/src/api/stockbit/corpaction.rs +++ b/crates/idx-core/src/api/stockbit/corpaction.rs @@ -47,7 +47,9 @@ pub fn parse_corporate_actions( let rows = match root.get("data") { Some(Value::Object(data)) => match data.values().find_map(Value::as_array) { 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())), }, Some(Value::Array(rows)) => rows.as_slice(), @@ -97,6 +99,9 @@ fn entitlement( match kind { ActionKind::Dividend => { action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, 2)); + action.currency = text(suffixed(row, "currency")) + .map(|c| c.trim_start_matches("CURRENCY_").to_ascii_uppercase()) + .filter(|c| c != "IDR"); } ActionKind::Rights => { action.price = whole(suffixed(row, "price")); @@ -376,10 +381,16 @@ mod tests { ), (ActionKind::ReverseSplit, r#"{"data":{"stock_reverse":[]}}"#), (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(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. let body = r#"{"data":{"stock_reverse":[{"company_symbol":"ABCD", "reverse_cumdate":"2026-12-01","reverse_exdate":"2026-12-02", @@ -389,6 +400,23 @@ mod tests { 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] fn test_parse_numbers_and_placeholders() { assert_eq!(number(Some(&Value::from("1,191.09"))), Some(1191.09)); diff --git a/crates/idx-core/src/api/stockbit/exodus.rs b/crates/idx-core/src/api/stockbit/exodus.rs index 1b3ec3d..d594ffe 100644 --- a/crates/idx-core/src/api/stockbit/exodus.rs +++ b/crates/idx-core/src/api/stockbit/exodus.rs @@ -375,6 +375,9 @@ pub fn parse_orderbook( } /// 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 { 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(); @@ -395,7 +398,10 @@ pub fn quote_from_book(book: &OrderBook) -> Quote { behind_session: false, bid: best(&book.bids), 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(), } } @@ -538,11 +544,14 @@ mod tests { let open = quote_from_book(&book(trading)); assert_eq!((open.bid, open.offer), (Some(4850), Some(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"] { let shut = quote_from_book(&book(parse_market_status(&fixture(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] diff --git a/crates/idx-core/src/api/stockbit/loopback.rs b/crates/idx-core/src/api/stockbit/loopback.rs index b154df3..f18e2f8 100644 --- a/crates/idx-core/src/api/stockbit/loopback.rs +++ b/crates/idx-core/src/api/stockbit/loopback.rs @@ -72,13 +72,24 @@ impl LoginPage { /// /// 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 - /// 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(&self, timeout: Duration, verify: F) -> Result<(), IdxError> where F: Fn(&str) -> Result<(), IdxError> + Send + Sync + 'static, { 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 path = std::sync::Arc::new(format!("/login/{}", self.secret)); let busy = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -575,6 +586,37 @@ mod tests { (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] fn serves_the_page_only_with_the_secret_and_accepts_a_good_paste() { let page = LoginPage::bind(0).unwrap(); diff --git a/crates/idx-core/src/api/stockbit/mod.rs b/crates/idx-core/src/api/stockbit/mod.rs index ead4d60..a0e6f45 100644 --- a/crates/idx-core/src/api/stockbit/mod.rs +++ b/crates/idx-core/src/api/stockbit/mod.rs @@ -277,6 +277,11 @@ pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError { /// Stores a pasted session (stockbit.com's `credentialStorage` cookie or a /// 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. +/// +/// 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 { let session = auth::session_from_paste(pasted).ok_or_else(|| { IdxError::InvalidInput( @@ -289,17 +294,40 @@ pub fn login(pasted: &str) -> Result { // A refused paste must not lose a session that already worked. let previous = std::fs::read(store.path()).ok(); store.save(&session)?; + let saved = std::fs::read(store.path()).ok(); #[cfg(feature = "testing")] let check = !crate::api::mock::enabled(); #[cfg(not(feature = "testing"))] let check = true; if check && let Err(err) = Exodus::new(store.clone()).verify() { - restore(&store, previous); - return Err(err); + let rotated = std::fs::read(store.path()).ok() != saved; + if !keep_after_failed_check(&err, rotated) { + restore(&store, previous); + 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())) } +/// 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>) { match previous { Some(bytes) => { @@ -326,7 +354,24 @@ pub fn logout() -> Result<(bool, auth::SessionStatus), IdxError> { #[cfg(test)] 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 /// 2026-10-22); the honest agent must never match one. diff --git a/crates/idx-core/src/api/types.rs b/crates/idx-core/src/api/types.rs index 2d7d1b1..6f4e854 100644 --- a/crates/idx-core/src/api/types.rs +++ b/crates/idx-core/src/api/types.rs @@ -65,8 +65,10 @@ pub struct Quote { /// Best offer in IDR (whole Rupiah), gated like `bid`. #[serde(default, skip_serializing_if = "Option::is_none")] pub offer: Option, - /// The provider's own session state, verbatim (Stockbit - /// `market_hour.status`, e.g. `close`). Absent for providers without one. + /// Stockbit's market state as its public page words it: `open` while + /// 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")] pub market_status: Option, /// What the provider says about this symbol's session: suspended, diff --git a/crates/idx-core/src/corporate/types.rs b/crates/idx-core/src/corporate/types.rs index 97984e3..b6086c8 100644 --- a/crates/idx-core/src/corporate/types.rs +++ b/crates/idx-core/src/corporate/types.rs @@ -3,14 +3,17 @@ use serde::{Deserialize, Serialize}; 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)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "kebab-case")] pub enum ActionKind { /// Cash dividend. Dividend, #[cfg_attr(feature = "clap", value(name = "stock-dividend"))] + #[serde(alias = "stock_dividend")] StockDividend, /// Bonus shares. Bonus, @@ -20,14 +23,17 @@ pub enum ActionKind { /// Stock split. Split, #[cfg_attr(feature = "clap", value(name = "reverse-split"))] + #[serde(alias = "reverse_split")] ReverseSplit, /// Shareholders' meeting (RUPS). Meeting, /// Public expose. #[cfg_attr(feature = "clap", value(name = "expose"))] + #[serde(rename = "expose", alias = "public_expose")] PublicExpose, Ipo, #[cfg_attr(feature = "clap", value(name = "tender"))] + #[serde(rename = "tender", alias = "tender_offer")] TenderOffer, } @@ -96,9 +102,10 @@ impl std::str::FromStr for ActionKind { type Err = IdxError; fn from_str(value: &str) -> Result { + let wanted = value.trim().to_ascii_lowercase().replace('_', "-"); Self::ALL .into_iter() - .find(|kind| kind.as_str() == value) + .find(|kind| kind.as_str() == wanted) .ok_or_else(|| { let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect(); IdxError::InvalidInput(format!( @@ -135,7 +142,8 @@ pub struct CorporateAction { /// 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 /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub cum_date: Option, @@ -150,6 +158,10 @@ pub struct CorporateAction { /// e.g. 3.8). #[serde(default, skip_serializing_if = "Option::is_none")] pub cash_per_share: Option, + /// The dividend's currency (ISO code, e.g. `USD`) when it is not IDR; + /// absent for IDR, which every recorded dividend was. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub currency: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ratio: Option, /// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a @@ -194,6 +206,7 @@ impl CorporateAction { record_date: None, pay_date: None, cash_per_share: None, + currency: None, ratio: None, factor: None, price: None, @@ -221,7 +234,8 @@ pub struct 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( from: Option<&str>, to: Option<&str>, @@ -233,16 +247,18 @@ impl CalendarRange { }) }; let from = from.map(|v| parse("from", v)).transpose()?.unwrap_or(today); - let to = to - .map(|v| parse("to", v)) - .transpose()? - .unwrap_or(from + Duration::days(CALENDAR_DEFAULT_DAYS)); + let to = match to { + Some(v) => parse("to", v)?, + None => from + .checked_add_signed(Duration::days(CALENDAR_DEFAULT_DAYS)) + .ok_or_else(|| IdxError::InvalidInput(format!("from ({from}) is out of range")))?, + }; if to < from { return Err(IdxError::InvalidInput(format!( "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!( "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"))); let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap(); 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 [ (Some("2026-11-10"), Some("2026-11-09")), (Some("2026-01-01"), Some("2027-06-01")), + (Some("2028-01-01"), Some("2029-01-01")), (Some("11/10/2026"), None), ] { 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] fn test_action_kind_names_round_trip() { for kind in ActionKind::ALL { assert_eq!(kind.as_str().parse::().unwrap(), kind); } assert!("rups".parse::().is_err()); + assert_eq!( + "stock_dividend".parse::().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::(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); } } diff --git a/crates/idx-core/src/market.rs b/crates/idx-core/src/market.rs index 151ad49..5282919 100644 --- a/crates/idx-core/src/market.rs +++ b/crates/idx-core/src/market.rs @@ -77,7 +77,7 @@ impl Market<'_> { let idx = self.idx; let resolved = idx.resolve(symbol)?; let provider = intraday_provider(); - cached_fetch( + let mut day = cached_fetch( &idx.cache, CacheSpec { bucket: "stockbit-intraday", @@ -89,7 +89,11 @@ impl Market<'_> { policy, || provider.intraday(&resolved), 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` @@ -123,23 +127,49 @@ impl Market<'_> { policy: CachePolicy, ) -> Result>, IdxError> { 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 key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str()); - cached_fetch( - &idx.cache, - CacheSpec { - bucket: &bucket, - key: &key, - ttl_secs: idx.config.quote_ttl, - subject: &resolved, - source: source.provider.as_str(), - }, - policy, - || provider.history(&resolved, period, interval), - Ok, - ) + 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( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &key, + ttl_secs: idx.config.quote_ttl, + subject: &resolved, + source: source.provider.as_str(), + }, + policy, + || provider.history(&resolved, period, interval), + 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( + &self, + source: HistorySource, + fetch: impl Fn(HistorySource) -> Result, IdxError>, + ) -> Result, 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, diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 4c48842..d78708f 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -126,8 +126,8 @@ impl IdxServer { 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 \ +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." )] @@ -149,8 +149,9 @@ minutes." `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." +`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." )] async fn get_intraday(&self, params: Parameters) -> ToolResult { 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 \ 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), \ +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 \ diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 6ba541f..aec2f76 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -236,7 +236,7 @@ "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": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -373,7 +373,7 @@ "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": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -536,7 +536,7 @@ "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": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b7c6832..315160a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,7 +82,7 @@ WIB as the fallback. `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 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 current. - `change`, `change_pct`, and `volume` are nullable when the provider omits them; diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index a63ba96..47c2c60 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -134,8 +134,12 @@ contract() { echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;; "stocks orderbook "*) 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"))' ;; + "stocks calendar"*) + echo 'type == "array" and all(.[]; (.symbol | type == "string") and (.kind | type == "string") and (.date | type == "string"))' ;; "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"))' ;; "stocks broker "*) diff --git a/web/src/content/docs/cli/stockbit.md b/web/src/content/docs/cli/stockbit.md index 735e066..910ac9f 100644 --- a/web/src/content/docs/cli/stockbit.md +++ b/web/src/content/docs/cli/stockbit.md @@ -198,6 +198,7 @@ What the login adds: | --- | --- | --- | | `--provider stockbit` quotes | public page, delayed | Stockbit's API, live | | 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 history` under `--provider stockbit` | Yahoo | Stockbit's daily candles for `5d` to `1y`; Yahoo for other windows | | `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 table and shares in JSON. `--history-provider stockbit` asks for Stockbit's 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 diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index 634dfb6..d955873 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -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 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 -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. ## 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 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 -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 serves one kind per request, 1 s apart, so a first call with every kind takes diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 4dca344..ecca1a1 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -65,7 +65,7 @@ Company data for one IDX ticker: profile, financial statements, earnings, analys ## `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 | | --- | --- | --- | --- | @@ -96,7 +96,7 @@ Foreign investors' buying and selling of one IDX ticker, session by session (old ## `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 | | --- | --- | --- | --- | @@ -144,7 +144,7 @@ OHLCV price history for one IDX ticker. ## `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 | | --- | --- | --- | --- |