mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
e7f1fa2c90
commit
63587e4dc2
4 changed files with 110 additions and 31 deletions
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -128,22 +128,24 @@ impl Analysis<'_> {
|
||||||
policy: CachePolicy,
|
policy: CachePolicy,
|
||||||
) -> Result<Fetched<TechnicalReport>, IdxError> {
|
) -> Result<Fetched<TechnicalReport>, IdxError> {
|
||||||
let idx = self.idx;
|
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)?;
|
let resolved = idx.resolve(symbol)?;
|
||||||
cached_fetch(
|
idx.market().with_history_fallback(source, |source| {
|
||||||
&idx.cache,
|
let provider = history_provider(source.provider, idx.config.verbose);
|
||||||
CacheSpec {
|
let bucket = format!("{}-technical", source.provider.as_str());
|
||||||
bucket: &bucket,
|
cached_fetch(
|
||||||
key: &resolved,
|
&idx.cache,
|
||||||
ttl_secs: idx.config.quote_ttl,
|
CacheSpec {
|
||||||
subject: &resolved,
|
bucket: &bucket,
|
||||||
source: source.provider.as_str(),
|
key: &resolved,
|
||||||
},
|
ttl_secs: idx.config.quote_ttl,
|
||||||
policy,
|
subject: &resolved,
|
||||||
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|
source: source.provider.as_str(),
|
||||||
|history| build_technical_report(&resolved, &history),
|
},
|
||||||
)
|
policy,
|
||||||
|
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|
||||||
|
|history| build_technical_report(&resolved, &history),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn growth(
|
pub fn growth(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,23 +127,49 @@ 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());
|
||||||
cached_fetch(
|
self.with_history_fallback(source, |source| {
|
||||||
&idx.cache,
|
let provider = history_provider(source.provider, idx.config.verbose);
|
||||||
CacheSpec {
|
let bucket = format!("{}-history", source.provider.as_str());
|
||||||
bucket: &bucket,
|
cached_fetch(
|
||||||
key: &key,
|
&idx.cache,
|
||||||
ttl_secs: idx.config.quote_ttl,
|
CacheSpec {
|
||||||
subject: &resolved,
|
bucket: &bucket,
|
||||||
source: source.provider.as_str(),
|
key: &key,
|
||||||
},
|
ttl_secs: idx.config.quote_ttl,
|
||||||
policy,
|
subject: &resolved,
|
||||||
|| provider.history(&resolved, period, interval),
|
source: source.provider.as_str(),
|
||||||
Ok,
|
},
|
||||||
)
|
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,
|
/// The order book of one IDX stock from the user's Stockbit session,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue