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

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

View file

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

View file

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