mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 01:35:36 +00:00
Merge branch 'feat/stockbit-corpaction' into feat/stockbit-movements
Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
commit
5a6b5d1e2e
26 changed files with 430 additions and 81 deletions
|
|
@ -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<f64> = 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<String> = 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<chrono::NaiveDate>) -> 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(())
|
||||
|
|
|
|||
|
|
@ -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::<Value>(&output.stdout).expect("JSON on stdout");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
let root = test_env_dir("stockbit-refused-explicit");
|
||||
run_error_json(
|
||||
mock_bin_with_root(&root)
|
||||
.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
|
||||
.env("IDX_MOCK_STOCKBIT_STATUS", "401")
|
||||
.args([
|
||||
"-o",
|
||||
"json",
|
||||
"--provider",
|
||||
"stockbit",
|
||||
"stocks",
|
||||
"history",
|
||||
"BBCA",
|
||||
])
|
||||
.args(["--period", "1mo", "--history-provider", "stockbit"]),
|
||||
"AUTHERROR",
|
||||
);
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
/// A test session, signed by nobody, that expires in 2100.
|
||||
const FAKE_STOCKBIT_TOKEN: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln";
|
||||
|
||||
/// An agent on a headless box gets told how to log in, not "nothing pasted".
|
||||
#[test]
|
||||
fn stockbit_login_with_empty_stdin_says_how_to_log_in() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -128,22 +128,24 @@ impl Analysis<'_> {
|
|||
policy: CachePolicy,
|
||||
) -> Result<Fetched<TechnicalReport>, 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(
|
||||
|
|
|
|||
|
|
@ -146,7 +146,8 @@ impl HistoryProvider for MockProvider {
|
|||
/// `stockbit_exodus_chart_candle_<code>_<window>.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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = None;
|
||||
// After a check fails for a passing reason, the next is held back.
|
||||
let mut retry_at: Option<Instant> = None;
|
||||
let mut last_error: Option<String> = None;
|
||||
// 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() {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<F>(&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();
|
||||
|
|
|
|||
|
|
@ -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<auth::SessionStatus, IdxError> {
|
||||
let session = auth::session_from_paste(pasted).ok_or_else(|| {
|
||||
IdxError::InvalidInput(
|
||||
|
|
@ -289,17 +294,40 @@ pub fn login(pasted: &str) -> Result<auth::SessionStatus, IdxError> {
|
|||
// A refused paste must not lose a session that already worked.
|
||||
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<Vec<u8>>) {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<i64>,
|
||||
/// 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<String>,
|
||||
/// What the provider says about this symbol's session: suspended,
|
||||
|
|
|
|||
|
|
@ -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<Self, Self::Err> {
|
||||
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<NaiveDate>,
|
||||
|
|
@ -150,6 +158,10 @@ pub struct CorporateAction {
|
|||
/// e.g. 3.8).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cash_per_share: Option<f64>,
|
||||
/// The dividend's currency (ISO code, e.g. `USD`) when it is not IDR;
|
||||
/// absent for IDR, which every recorded dividend was.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub currency: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ratio: Option<Ratio>,
|
||||
/// 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::<ActionKind>().unwrap(), kind);
|
||||
}
|
||||
assert!("rups".parse::<ActionKind>().is_err());
|
||||
assert_eq!(
|
||||
"stock_dividend".parse::<ActionKind>().unwrap(),
|
||||
ActionKind::StockDividend
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON's `kind` is the name `--kind` and MCP take.
|
||||
#[test]
|
||||
fn test_action_kind_json_is_the_input_name() {
|
||||
for kind in ActionKind::ALL {
|
||||
let json = serde_json::to_value(kind).unwrap();
|
||||
assert_eq!(json, kind.as_str(), "{kind:?}");
|
||||
assert_eq!(serde_json::from_value::<ActionKind>(json).unwrap(), kind);
|
||||
}
|
||||
// Entries cached before the names changed still read.
|
||||
let old: ActionKind = serde_json::from_str("\"public_expose\"").unwrap();
|
||||
assert_eq!(old, ActionKind::PublicExpose);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Fetched<Vec<Ohlc>>, 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<T>(
|
||||
&self,
|
||||
source: HistorySource,
|
||||
fetch: impl Fn(HistorySource) -> Result<Fetched<T>, IdxError>,
|
||||
) -> Result<Fetched<T>, IdxError> {
|
||||
match fetch(source) {
|
||||
Err(IdxError::AuthError(reason))
|
||||
if source.mode == HistoryProviderKind::Auto
|
||||
&& source.provider == ProviderKind::Stockbit =>
|
||||
{
|
||||
crate::runtime::warn(format!("{reason}; history falls back to yahoo meanwhile"));
|
||||
fetch(HistorySource {
|
||||
provider: ProviderKind::Yahoo,
|
||||
is_fallback: self.idx.provider.kind() != ProviderKind::Yahoo,
|
||||
..source
|
||||
})
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The order book of one IDX stock from the user's Stockbit session,
|
||||
|
|
|
|||
|
|
@ -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<IntradayParams>) -> 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 \
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 "*)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| --- | --- | --- | --- |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue