fix(history): fall back to Yahoo when Stockbit refuses the session

auto history picks Stockbit when a session file exists, so an expired
session failed history and technical with AUTHERROR. auto now retries on
Yahoo with a warning; an explicit --history-provider stockbit still fails.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-04 17:31:20 +07:00
commit 63587e4dc2
4 changed files with 110 additions and 31 deletions

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

@ -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

@ -143,7 +143,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 {
@ -178,6 +179,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

@ -127,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 {
@ -144,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,