mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
Merge branch 'feat/stockbit-login' into feat/stockbit-corpaction
Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
commit
6ad57dd997
21 changed files with 299 additions and 57 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -145,7 +145,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 {
|
||||
|
|
@ -180,6 +181,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -355,6 +355,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();
|
||||
|
|
@ -375,7 +378,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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -518,11 +524,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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -265,6 +265,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(
|
||||
|
|
@ -277,17 +282,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) => {
|
||||
|
|
@ -314,7 +342,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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue