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

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-04 17:31:29 +07:00
commit 6ad57dd997
21 changed files with 299 additions and 57 deletions

View file

@ -236,6 +236,9 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> {
if let Some(note) = &q.session_note { if let Some(note) = &q.session_note {
println!("Note: {}", terminal_safe(note)); println!("Note: {}", terminal_safe(note));
} }
for note in stale_quote_notes(std::slice::from_ref(q), chrono::Utc::now()) {
println!("{}", terminal_safe(&note));
}
let prices: Vec<f64> = day.points.iter().map(|p| p.price).collect(); let prices: Vec<f64> = day.points.iter().map(|p| p.price).collect();
if let (Some(first), Some(last)) = (day.points.first(), day.points.last()) { if let (Some(first), Some(last)) = (day.points.first(), day.points.last()) {

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -125,8 +125,8 @@ impl IdxServer {
for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true \ for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true \
when that is before the latest IDX session, so the price is not that session's close: the \ when that is before the latest IDX session, so the price is not that session's close: the \
provider lags, the stock did not trade, or the day was an exchange holiday. When the server \ provider lags, the stock did not trade, or the day was an exchange holiday. When the server \
runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. \ runs with the stockbit provider, quotes also carry `market_status` (\"open\" while the \
\"open\", \"close\"), `bid`/`offer` (best price, only while the market is open) and \ regular market trades, else \"close\", with or without a login), `bid`/`offer` (best price, only while the market is open) and \
`session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 \ `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 \
minutes." minutes."
)] )]
@ -148,8 +148,9 @@ minutes."
`market_status`, `bid`/`offer` only while the market is open, `session_note`) and \ `market_status`, `bid`/`offer` only while the market is open, `session_note`) and \
`points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no \ `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no \
per-minute volume. Empty `points` means the stock did not trade this session (see \ per-minute volume. Empty `points` means the stock did not trade this session (see \
`session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine \ `session_note`), not an error. `quote.behind_session` is true when the chart is from \
(HTTP 403): do not retry at once." before the latest IDX session (a holiday, or Stockbit lagging), as in get_quotes. \
UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once."
)] )]
async fn get_intraday(&self, params: Parameters<IntradayParams>) -> ToolResult { async fn get_intraday(&self, params: Parameters<IntradayParams>) -> ToolResult {
let IntradayParams { symbol, tail } = params.0; let IntradayParams { symbol, tail } = params.0;

View file

@ -373,7 +373,7 @@
"name": "get_foreign_flow" "name": "get_foreign_flow"
}, },
{ {
"description": "Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.", "description": "Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. `quote.behind_session` is true when the chart is from before the latest IDX session (a holiday, or Stockbit lagging), as in get_quotes. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.",
"inputSchema": { "inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {
@ -536,7 +536,7 @@
"name": "get_price_history" "name": "get_price_history"
}, },
{ {
"description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. \"open\", \"close\"), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.", "description": "Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (\"open\" while the regular market trades, else \"close\", with or without a login), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.",
"inputSchema": { "inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {

View file

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

View file

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

View file

@ -30,7 +30,8 @@ idx -o json stocks intraday BBCA | jq '.points[-1]'
`intraday` shows today's session from Stockbit's public page, without a `intraday` shows today's session from Stockbit's public page, without a
login: the quote, the best bid and offer while the market is open, a login: the quote, the best bid and offer while the market is open, a
sparkline and the last five minutes. JSON gives every minute in `points`. It sparkline and the last five minutes. JSON gives every minute in `points`. It
is delayed at least 10 minutes. See [Stockbit](/cli/stockbit/) for what it is delayed at least 10 minutes. A chart from before the latest session is
noted under the header and sets `quote.behind_session`, as with quotes. See [Stockbit](/cli/stockbit/) for what it
covers and the terms it comes with. covers and the terms it comes with.
## Order book (Stockbit login) ## Order book (Stockbit login)

View file

@ -96,7 +96,7 @@ Foreign investors' buying and selling of one IDX ticker, session by session (old
## `get_intraday` ## `get_intraday`
Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once. Today's session of one IDX stock or index from Stockbit's public page (no login), whichever provider the server uses: `quote` (price, change, volume, `market_status`, `bid`/`offer` only while the market is open, `session_note`) and `points`, the price per minute in WIB, oldest first. Delayed at least 10 minutes; no per-minute volume. Empty `points` means the stock did not trade this session (see `session_note`), not an error. `quote.behind_session` is true when the chart is from before the latest IDX session (a holiday, or Stockbit lagging), as in get_quotes. UPSTREAMBLOCKED means stockbit.com refused this machine (HTTP 403): do not retry at once.
| Input | Type | Required | Description | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -144,7 +144,7 @@ OHLCV price history for one IDX ticker.
## `get_quotes` ## `get_quotes`
Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` (Stockbit's, e.g. "open", "close"), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes. Latest quotes (price, change, volume, 52-week range, market cap) for one or more IDX tickers. `as_of` is the last trade in WIB; `behind_session` is true when that is before the latest IDX session, so the price is not that session's close: the provider lags, the stock did not trade, or the day was an exchange holiday. When the server runs with the stockbit provider, quotes also carry `market_status` ("open" while the regular market trades, else "close", with or without a login), `bid`/`offer` (best price, only while the market is open) and `session_note` (suspended, delisted, no trades); Stockbit data is delayed at least 10 minutes.
| Input | Type | Required | Description | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |