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

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-04 17:36:04 +07:00
commit 5a6b5d1e2e
26 changed files with 430 additions and 81 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

@ -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);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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);
}
}

View file

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