mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
feat: Stockbit login for the order book, live quotes and daily history
Opt-in, with the user's own Stockbit session from a pasted credentialStorage cookie (or IDX_STOCKBIT_REFRESH_TOKEN): - idx stockbit login/status/logout; session file 0600, tokens never shown - rotating refresh under a cross-process lock; one refresh+retry on 401 - stocks orderbook: depth with lots and orders, OHLC, value, foreign flow - --provider stockbit quotes from the session API when logged in - --history-provider stockbit, and auto under the stockbit provider for 5d-1y daily windows - MCP get_orderbook and stockbit_status (17 tools) Endpoints, units and recorded responses from fzn0x/idx (MIT).
This commit is contained in:
parent
570439369e
commit
b5ae9b98de
61 changed files with 2882 additions and 129 deletions
|
|
@ -11,6 +11,7 @@ use crate::api::history_provider;
|
|||
use crate::api::types::{Fundamentals, Interval, Period};
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::config::HistoryProviderKind;
|
||||
use crate::error::IdxError;
|
||||
use crate::market::HistorySource;
|
||||
use bandar::{BandarReport, analyze_bandar, check_top};
|
||||
|
|
@ -110,8 +111,16 @@ impl Analysis<'_> {
|
|||
Ok(BandarScreen { reports, failures })
|
||||
}
|
||||
|
||||
/// Where `technical` reads its year of daily history from, for an
|
||||
/// optional `--history-provider` override.
|
||||
pub fn technical_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
|
||||
self.idx
|
||||
.market()
|
||||
.history_source(mode, &Period::OneYear, &Interval::Day)
|
||||
}
|
||||
|
||||
/// Technical analysis over one year of daily history from `source`
|
||||
/// (see `Market::history_source`).
|
||||
/// (see `technical_source`).
|
||||
pub fn technical(
|
||||
&self,
|
||||
symbol: &str,
|
||||
|
|
@ -119,9 +128,8 @@ impl Analysis<'_> {
|
|||
policy: CachePolicy,
|
||||
) -> Result<Fetched<TechnicalReport>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let (provider_kind, provider) =
|
||||
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
|
||||
let bucket = format!("{}-technical", provider_kind.as_str());
|
||||
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,
|
||||
|
|
@ -130,7 +138,7 @@ impl Analysis<'_> {
|
|||
key: &resolved,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
subject: &resolved,
|
||||
source: provider_kind.as_str(),
|
||||
source: source.provider.as_str(),
|
||||
},
|
||||
policy,
|
||||
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
||||
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
||||
|
||||
use super::types::{Bar, Fundamentals, Interval, Intraday, Period, Quote};
|
||||
use super::stockbit::exodus;
|
||||
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||
use super::{
|
||||
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, QuoteProvider,
|
||||
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
||||
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
|
||||
OrderBookProvider, QuoteProvider, TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
||||
};
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||
use crate::config::ProviderKind;
|
||||
|
|
@ -135,22 +136,31 @@ impl HistoryProvider for MockProvider {
|
|||
}
|
||||
}
|
||||
|
||||
/// Stockbit symbol pages from `tests/fixtures/stockbit_symbol_<code>.html`;
|
||||
/// 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.
|
||||
/// Stockbit from fixtures. Without a session (none stored or seeded through
|
||||
/// `IDX_STOCKBIT_REFRESH_TOKEN`): the public page,
|
||||
/// `tests/fixtures/stockbit_symbol_<code>.html`. With one: the session API,
|
||||
/// `tests/fixtures/stockbit_exodus_orderbook_<code>.json` and
|
||||
/// `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.
|
||||
pub struct MockStockbitProvider;
|
||||
|
||||
impl MockStockbitProvider {
|
||||
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
|
||||
fn failure(&self, symbol: &str) -> Option<IdxError> {
|
||||
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
||||
return Err(IdxError::ProviderUnavailable);
|
||||
return Some(IdxError::ProviderUnavailable);
|
||||
}
|
||||
if let Ok(status) = std::env::var("IDX_MOCK_STOCKBIT_STATUS") {
|
||||
return Err(stockbit::status_error(
|
||||
status.parse().unwrap_or(500),
|
||||
symbol,
|
||||
));
|
||||
let status = std::env::var("IDX_MOCK_STOCKBIT_STATUS").ok()?;
|
||||
Some(stockbit::status_error(
|
||||
status.parse().unwrap_or(500),
|
||||
symbol,
|
||||
))
|
||||
}
|
||||
|
||||
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
|
||||
if let Some(err) = self.failure(symbol) {
|
||||
return Err(err);
|
||||
}
|
||||
let code = stockbit::stockbit_code(symbol)?;
|
||||
let path = format!(
|
||||
|
|
@ -162,10 +172,32 @@ impl MockStockbitProvider {
|
|||
Err(_) => Err(stockbit::status_error(404, symbol)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A session API fixture, or the error a request would meet.
|
||||
fn exodus(&self, symbol: &str, name: &str) -> Result<String, IdxError> {
|
||||
if !stockbit::has_session() {
|
||||
return Err(IdxError::AuthError(stockbit::auth::NO_SESSION.to_string()));
|
||||
}
|
||||
if let Some(err) = self.failure(symbol) {
|
||||
return Err(err);
|
||||
}
|
||||
std::fs::read_to_string(format!("tests/fixtures/stockbit_exodus_{name}"))
|
||||
.map_err(|_| IdxError::SymbolNotFound(symbol.to_string()))
|
||||
}
|
||||
|
||||
fn book(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||
let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase();
|
||||
let body = self.exodus(symbol, &format!("orderbook_{code}.json"))?;
|
||||
let state = self.exodus(symbol, "market_session.json")?;
|
||||
exodus::parse_orderbook(symbol, &body, exodus::parse_market_status(&state))
|
||||
}
|
||||
}
|
||||
|
||||
impl QuoteProvider for MockStockbitProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||
if stockbit::has_session() {
|
||||
return self.book(symbol).map(|book| exodus::quote_from_book(&book));
|
||||
}
|
||||
let mut quote = self.snapshot(symbol)?.quote;
|
||||
// Quotes are judged against the clock (behind the session, a week
|
||||
// old); a fixed trade time would make their output age.
|
||||
|
|
@ -186,6 +218,27 @@ impl IntradayProvider for MockStockbitProvider {
|
|||
}
|
||||
}
|
||||
|
||||
impl OrderBookProvider for MockStockbitProvider {
|
||||
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||
self.book(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryProvider for MockStockbitProvider {
|
||||
fn history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> Result<Vec<Bar>, IdxError> {
|
||||
let window = exodus::history_window(period, interval)
|
||||
.ok_or_else(|| exodus::unsupported_window(period, interval))?;
|
||||
let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase();
|
||||
let body = self.exodus(symbol, &format!("chart_candle_{code}_{window}.json"))?;
|
||||
exodus::daily_for_period(&body, period)
|
||||
}
|
||||
}
|
||||
|
||||
/// Broker summaries from `tests/fixtures/ipot_brokersummary_<code>_<from>
|
||||
/// [_<to>][_<investor>_<board>].html`; a query without a fixture reads as an
|
||||
/// unknown code.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind};
|
|||
use crate::error::IdxError;
|
||||
use types::{
|
||||
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
|
||||
Intraday, NewsItem, Period, Quote, SentimentData,
|
||||
Intraday, NewsItem, OrderBook, Period, Quote, SentimentData,
|
||||
};
|
||||
|
||||
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
|
||||
|
|
@ -72,6 +72,12 @@ pub trait IntradayProvider {
|
|||
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError>;
|
||||
}
|
||||
|
||||
/// The order book of one IDX stock: needs the user's Stockbit session.
|
||||
pub trait OrderBookProvider {
|
||||
/// `symbol` is resolved, e.g. `BBCA.JK`.
|
||||
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError>;
|
||||
}
|
||||
|
||||
pub trait FundamentalsProvider {
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||
}
|
||||
|
|
@ -242,9 +248,12 @@ fn mock_market(_provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
|
|||
|
||||
#[cfg(feature = "testing")]
|
||||
fn mock_history(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> {
|
||||
Some(Box::new(
|
||||
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
|
||||
))
|
||||
match provider {
|
||||
ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)),
|
||||
_ => Some(Box::new(
|
||||
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "testing"))]
|
||||
|
|
@ -285,7 +294,7 @@ fn build_selected_provider(
|
|||
news: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
screener: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
},
|
||||
// Quotes only; history falls back to Yahoo (see `history_source`).
|
||||
// Quotes only; history needs a session (see `history_source`).
|
||||
ProviderKind::Stockbit => SelectedProvider {
|
||||
kind: ProviderKind::Stockbit,
|
||||
market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())),
|
||||
|
|
@ -328,6 +337,14 @@ pub fn intraday_provider() -> Box<dyn IntradayProvider> {
|
|||
Box::new(stockbit::StockbitProvider::new())
|
||||
}
|
||||
|
||||
pub fn orderbook_provider() -> Box<dyn OrderBookProvider> {
|
||||
#[cfg(feature = "testing")]
|
||||
if mock::enabled() {
|
||||
return Box::new(mock::MockStockbitProvider);
|
||||
}
|
||||
Box::new(stockbit::StockbitProvider::new())
|
||||
}
|
||||
|
||||
/// Today in WIB, the date IDX trades on.
|
||||
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||
#[cfg(feature = "testing")]
|
||||
|
|
@ -338,40 +355,44 @@ pub(crate) fn today_wib() -> chrono::NaiveDate {
|
|||
}
|
||||
|
||||
/// The provider that serves history for `provider` under `history_mode`.
|
||||
pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind {
|
||||
/// `auto` uses Stockbit when it is the selected provider, the user has a
|
||||
/// session and the window is one of its daily charts; otherwise Yahoo, whose
|
||||
/// candles are full OHLCV for any window.
|
||||
pub fn history_source(
|
||||
provider: ProviderKind,
|
||||
history_mode: HistoryProviderKind,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
stockbit_session: bool,
|
||||
) -> ProviderKind {
|
||||
match history_mode {
|
||||
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
|
||||
HistoryProviderKind::Msn => ProviderKind::Msn,
|
||||
HistoryProviderKind::Auto => match provider {
|
||||
ProviderKind::Yahoo | ProviderKind::Msn | ProviderKind::Stockbit => ProviderKind::Yahoo,
|
||||
},
|
||||
HistoryProviderKind::Stockbit => ProviderKind::Stockbit,
|
||||
HistoryProviderKind::Auto => {
|
||||
if provider == ProviderKind::Stockbit
|
||||
&& stockbit_session
|
||||
&& stockbit::exodus::history_window(period, interval).is_some()
|
||||
{
|
||||
ProviderKind::Stockbit
|
||||
} else {
|
||||
ProviderKind::Yahoo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a history provider based on the selected market data provider and
|
||||
/// history provider strategy.
|
||||
///
|
||||
/// `history_mode=auto` keeps using Yahoo for IDX history because Yahoo provides
|
||||
/// full OHLCV candles. Explicit `msn` opts into MSN's price-only chart feed.
|
||||
pub fn history_provider(
|
||||
provider: ProviderKind,
|
||||
history_mode: HistoryProviderKind,
|
||||
verbose: bool,
|
||||
) -> Result<(ProviderKind, Box<dyn HistoryProvider>), IdxError> {
|
||||
let resolved = history_source(provider, history_mode);
|
||||
|
||||
/// The history provider for a source from `history_source`.
|
||||
pub fn history_provider(source: ProviderKind, verbose: bool) -> Box<dyn HistoryProvider> {
|
||||
if mock_enabled()
|
||||
&& let Some(mock) = mock_history(resolved, verbose)
|
||||
&& let Some(mock) = mock_history(source, verbose)
|
||||
{
|
||||
return Ok((resolved, mock));
|
||||
return mock;
|
||||
}
|
||||
|
||||
match resolved {
|
||||
ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))),
|
||||
ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))),
|
||||
ProviderKind::Stockbit => Err(IdxError::Unsupported(
|
||||
"Stockbit serves no price history; use --history-provider yahoo or msn".to_string(),
|
||||
)),
|
||||
match source {
|
||||
ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)),
|
||||
ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)),
|
||||
ProviderKind::Stockbit => Box::new(stockbit::StockbitProvider::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -472,4 +493,72 @@ mod tests {
|
|||
"unsupported: screen: command requires --provider msn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_history_uses_stockbit_only_with_a_session_and_a_daily_window() {
|
||||
use super::history_source;
|
||||
use crate::api::types::{Interval, Period};
|
||||
use crate::config::HistoryProviderKind::{Auto, Stockbit, Yahoo};
|
||||
|
||||
let source = |provider, mode, period, interval, session| {
|
||||
history_source(provider, mode, &period, &interval, session)
|
||||
};
|
||||
let year = (Period::OneYear, Interval::Day);
|
||||
assert_eq!(
|
||||
source(
|
||||
ProviderKind::Stockbit,
|
||||
Auto,
|
||||
year.0.clone(),
|
||||
year.1.clone(),
|
||||
true
|
||||
),
|
||||
ProviderKind::Stockbit
|
||||
);
|
||||
// No session, a window Stockbit charts weekly, or another provider.
|
||||
assert_eq!(
|
||||
source(
|
||||
ProviderKind::Stockbit,
|
||||
Auto,
|
||||
year.0.clone(),
|
||||
year.1.clone(),
|
||||
false
|
||||
),
|
||||
ProviderKind::Yahoo
|
||||
);
|
||||
assert_eq!(
|
||||
source(
|
||||
ProviderKind::Stockbit,
|
||||
Auto,
|
||||
Period::FiveYears,
|
||||
Interval::Day,
|
||||
true
|
||||
),
|
||||
ProviderKind::Yahoo
|
||||
);
|
||||
assert_eq!(
|
||||
source(
|
||||
ProviderKind::Msn,
|
||||
Auto,
|
||||
year.0.clone(),
|
||||
year.1.clone(),
|
||||
true
|
||||
),
|
||||
ProviderKind::Yahoo
|
||||
);
|
||||
// An explicit choice wins either way.
|
||||
assert_eq!(
|
||||
source(
|
||||
ProviderKind::Msn,
|
||||
Stockbit,
|
||||
year.0.clone(),
|
||||
year.1.clone(),
|
||||
false
|
||||
),
|
||||
ProviderKind::Stockbit
|
||||
);
|
||||
assert_eq!(
|
||||
source(ProviderKind::Stockbit, Yahoo, year.0, year.1, true),
|
||||
ProviderKind::Yahoo
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
607
crates/idx-core/src/api/stockbit/auth.rs
Normal file
607
crates/idx-core/src/api/stockbit/auth.rs
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
//! The user's own Stockbit session: two bearer JWTs, where they are kept, and
|
||||
//! how they are refreshed. Opt-in; nothing here runs without a session.
|
||||
//!
|
||||
//! - The **access** token (about a day) authorises every
|
||||
//! `exodus.stockbit.com` request.
|
||||
//! - The **refresh** token (about a week) is spent on `POST /login/refresh`,
|
||||
//! which answers a new access token *and a new refresh token*, retiring the
|
||||
//! one presented. So exactly one party may refresh a session: two idx
|
||||
//! processes refresh under a file lock and re-read what the other wrote, and
|
||||
//! a browser sharing the session gets logged out once idx refreshes.
|
||||
//!
|
||||
//! The session lives in `<config dir>/stockbit-session.json` (mode 0600).
|
||||
//! `IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` seed fields the
|
||||
//! file lacks, for servers. Tokens never reach logs, errors or cache keys.
|
||||
//! The token shapes follow fzn0x/idx (MIT).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::IdxError;
|
||||
|
||||
pub const ENV_REFRESH: &str = "IDX_STOCKBIT_REFRESH_TOKEN";
|
||||
pub const ENV_ACCESS: &str = "IDX_STOCKBIT_ACCESS_TOKEN";
|
||||
const SESSION_FILE: &str = "stockbit-session.json";
|
||||
const LOCK_FILE: &str = "stockbit-session.lock";
|
||||
/// Refresh this long before the access token expires, so no request races
|
||||
/// the expiry.
|
||||
const REFRESH_BUFFER_SECS: i64 = 60 * 60;
|
||||
|
||||
pub(crate) const NO_SESSION: &str =
|
||||
"no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)";
|
||||
|
||||
/// The persisted token pair. Expiries are read from the tokens themselves.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Session {
|
||||
#[serde(default)]
|
||||
pub access: String,
|
||||
#[serde(default)]
|
||||
pub refresh: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Session {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Session")
|
||||
.field("access", &(!self.access.is_empty()).then_some("<redacted>"))
|
||||
.field(
|
||||
"refresh",
|
||||
&(!self.refresh.is_empty()).then_some("<redacted>"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.access.is_empty() && self.refresh.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A token's presence and expiry, never its value.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TokenInfo {
|
||||
pub present: bool,
|
||||
/// When the token expires; `null` when absent or not a JWT.
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
/// Seconds until expiry, negative once expired.
|
||||
pub expires_in_secs: Option<i64>,
|
||||
}
|
||||
|
||||
impl TokenInfo {
|
||||
fn of(token: &str, now: i64) -> Self {
|
||||
let present = !token.is_empty();
|
||||
let exp = present.then(|| token_expiry(token)).flatten();
|
||||
Self {
|
||||
present,
|
||||
expires_at: exp.and_then(|e| DateTime::from_timestamp(e, 0)),
|
||||
expires_in_secs: exp.map(|e| e - now),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `idx stockbit status` and the MCP `stockbit_status` tool report.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionStatus {
|
||||
/// A refresh or access token is stored or seeded (live or not).
|
||||
pub logged_in: bool,
|
||||
pub access: TokenInfo,
|
||||
pub refresh: TokenInfo,
|
||||
pub session_file: String,
|
||||
}
|
||||
|
||||
/// Reads, writes and refreshes the session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionStore {
|
||||
dir: PathBuf,
|
||||
seed: Session,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// The session beside `config.toml`, seeded from the environment.
|
||||
pub fn open() -> Result<Self, IdxError> {
|
||||
let config = crate::config::config_path()?;
|
||||
let dir = config
|
||||
.parent()
|
||||
.ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))?;
|
||||
let env = |name| {
|
||||
std::env::var(name)
|
||||
.map(|v| normalize_token(&v))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
Ok(Self::at(
|
||||
dir.to_path_buf(),
|
||||
Session {
|
||||
access: env(ENV_ACCESS),
|
||||
refresh: env(ENV_REFRESH),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn at(dir: PathBuf, seed: Session) -> Self {
|
||||
Self { dir, seed }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> PathBuf {
|
||||
self.dir.join(SESSION_FILE)
|
||||
}
|
||||
|
||||
/// The file's tokens, each missing one taken from the seed.
|
||||
pub fn load(&self) -> Session {
|
||||
let mut session = std::fs::read_to_string(self.path())
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<Session>(&raw).ok())
|
||||
.unwrap_or_default();
|
||||
session.access = normalize_token(&session.access);
|
||||
session.refresh = normalize_token(&session.refresh);
|
||||
if session.access.is_empty() {
|
||||
session.access.clone_from(&self.seed.access);
|
||||
}
|
||||
if session.refresh.is_empty() {
|
||||
session.refresh.clone_from(&self.seed.refresh);
|
||||
}
|
||||
session
|
||||
}
|
||||
|
||||
pub fn has_session(&self) -> bool {
|
||||
!self.load().is_empty()
|
||||
}
|
||||
|
||||
/// Writes the session atomically, readable by the owner only.
|
||||
pub fn save(&self, session: &Session) -> Result<(), IdxError> {
|
||||
let io = |e: std::io::Error| IdxError::Io(format!("stockbit session: {e}"));
|
||||
std::fs::create_dir_all(&self.dir).map_err(io)?;
|
||||
let tmp = self.dir.join(format!("{SESSION_FILE}.tmp"));
|
||||
let body = serde_json::to_vec_pretty(session)
|
||||
.map_err(|e| IdxError::Io(format!("stockbit session: {e}")))?;
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let mut file = options.open(&tmp).map_err(io)?;
|
||||
std::io::Write::write_all(&mut file, &body).map_err(io)?;
|
||||
file.sync_all().map_err(io)?;
|
||||
drop(file);
|
||||
std::fs::rename(&tmp, self.path()).map_err(io)
|
||||
}
|
||||
|
||||
/// Deletes the stored session; false when there was none. Seeds from the
|
||||
/// environment are not affected.
|
||||
pub fn clear(&self) -> Result<bool, IdxError> {
|
||||
match std::fs::remove_file(self.path()) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(IdxError::Io(format!("stockbit session: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self, now: i64) -> SessionStatus {
|
||||
let session = self.load();
|
||||
SessionStatus {
|
||||
logged_in: !session.is_empty(),
|
||||
access: TokenInfo::of(&session.access, now),
|
||||
refresh: TokenInfo::of(&session.refresh, now),
|
||||
session_file: self.path().display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An access token good for at least `REFRESH_BUFFER_SECS`, refreshing
|
||||
/// with `refresher` (refresh token in, response body out) when it is not.
|
||||
pub fn access(
|
||||
&self,
|
||||
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
|
||||
now: i64,
|
||||
) -> Result<String, IdxError> {
|
||||
let current = self.load();
|
||||
if current.is_empty() {
|
||||
return Err(IdxError::AuthError(NO_SESSION.to_string()));
|
||||
}
|
||||
if fresh(¤t.access, now) {
|
||||
return Ok(current.access);
|
||||
}
|
||||
self.locked(|| {
|
||||
// Another process may have refreshed while this one waited.
|
||||
let session = self.load();
|
||||
if fresh(&session.access, now) {
|
||||
return Ok(session.access);
|
||||
}
|
||||
match self.rotate(&session, refresher) {
|
||||
Ok(access) => Ok(access),
|
||||
// A failed refresh must not block a token that still works.
|
||||
Err(_) if token_expiry(&session.access).is_some_and(|exp| now < exp) => {
|
||||
Ok(session.access)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A new access token after the server rejected `rejected`, unless
|
||||
/// another process already replaced it.
|
||||
pub fn after_rejection(
|
||||
&self,
|
||||
rejected: &str,
|
||||
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
|
||||
) -> Result<String, IdxError> {
|
||||
self.locked(|| {
|
||||
let session = self.load();
|
||||
if !session.access.is_empty() && session.access != rejected {
|
||||
return Ok(session.access);
|
||||
}
|
||||
self.rotate(&session, refresher)
|
||||
})
|
||||
}
|
||||
|
||||
/// Spends the refresh token and stores the new pair.
|
||||
fn rotate(
|
||||
&self,
|
||||
session: &Session,
|
||||
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
|
||||
) -> Result<String, IdxError> {
|
||||
if session.refresh.is_empty() {
|
||||
return Err(IdxError::AuthError(
|
||||
"the Stockbit session has no refresh token; run `idx stockbit login`".to_string(),
|
||||
));
|
||||
}
|
||||
let body = refresher(&session.refresh)?;
|
||||
let next = parse_refresh(&body, session)?;
|
||||
self.save(&next)?;
|
||||
Ok(next.access)
|
||||
}
|
||||
|
||||
/// Runs `f` holding the session lock, so only one process spends the
|
||||
/// rotating refresh token at a time.
|
||||
fn locked<T>(&self, f: impl FnOnce() -> Result<T, IdxError>) -> Result<T, IdxError> {
|
||||
let io = |e: std::io::Error| IdxError::Io(format!("stockbit session lock: {e}"));
|
||||
std::fs::create_dir_all(&self.dir).map_err(io)?;
|
||||
let lock = lock_file(&self.dir.join(LOCK_FILE)).map_err(io)?;
|
||||
lock.lock().map_err(io)?;
|
||||
let result = f();
|
||||
drop(lock);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_file(path: &Path) -> std::io::Result<File> {
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
}
|
||||
|
||||
fn fresh(access: &str, now: i64) -> bool {
|
||||
token_expiry(access).is_some_and(|exp| now < exp - REFRESH_BUFFER_SECS)
|
||||
}
|
||||
|
||||
/// Strips whitespace and a leading `Bearer `.
|
||||
pub fn normalize_token(raw: &str) -> String {
|
||||
let t = raw.trim();
|
||||
t.strip_prefix("Bearer ")
|
||||
.or_else(|| t.strip_prefix("bearer "))
|
||||
.unwrap_or(t)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A JWT's `exp` (Unix seconds), read without verifying the signature: idx
|
||||
/// only carries these tokens.
|
||||
pub fn token_expiry(token: &str) -> Option<i64> {
|
||||
let payload = token.split('.').nth(1)?;
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload.trim_end_matches('='))
|
||||
.ok()?;
|
||||
serde_json::from_slice::<Value>(&bytes)
|
||||
.ok()?
|
||||
.get("exp")?
|
||||
.as_i64()
|
||||
}
|
||||
|
||||
/// Three dot-separated base64url parts with a JSON header (`eyJ`).
|
||||
fn looks_like_jwt(value: &str) -> bool {
|
||||
let mut parts = value.split('.');
|
||||
matches!(parts.next(), Some(h) if h.starts_with("eyJ"))
|
||||
&& parts.next().is_some_and(|p| !p.is_empty())
|
||||
&& parts.next().is_some_and(|p| !p.is_empty())
|
||||
&& parts.next().is_none()
|
||||
}
|
||||
|
||||
/// The JWT under key `slot` anywhere in `value`: a bare string or an object
|
||||
/// with `token`. Stockbit's envelopes have moved between versions.
|
||||
fn find_token(value: &Value, slot: &str) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let direct = map.get(slot).and_then(|hit| {
|
||||
hit.as_str()
|
||||
.or_else(|| hit.get("token").and_then(Value::as_str))
|
||||
.filter(|t| looks_like_jwt(t))
|
||||
.map(str::to_string)
|
||||
});
|
||||
direct.or_else(|| map.values().find_map(|child| find_token(child, slot)))
|
||||
}
|
||||
Value::Array(items) => items.iter().find_map(|child| find_token(child, slot)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The new pair from a `/login/refresh` response; a response that rotates
|
||||
/// only the access token keeps the presented refresh token.
|
||||
pub fn parse_refresh(body: &str, previous: &Session) -> Result<Session, IdxError> {
|
||||
let value: Value = serde_json::from_str(body)
|
||||
.map_err(|e| IdxError::ParseError(format!("stockbit refresh response: {e}")))?;
|
||||
let access = find_token(&value, "access").ok_or_else(|| {
|
||||
IdxError::ParseError("stockbit refresh response carried no access token".to_string())
|
||||
})?;
|
||||
Ok(Session {
|
||||
access,
|
||||
refresh: find_token(&value, "refresh").unwrap_or_else(|| previous.refresh.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// A session from what a user pastes: the `credentialStorage` cookie of
|
||||
/// stockbit.com (percent-encoded JSON), that JSON decoded, or a bare refresh
|
||||
/// token.
|
||||
pub fn session_from_paste(raw: &str) -> Option<Session> {
|
||||
let raw = raw.trim();
|
||||
let decoded = percent_decode(raw);
|
||||
for candidate in [decoded.as_str(), raw] {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(candidate) {
|
||||
let session = Session {
|
||||
access: find_token(&value, "access").unwrap_or_default(),
|
||||
refresh: find_token(&value, "refresh").unwrap_or_default(),
|
||||
};
|
||||
if !session.is_empty() {
|
||||
return Some(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
let token = normalize_token(raw);
|
||||
looks_like_jwt(&token).then(|| Session {
|
||||
access: String::new(),
|
||||
refresh: token,
|
||||
})
|
||||
}
|
||||
|
||||
/// `%XX` escapes decoded; anything else kept as is.
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& let Some(hex) = s.get(i + 1..i + 3)
|
||||
&& let Ok(byte) = u8::from_str_radix(hex, 16)
|
||||
{
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
std::fs::read_to_string(format!(
|
||||
"{}/tests/fixtures/{name}",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// An unsigned JWT expiring at `exp`.
|
||||
fn jwt(exp: i64, tag: &str) -> String {
|
||||
let enc = |v: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v);
|
||||
format!(
|
||||
"{}.{}.{}",
|
||||
enc(r#"{"alg":"none"}"#),
|
||||
enc(&format!(r#"{{"exp":{exp},"tag":"{tag}"}}"#)),
|
||||
enc("sig")
|
||||
)
|
||||
}
|
||||
|
||||
fn refresh_body(access: &str, refresh: &str) -> String {
|
||||
format!(
|
||||
r#"{{"data":{{"access":{{"token":"{access}"}},"refresh":{{"token":"{refresh}"}}}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("idx-stockbit-auth-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Self(dir)
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_800_000_000;
|
||||
|
||||
#[test]
|
||||
fn reads_the_pair_from_a_refresh_response_and_the_cookie() {
|
||||
let pair = parse_refresh(
|
||||
&fixture("stockbit_exodus_login_refresh_constructed.json"),
|
||||
&Session::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(token_expiry(&pair.access), Some(1_792_385_008));
|
||||
assert!(looks_like_jwt(&pair.refresh));
|
||||
|
||||
let pasted = session_from_paste(&fixture(
|
||||
"stockbit_exodus_credential_storage_cookie_constructed.txt",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(looks_like_jwt(&pasted.access) && looks_like_jwt(&pasted.refresh));
|
||||
|
||||
let bare = jwt(NOW, "r");
|
||||
assert_eq!(
|
||||
session_from_paste(&format!(" Bearer {bare}\n")),
|
||||
Some(Session {
|
||||
access: String::new(),
|
||||
refresh: bare
|
||||
})
|
||||
);
|
||||
assert_eq!(session_from_paste("not a token"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_without_a_new_refresh_token_keeps_the_old_one() {
|
||||
let previous = Session {
|
||||
access: String::new(),
|
||||
refresh: jwt(NOW, "old"),
|
||||
};
|
||||
let access = jwt(NOW + 86_400, "a");
|
||||
let next =
|
||||
parse_refresh(&format!(r#"{{"data":{{"access":"{access}"}}}}"#), &previous).unwrap();
|
||||
assert_eq!(
|
||||
next,
|
||||
Session {
|
||||
access,
|
||||
refresh: previous.refresh
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_access_token_is_used_without_refreshing() {
|
||||
let dir = TempDir::new("fresh");
|
||||
let store = SessionStore::at(dir.0.clone(), Session::default());
|
||||
let access = jwt(NOW + 2 * REFRESH_BUFFER_SECS, "a");
|
||||
store
|
||||
.save(&Session {
|
||||
access: access.clone(),
|
||||
refresh: jwt(NOW + 7 * 86_400, "r"),
|
||||
})
|
||||
.unwrap();
|
||||
let refresher = |_: &str| -> Result<String, IdxError> { panic!("must not refresh") };
|
||||
assert_eq!(store.access(&refresher, NOW).unwrap(), access);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_access_token_rotates_both_tokens_and_persists_them() {
|
||||
let dir = TempDir::new("rotate");
|
||||
let store = SessionStore::at(dir.0.clone(), Session::default());
|
||||
let old_refresh = jwt(NOW + 7 * 86_400, "r1");
|
||||
store
|
||||
.save(&Session {
|
||||
access: jwt(NOW + 60, "a1"),
|
||||
refresh: old_refresh.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
let (new_access, new_refresh) = (jwt(NOW + 86_400, "a2"), jwt(NOW + 8 * 86_400, "r2"));
|
||||
let calls = Cell::new(0);
|
||||
let refresher = |presented: &str| {
|
||||
calls.set(calls.get() + 1);
|
||||
assert_eq!(presented, old_refresh);
|
||||
Ok(refresh_body(&new_access, &new_refresh))
|
||||
};
|
||||
assert_eq!(store.access(&refresher, NOW).unwrap(), new_access);
|
||||
assert_eq!(calls.get(), 1);
|
||||
// The rotated pair is what the next process reads.
|
||||
assert_eq!(
|
||||
store.load(),
|
||||
Session {
|
||||
access: new_access,
|
||||
refresh: new_refresh
|
||||
}
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(store.path())
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_token_already_replaced_by_another_process_is_not_refreshed_again() {
|
||||
let dir = TempDir::new("rejected");
|
||||
let store = SessionStore::at(dir.0.clone(), Session::default());
|
||||
let replaced = jwt(NOW + 86_400, "theirs");
|
||||
store
|
||||
.save(&Session {
|
||||
access: replaced.clone(),
|
||||
refresh: jwt(NOW + 86_400, "r"),
|
||||
})
|
||||
.unwrap();
|
||||
let refresher = |_: &str| -> Result<String, IdxError> { panic!("must not refresh") };
|
||||
assert_eq!(
|
||||
store
|
||||
.after_rejection(&jwt(NOW, "mine"), &refresher)
|
||||
.unwrap(),
|
||||
replaced
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_session_is_an_auth_error_and_the_file_beats_the_seed() {
|
||||
let dir = TempDir::new("seed");
|
||||
let empty = SessionStore::at(dir.0.clone(), Session::default());
|
||||
let refresher = |_: &str| -> Result<String, IdxError> { unreachable!() };
|
||||
assert!(matches!(
|
||||
empty.access(&refresher, NOW),
|
||||
Err(IdxError::AuthError(_))
|
||||
));
|
||||
|
||||
let seeded = SessionStore::at(
|
||||
dir.0.clone(),
|
||||
Session {
|
||||
access: String::new(),
|
||||
refresh: jwt(NOW, "env"),
|
||||
},
|
||||
);
|
||||
assert!(seeded.has_session());
|
||||
let stored = jwt(NOW, "file");
|
||||
seeded
|
||||
.save(&Session {
|
||||
access: String::new(),
|
||||
refresh: stored.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(seeded.load().refresh, stored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_debug_never_show_a_token() {
|
||||
let dir = TempDir::new("status");
|
||||
let token = jwt(NOW + 100, "secret");
|
||||
let store = SessionStore::at(
|
||||
dir.0.clone(),
|
||||
Session {
|
||||
access: token.clone(),
|
||||
refresh: String::new(),
|
||||
},
|
||||
);
|
||||
let status = store.status(NOW);
|
||||
assert!(status.logged_in);
|
||||
assert_eq!(status.access.expires_in_secs, Some(100));
|
||||
assert!(!status.refresh.present);
|
||||
let shown = format!(
|
||||
"{} {:?}",
|
||||
serde_json::to_string(&status).unwrap(),
|
||||
store.load()
|
||||
);
|
||||
assert!(!shown.contains(&token), "{shown}");
|
||||
}
|
||||
}
|
||||
543
crates/idx-core/src/api/stockbit/exodus.rs
Normal file
543
crates/idx-core/src/api/stockbit/exodus.rs
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
//! Stockbit's own market-data API, `https://exodus.stockbit.com`, with the
|
||||
//! user's session (see `auth`). Opt-in: used only when a session is stored.
|
||||
//!
|
||||
//! - `GET /company-price-feed/v2/orderbook/companies/{CODE}`: last price,
|
||||
//! OHLC, volume (shares), value, frequency, foreign buy/sell/net (IDR) and
|
||||
//! the depth ladder `bid[]`/`offer[]` of `{price, que_num, volume}` (shares).
|
||||
//! - `GET /company-price-feed/market-time/session`: the market's state,
|
||||
//! `data.detail.regular.state_name` (e.g. `STATE_NAME_MARKET_CLOSED`).
|
||||
//! - `GET /charts/{CODE}/daily?timeframe=1m|3m|1y&chart_type=PRICE_CHART_TYPE_CANDLE`:
|
||||
//! daily candles, `formatted_date` `YYYY-MM-DD`, string OHLC, `value` the
|
||||
//! close and `volume` in lots.
|
||||
//! - `POST /login/refresh` with the refresh token as bearer rotates the pair.
|
||||
//!
|
||||
//! A 401 (or a JSON 403) spends one refresh and retries once. Requests share
|
||||
//! the public page's pacing. Endpoints and units were mapped by fzn0x/idx
|
||||
//! (MIT); `tests/fixtures/stockbit_exodus_*` are its recordings.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{Months, NaiveDate};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::auth::SessionStore;
|
||||
use super::parse::number;
|
||||
use super::{USER_AGENT, pace, stockbit_code};
|
||||
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
|
||||
use crate::error::IdxError;
|
||||
|
||||
const BASE_URL: &str = "https://exodus.stockbit.com";
|
||||
const REFRESH_PATH: &str = "/login/refresh";
|
||||
/// The state in which the book is live. Assumed from Stockbit's naming
|
||||
/// (only `STATE_NAME_MARKET_CLOSED` has been recorded); any other state hides
|
||||
/// bid/offer in quotes, as on the public page.
|
||||
pub const MARKET_OPEN: &str = "STATE_NAME_MARKET_OPEN";
|
||||
/// The market's state changes a few times a day; one read serves this long.
|
||||
const MARKET_STATE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
pub(crate) struct Exodus {
|
||||
agent: ureq::Agent,
|
||||
store: SessionStore,
|
||||
}
|
||||
|
||||
struct Reply {
|
||||
status: u16,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl Reply {
|
||||
/// The server refused the access token: 401, or a 403 with a JSON body
|
||||
/// (a CDN block is an HTML page).
|
||||
fn rejected(&self) -> bool {
|
||||
self.status == 401
|
||||
|| (self.status == 403 && serde_json::from_str::<Value>(&self.body).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
impl Exodus {
|
||||
pub(crate) fn new(store: SessionStore) -> Self {
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.timeout_recv_body(Some(Duration::from_secs(10)))
|
||||
.timeout_global(Some(Duration::from_secs(20)))
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.into();
|
||||
Self { agent, store }
|
||||
}
|
||||
|
||||
pub(crate) fn store(&self) -> &SessionStore {
|
||||
&self.store
|
||||
}
|
||||
|
||||
/// One authorised request (the market's state), refreshing the session
|
||||
/// when needed: proves Stockbit accepts it.
|
||||
pub(crate) fn verify(&self) -> Result<(), IdxError> {
|
||||
self.get("/company-price-feed/market-time/session", "market session")
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn orderbook(&self, resolved: &str) -> Result<OrderBook, IdxError> {
|
||||
let code = stockbit_code(resolved)?;
|
||||
let body = self.get(
|
||||
&format!("/company-price-feed/v2/orderbook/companies/{code}"),
|
||||
resolved,
|
||||
)?;
|
||||
parse_orderbook(resolved, &body, self.market_status())
|
||||
}
|
||||
|
||||
pub(crate) fn quote(&self, resolved: &str) -> Result<Quote, IdxError> {
|
||||
self.orderbook(resolved).map(|book| quote_from_book(&book))
|
||||
}
|
||||
|
||||
/// Daily candles covering `period`, oldest first.
|
||||
pub(crate) fn daily(
|
||||
&self,
|
||||
resolved: &str,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> Result<Vec<Ohlc>, IdxError> {
|
||||
let window =
|
||||
history_window(period, interval).ok_or_else(|| unsupported_window(period, interval))?;
|
||||
let code = stockbit_code(resolved)?;
|
||||
let body = self.get(
|
||||
&format!("/charts/{code}/daily?timeframe={window}&chart_type=PRICE_CHART_TYPE_CANDLE"),
|
||||
resolved,
|
||||
)?;
|
||||
daily_for_period(&body, period)
|
||||
}
|
||||
|
||||
/// The market's state, read at most every `MARKET_STATE_TTL` per process.
|
||||
/// Unknown on any failure, which hides bid/offer.
|
||||
fn market_status(&self) -> Option<String> {
|
||||
static CACHE: Mutex<Option<(Instant, Option<String>)>> = Mutex::new(None);
|
||||
let mut cached = CACHE.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if let Some((at, state)) = cached.as_ref()
|
||||
&& at.elapsed() < MARKET_STATE_TTL
|
||||
{
|
||||
return state.clone();
|
||||
}
|
||||
let state = self
|
||||
.get("/company-price-feed/market-time/session", "market session")
|
||||
.ok()
|
||||
.and_then(|body| parse_market_status(&body));
|
||||
*cached = Some((Instant::now(), state.clone()));
|
||||
state
|
||||
}
|
||||
|
||||
fn get(&self, path: &str, subject: &str) -> Result<String, IdxError> {
|
||||
let refresher = |refresh: &str| self.refresh(refresh);
|
||||
let token = self.store.access(&refresher, unix_now())?;
|
||||
let mut reply = self.send(path, &token)?;
|
||||
if reply.rejected() {
|
||||
let token = self.store.after_rejection(&token, &refresher)?;
|
||||
reply = self.send(path, &token)?;
|
||||
if reply.rejected() {
|
||||
return Err(IdxError::AuthError(
|
||||
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
classify(reply, subject)
|
||||
}
|
||||
|
||||
fn send(&self, path: &str, token: &str) -> Result<Reply, IdxError> {
|
||||
pace();
|
||||
let response = self
|
||||
.agent
|
||||
.get(format!("{BASE_URL}{path}"))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "application/json")
|
||||
.header("Origin", "https://stockbit.com")
|
||||
.header("Referer", "https://stockbit.com/")
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.call()
|
||||
.map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?;
|
||||
read_reply(response)
|
||||
}
|
||||
|
||||
/// `POST /login/refresh`; the response body carries the new pair.
|
||||
fn refresh(&self, refresh: &str) -> Result<String, IdxError> {
|
||||
pace();
|
||||
let response = self
|
||||
.agent
|
||||
.post(format!("{BASE_URL}{REFRESH_PATH}"))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Origin", "https://stockbit.com")
|
||||
.header("Referer", "https://stockbit.com/")
|
||||
.header("Authorization", format!("Bearer {refresh}"))
|
||||
.send("{}")
|
||||
.map_err(|e| IdxError::Http(format!("stockbit refresh: {e}")))?;
|
||||
let reply = read_reply(response)?;
|
||||
match reply.status {
|
||||
200..=299 => Ok(reply.body),
|
||||
401 | 403 => Err(IdxError::AuthError(
|
||||
"Stockbit rejected the refresh token (expired or revoked); run `idx stockbit \
|
||||
login` again"
|
||||
.to_string(),
|
||||
)),
|
||||
status => Err(IdxError::Http(format!("stockbit refresh: HTTP {status}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_reply(response: ureq::http::Response<ureq::Body>) -> Result<Reply, IdxError> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.into_body()
|
||||
.read_to_string()
|
||||
.map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?;
|
||||
Ok(Reply { status, body })
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
|
||||
/// A non-rejected reply as data or the error its status means.
|
||||
fn classify(reply: Reply, subject: &str) -> Result<String, IdxError> {
|
||||
match reply.status {
|
||||
200..=299 => Ok(reply.body),
|
||||
404 if error_type(&reply.body).as_deref() == Some("NOT_FOUND") => {
|
||||
Err(IdxError::SymbolNotFound(subject.to_string()))
|
||||
}
|
||||
429 => Err(IdxError::RateLimited),
|
||||
403 => Err(IdxError::UpstreamBlocked(
|
||||
"exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \
|
||||
retry a block. Try again later or from another network"
|
||||
.to_string(),
|
||||
)),
|
||||
status => Err(IdxError::Http(format!(
|
||||
"stockbit api: HTTP {status}{}",
|
||||
message(&reply.body)
|
||||
.map(|m| format!(": {m}"))
|
||||
.unwrap_or_default()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_type(body: &str) -> Option<String> {
|
||||
serde_json::from_str::<Value>(body)
|
||||
.ok()?
|
||||
.get("error_type")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn message(body: &str) -> Option<String> {
|
||||
serde_json::from_str::<Value>(body)
|
||||
.ok()?
|
||||
.get("message")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn data(body: &str, what: &str) -> Result<Value, IdxError> {
|
||||
let mut value: Value = serde_json::from_str(body)
|
||||
.map_err(|e| IdxError::ParseError(format!("stockbit {what}: {e}")))?;
|
||||
match value.get_mut("data") {
|
||||
Some(data) => Ok(data.take()),
|
||||
None => Err(IdxError::ParseError(format!("stockbit {what}: no data"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn whole(value: Option<&Value>) -> Option<i64> {
|
||||
value.and_then(number).map(|n| n.round() as i64)
|
||||
}
|
||||
|
||||
fn count(value: Option<&Value>) -> Option<u64> {
|
||||
value
|
||||
.and_then(number)
|
||||
.filter(|n| *n >= 0.0)
|
||||
.map(|n| n.round() as u64)
|
||||
}
|
||||
|
||||
/// The order book and session figures; `market_status` comes from the
|
||||
/// market-time endpoint.
|
||||
pub fn parse_orderbook(
|
||||
resolved: &str,
|
||||
body: &str,
|
||||
market_status: Option<String>,
|
||||
) -> Result<OrderBook, IdxError> {
|
||||
let d = data(body, "order book")?;
|
||||
let price = whole(d.get("lastprice").or_else(|| d.get("close")))
|
||||
.ok_or_else(|| IdxError::ParseError("stockbit order book: no last price".to_string()))?;
|
||||
let levels = |key: &str| -> Vec<BookLevel> {
|
||||
d.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|rows| {
|
||||
rows.iter()
|
||||
.filter_map(|row| {
|
||||
Some(BookLevel {
|
||||
price: whole(row.get("price")).filter(|p| *p > 0)?,
|
||||
shares: count(row.get("volume")).unwrap_or(0),
|
||||
orders: count(row.get("que_num")).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let listing = d.get("status").and_then(Value::as_str).unwrap_or_default();
|
||||
let volume = count(d.get("volume"));
|
||||
let session_note = if !listing.is_empty() && !listing.eq_ignore_ascii_case("active") {
|
||||
Some(format!(
|
||||
"Stockbit lists the stock as {}",
|
||||
listing.to_ascii_lowercase()
|
||||
))
|
||||
} else if volume == Some(0) {
|
||||
Some("no trades this session".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(OrderBook {
|
||||
symbol: resolved.to_string(),
|
||||
name: d.get("name").and_then(Value::as_str).map(str::to_string),
|
||||
price,
|
||||
change: whole(d.get("change")),
|
||||
change_pct: d.get("percentage_change").and_then(number),
|
||||
prev_close: whole(d.get("previous")),
|
||||
open: whole(d.get("open")),
|
||||
high: whole(d.get("high")),
|
||||
low: whole(d.get("low")),
|
||||
volume,
|
||||
value: count(d.get("value")),
|
||||
frequency: count(d.get("frequency")),
|
||||
foreign_buy: whole(d.get("fbuy")),
|
||||
foreign_sell: whole(d.get("fsell")),
|
||||
foreign_net: whole(d.get("fnet")),
|
||||
bids: levels("bid"),
|
||||
offers: levels("offer"),
|
||||
market_status,
|
||||
session_note,
|
||||
})
|
||||
}
|
||||
|
||||
/// A quote from the order book; bid/offer only while the market is open.
|
||||
pub fn quote_from_book(book: &OrderBook) -> Quote {
|
||||
let open = book.market_status.as_deref() == Some(MARKET_OPEN);
|
||||
let best = |levels: &[BookLevel]| open.then(|| levels.first().map(|l| l.price)).flatten();
|
||||
Quote {
|
||||
symbol: book.symbol.clone(),
|
||||
price: book.price,
|
||||
change: book.change,
|
||||
change_pct: book.change_pct,
|
||||
volume: book.volume,
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
week52_position: None,
|
||||
range_signal: None,
|
||||
prev_close: book.prev_close,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
behind_session: false,
|
||||
bid: best(&book.bids),
|
||||
offer: best(&book.offers),
|
||||
market_status: book.market_status.clone(),
|
||||
session_note: book.session_note.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_market_status(body: &str) -> Option<String> {
|
||||
data(body, "market session")
|
||||
.ok()?
|
||||
.pointer("/detail/regular/state_name")?
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// The chart window holding `period` of daily candles: Stockbit picks the
|
||||
/// bar size from the window (`1w` is hourly, `3y`/`5y` weekly), so only the
|
||||
/// daily windows qualify.
|
||||
pub fn history_window(period: &Period, interval: &Interval) -> Option<&'static str> {
|
||||
match (interval, period) {
|
||||
(Interval::Day, Period::FiveDays | Period::OneMonth) => Some("1m"),
|
||||
(Interval::Day, Period::ThreeMonths) => Some("3m"),
|
||||
(Interval::Day, Period::SixMonths | Period::OneYear) => Some("1y"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unsupported_window(period: &Period, interval: &Interval) -> IdxError {
|
||||
IdxError::Unsupported(format!(
|
||||
"Stockbit history covers 5d to 1y of daily candles, not {} at {}; use \
|
||||
--history-provider yahoo",
|
||||
period.as_str(),
|
||||
interval.as_str()
|
||||
))
|
||||
}
|
||||
|
||||
/// The candles of `period` from a daily chart response.
|
||||
pub fn daily_for_period(body: &str, period: &Period) -> Result<Vec<Ohlc>, IdxError> {
|
||||
Ok(trim_to_period(parse_daily(body)?, period))
|
||||
}
|
||||
|
||||
/// Daily candles, oldest first. Volume arrives in lots and is returned in
|
||||
/// shares, as from the other providers.
|
||||
pub fn parse_daily(body: &str) -> Result<Vec<Ohlc>, IdxError> {
|
||||
let d = data(body, "chart")?;
|
||||
let rows = d
|
||||
.get("prices")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| IdxError::ParseError("stockbit chart: no prices".to_string()))?;
|
||||
let mut bars = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let Some(close) = whole(row.get("value")) else {
|
||||
continue;
|
||||
};
|
||||
let Some(date) = row
|
||||
.get("formatted_date")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|d| NaiveDate::parse_from_str(d.trim(), "%Y-%m-%d").ok())
|
||||
else {
|
||||
return Err(IdxError::ParseError(format!(
|
||||
"stockbit chart: row without a daily date: {row}"
|
||||
)));
|
||||
};
|
||||
bars.push(Ohlc {
|
||||
date,
|
||||
open: whole(row.get("open")).unwrap_or(close),
|
||||
high: whole(row.get("high")).unwrap_or(close),
|
||||
low: whole(row.get("low")).unwrap_or(close),
|
||||
close,
|
||||
volume: count(row.get("volume")).unwrap_or(0).saturating_mul(100),
|
||||
});
|
||||
}
|
||||
bars.sort_by_key(|b| b.date);
|
||||
Ok(bars)
|
||||
}
|
||||
|
||||
/// The candles within `period` of the latest one (5 sessions for `5d`).
|
||||
fn trim_to_period(mut bars: Vec<Ohlc>, period: &Period) -> Vec<Ohlc> {
|
||||
let Some(last) = bars.last().map(|b| b.date) else {
|
||||
return bars;
|
||||
};
|
||||
let months = match period {
|
||||
Period::FiveDays => {
|
||||
let start = bars.len().saturating_sub(5);
|
||||
return bars.split_off(start);
|
||||
}
|
||||
Period::OneMonth => 1,
|
||||
Period::ThreeMonths => 3,
|
||||
Period::SixMonths => 6,
|
||||
_ => 12,
|
||||
};
|
||||
match last.checked_sub_months(Months::new(months)) {
|
||||
Some(start) => bars.into_iter().filter(|b| b.date > start).collect(),
|
||||
None => bars,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
std::fs::read_to_string(format!(
|
||||
"{}/tests/fixtures/stockbit_exodus_{name}",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_book_carries_depth_in_shares_and_foreign_flow() {
|
||||
let book = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
|
||||
assert_eq!(
|
||||
(book.price, book.prev_close, book.change),
|
||||
(5175, Some(6075), Some(-900))
|
||||
);
|
||||
assert_eq!(
|
||||
(book.open, book.high, book.low),
|
||||
(Some(5825), Some(5825), Some(5175))
|
||||
);
|
||||
assert_eq!(book.foreign_net, Some(-2_329_493_087_500));
|
||||
// BBCA closed at its lower limit: no bids, offers best price first.
|
||||
assert!(book.bids.is_empty());
|
||||
assert_eq!(
|
||||
book.offers[0],
|
||||
BookLevel {
|
||||
price: 5175,
|
||||
shares: 264_462_500,
|
||||
orders: 3284
|
||||
}
|
||||
);
|
||||
assert!(book.offers.windows(2).all(|w| w[0].price < w[1].price));
|
||||
assert_eq!(book.session_note, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_shows_the_best_levels_only_while_the_market_is_open() {
|
||||
let mut book = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
|
||||
let closed = quote_from_book(&book);
|
||||
assert_eq!((closed.bid, closed.offer), (None, None));
|
||||
book.market_status = Some(MARKET_OPEN.to_string());
|
||||
let open = quote_from_book(&book);
|
||||
assert_eq!((open.bid, open.offer), (None, Some(5175)));
|
||||
assert_eq!(open.price, 5175);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn market_status_is_read_verbatim() {
|
||||
assert_eq!(
|
||||
parse_market_status(&fixture("market_session.json")).as_deref(),
|
||||
Some("STATE_NAME_MARKET_CLOSED")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_map_to_their_codes() {
|
||||
let reply = |status, body: &str| Reply {
|
||||
status,
|
||||
body: body.to_string(),
|
||||
};
|
||||
assert!(matches!(
|
||||
classify(reply(404, &fixture("orderbook_zzzz.json")), "ZZZZ.JK"),
|
||||
Err(IdxError::SymbolNotFound(s)) if s == "ZZZZ.JK"
|
||||
));
|
||||
assert!(reply(401, &fixture("unauthorized.json")).rejected());
|
||||
assert!(reply(403, &fixture("unauthorized.json")).rejected());
|
||||
let blocked = reply(403, "<HTML>Request blocked.</HTML>");
|
||||
assert!(!blocked.rejected());
|
||||
assert!(matches!(
|
||||
classify(blocked, "BBCA.JK"),
|
||||
Err(IdxError::UpstreamBlocked(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
classify(reply(429, ""), "x"),
|
||||
Err(IdxError::RateLimited)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_candles_convert_lots_to_shares_and_trim_to_the_period() {
|
||||
let year = parse_daily(&fixture("chart_candle_bbca_1y.json")).unwrap();
|
||||
assert_eq!(year.len(), 240);
|
||||
assert!(year.windows(2).all(|w| w[0].date < w[1].date));
|
||||
let month = parse_daily(&fixture("chart_candle_bbca_1m.json")).unwrap();
|
||||
let first = &month[0];
|
||||
assert_eq!(first.date.to_string(), "2026-09-16");
|
||||
assert_eq!(
|
||||
(first.open, first.high, first.low, first.close),
|
||||
(6350, 6425, 6325, 6325)
|
||||
);
|
||||
assert_eq!(first.volume, 72_157_400);
|
||||
|
||||
let six = trim_to_period(year.clone(), &Period::SixMonths);
|
||||
assert_eq!(six.first().unwrap().date.to_string(), "2026-04-17");
|
||||
assert_eq!(trim_to_period(month, &Period::FiveDays).len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_daily_windows_map_to_a_chart() {
|
||||
assert_eq!(history_window(&Period::OneYear, &Interval::Day), Some("1y"));
|
||||
assert_eq!(history_window(&Period::TwoYears, &Interval::Day), None);
|
||||
assert_eq!(history_window(&Period::OneYear, &Interval::Week), None);
|
||||
assert_eq!(history_window(&Period::OneDay, &Interval::Day), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@
|
|||
//! - Stockbit's terms describe its market data as delayed at least 10
|
||||
//! minutes and for personal use; frontends say so.
|
||||
|
||||
pub mod auth;
|
||||
pub mod exodus;
|
||||
mod parse;
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
|
|
@ -24,9 +26,13 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|||
|
||||
pub use parse::{parse_symbol_page, status_error, stockbit_code};
|
||||
|
||||
use super::types::{Fundamentals, Intraday, Quote};
|
||||
use super::{FundamentalsProvider, IntradayProvider, QuoteProvider};
|
||||
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||
use super::{
|
||||
FundamentalsProvider, HistoryProvider, IntradayProvider, OrderBookProvider, QuoteProvider,
|
||||
};
|
||||
use crate::error::IdxError;
|
||||
use auth::SessionStore;
|
||||
use exodus::Exodus;
|
||||
|
||||
pub const SOURCE: &str = "stockbit";
|
||||
/// Identifies idx-cli and where to reach its maintainers.
|
||||
|
|
@ -45,8 +51,13 @@ pub(crate) const CACHE_TTL_SECS: u64 = 5;
|
|||
const MIN_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const BASE_URL: &str = "https://stockbit.com/symbol/";
|
||||
|
||||
/// Quotes come from the user's session when one is stored (live, from
|
||||
/// `exodus`), else from the public page. The order book and history need the
|
||||
/// session; intraday always reads the public page.
|
||||
pub struct StockbitProvider {
|
||||
agent: ureq::Agent,
|
||||
/// `None` when the config directory cannot be resolved.
|
||||
exodus: Option<Exodus>,
|
||||
}
|
||||
|
||||
impl StockbitProvider {
|
||||
|
|
@ -59,7 +70,20 @@ impl StockbitProvider {
|
|||
.timeout_global(Some(Duration::from_secs(20)))
|
||||
.build()
|
||||
.into();
|
||||
Self { agent }
|
||||
Self {
|
||||
agent,
|
||||
exodus: SessionStore::open().ok().map(Exodus::new),
|
||||
}
|
||||
}
|
||||
|
||||
/// The session API when the user has a session.
|
||||
fn session(&self) -> Option<&Exodus> {
|
||||
self.exodus.as_ref().filter(|e| e.store().has_session())
|
||||
}
|
||||
|
||||
fn require_session(&self) -> Result<&Exodus, IdxError> {
|
||||
self.session()
|
||||
.ok_or_else(|| IdxError::AuthError(auth::NO_SESSION.to_string()))
|
||||
}
|
||||
|
||||
/// The quote and today's series for a resolved symbol (`BBCA.JK`).
|
||||
|
|
@ -99,7 +123,7 @@ impl Default for StockbitProvider {
|
|||
/// `idx` calls, a CLI beside idx-mcp) share the time of the last request in
|
||||
/// `<cache>/stockbit.pace` under an exclusive file lock. When that file is
|
||||
/// unusable, pacing is per process.
|
||||
fn pace() {
|
||||
pub(crate) fn pace() {
|
||||
static LAST: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(prev) = *last {
|
||||
|
|
@ -151,7 +175,10 @@ fn now_ms() -> u128 {
|
|||
|
||||
impl QuoteProvider for StockbitProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||
self.snapshot(symbol).map(|day| day.quote)
|
||||
match self.session() {
|
||||
Some(exodus) => exodus.quote(symbol),
|
||||
None => self.snapshot(symbol).map(|day| day.quote),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,12 +194,71 @@ impl IntradayProvider for StockbitProvider {
|
|||
}
|
||||
}
|
||||
|
||||
impl OrderBookProvider for StockbitProvider {
|
||||
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||
self.require_session()?.orderbook(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryProvider for StockbitProvider {
|
||||
fn history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> Result<Vec<Bar>, IdxError> {
|
||||
self.require_session()?.daily(symbol, period, interval)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user has a Stockbit session stored or seeded.
|
||||
pub fn has_session() -> bool {
|
||||
SessionStore::open().is_ok_and(|store| store.has_session())
|
||||
}
|
||||
|
||||
pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError {
|
||||
IdxError::Unsupported(format!(
|
||||
"{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo"
|
||||
))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn login(pasted: &str) -> Result<auth::SessionStatus, IdxError> {
|
||||
let session = auth::session_from_paste(pasted).ok_or_else(|| {
|
||||
IdxError::InvalidInput(
|
||||
"not a Stockbit session: paste the value of stockbit.com's `credentialStorage` \
|
||||
cookie, or a refresh token"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let store = SessionStore::open()?;
|
||||
store.save(&session)?;
|
||||
#[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() {
|
||||
let _ = store.clear();
|
||||
return Err(err);
|
||||
}
|
||||
Ok(store.status(chrono::Utc::now().timestamp()))
|
||||
}
|
||||
|
||||
/// The stored session's state, without a request and without any token.
|
||||
pub fn session_status() -> Result<auth::SessionStatus, IdxError> {
|
||||
Ok(SessionStore::open()?.status(chrono::Utc::now().timestamp()))
|
||||
}
|
||||
|
||||
/// Deletes the stored session: whether there was one, and the state after
|
||||
/// (still logged in when `IDX_STOCKBIT_*` seeds one).
|
||||
pub fn logout() -> Result<(bool, auth::SessionStatus), IdxError> {
|
||||
let store = SessionStore::open()?;
|
||||
let removed = store.clear()?;
|
||||
Ok((removed, store.status(chrono::Utc::now().timestamp())))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::USER_AGENT;
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ fn wib(local: NaiveDateTime) -> Option<DateTime<FixedOffset>> {
|
|||
|
||||
/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`,
|
||||
/// `"NA"` and empty strings are absent.
|
||||
fn number(value: &Value) -> Option<f64> {
|
||||
pub(super) fn number(value: &Value) -> Option<f64> {
|
||||
let n = match value {
|
||||
Value::Number(n) => n.as_f64()?,
|
||||
Value::String(s) => s.trim().parse::<f64>().ok()?,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,52 @@ pub struct Intraday {
|
|||
pub points: Vec<IntradayPoint>,
|
||||
}
|
||||
|
||||
/// One price level of an order book.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BookLevel {
|
||||
/// Limit price in IDR (whole Rupiah).
|
||||
pub price: i64,
|
||||
/// Size resting at this price, in shares (1 lot = 100 shares).
|
||||
pub shares: u64,
|
||||
/// Orders queued at this price.
|
||||
pub orders: u64,
|
||||
}
|
||||
|
||||
/// The order book of one IDX stock from the user's Stockbit session, with
|
||||
/// the session's figures and foreign flow. Outside trading it is the book as
|
||||
/// the market left it; `market_status` says which.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrderBook {
|
||||
pub symbol: String,
|
||||
pub name: Option<String>,
|
||||
/// Last price in IDR (whole Rupiah).
|
||||
pub price: i64,
|
||||
pub change: Option<i64>,
|
||||
pub change_pct: Option<f64>,
|
||||
pub prev_close: Option<i64>,
|
||||
pub open: Option<i64>,
|
||||
pub high: Option<i64>,
|
||||
pub low: Option<i64>,
|
||||
/// Session volume in shares.
|
||||
pub volume: Option<u64>,
|
||||
/// Session value traded in IDR.
|
||||
pub value: Option<u64>,
|
||||
/// Trades in the session.
|
||||
pub frequency: Option<u64>,
|
||||
/// Foreign buy, sell and net value over the session, IDR.
|
||||
pub foreign_buy: Option<i64>,
|
||||
pub foreign_sell: Option<i64>,
|
||||
pub foreign_net: Option<i64>,
|
||||
/// Best price first.
|
||||
pub bids: Vec<BookLevel>,
|
||||
/// Best price first.
|
||||
pub offers: Vec<BookLevel>,
|
||||
/// Stockbit's session state, verbatim (e.g. `STATE_NAME_MARKET_CLOSED`).
|
||||
pub market_status: Option<String>,
|
||||
/// Stockbit's listing status when it is not active (e.g. suspended).
|
||||
pub session_note: Option<String>,
|
||||
}
|
||||
|
||||
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Ohlc {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ pub enum HistoryProviderKind {
|
|||
Auto,
|
||||
Yahoo,
|
||||
Msn,
|
||||
// Needs the user's Stockbit session (`idx stockbit login`).
|
||||
Stockbit,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
|
|
@ -63,6 +65,7 @@ impl HistoryProviderKind {
|
|||
Self::Auto => "auto",
|
||||
Self::Yahoo => "yahoo",
|
||||
Self::Msn => "msn",
|
||||
Self::Stockbit => "stockbit",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,9 +76,11 @@ impl HistoryProviderKind {
|
|||
Ok(Self::Yahoo)
|
||||
} else if value.eq_ignore_ascii_case("msn") {
|
||||
Ok(Self::Msn)
|
||||
} else if value.eq_ignore_ascii_case("stockbit") {
|
||||
Ok(Self::Stockbit)
|
||||
} else {
|
||||
Err(IdxError::ConfigError(format!(
|
||||
"invalid history provider '{value}' (expected auto, yahoo, or msn)"
|
||||
"invalid history provider '{value}' (expected auto, yahoo, msn, or stockbit)"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ use std::str::FromStr;
|
|||
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
|
||||
|
||||
use crate::api::types::{Interval, Intraday, Ohlc, Period, Quote};
|
||||
use crate::api::{history_provider, history_source, idx_offset, intraday_provider, stockbit};
|
||||
use crate::api::types::{Interval, Intraday, Ohlc, OrderBook, Period, Quote};
|
||||
use crate::api::{
|
||||
history_provider, history_source, idx_offset, intraday_provider, orderbook_provider, stockbit,
|
||||
};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||
use crate::error::IdxError;
|
||||
|
|
@ -91,11 +93,20 @@ impl Market<'_> {
|
|||
}
|
||||
|
||||
/// Resolves the history source for an optional `--history-provider`
|
||||
/// override, without fetching.
|
||||
pub fn history_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
|
||||
/// override and the window asked for, without fetching.
|
||||
pub fn history_source(
|
||||
&self,
|
||||
mode: Option<HistoryProviderKind>,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
) -> HistorySource {
|
||||
let mode = mode.unwrap_or(self.idx.config.history_provider);
|
||||
let selected = self.idx.provider.kind();
|
||||
let provider = history_source(selected, mode);
|
||||
// Only `auto` under the stockbit provider looks for a session.
|
||||
let session = mode == HistoryProviderKind::Auto
|
||||
&& selected == ProviderKind::Stockbit
|
||||
&& stockbit::has_session();
|
||||
let provider = history_source(selected, mode, period, interval, session);
|
||||
HistorySource {
|
||||
provider,
|
||||
mode,
|
||||
|
|
@ -112,9 +123,8 @@ impl Market<'_> {
|
|||
policy: CachePolicy,
|
||||
) -> Result<Fetched<Vec<Ohlc>>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let (provider_kind, provider) =
|
||||
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
|
||||
let bucket = format!("{}-history", provider_kind.as_str());
|
||||
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(
|
||||
|
|
@ -124,7 +134,7 @@ impl Market<'_> {
|
|||
key: &key,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
subject: &resolved,
|
||||
source: provider_kind.as_str(),
|
||||
source: source.provider.as_str(),
|
||||
},
|
||||
policy,
|
||||
|| provider.history(&resolved, period, interval),
|
||||
|
|
@ -132,6 +142,31 @@ impl Market<'_> {
|
|||
)
|
||||
}
|
||||
|
||||
/// The order book of one IDX stock from the user's Stockbit session,
|
||||
/// whichever market provider is selected.
|
||||
pub fn orderbook(
|
||||
&self,
|
||||
symbol: &str,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<OrderBook>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let resolved = idx.resolve(symbol)?;
|
||||
let provider = orderbook_provider();
|
||||
cached_fetch(
|
||||
&idx.cache,
|
||||
CacheSpec {
|
||||
bucket: "stockbit-orderbook",
|
||||
key: &resolved,
|
||||
ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
|
||||
subject: &resolved,
|
||||
source: stockbit::SOURCE,
|
||||
},
|
||||
policy,
|
||||
|| provider.orderbook(&resolved),
|
||||
Ok,
|
||||
)
|
||||
}
|
||||
|
||||
/// Screener results, ranked locally and truncated to `query.limit`.
|
||||
pub fn screen(
|
||||
&self,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue