mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
Merge pull request #73 from 0xrsydn/feat/stockbit
feat: login-free Stockbit intraday and quote provider
This commit is contained in:
commit
be9d17bb80
157 changed files with 14699 additions and 481 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,23 +128,24 @@ 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 resolved = idx.resolve(symbol)?;
|
||||
cached_fetch(
|
||||
&idx.cache,
|
||||
CacheSpec {
|
||||
bucket: &bucket,
|
||||
key: &resolved,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
subject: &resolved,
|
||||
source: provider_kind.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(
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
//! 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, Period, Quote};
|
||||
use super::stockbit::{corpaction, exodus, movement};
|
||||
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||
use super::{
|
||||
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider,
|
||||
TradingSummaryProvider, idx, ipot, msn, yahoo,
|
||||
BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider,
|
||||
IntradayProvider, OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
|
||||
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
||||
};
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||
use crate::config::ProviderKind;
|
||||
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||
use crate::error::IdxError;
|
||||
use crate::movements::types::{MovementPage, MovementQuery};
|
||||
|
||||
pub(crate) fn enabled() -> bool {
|
||||
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
|
||||
|
|
@ -45,6 +49,10 @@ impl MockProvider {
|
|||
match provider {
|
||||
ProviderKind::Yahoo => Self::from_yahoo_fixtures(history_verbose),
|
||||
ProviderKind::Msn => Self::from_msn_fixtures(),
|
||||
// `MockStockbitProvider` serves Stockbit; it has no history.
|
||||
ProviderKind::Stockbit => Self::with_error(IdxError::Unsupported(
|
||||
"Stockbit serves no price history".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +139,181 @@ impl HistoryProvider for MockProvider {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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 (`401` on the session API: Stockbit refused the session),
|
||||
/// and `IDX_MOCK_ERROR` fails as for the other providers.
|
||||
pub struct MockStockbitProvider;
|
||||
|
||||
impl MockStockbitProvider {
|
||||
fn failure(&self, symbol: &str) -> Option<IdxError> {
|
||||
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
||||
return Some(IdxError::ProviderUnavailable);
|
||||
}
|
||||
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!(
|
||||
"tests/fixtures/stockbit_symbol_{}.html",
|
||||
code.to_ascii_lowercase()
|
||||
);
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(html) => stockbit::parse_symbol_page(symbol, &html),
|
||||
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 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(),
|
||||
));
|
||||
}
|
||||
// The session API's own block, as `exodus::fetch` reports it.
|
||||
if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("403") {
|
||||
return Err(stockbit::exodus::blocked());
|
||||
}
|
||||
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.
|
||||
quote.as_of = None;
|
||||
Ok(quote)
|
||||
}
|
||||
}
|
||||
|
||||
impl FundamentalsProvider for MockStockbitProvider {
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
|
||||
Err(stockbit::fundamentals_unsupported(symbol))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntradayProvider for MockStockbitProvider {
|
||||
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError> {
|
||||
self.snapshot(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
impl OrderBookProvider for MockStockbitProvider {
|
||||
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||
self.book(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
/// `stockbit_exodus_corpaction_<type>.json` for the market,
|
||||
/// `..._<type>_<code>.json` for one stock; a stock without a fixture has none
|
||||
/// of that kind, as Stockbit answers.
|
||||
impl CorporateActionProvider for MockStockbitProvider {
|
||||
fn corporate_actions(
|
||||
&self,
|
||||
kind: ActionKind,
|
||||
symbol: Option<&str>,
|
||||
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||
let subject = symbol.unwrap_or("corporate actions");
|
||||
let name = match symbol {
|
||||
Some(symbol) => format!(
|
||||
"corpaction_{}_{}.json",
|
||||
corpaction::path_type(kind),
|
||||
stockbit::stockbit_code(symbol)?.to_ascii_lowercase()
|
||||
),
|
||||
None => format!("corpaction_{}.json", corpaction::path_type(kind)),
|
||||
};
|
||||
let body = match self.exodus(subject, &name) {
|
||||
Err(IdxError::SymbolNotFound(_)) if symbol.is_some() => {
|
||||
r#"{"data":{"none":[]}}"#.to_string()
|
||||
}
|
||||
other => other?,
|
||||
};
|
||||
corpaction::parse_corporate_actions(kind, &body)
|
||||
}
|
||||
}
|
||||
|
||||
/// `stockbit_exodus_majorholder[_<code>][_p<page>].json` (no suffix for page
|
||||
/// 1); a missing fixture is an empty page, as Stockbit answers past the end.
|
||||
/// The query's filters apply to the fixture's rows, as Stockbit applies them.
|
||||
impl ShareholderMovementProvider for MockStockbitProvider {
|
||||
fn shareholder_movements(
|
||||
&self,
|
||||
query: &MovementQuery,
|
||||
page: u32,
|
||||
) -> Result<MovementPage, IdxError> {
|
||||
let subject = query.symbol.as_deref().unwrap_or("shareholder movements");
|
||||
// `IDX_MOCK_MOVEMENTS_FAIL_PAGE=N` rate-limits page N alone.
|
||||
if std::env::var("IDX_MOCK_MOVEMENTS_FAIL_PAGE").ok() == Some(page.to_string()) {
|
||||
return Err(IdxError::RateLimited);
|
||||
}
|
||||
let mut name = "majorholder".to_string();
|
||||
if let Some(symbol) = &query.symbol {
|
||||
name.push('_');
|
||||
name.push_str(&stockbit::stockbit_code(symbol)?.to_ascii_lowercase());
|
||||
}
|
||||
if page > 1 {
|
||||
name.push_str(&format!("_p{page}"));
|
||||
}
|
||||
let body = match self.exodus(subject, &format!("{name}.json")) {
|
||||
Err(IdxError::SymbolNotFound(_)) if query.symbol.is_some() || page > 1 => {
|
||||
r#"{"data":{"is_more":false,"movement":[]}}"#.to_string()
|
||||
}
|
||||
other => other?,
|
||||
};
|
||||
let mut parsed = movement::parse_movements(&body)?;
|
||||
parsed.movements.retain(|m| query.admits(m));
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pub mod ipot;
|
|||
#[cfg(feature = "testing")]
|
||||
pub mod mock;
|
||||
pub mod msn;
|
||||
pub mod stockbit;
|
||||
pub mod types;
|
||||
pub mod yahoo;
|
||||
|
||||
|
|
@ -13,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind};
|
|||
use crate::error::IdxError;
|
||||
use types::{
|
||||
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
|
||||
NewsItem, Period, Quote, SentimentData,
|
||||
Intraday, NewsItem, OrderBook, Period, Quote, SentimentData,
|
||||
};
|
||||
|
||||
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
|
||||
|
|
@ -64,6 +65,41 @@ pub trait TradingSummaryProvider {
|
|||
) -> Result<Vec<crate::flow::types::StockDay>, IdxError>;
|
||||
}
|
||||
|
||||
/// Today's session of one IDX symbol: quote and price per minute. Served by
|
||||
/// Stockbit's public page whichever market provider is selected.
|
||||
pub trait IntradayProvider {
|
||||
/// `symbol` is resolved, e.g. `BBCA.JK`.
|
||||
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>;
|
||||
}
|
||||
|
||||
/// Corporate actions of one kind: needs the user's Stockbit session.
|
||||
pub trait CorporateActionProvider {
|
||||
/// One stock's history (`symbol` resolved, e.g. `BBCA.JK`), or the
|
||||
/// market's announced and recent actions with `None`.
|
||||
fn corporate_actions(
|
||||
&self,
|
||||
kind: crate::corporate::types::ActionKind,
|
||||
symbol: Option<&str>,
|
||||
) -> Result<Vec<crate::corporate::types::CorporateAction>, IdxError>;
|
||||
}
|
||||
|
||||
/// Shareholder movements: needs the user's Stockbit session.
|
||||
pub trait ShareholderMovementProvider {
|
||||
/// Page `page` (from 1) of `query`, whose `symbol` is resolved
|
||||
/// (`BBCA.JK`) or `None` for the market.
|
||||
fn shareholder_movements(
|
||||
&self,
|
||||
query: &crate::movements::types::MovementQuery,
|
||||
page: u32,
|
||||
) -> Result<crate::movements::types::MovementPage, IdxError>;
|
||||
}
|
||||
|
||||
pub trait FundamentalsProvider {
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||
}
|
||||
|
|
@ -221,7 +257,10 @@ fn mock_enabled() -> bool {
|
|||
|
||||
#[cfg(feature = "testing")]
|
||||
fn mock_market(provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
|
||||
Some(Box::new(mock::MockProvider::from_fixtures(provider)))
|
||||
match provider {
|
||||
ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)),
|
||||
_ => Some(Box::new(mock::MockProvider::from_fixtures(provider))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "testing"))]
|
||||
|
|
@ -231,9 +270,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"))]
|
||||
|
|
@ -274,6 +316,18 @@ fn build_selected_provider(
|
|||
news: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
screener: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||
},
|
||||
// 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())),
|
||||
profile: None,
|
||||
financials: None,
|
||||
earnings: None,
|
||||
sentiment: None,
|
||||
insights: None,
|
||||
news: None,
|
||||
screener: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,6 +351,38 @@ pub fn trading_summary_provider() -> Box<dyn TradingSummaryProvider> {
|
|||
Box::new(idx::IdxSiteProvider)
|
||||
}
|
||||
|
||||
pub fn intraday_provider() -> Box<dyn IntradayProvider> {
|
||||
#[cfg(feature = "testing")]
|
||||
if mock::enabled() {
|
||||
return Box::new(mock::MockStockbitProvider);
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
pub fn corporate_action_provider() -> Box<dyn CorporateActionProvider> {
|
||||
#[cfg(feature = "testing")]
|
||||
if mock::enabled() {
|
||||
return Box::new(mock::MockStockbitProvider);
|
||||
}
|
||||
Box::new(stockbit::StockbitProvider::new())
|
||||
}
|
||||
|
||||
pub fn shareholder_movement_provider() -> Box<dyn ShareholderMovementProvider> {
|
||||
#[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")]
|
||||
|
|
@ -307,38 +393,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::Yahoo,
|
||||
ProviderKind::Msn => 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)))),
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -439,4 +531,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result<Quote, Id
|
|||
avg_volume: round_u64(quote.average_volume),
|
||||
as_of: crate::api::parse_as_of(quote.time_last_traded.as_deref()),
|
||||
behind_session: false,
|
||||
bid: None,
|
||||
offer: None,
|
||||
market_status: None,
|
||||
session_note: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -720,6 +724,10 @@ pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Qu
|
|||
avg_volume: round_u64(q.average_volume),
|
||||
as_of: crate::api::parse_as_of(q.time_last_traded.as_deref()),
|
||||
behind_session: false,
|
||||
bid: None,
|
||||
offer: None,
|
||||
market_status: None,
|
||||
session_note: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
698
crates/idx-core/src/api/stockbit/auth.rs
Normal file
698
crates/idx-core/src/api/stockbit/auth.rs
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
//! 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,
|
||||
}
|
||||
|
||||
/// Where the session and idx's browser profile live: beside `config.toml`,
|
||||
/// or in the other default location when that is where they are.
|
||||
///
|
||||
/// On macOS the config directory depends on the environment: a shell that
|
||||
/// sets `XDG_CONFIG_HOME=~/.config` and an app started from the Dock, which
|
||||
/// does not, resolve to different places, and the CLI and an MCP client must
|
||||
/// still find one session. A custom `XDG_CONFIG_HOME` has no other location,
|
||||
/// so a run isolated with one never reaches the user's session.
|
||||
pub fn session_dir() -> Result<PathBuf, IdxError> {
|
||||
let config = crate::config::config_path()?;
|
||||
let primary = config
|
||||
.parent()
|
||||
.ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))?
|
||||
.to_path_buf();
|
||||
let other = if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").and_then(|home| other_default(&primary, Path::new(&home)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(choose_dir(primary, other))
|
||||
}
|
||||
|
||||
/// The second default config location, when `primary` is one of the two.
|
||||
fn other_default(primary: &Path, home: &Path) -> Option<PathBuf> {
|
||||
let dot_config = home.join(".config/idx");
|
||||
let library = home.join("Library/Application Support/idx");
|
||||
if primary == dot_config {
|
||||
Some(library)
|
||||
} else if primary == library {
|
||||
Some(dot_config)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory holding a session, else a browser profile, else `primary`.
|
||||
fn choose_dir(primary: PathBuf, other: Option<PathBuf>) -> PathBuf {
|
||||
let Some(other) = other else {
|
||||
return primary;
|
||||
};
|
||||
for entry in [SESSION_FILE, super::browser::PROFILE_DIR] {
|
||||
if primary.join(entry).exists() {
|
||||
return primary;
|
||||
}
|
||||
if other.join(entry).exists() {
|
||||
return other;
|
||||
}
|
||||
}
|
||||
primary
|
||||
}
|
||||
|
||||
/// Reads, writes and refreshes the session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionStore {
|
||||
dir: PathBuf,
|
||||
seed: Session,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// The session in `session_dir`, seeded from the environment.
|
||||
pub fn open() -> Result<Self, IdxError> {
|
||||
let env = |name| {
|
||||
std::env::var(name)
|
||||
.map(|v| normalize_token(&v))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
Ok(Self::at(
|
||||
session_dir()?,
|
||||
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::*;
|
||||
|
||||
/// The two default locations pair up; anything else stands alone, so
|
||||
/// a run with its own `XDG_CONFIG_HOME` cannot reach the user's session.
|
||||
#[test]
|
||||
fn only_the_two_default_locations_pair_up() {
|
||||
let home = Path::new("/Users/me");
|
||||
let dot_config = home.join(".config/idx");
|
||||
let library = home.join("Library/Application Support/idx");
|
||||
assert_eq!(other_default(&dot_config, home), Some(library.clone()));
|
||||
assert_eq!(other_default(&library, home), Some(dot_config));
|
||||
assert_eq!(other_default(Path::new("/tmp/run/idx"), home), None);
|
||||
}
|
||||
|
||||
/// A session anywhere beats a profile, a profile beats nothing, and with
|
||||
/// neither the primary location is used.
|
||||
#[test]
|
||||
fn the_session_is_found_in_either_location() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"idx-session-dir-{}-{}",
|
||||
std::process::id(),
|
||||
fastrand::u32(..)
|
||||
));
|
||||
let (primary, other) = (root.join("primary"), root.join("other"));
|
||||
std::fs::create_dir_all(&primary).unwrap();
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
let choose = || choose_dir(primary.clone(), Some(other.clone()));
|
||||
|
||||
assert_eq!(choose(), primary);
|
||||
assert_eq!(choose_dir(primary.clone(), None), primary);
|
||||
|
||||
std::fs::create_dir(other.join(super::super::browser::PROFILE_DIR)).unwrap();
|
||||
assert_eq!(choose(), other, "a profile says where the user signs in");
|
||||
|
||||
std::fs::write(primary.join(SESSION_FILE), "{}").unwrap();
|
||||
assert_eq!(choose(), primary, "a session beats a profile");
|
||||
|
||||
std::fs::remove_file(primary.join(SESSION_FILE)).unwrap();
|
||||
std::fs::write(other.join(SESSION_FILE), "{}").unwrap();
|
||||
assert_eq!(choose(), other);
|
||||
|
||||
std::fs::write(primary.join(SESSION_FILE), "{}").unwrap();
|
||||
assert_eq!(choose(), primary, "with two, the primary one wins");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
1478
crates/idx-core/src/api/stockbit/browser.rs
Normal file
1478
crates/idx-core/src/api/stockbit/browser.rs
Normal file
File diff suppressed because it is too large
Load diff
437
crates/idx-core/src/api/stockbit/corpaction.rs
Normal file
437
crates/idx-core/src/api/stockbit/corpaction.rs
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
//! Corporate actions from Stockbit's session API:
|
||||
//! `GET /corpaction/{type}?symbol={CODE}` (one stock's history) or without
|
||||
//! `symbol` (the market, about a year back and the announced ones ahead; the
|
||||
//! `limit` the web app sends is not honoured by every type).
|
||||
//!
|
||||
//! Each type names its fields with its own prefix (`dividend_`,
|
||||
//! `rightissue_`, `wrant_`, `rups_`, `puexp_`, `tender_`, ...), bonus rows
|
||||
//! reuse `stocksplit_*`, and the list sits under a key that is not always the
|
||||
//! path's (`tenderoffer` → `tender`, `reversesplit` → `stock_reverse`). So
|
||||
//! the entitlement kinds are read by field suffix (`_cumdate`, `_exdate`,
|
||||
//! ...), which also covers the two kinds no row was recorded for yet
|
||||
//! (`stock_dividend`, `reversesplit`, both empty on 2026-11-03). Placeholders
|
||||
//! (`""`, `0`, `0001-01-01 00:00:00`) read as absent; a row without a symbol
|
||||
//! or a date is dropped.
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::corporate::types::{ActionKind, CorporateAction, DateRange, Ratio};
|
||||
use crate::error::IdxError;
|
||||
|
||||
/// Stockbit's path segment for `kind`.
|
||||
pub fn path_type(kind: ActionKind) -> &'static str {
|
||||
match kind {
|
||||
ActionKind::Dividend => "dividend",
|
||||
ActionKind::StockDividend => "stock_dividend",
|
||||
ActionKind::Bonus => "bonus",
|
||||
ActionKind::Rights => "rightissue",
|
||||
ActionKind::Warrant => "warrant",
|
||||
ActionKind::Split => "stocksplit",
|
||||
ActionKind::ReverseSplit => "reversesplit",
|
||||
ActionKind::Meeting => "rups",
|
||||
ActionKind::PublicExpose => "pubex",
|
||||
ActionKind::Ipo => "ipo",
|
||||
ActionKind::TenderOffer => "tenderoffer",
|
||||
}
|
||||
}
|
||||
|
||||
/// The actions of one `kind` in a `/corpaction/{type}` response, in the
|
||||
/// response's order.
|
||||
pub fn parse_corporate_actions(
|
||||
kind: ActionKind,
|
||||
body: &str,
|
||||
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||
let bad = |what: String| IdxError::ParseError(format!("stockbit corporate actions: {what}"));
|
||||
let root: Value = serde_json::from_str(body).map_err(|e| bad(e.to_string()))?;
|
||||
let rows = match root.get("data") {
|
||||
Some(Value::Object(data)) => match data.values().find_map(Value::as_array) {
|
||||
Some(rows) => rows.as_slice(),
|
||||
// No rows reads `{"dividend": null, "today": "..."}`: a null list
|
||||
// beside scalars. An object in their place is a new layout.
|
||||
None if !data.values().any(Value::is_object) => &[],
|
||||
None => return Err(bad("no list in data".to_string())),
|
||||
},
|
||||
Some(Value::Array(rows)) => rows.as_slice(),
|
||||
Some(Value::Null) => &[],
|
||||
_ => return Err(bad("no data".to_string())),
|
||||
};
|
||||
Ok(rows
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.filter_map(|row| parse_row(kind, row))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_row(kind: ActionKind, row: &Map<String, Value>) -> Option<CorporateAction> {
|
||||
let code = text(row.get("company_symbol"))?.to_ascii_uppercase();
|
||||
if !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
let symbol = format!("{code}.JK");
|
||||
let mut action = if kind.is_entitlement() {
|
||||
entitlement(kind, symbol, row)?
|
||||
} else {
|
||||
event(kind, symbol, row)?
|
||||
};
|
||||
if matches!(kind, ActionKind::Ipo | ActionKind::TenderOffer) {
|
||||
action.name = text(row.get("company_name")).map(str::to_string);
|
||||
}
|
||||
Some(action)
|
||||
}
|
||||
|
||||
/// Dividends, bonuses, rights and splits: cum/ex/record/pay dates, a ratio
|
||||
/// and an adjustment factor.
|
||||
fn entitlement(
|
||||
kind: ActionKind,
|
||||
symbol: String,
|
||||
row: &Map<String, Value>,
|
||||
) -> Option<CorporateAction> {
|
||||
let cum = date(suffixed(row, "cumdate"));
|
||||
let ex = date(suffixed(row, "exdate"));
|
||||
let mut action = CorporateAction::new(symbol, kind, cum.or(ex)?);
|
||||
action.cum_date = cum;
|
||||
action.ex_date = ex;
|
||||
action.record_date = date(suffixed(row, "recdate"));
|
||||
action.pay_date = date(suffixed(row, "paydate")).or_else(|| date(suffixed(row, "paymentdate")));
|
||||
action.ratio = ratio(row);
|
||||
action.factor = number(suffixed(row, "factor"));
|
||||
match kind {
|
||||
ActionKind::Dividend => {
|
||||
action.currency = text(suffixed(row, "currency"))
|
||||
.map(|c| c.trim_start_matches("CURRENCY_").to_ascii_uppercase())
|
||||
.filter(|c| c != "IDR");
|
||||
// Rupiah to the sen; a dollar dividend can be a fraction of a cent.
|
||||
let places = if action.currency.is_some() { 6 } else { 2 };
|
||||
action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, places));
|
||||
}
|
||||
ActionKind::Rights => {
|
||||
action.price = whole(suffixed(row, "price"));
|
||||
action.period = range(suffixed(row, "trading_start"), suffixed(row, "trading_end"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(action)
|
||||
}
|
||||
|
||||
/// Warrants, meetings, exposes, IPOs and tender offers.
|
||||
fn event(kind: ActionKind, symbol: String, row: &Map<String, Value>) -> Option<CorporateAction> {
|
||||
let get = |key: &str| row.get(key);
|
||||
let mut action;
|
||||
match kind {
|
||||
ActionKind::Warrant => {
|
||||
let trading = range(get("wrant_trading_from"), get("wrant_trading_end"));
|
||||
let exercise = range(get("wrant_exc_from"), get("wrant_exc_end"));
|
||||
let first = date(get("wrant_trading_from")).or(exercise.map(|r| r.start))?;
|
||||
action = CorporateAction::new(symbol, kind, first);
|
||||
action.period = trading;
|
||||
action.exercise = exercise;
|
||||
action.price = whole(get("wrant_exc_price"));
|
||||
}
|
||||
ActionKind::Meeting | ActionKind::PublicExpose => {
|
||||
let prefix = if kind == ActionKind::Meeting {
|
||||
"rups"
|
||||
} else {
|
||||
"puexp"
|
||||
};
|
||||
let field = |name: &str| row.get(&format!("{prefix}_{name}"));
|
||||
action = CorporateAction::new(symbol, kind, date(field("date"))?);
|
||||
action.time = text(field("time")).map(clock);
|
||||
action.venue = text(field("venue")).map(squeeze);
|
||||
if kind == ActionKind::Meeting {
|
||||
action.record_date = date(field("eligible_date"));
|
||||
}
|
||||
}
|
||||
ActionKind::Ipo => {
|
||||
action = CorporateAction::new(symbol, kind, date(get("ipo_listing_date"))?);
|
||||
action.price = whole(row.get("ipo_price").and_then(|p| p.get("final")));
|
||||
let detail = row.get("ipo_data_detail");
|
||||
let detail = |key: &str| detail.and_then(|d| d.get(key));
|
||||
action.shares = count(detail("shares"));
|
||||
action.period = range(detail("offering_start"), detail("offering_end"));
|
||||
}
|
||||
ActionKind::TenderOffer => {
|
||||
let period = range(get("tender_start"), get("tender_end"));
|
||||
let first = date(get("tender_start")).or(date(get("tender_end")))?;
|
||||
action = CorporateAction::new(symbol, kind, first);
|
||||
action.period = period;
|
||||
action.pay_date = date(get("tender_paydate"));
|
||||
action.price = whole(get("tender_price"));
|
||||
action.shares = count(get("tender_shares"));
|
||||
action.percentage_bps =
|
||||
number(get("tender_percentage")).map(|p| (p * 100.0).round() as i64);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(action)
|
||||
}
|
||||
|
||||
/// The value of the one field named `<prefix>_<suffix>`; the shortest name
|
||||
/// wins should several end so.
|
||||
fn suffixed<'a>(row: &'a Map<String, Value>, suffix: &str) -> Option<&'a Value> {
|
||||
let tail = format!("_{suffix}");
|
||||
row.iter()
|
||||
.filter(|(key, _)| key.ends_with(&tail))
|
||||
.min_by_key(|(key, _)| key.len())
|
||||
.map(|(_, value)| value)
|
||||
}
|
||||
|
||||
/// `old`/`new` fields, else the `"6 : 4"` text.
|
||||
fn ratio(row: &Map<String, Value>) -> Option<Ratio> {
|
||||
let pair = |old: Option<u64>, new: Option<u64>| {
|
||||
Some(Ratio {
|
||||
old: old?,
|
||||
new: new?,
|
||||
})
|
||||
};
|
||||
pair(count(suffixed(row, "old")), count(suffixed(row, "new"))).or_else(|| {
|
||||
let text = text(suffixed(row, "ratio"))?;
|
||||
let (old, new) = text.split_once(':')?;
|
||||
pair(
|
||||
old.trim().replace(',', "").parse().ok().filter(|n| *n > 0),
|
||||
new.trim().replace(',', "").parse().ok().filter(|n| *n > 0),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn text(value: Option<&Value>) -> Option<&str> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD`, optionally followed by a time; year 1 is a placeholder.
|
||||
fn date(value: Option<&Value>) -> Option<NaiveDate> {
|
||||
let text = text(value)?;
|
||||
let day = text.get(..10)?;
|
||||
NaiveDate::parse_from_str(day, "%Y-%m-%d")
|
||||
.ok()
|
||||
.filter(|d| d.year() >= 1900)
|
||||
}
|
||||
|
||||
fn range(start: Option<&Value>, end: Option<&Value>) -> Option<DateRange> {
|
||||
let (start, end) = (date(start)?, date(end)?);
|
||||
(start <= end).then_some(DateRange { start, end })
|
||||
}
|
||||
|
||||
/// A positive number from a JSON number or a display string (`"1,191.09"`,
|
||||
/// `"Rp 25"`); zero is a placeholder.
|
||||
fn number(value: Option<&Value>) -> Option<f64> {
|
||||
let n = match value? {
|
||||
Value::Number(n) => n.as_f64()?,
|
||||
Value::String(s) => {
|
||||
let cleaned: String = s
|
||||
.trim()
|
||||
.trim_start_matches("Rp")
|
||||
.chars()
|
||||
.filter(|c| *c != ',' && !c.is_whitespace())
|
||||
.collect();
|
||||
cleaned.parse().ok()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
(n.is_finite() && n > 0.0).then_some(n)
|
||||
}
|
||||
|
||||
fn whole(value: Option<&Value>) -> Option<i64> {
|
||||
number(value).map(|n| n.round() as i64)
|
||||
}
|
||||
|
||||
fn count(value: Option<&Value>) -> Option<u64> {
|
||||
number(value).map(|n| n.round() as u64)
|
||||
}
|
||||
|
||||
fn round_to(value: f64, places: i32) -> f64 {
|
||||
let scale = 10f64.powi(places);
|
||||
(value * scale).round() / scale
|
||||
}
|
||||
|
||||
/// `13:00:00` → `13:00`; anything else as given.
|
||||
fn clock(raw: &str) -> String {
|
||||
match raw.split(':').collect::<Vec<_>>().as_slice() {
|
||||
[h, m, s] if s.len() == 2 && h.len() <= 2 && m.len() == 2 => format!("{h}:{m}"),
|
||||
_ => raw.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn squeeze(raw: &str) -> String {
|
||||
raw.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
std::fs::read_to_string(format!(
|
||||
"{}/tests/fixtures/stockbit_exodus_corpaction_{name}.json",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn day(s: &str) -> NaiveDate {
|
||||
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
|
||||
fn find<'a>(actions: &'a [CorporateAction], symbol: &str) -> &'a CorporateAction {
|
||||
actions.iter().find(|a| a.symbol == symbol).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dividends_keep_cents_and_dates() {
|
||||
let bbca =
|
||||
parse_corporate_actions(ActionKind::Dividend, &fixture("dividend_bbca")).unwrap();
|
||||
assert_eq!(bbca.len(), 37);
|
||||
let latest = &bbca[0];
|
||||
assert_eq!(latest.symbol, "BBCA.JK");
|
||||
assert_eq!(latest.date, day("2026-08-28"));
|
||||
assert_eq!(
|
||||
(latest.ex_date, latest.record_date, latest.pay_date),
|
||||
(
|
||||
Some(day("2026-08-31")),
|
||||
Some(day("2026-09-01")),
|
||||
Some(day("2026-09-16"))
|
||||
)
|
||||
);
|
||||
assert_eq!(latest.cash_per_share, Some(25.0));
|
||||
assert_eq!(latest.ratio, None);
|
||||
|
||||
let market = parse_corporate_actions(ActionKind::Dividend, &fixture("dividend")).unwrap();
|
||||
assert_eq!(find(&market, "ITMG.JK").cash_per_share, Some(1191.09));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_bonus_reads_split_named_fields() {
|
||||
let bonus = parse_corporate_actions(ActionKind::Bonus, &fixture("bonus")).unwrap();
|
||||
let ppgl = find(&bonus, "PPGL.JK");
|
||||
assert_eq!(ppgl.date, day("2026-11-02"));
|
||||
assert_eq!(ppgl.ex_date, Some(day("2026-11-03")));
|
||||
assert_eq!(ppgl.pay_date, Some(day("2026-11-25")));
|
||||
assert_eq!(ppgl.ratio, Some(Ratio { old: 6, new: 4 }));
|
||||
assert_eq!(ppgl.factor, Some(1.6667));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rights_and_split() {
|
||||
let rights = parse_corporate_actions(ActionKind::Rights, &fixture("rightissue")).unwrap();
|
||||
let bmas = find(&rights, "BMAS.JK");
|
||||
assert_eq!(bmas.date, day("2026-11-23"));
|
||||
assert_eq!(bmas.ratio, Some(Ratio { old: 655, new: 104 }));
|
||||
assert_eq!(bmas.price, Some(350));
|
||||
assert_eq!(
|
||||
bmas.period,
|
||||
Some(DateRange {
|
||||
start: day("2026-11-27"),
|
||||
end: day("2026-12-03")
|
||||
})
|
||||
);
|
||||
let split =
|
||||
parse_corporate_actions(ActionKind::Split, &fixture("stocksplit_bbca")).unwrap();
|
||||
assert_eq!(split[0].date, day("2021-10-12"));
|
||||
assert_eq!(split[0].ratio, Some(Ratio { old: 1, new: 5 }));
|
||||
assert_eq!(split[0].factor, Some(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_events() {
|
||||
let warrants = parse_corporate_actions(ActionKind::Warrant, &fixture("warrant")).unwrap();
|
||||
let brna = find(&warrants, "BRNA.JK");
|
||||
assert_eq!(brna.date, day("2026-10-16"));
|
||||
assert_eq!(brna.price, Some(800));
|
||||
assert_eq!(brna.exercise.map(|r| r.end), Some(day("2029-10-15")));
|
||||
|
||||
let meetings = parse_corporate_actions(ActionKind::Meeting, &fixture("rups")).unwrap();
|
||||
let arti = find(&meetings, "ARTI.JK");
|
||||
assert_eq!(arti.date, day("2026-12-09"));
|
||||
assert_eq!(arti.time.as_deref(), Some("10:30"));
|
||||
assert_eq!(arti.record_date, Some(day("2026-11-12")));
|
||||
|
||||
let exposes = parse_corporate_actions(ActionKind::PublicExpose, &fixture("pubex")).unwrap();
|
||||
assert_eq!(find(&exposes, "MEJA.JK").time.as_deref(), Some("13:00"));
|
||||
|
||||
let tenders =
|
||||
parse_corporate_actions(ActionKind::TenderOffer, &fixture("tenderoffer")).unwrap();
|
||||
let auto = find(&tenders, "AUTO.JK");
|
||||
assert_eq!(auto.date, day("2026-10-13"));
|
||||
assert_eq!(auto.price, Some(3600));
|
||||
assert_eq!(auto.shares, Some(238_442_293));
|
||||
assert_eq!(auto.percentage_bps, Some(495));
|
||||
assert_eq!(auto.pay_date, Some(day("2026-11-18")));
|
||||
assert_eq!(auto.name.as_deref(), Some("Astra Otoparts Tbk."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ipo_drops_placeholders() {
|
||||
let ipos = parse_corporate_actions(ActionKind::Ipo, &fixture("ipo")).unwrap();
|
||||
let rans = find(&ipos, "RANS.JK");
|
||||
assert_eq!(rans.price, Some(170));
|
||||
assert_eq!(rans.shares, Some(2_525_000_000));
|
||||
assert_eq!(rans.period.map(|r| r.start), Some(day("2026-07-02")));
|
||||
// An ETF listed without offer terms: no price, no period.
|
||||
let xkgs = find(&ipos, "XKGS.JK");
|
||||
assert_eq!((xkgs.price, xkgs.period, xkgs.shares), (None, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_lists_and_odd_keys() {
|
||||
for (kind, body) in [
|
||||
(
|
||||
ActionKind::StockDividend,
|
||||
r#"{"data":{"stock_dividend":[]}}"#,
|
||||
),
|
||||
(ActionKind::ReverseSplit, r#"{"data":{"stock_reverse":[]}}"#),
|
||||
(ActionKind::Dividend, r#"{"data":null}"#),
|
||||
(
|
||||
ActionKind::Dividend,
|
||||
r#"{"data":{"dividend":null,"today":"2026-11-04"}}"#,
|
||||
),
|
||||
] {
|
||||
assert!(parse_corporate_actions(kind, body).unwrap().is_empty());
|
||||
}
|
||||
assert!(parse_corporate_actions(ActionKind::Dividend, r#"{"message":"x"}"#).is_err());
|
||||
let moved = r#"{"data":{"dividend":{"rows":[]},"today":"2026-11-04"}}"#;
|
||||
assert!(parse_corporate_actions(ActionKind::Dividend, moved).is_err());
|
||||
// A reverse split named like a split, read by suffix.
|
||||
let body = r#"{"data":{"stock_reverse":[{"company_symbol":"ABCD",
|
||||
"reverse_cumdate":"2026-12-01","reverse_exdate":"2026-12-02",
|
||||
"reverse_old":"10","reverse_new":"1","reverse_factor":"0.1"}]}}"#;
|
||||
let rows = parse_corporate_actions(ActionKind::ReverseSplit, body).unwrap();
|
||||
assert_eq!(rows[0].ratio, Some(Ratio { old: 10, new: 1 }));
|
||||
assert_eq!(rows[0].factor, Some(0.1));
|
||||
}
|
||||
|
||||
/// A dividend not in IDR names its currency; one in IDR leaves it out.
|
||||
/// Without a cum date, the action sits on its ex date.
|
||||
#[test]
|
||||
fn test_parse_dividend_currency_and_missing_cum_date() {
|
||||
let body = r#"{"data":{"dividend":[
|
||||
{"company_symbol":"ABCD","dividend_cumdate":"2026-11-11",
|
||||
"dividend_exdate":"2026-11-12","dividend_value":"0.0012",
|
||||
"dividend_currency":"CURRENCY_USD"},
|
||||
{"company_symbol":"EFGH","dividend_cumdate":"0001-01-01 00:00:00",
|
||||
"dividend_exdate":"2026-11-12","dividend_value":"5",
|
||||
"dividend_currency":"CURRENCY_IDR"}]}}"#;
|
||||
let rows = parse_corporate_actions(ActionKind::Dividend, body).unwrap();
|
||||
assert_eq!(rows[0].currency.as_deref(), Some("USD"));
|
||||
assert_eq!(rows[0].cash_per_share, Some(0.0012), "not rounded to cents");
|
||||
assert_eq!(rows[1].currency, None);
|
||||
assert_eq!(rows[1].cash_per_share, Some(5.0));
|
||||
assert_eq!((rows[1].date, rows[1].cum_date), (day("2026-11-12"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_numbers_and_placeholders() {
|
||||
assert_eq!(number(Some(&Value::from("1,191.09"))), Some(1191.09));
|
||||
assert_eq!(number(Some(&Value::from("Rp 25"))), Some(25.0));
|
||||
assert_eq!(number(Some(&Value::from(0))), None);
|
||||
assert_eq!(date(Some(&Value::from("0001-01-01 00:00:00"))), None);
|
||||
assert_eq!(
|
||||
date(Some(&Value::from("2026-07-02 00:00:00"))),
|
||||
Some(day("2026-07-02"))
|
||||
);
|
||||
assert_eq!(clock("09:00:00"), "09:00");
|
||||
assert_eq!(clock("10:30"), "10:30");
|
||||
}
|
||||
}
|
||||
708
crates/idx-core/src/api/stockbit/exodus.rs
Normal file
708
crates/idx-core/src/api/stockbit/exodus.rs
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
//! 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, used
|
||||
//! only when `has_foreign_bs` is true) 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`: `STATE_NAME_SESSION_2` while trading,
|
||||
//! `STATE_NAME_BREAK`, `STATE_NAME_MARKET_CLOSED` (recorded). The `fca`
|
||||
//! (full call auction) board beside it keeps its own state and is not read.
|
||||
//! - `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 super::{corpaction, movement};
|
||||
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
|
||||
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||
use crate::error::IdxError;
|
||||
use crate::movements::types::{MovementPage, MovementQuery};
|
||||
|
||||
const BASE_URL: &str = "https://exodus.stockbit.com";
|
||||
const REFRESH_PATH: &str = "/login/refresh";
|
||||
/// The only paths idx GETs from the session API. The same API places orders,
|
||||
/// withdraws cash and changes the PIN; a path outside these fails before any
|
||||
/// request (or token refresh) leaves the process. A feature that reads a new
|
||||
/// endpoint adds its prefix here.
|
||||
const READ_PATHS: &[&str] = &[
|
||||
"/company-price-feed/",
|
||||
"/charts/",
|
||||
"/corpaction/",
|
||||
"/insider/company/majorholder?",
|
||||
];
|
||||
/// The prefix of the states in which the regular market trades:
|
||||
/// `STATE_NAME_SESSION_2` was recorded on 2026-11-03 from 13:30; session 1 is
|
||||
/// assumed to follow the same naming. Every other state (`STATE_NAME_BREAK`,
|
||||
/// `STATE_NAME_MARKET_CLOSED`, auctions) hides bid/offer in quotes, as on the
|
||||
/// public page.
|
||||
const TRADING_PREFIX: &str = "STATE_NAME_SESSION_";
|
||||
|
||||
/// Whether Stockbit's market state is a trading session.
|
||||
pub fn is_trading(state: &str) -> bool {
|
||||
state
|
||||
.strip_prefix(TRADING_PREFIX)
|
||||
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Corporate actions of `kind`: one stock's (`resolved`) or the market's.
|
||||
pub(crate) fn corporate_actions(
|
||||
&self,
|
||||
kind: ActionKind,
|
||||
resolved: Option<&str>,
|
||||
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||
let path = format!("/corpaction/{}", corpaction::path_type(kind));
|
||||
let (path, subject) = match resolved {
|
||||
Some(resolved) => (
|
||||
format!("{path}?symbol={}", stockbit_code(resolved)?),
|
||||
resolved.to_string(),
|
||||
),
|
||||
None => (path, "corporate actions".to_string()),
|
||||
};
|
||||
let body = self.get(&path, &subject)?;
|
||||
corpaction::parse_corporate_actions(kind, &body)
|
||||
}
|
||||
|
||||
/// Page `page` of shareholder movements: one stock's (`query.symbol`,
|
||||
/// resolved) or the market's.
|
||||
pub(crate) fn shareholder_movements(
|
||||
&self,
|
||||
query: &MovementQuery,
|
||||
page: u32,
|
||||
) -> Result<MovementPage, IdxError> {
|
||||
let code = query.symbol.as_deref().map(stockbit_code).transpose()?;
|
||||
let subject = query
|
||||
.symbol
|
||||
.clone()
|
||||
.unwrap_or_else(|| "shareholder movements".to_string());
|
||||
let body = self.get(
|
||||
&movement::request_path(query, code.as_deref(), page),
|
||||
&subject,
|
||||
)?;
|
||||
movement::parse_movements(&body)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if !is_read_path(path) {
|
||||
return Err(IdxError::Unsupported(format!(
|
||||
"idx does not call {path} on Stockbit's session API"
|
||||
)));
|
||||
}
|
||||
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(blocked()),
|
||||
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
|
||||
};
|
||||
// Stockbit flags whether `fbuy`/`fsell`/`fnet` are current. While the
|
||||
// flag was off (2026-11-03, in session) they were the previous session's
|
||||
// figures (equal to IPOT's for 2026-11-02 for BBCA, BBRI and TLKM), so
|
||||
// they are dropped rather than shown beside today's prices.
|
||||
let foreign_current = d.get("has_foreign_bs").and_then(Value::as_bool) == Some(true);
|
||||
let foreign = |key: &str| foreign_current.then(|| whole(d.get(key))).flatten();
|
||||
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: foreign("fbuy"),
|
||||
foreign_sell: foreign("fsell"),
|
||||
foreign_net: foreign("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.
|
||||
/// 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();
|
||||
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
|
||||
.as_ref()
|
||||
.map(|_| if open { "open" } else { "close" }.to_string()),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// A 403 from the CDN in front of the session API: a block, not a refused
|
||||
/// session, so nothing is retried.
|
||||
pub(crate) fn blocked() -> IdxError {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `path` is one idx reads; `..` never climbs out of a prefix.
|
||||
fn is_read_path(path: &str) -> bool {
|
||||
READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Only the endpoints idx reads pass; trading and account paths, and a
|
||||
/// path that climbs out of a prefix, never leave the process.
|
||||
#[test]
|
||||
fn only_read_paths_are_called() {
|
||||
for path in [
|
||||
"/company-price-feed/market-time/session",
|
||||
"/company-price-feed/v2/orderbook/companies/BBCA",
|
||||
"/charts/BBCA/daily?timeframe=1y&chart_type=PRICE_CHART_TYPE_CANDLE",
|
||||
"/corpaction/dividend?symbol=BBCA",
|
||||
"/insider/company/majorholder?page=1&symbol=BBCA",
|
||||
] {
|
||||
assert!(is_read_path(path), "{path}");
|
||||
}
|
||||
// Paths another module builds come from its own builder, so the
|
||||
// allowlist cannot drift from them.
|
||||
let query = MovementQuery::default();
|
||||
for path in [
|
||||
movement::request_path(&query, None, 1),
|
||||
movement::request_path(&query, Some("BBCA"), 3),
|
||||
] {
|
||||
assert!(is_read_path(&path), "{path}");
|
||||
}
|
||||
for path in [
|
||||
"/order/buy",
|
||||
"/login/refresh",
|
||||
"/corpaction/../order/buy",
|
||||
"/insider/company/majorholder/../../order",
|
||||
"/charts",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_read_path(path), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
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 trading = parse_market_status(&fixture("market_session_trading.json"));
|
||||
let book = |state: Option<String>| {
|
||||
parse_orderbook("BBCA.JK", &fixture("orderbook_bbca_in_session.json"), state).unwrap()
|
||||
};
|
||||
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}");
|
||||
}
|
||||
let unknown = quote_from_book(&book(None));
|
||||
assert_eq!((unknown.bid, unknown.market_status), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn market_status_is_read_verbatim() {
|
||||
for (name, state) in [
|
||||
("market_session.json", "STATE_NAME_MARKET_CLOSED"),
|
||||
("market_session_break.json", "STATE_NAME_BREAK"),
|
||||
("market_session_trading.json", "STATE_NAME_SESSION_2"),
|
||||
] {
|
||||
assert_eq!(parse_market_status(&fixture(name)).as_deref(), Some(state));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_numbered_sessions_are_trading() {
|
||||
assert!(is_trading("STATE_NAME_SESSION_1"));
|
||||
assert!(is_trading("STATE_NAME_SESSION_2"));
|
||||
for state in [
|
||||
"STATE_NAME_BREAK",
|
||||
"STATE_NAME_MARKET_CLOSED",
|
||||
"STATE_NAME_MARKET_OPEN",
|
||||
"STATE_NAME_SESSION_",
|
||||
"STATE_NAME_SESSION_PRE_OPENING",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_trading(state), "{state}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_flow_only_when_stockbit_marks_it_current() {
|
||||
// In session (has_foreign_bs false) the figures were 2026-11-02's.
|
||||
let live =
|
||||
parse_orderbook("BBCA.JK", &fixture("orderbook_bbca_in_session.json"), None).unwrap();
|
||||
assert_eq!(
|
||||
(live.foreign_buy, live.foreign_sell, live.foreign_net),
|
||||
(None, None, None)
|
||||
);
|
||||
assert_eq!(live.volume, Some(134_744_100));
|
||||
let done = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
|
||||
assert_eq!(done.foreign_buy, Some(1_616_461_977_500));
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
723
crates/idx-core/src/api/stockbit/loopback.rs
Normal file
723
crates/idx-core/src/api/stockbit/loopback.rs
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
//! The local page `idx stockbit login` opens: a loopback server on
|
||||
//! 127.0.0.1 that takes the pasted session, checks it, and reports back.
|
||||
//!
|
||||
//! Stockbit has no OAuth redirect that could hand idx a session, and the
|
||||
//! browser will not give one site another site's cookie, so the page takes
|
||||
//! the value two ways, both one click:
|
||||
//! - **Read my clipboard**: the user copies `credentialStorage` in DevTools
|
||||
//! once and clicks the button (`navigator.clipboard`, allowed on localhost).
|
||||
//! - **Bookmarklet**: a link rendered with this run's port and secret that
|
||||
//! reads `document.cookie` on stockbit.com and opens the page with the value
|
||||
//! in the URL *fragment*, which browsers never send to the server.
|
||||
//!
|
||||
//! Safeguards: bound to 127.0.0.1, a random port, a per-run secret path (no
|
||||
//! secret, no page), a short life, `no-store`, no referrer, and a strict CSP.
|
||||
//! The session value is held in memory only on the way to `stockbit::login`;
|
||||
//! it is never written to a log or the response.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Ipv4Addr, TcpListener, TcpStream};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::error::IdxError;
|
||||
|
||||
/// Largest paste accepted (the cookie is a few KB).
|
||||
const MAX_BODY: usize = 16 * 1024;
|
||||
/// Requests time out instead of pinning a thread; short, because a browser
|
||||
/// preconnect sends nothing at all.
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Connections answered at once; beyond this, new ones are dropped.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
/// A running login page.
|
||||
pub struct LoginPage {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
secret: String,
|
||||
}
|
||||
|
||||
impl LoginPage {
|
||||
/// Binds 127.0.0.1 on `port` (0 picks a free one).
|
||||
pub fn bind(port: u16) -> Result<Self, IdxError> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).map_err(|e| {
|
||||
IdxError::Io(format!(
|
||||
"cannot open the Stockbit login page on 127.0.0.1:{port}: {e}"
|
||||
))
|
||||
})?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| IdxError::Io(e.to_string()))?
|
||||
.port();
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| IdxError::Io(e.to_string()))?;
|
||||
Ok(Self {
|
||||
listener,
|
||||
port,
|
||||
secret: random_secret(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/login/{}", self.port, self.secret)
|
||||
}
|
||||
|
||||
/// The port the page listens on.
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Serves until `verify` accepts a paste or `timeout` passes. The error
|
||||
/// `verify` returns for a refused paste is shown on the page.
|
||||
///
|
||||
/// 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. 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 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));
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if done_rx.try_recv().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
match self.listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
// Browsers open a handful; anything beyond this is junk.
|
||||
if busy.load(std::sync::atomic::Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
continue;
|
||||
}
|
||||
busy.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let (verify, page, path) = (verify.clone(), page.clone(), path.clone());
|
||||
let busy = busy.clone();
|
||||
let done = done_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
if serve_once(stream, &path, &page, verify.as_ref()) {
|
||||
let _ = done.send(());
|
||||
}
|
||||
busy.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
Err(e) => return Err(IdxError::Io(format!("stockbit login page: {e}"))),
|
||||
}
|
||||
}
|
||||
Err(IdxError::Io(format!(
|
||||
"the Stockbit login page timed out after {}s; its URL was {} (re-run it, or pin the \
|
||||
port with --port)",
|
||||
timeout.as_secs(),
|
||||
self.url()
|
||||
)))
|
||||
}
|
||||
|
||||
/// The page, with this run's port and secret baked into the bookmarklet.
|
||||
fn page(&self) -> String {
|
||||
let target = self.url();
|
||||
// Reads stockbit.com's session cookie and opens the page with it in
|
||||
// the fragment (never sent to a server, never in a referrer).
|
||||
let bookmarklet = format!(
|
||||
"javascript:(()=>{{const m=document.cookie.match(/(?:^|;\\s*)credentialStorage=([^;]*)/);\
|
||||
if(!m){{alert('No Stockbit session in this browser: log in to stockbit.com first.');return}}\
|
||||
window.open('{target}#t='+encodeURIComponent(m[1]))}})()"
|
||||
);
|
||||
PAGE.replace("__TARGET__", &target)
|
||||
.replace("__BOOKMARKLET__", &html_escape(&bookmarklet))
|
||||
}
|
||||
}
|
||||
|
||||
/// The forward to run where the browser is, for the page on `port`:
|
||||
/// `ssh -L <port>:127.0.0.1:<port> <user>@<host>`.
|
||||
///
|
||||
/// The target comes from `SSH_CONNECTION` (`client_ip client_port server_ip
|
||||
/// server_port`) when idx runs inside an SSH session, else from `USER` and
|
||||
/// `HOSTNAME`, else it is left for the reader to fill in.
|
||||
pub fn ssh_forward(port: u16) -> String {
|
||||
let target = ssh_target().unwrap_or_else(|| "<this host>".to_string());
|
||||
format!("ssh -L {port}:127.0.0.1:{port} {target}")
|
||||
}
|
||||
|
||||
fn ssh_target() -> Option<String> {
|
||||
if let Ok(connection) = std::env::var("SSH_CONNECTION") {
|
||||
let fields: Vec<&str> = connection.split_whitespace().collect();
|
||||
if let Some(server) = fields.get(2) {
|
||||
return Some(match std::env::var("USER") {
|
||||
Ok(user) => format!("{user}@{server}"),
|
||||
Err(_) => (*server).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let user = std::env::var("USER").ok()?;
|
||||
let host = std::env::var("HOSTNAME").ok()?;
|
||||
Some(format!("{user}@{host}"))
|
||||
}
|
||||
|
||||
/// Answers one request; true when a paste was accepted.
|
||||
fn serve_once(
|
||||
mut stream: TcpStream,
|
||||
page_path: &str,
|
||||
page: &str,
|
||||
verify: &dyn Fn(&str) -> Result<(), IdxError>,
|
||||
) -> bool {
|
||||
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
|
||||
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
|
||||
let Some(request) = read_request(&mut stream) else {
|
||||
// An idle preconnect or a malformed request: drop it quietly.
|
||||
return false;
|
||||
};
|
||||
match (request.method.as_str(), request.path.as_str()) {
|
||||
("GET", p) if p == page_path => {
|
||||
respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
page.as_bytes(),
|
||||
);
|
||||
}
|
||||
("POST", p) if p == page_path => match verify(&request.body) {
|
||||
Ok(()) => {
|
||||
respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/json",
|
||||
br#"{"ok":true,"message":"Connected. You can close this tab."}"#,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
Err(err) => {
|
||||
let body = serde_json::json!({ "ok": false, "message": err.to_string() });
|
||||
respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/json",
|
||||
body.to_string().as_bytes(),
|
||||
);
|
||||
}
|
||||
},
|
||||
("GET", "/favicon.ico") => respond(&mut stream, 204, "text/plain", b""),
|
||||
_ => respond(&mut stream, 404, "text/plain", b"not found"),
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn random_secret() -> String {
|
||||
(0..4)
|
||||
.map(|_| format!("{:08x}", fastrand::u32(..)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn html_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
struct Request {
|
||||
method: String,
|
||||
path: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
/// A minimal HTTP/1.1 request: request line, headers, and a bounded body.
|
||||
fn read_request(stream: &mut TcpStream) -> Option<Request> {
|
||||
let mut raw = Vec::new();
|
||||
let mut chunk = [0u8; 2048];
|
||||
let header_end = loop {
|
||||
let n = stream.read(&mut chunk).ok()?;
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
raw.extend_from_slice(&chunk[..n]);
|
||||
if let Some(at) = find(&raw, b"\r\n\r\n") {
|
||||
break at + 4;
|
||||
}
|
||||
if raw.len() > MAX_BODY {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let head = String::from_utf8_lossy(&raw[..header_end]).into_owned();
|
||||
let mut lines = head.lines();
|
||||
let mut request_line = lines.next()?.split_whitespace();
|
||||
let method = request_line.next()?.to_string();
|
||||
let path = request_line.next()?.split('?').next()?.to_string();
|
||||
let length = lines
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
|
||||
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0)
|
||||
.min(MAX_BODY);
|
||||
let mut body = raw[header_end..].to_vec();
|
||||
while body.len() < length {
|
||||
let n = stream.read(&mut chunk).ok()?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
body.truncate(length);
|
||||
Some(Request {
|
||||
method,
|
||||
path,
|
||||
body: String::from_utf8_lossy(&body).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) {
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
204 => "No Content",
|
||||
400 => "Bad Request",
|
||||
_ => "Not Found",
|
||||
};
|
||||
let head = format!(
|
||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\
|
||||
Content-Length: {}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\n\
|
||||
X-Content-Type-Options: nosniff\r\n\
|
||||
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; \
|
||||
script-src 'unsafe-inline'; connect-src 'self'\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
let _ = stream.write_all(body);
|
||||
let _ = stream.flush();
|
||||
}
|
||||
|
||||
const PAGE: &str = r#"<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Connect Stockbit to idx</title>
|
||||
<style>
|
||||
:root{
|
||||
color-scheme:light dark;
|
||||
--bg:#f4f5f8;--glow:rgba(47,92,240,.10);--panel:#fff;--panel-2:#f7f8fb;
|
||||
--ink:#12141a;--muted:#6a7180;--line:#e5e8ef;--line-strong:#d2d7e2;
|
||||
--accent:#2f5cf0;--accent-ink:#fff;
|
||||
--ok:#0e7a48;--ok-bg:#eaf7f0;--ok-line:#bfe6d1;
|
||||
--err:#b42318;--err-bg:#fdf2f1;--err-line:#f0cecb;
|
||||
--shadow:0 1px 2px rgba(16,24,40,.05),0 18px 40px -20px rgba(16,24,40,.25)
|
||||
}
|
||||
@media (prefers-color-scheme:dark){:root{
|
||||
--bg:#0b0c0f;--glow:rgba(122,162,255,.09);--panel:#14161b;--panel-2:#191c22;
|
||||
--ink:#edeef2;--muted:#99a0b0;--line:#252932;--line-strong:#333a46;
|
||||
--accent:#7aa2ff;--accent-ink:#0b0c0f;
|
||||
--ok:#5fd39a;--ok-bg:#0f1f19;--ok-line:#1e4135;
|
||||
--err:#ff8f86;--err-bg:#231413;--err-line:#4a2523;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.5),0 18px 44px -20px rgba(0,0,0,.9)
|
||||
}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding:clamp(20px,5vw,56px) 20px;background:var(--bg);
|
||||
background-image:radial-gradient(900px 420px at 50% -180px,var(--glow),transparent 70%);
|
||||
background-repeat:no-repeat;color:var(--ink);
|
||||
font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
|
||||
-webkit-font-smoothing:antialiased}
|
||||
.card{max-width:640px;margin:0 auto;background:var(--panel);border:1px solid var(--line);
|
||||
border-radius:20px;padding:clamp(24px,4.4vw,40px);box-shadow:var(--shadow)}
|
||||
.brand{display:flex;align-items:center;gap:9px;font-size:13px;font-weight:600;color:var(--muted);margin-bottom:20px}
|
||||
.mark{display:grid;place-items:center;width:28px;height:28px;border-radius:9px;background:var(--accent);color:var(--accent-ink)}
|
||||
.mark svg{width:16px;height:16px}
|
||||
.sep{opacity:.45}
|
||||
h1{font-size:clamp(22px,3.2vw,26px);line-height:1.25;letter-spacing:-.02em;margin:0 0 8px;text-wrap:balance}
|
||||
.lede{color:var(--muted);margin:0 0 26px}
|
||||
.steps{list-style:none;margin:0;padding:0;display:grid;gap:12px}
|
||||
.step{display:flex;gap:14px;border:1px solid var(--line);background:var(--panel-2);border-radius:14px;padding:16px 18px}
|
||||
.num{flex:0 0 auto;display:grid;place-items:center;width:24px;height:24px;margin-top:2px;border-radius:50%;
|
||||
border:1px solid var(--line-strong);background:var(--panel);color:var(--muted);font-size:12px;font-weight:700}
|
||||
.step h2{font-size:15px;letter-spacing:-.01em;margin:0 0 4px}
|
||||
.step p{margin:0 0 13px;color:var(--muted);font-size:14px}
|
||||
.step p:last-child{margin-bottom:0}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;border:1px solid transparent;border-radius:10px;
|
||||
padding:10px 16px;font:inherit;font-size:14px;font-weight:600;line-height:1.2;cursor:pointer;
|
||||
text-decoration:none;background:var(--accent);color:var(--accent-ink);
|
||||
box-shadow:0 1px 2px rgba(16,24,40,.10);transition:filter .12s ease,transform .12s ease,background-color .12s ease}
|
||||
.btn:hover{filter:brightness(1.07)}
|
||||
.btn:active{transform:translateY(1px)}
|
||||
.btn.ghost{background:var(--panel);color:var(--ink);border-color:var(--line-strong);box-shadow:none}
|
||||
.btn.ghost:hover{background:var(--panel-2);filter:none}
|
||||
.btn[disabled]{opacity:.55;cursor:progress;transform:none}
|
||||
.hint{margin:13px 0 0;color:var(--muted);font-size:13px}
|
||||
.manual{margin-top:14px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}
|
||||
summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;
|
||||
cursor:pointer;font-size:14px;font-weight:600;list-style:none}
|
||||
summary::-webkit-details-marker{display:none}
|
||||
summary svg{width:16px;height:16px;flex:0 0 auto;color:var(--muted);transition:transform .16s ease}
|
||||
.manual[open] summary svg{transform:rotate(180deg)}
|
||||
.manual-body{padding:0 18px 18px;border-top:1px solid var(--line)}
|
||||
.mini{margin:15px 0;padding-left:19px;color:var(--muted);font-size:13.5px;line-height:1.75}
|
||||
.mini li::marker{color:var(--line-strong)}
|
||||
textarea{width:100%;min-height:84px;border:1px solid var(--line-strong);border-radius:10px;
|
||||
background:var(--panel);color:var(--ink);padding:11px 12px;resize:vertical;
|
||||
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
textarea::placeholder{color:var(--muted);opacity:.75}
|
||||
.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
|
||||
code{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--panel-2);
|
||||
border:1px solid var(--line);border-radius:6px;padding:1px 5px;white-space:nowrap}
|
||||
kbd{font:12px ui-monospace,Menlo,monospace;background:var(--panel-2);border:1px solid var(--line-strong);
|
||||
border-bottom-width:2px;border-radius:5px;padding:0 5px}
|
||||
em{font-style:normal;color:var(--ink)}
|
||||
.status{display:flex;align-items:center;gap:10px;margin:18px 0 0;padding:12px 14px;
|
||||
border:1px solid transparent;border-radius:11px;font-size:14px;font-weight:600}
|
||||
.status[hidden]{display:none}
|
||||
.status.pending{color:var(--muted);background:var(--panel-2);border-color:var(--line)}
|
||||
.status.pending::before{content:"";flex:0 0 auto;width:14px;height:14px;border-radius:50%;
|
||||
border:2px solid var(--line-strong);border-top-color:var(--accent);animation:spin .7s linear infinite}
|
||||
.status.err{color:var(--err);background:var(--err-bg);border-color:var(--err-line)}
|
||||
.status.err::before{content:"!";flex:0 0 auto;display:grid;place-items:center;width:16px;height:16px;
|
||||
border-radius:50%;background:var(--err);color:var(--err-bg);font-size:11px;font-weight:800}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.status.pending::before{animation-duration:2.4s}
|
||||
.btn{transition:none}
|
||||
}
|
||||
.done{padding:6px 0 10px;text-align:center}
|
||||
.done[hidden]{display:none}
|
||||
.done .check{display:grid;place-items:center;width:52px;height:52px;margin:0 auto 16px;
|
||||
border-radius:50%;background:var(--ok-bg);border:1px solid var(--ok-line);color:var(--ok)}
|
||||
.done .check svg{width:26px;height:26px}
|
||||
.done h2{font-size:18px;letter-spacing:-.01em;margin:0 0 6px}
|
||||
.done p{margin:0;color:var(--muted);font-size:14px}
|
||||
.foot{margin-top:26px;padding-top:18px;border-top:1px solid var(--line);
|
||||
color:var(--muted);font-size:13px;line-height:1.65}
|
||||
.foot p{margin:0 0 8px}
|
||||
.foot p:last-child{margin-bottom:0}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
|
||||
</style></head>
|
||||
<body><main class="card">
|
||||
<div class="brand">
|
||||
<span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.7 14.3 14.3 9.7"/><path d="M7.4 12.6l-1.3 1.3a3.6 3.6 0 0 0 5 5l1.3-1.3"/><path d="M16.6 11.4l1.3-1.3a3.6 3.6 0 0 0-5-5L11.6 6.4"/></svg></span>
|
||||
<span>idx</span><span class="sep" aria-hidden="true">/</span><span>Stockbit</span>
|
||||
</div>
|
||||
|
||||
<header class="head">
|
||||
<h1>Connect your Stockbit account</h1>
|
||||
<p class="lede">idx then reads live quotes and the order book with your own session. The session stays on this machine.</p>
|
||||
</header>
|
||||
|
||||
<section id="app">
|
||||
<ol class="steps">
|
||||
<li class="step">
|
||||
<span class="num" aria-hidden="true">1</span>
|
||||
<div>
|
||||
<h2>Sign in to Stockbit</h2>
|
||||
<p>Open stockbit.com in this browser and sign in. If the tab is already signed in, go to the next step.</p>
|
||||
<a class="btn ghost" href="https://stockbit.com/symbol/BBCA" target="_blank" rel="noreferrer">Open stockbit.com</a>
|
||||
</div>
|
||||
</li>
|
||||
<li class="step">
|
||||
<span class="num" aria-hidden="true">2</span>
|
||||
<div>
|
||||
<h2>Send the session to idx</h2>
|
||||
<p>While the Stockbit tab is open, click this button. It reads the session cookie in your own browser.</p>
|
||||
<a class="btn" id="bookmarklet" href="__BOOKMARKLET__">Send my Stockbit session</a>
|
||||
<p class="hint">If the browser blocks it, drag the button to your bookmarks bar, open stockbit.com, then click it there.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<details class="manual" id="manual">
|
||||
<summary><span>Or paste the cookie by hand</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||
<div class="manual-body">
|
||||
<ol class="mini">
|
||||
<li>On stockbit.com, open DevTools with <kbd>F12</kbd>.</li>
|
||||
<li>Go to <em>Application</em>, then <em>Cookies</em>, then <code>https://stockbit.com</code>.</li>
|
||||
<li>Copy the value of <code>credentialStorage</code> and paste it below.</li>
|
||||
</ol>
|
||||
<textarea id="paste" placeholder="credentialStorage value" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>
|
||||
<div class="row">
|
||||
<button class="btn" id="send">Connect</button>
|
||||
<button class="btn ghost" id="clip">Paste from clipboard</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p id="status" class="status" role="status" aria-live="polite" hidden></p>
|
||||
</section>
|
||||
|
||||
<section id="done" class="done" hidden aria-live="polite">
|
||||
<span class="check" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m20 6.5-10.5 11L4 12"/></svg></span>
|
||||
<h2>Connected</h2>
|
||||
<p id="done-msg">You can close this tab and go back to idx.</p>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<p>idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser that shares this session signs out once idx refreshes it.</p>
|
||||
<p>This page stops listening after a few minutes.</p>
|
||||
</footer>
|
||||
</main>
|
||||
<script>
|
||||
const target = "__TARGET__";
|
||||
const app = document.getElementById('app');
|
||||
const done = document.getElementById('done');
|
||||
const statusEl = document.getElementById('status');
|
||||
const manual = document.getElementById('manual');
|
||||
const paste = document.getElementById('paste');
|
||||
const head = document.querySelector('.head');
|
||||
const buttons = [document.getElementById('send'), document.getElementById('clip')];
|
||||
let busy = false;
|
||||
|
||||
function setStatus(text, kind) {
|
||||
statusEl.hidden = !text;
|
||||
statusEl.className = 'status' + (kind ? ' ' + kind : '');
|
||||
statusEl.textContent = text || '';
|
||||
}
|
||||
function setBusy(value) {
|
||||
busy = value;
|
||||
for (const button of buttons) { button.disabled = value; }
|
||||
}
|
||||
function askForPaste(message) {
|
||||
if (message) { setStatus(message, 'err'); }
|
||||
manual.open = true;
|
||||
paste.focus();
|
||||
}
|
||||
|
||||
async function send(value) {
|
||||
const text = (value || '').trim();
|
||||
if (busy) { return false; }
|
||||
if (!text) { askForPaste('Paste the credentialStorage value first.'); return false; }
|
||||
setBusy(true);
|
||||
setStatus('Checking the session with Stockbit\u2026', 'pending');
|
||||
try {
|
||||
const response = await fetch(target, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: text
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.ok) {
|
||||
setStatus('');
|
||||
head.hidden = true;
|
||||
app.hidden = true;
|
||||
done.hidden = false;
|
||||
return true;
|
||||
}
|
||||
setStatus(result.message, 'err');
|
||||
} catch (error) {
|
||||
setStatus('Could not reach idx: ' + error, 'err');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
document.getElementById('send').addEventListener('click', () => send(paste.value));
|
||||
document.getElementById('clip').addEventListener('click', async () => {
|
||||
try {
|
||||
send(await navigator.clipboard.readText());
|
||||
} catch (error) {
|
||||
askForPaste('The browser blocked the clipboard. Paste the value into the box instead.');
|
||||
}
|
||||
});
|
||||
paste.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
send(paste.value);
|
||||
}
|
||||
});
|
||||
if (location.hash.startsWith('#t=')) {
|
||||
const value = decodeURIComponent(location.hash.slice(3));
|
||||
history.replaceState(null, '', location.pathname);
|
||||
send(value).then((ok) => {
|
||||
if (!ok) { paste.value = value; manual.open = true; }
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body></html>
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::net::TcpStream;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn request(url: &str, method: &str, body: &str) -> (u16, String) {
|
||||
let rest = url.trim_start_matches("http://");
|
||||
let (authority, path) = rest.split_once('/').expect("path");
|
||||
let mut stream = TcpStream::connect(authority).expect("connect");
|
||||
// A server that never answers must fail the test, not hang it.
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"{method} /{path} HTTP/1.1\r\nHost: {authority}\r\nContent-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.unwrap();
|
||||
let mut raw = String::new();
|
||||
stream.read_to_string(&mut raw).unwrap();
|
||||
let status = raw
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.expect("status");
|
||||
let body = raw.split("\r\n\r\n").nth(1).unwrap_or_default().to_string();
|
||||
(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();
|
||||
let url = page.url();
|
||||
let bad = url.replace(&page.secret, "not-the-secret");
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
let verify = |paste: &str| match paste.trim() {
|
||||
"good" => Ok(()),
|
||||
_ => Err(IdxError::InvalidInput("not a Stockbit session".to_string())),
|
||||
};
|
||||
page.wait(Duration::from_secs(10), verify)
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
assert_eq!(request(&bad, "GET", "").0, 404);
|
||||
let (status, html) = request(&url, "GET", "");
|
||||
assert_eq!(status, 200);
|
||||
assert!(html.contains("Connect Stockbit to idx"));
|
||||
assert!(html.contains("javascript:"), "bookmarklet rendered");
|
||||
// The bookmarklet carries this run's page and a fragment, not the
|
||||
// secret in a query string.
|
||||
assert!(html.contains(&format!("{url}#t=")), "bookmarklet target");
|
||||
|
||||
let (status, body) = request(&url, "POST", "nonsense");
|
||||
assert_eq!(status, 200);
|
||||
assert!(body.contains("\"ok\":false"), "{body}");
|
||||
|
||||
let (status, body) = request(&url, "POST", "good");
|
||||
assert_eq!(status, 200);
|
||||
assert!(body.contains("\"ok\":true"), "{body}");
|
||||
handle.join().unwrap().expect("accepted");
|
||||
}
|
||||
|
||||
/// The CSP allows no fetch of its own, so a stray asset would fail
|
||||
/// silently; the page also has to keep the two entry points the docs name.
|
||||
#[test]
|
||||
fn the_page_is_self_contained_and_keeps_both_entry_points() {
|
||||
let page = LoginPage::bind(0).unwrap().page();
|
||||
for forbidden in ["<script src", "<link", "@import", "url(http"] {
|
||||
assert!(!page.contains(forbidden), "page needs {forbidden}");
|
||||
}
|
||||
assert_eq!(
|
||||
page.matches("https://").count(),
|
||||
2,
|
||||
"one link out, and the cookie path spelled out"
|
||||
);
|
||||
for url in page.split("https://").skip(1) {
|
||||
assert!(
|
||||
url.starts_with("stockbit.com"),
|
||||
"page links somewhere else: {}",
|
||||
&url[..url.len().min(40)]
|
||||
);
|
||||
}
|
||||
assert!(page.contains("https://stockbit.com/symbol/BBCA"));
|
||||
assert!(page.contains("Send my Stockbit session"));
|
||||
assert!(page.contains("Paste from clipboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn times_out_without_a_paste() {
|
||||
let page = LoginPage::bind(0).unwrap();
|
||||
let started = Instant::now();
|
||||
let err = page
|
||||
.wait(Duration::from_millis(150), |_| {
|
||||
Err(IdxError::InvalidInput("no".to_string()))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("timed out"), "{err}");
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
|
||||
/// Browsers open idle preconnects; one must not delay the page.
|
||||
#[test]
|
||||
fn an_idle_connection_does_not_delay_the_page() {
|
||||
let page = LoginPage::bind(0).unwrap();
|
||||
let url = page.url();
|
||||
let handle = std::thread::spawn(move || {
|
||||
page.wait(Duration::from_secs(10), |_| {
|
||||
Err(IdxError::InvalidInput("not a Stockbit session".to_string()))
|
||||
})
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
// Two sockets that say nothing, as Chrome's preconnects do.
|
||||
let rest = url.trim_start_matches("http://");
|
||||
let (authority, _) = rest.split_once('/').unwrap();
|
||||
let idle: Vec<TcpStream> = (0..2)
|
||||
.map(|_| TcpStream::connect(authority).unwrap())
|
||||
.collect();
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
let started = Instant::now();
|
||||
let (status, _) = request(&url, "GET", "");
|
||||
assert_eq!(status, 200);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(2),
|
||||
"page waited {:?} behind idle connections",
|
||||
started.elapsed()
|
||||
);
|
||||
drop(idle);
|
||||
drop(handle);
|
||||
}
|
||||
}
|
||||
390
crates/idx-core/src/api/stockbit/mod.rs
Normal file
390
crates/idx-core/src/api/stockbit/mod.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
//! Stockbit's public symbol page: a quote, the best bid/offer and today's
|
||||
//! price per minute for one IDX symbol, without a login or key.
|
||||
//!
|
||||
//! `GET https://stockbit.com/symbol/{CODE}` server-renders everything into
|
||||
//! the page's `__NEXT_DATA__` JSON (see `parse`). Stockbit's own JSON API
|
||||
//! (`exodus.stockbit.com`) needs a logged-in bearer token and is not used.
|
||||
//!
|
||||
//! Access rules this module keeps:
|
||||
//! - An honest user agent. CloudFront in front of the page answers a static
|
||||
//! HTTP 403 to `curl/`, `axios/`, `Go-http-client/` and empty agents; it
|
||||
//! does not fingerprint TLS, so no browser impersonation is involved.
|
||||
//! - One request per symbol, at least `MIN_INTERVAL` apart across every idx
|
||||
//! process of the user (see `pace`), no retries, and a 403 surfaces as
|
||||
//! `UPSTREAMBLOCKED` at once.
|
||||
//! - 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 browser;
|
||||
pub mod corpaction;
|
||||
pub mod exodus;
|
||||
pub mod loopback;
|
||||
pub mod movement;
|
||||
mod parse;
|
||||
pub mod signin;
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub use parse::{parse_symbol_page, status_error, stockbit_code};
|
||||
|
||||
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||
use super::{
|
||||
CorporateActionProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
|
||||
OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
|
||||
};
|
||||
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||
use crate::error::IdxError;
|
||||
use crate::movements::types::{MovementPage, MovementQuery};
|
||||
use auth::SessionStore;
|
||||
use exodus::Exodus;
|
||||
|
||||
pub const SOURCE: &str = "stockbit";
|
||||
/// Identifies idx-cli and where to reach its maintainers.
|
||||
pub const USER_AGENT: &str = concat!(
|
||||
"idx-cli/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (+",
|
||||
env!("CARGO_PKG_REPOSITORY"),
|
||||
")"
|
||||
);
|
||||
/// Longest a snapshot is served from cache: enough to coalesce a burst (an
|
||||
/// agent asking for a quote and the intraday series), short enough that a
|
||||
/// session view stays current.
|
||||
pub(crate) const CACHE_TTL_SECS: u64 = 5;
|
||||
/// Least time between two requests to stockbit.com from one process.
|
||||
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 {
|
||||
pub fn new() -> Self {
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.timeout_recv_body(Some(Duration::from_secs(10)))
|
||||
// A server that stalls before sending headers must not hold a
|
||||
// call (and an MCP call slot) forever.
|
||||
.timeout_global(Some(Duration::from_secs(20)))
|
||||
.build()
|
||||
.into();
|
||||
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`).
|
||||
pub fn snapshot(&self, resolved: &str) -> Result<Intraday, IdxError> {
|
||||
let code = stockbit_code(resolved)?;
|
||||
let html = self.page(&code, resolved)?;
|
||||
parse_symbol_page(resolved, &html)
|
||||
}
|
||||
|
||||
fn page(&self, code: &str, resolved: &str) -> Result<String, IdxError> {
|
||||
pace();
|
||||
let response = self
|
||||
.agent
|
||||
.get(format!("{BASE_URL}{code}"))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "text/html")
|
||||
.call();
|
||||
match response {
|
||||
Ok(ok) => ok
|
||||
.into_body()
|
||||
.read_to_string()
|
||||
.map_err(|e| IdxError::Http(format!("stockbit: {e}"))),
|
||||
Err(ureq::Error::StatusCode(status)) => Err(status_error(status, resolved)),
|
||||
Err(err) => Err(IdxError::Http(format!("stockbit: {err}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StockbitProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until `MIN_INTERVAL` has passed since the previous request. Threads
|
||||
/// of one process queue on a mutex; separate processes (a shell loop of
|
||||
/// `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.
|
||||
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 {
|
||||
let wait = MIN_INTERVAL.saturating_sub(prev.elapsed());
|
||||
if !wait.is_zero() {
|
||||
std::thread::sleep(wait);
|
||||
}
|
||||
}
|
||||
// Best effort: the in-process pacing above already holds.
|
||||
let _ = pace_across_processes();
|
||||
*last = Some(Instant::now());
|
||||
}
|
||||
|
||||
fn pace_across_processes() -> std::io::Result<()> {
|
||||
let dir = crate::cache::cache_dir().map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(dir.join("stockbit.pace"))?;
|
||||
// Released when `file` is dropped, after the new time is written.
|
||||
file.lock()?;
|
||||
let mut raw = String::new();
|
||||
file.read_to_string(&mut raw)?;
|
||||
if let Ok(prev_ms) = raw.trim().parse::<u128>() {
|
||||
// A clock that went backwards waits at most one interval.
|
||||
let elapsed = now_ms().saturating_sub(prev_ms);
|
||||
let wait = MIN_INTERVAL.as_millis().saturating_sub(elapsed);
|
||||
if wait > 0 {
|
||||
std::thread::sleep(Duration::from_millis(wait as u64));
|
||||
}
|
||||
}
|
||||
stamp(&mut file)
|
||||
}
|
||||
|
||||
fn stamp(file: &mut File) -> std::io::Result<()> {
|
||||
file.set_len(0)?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
write!(file, "{}", now_ms())
|
||||
}
|
||||
|
||||
fn now_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis())
|
||||
}
|
||||
|
||||
impl QuoteProvider for StockbitProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||
match self.session() {
|
||||
Some(exodus) => exodus.quote(symbol),
|
||||
None => self.snapshot(symbol).map(|day| day.quote),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FundamentalsProvider for StockbitProvider {
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
|
||||
Err(fundamentals_unsupported(symbol))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntradayProvider for StockbitProvider {
|
||||
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError> {
|
||||
self.snapshot(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
impl OrderBookProvider for StockbitProvider {
|
||||
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||
self.require_session()?.orderbook(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
impl CorporateActionProvider for StockbitProvider {
|
||||
fn corporate_actions(
|
||||
&self,
|
||||
kind: ActionKind,
|
||||
symbol: Option<&str>,
|
||||
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||
self.require_session()?.corporate_actions(kind, symbol)
|
||||
}
|
||||
}
|
||||
|
||||
impl ShareholderMovementProvider for StockbitProvider {
|
||||
fn shareholder_movements(
|
||||
&self,
|
||||
query: &MovementQuery,
|
||||
page: u32,
|
||||
) -> Result<MovementPage, IdxError> {
|
||||
self.require_session()?.shareholder_movements(query, page)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// Whether the stored session still works, checked with one request.
|
||||
///
|
||||
/// The check refreshes the access token when it is close to expiry, which
|
||||
/// spends the refresh token and rotates the pair; a session Stockbit refuses
|
||||
/// is reported as `AUTHERROR` and left in place. Frontends use this to skip a
|
||||
/// fresh sign-in when the session in hand is still good.
|
||||
pub fn verify_session() -> Result<(), IdxError> {
|
||||
let store = SessionStore::open()?;
|
||||
if !store.has_session() {
|
||||
return Err(IdxError::AuthError(
|
||||
"no Stockbit session stored".to_string(),
|
||||
));
|
||||
}
|
||||
// As in `login`: the mock provider stands in for the network.
|
||||
#[cfg(feature = "testing")]
|
||||
let check = !crate::api::mock::enabled();
|
||||
#[cfg(not(feature = "testing"))]
|
||||
let check = true;
|
||||
if check {
|
||||
Exodus::new(store).verify()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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(
|
||||
"not a Stockbit session: paste the value of stockbit.com's `credentialStorage` \
|
||||
cookie, or a refresh token"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let store = SessionStore::open()?;
|
||||
// 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() {
|
||||
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. A CDN
|
||||
/// block (`UpstreamBlocked`) is not: idx never retries a block.
|
||||
pub(crate) fn check_is_transient(err: &IdxError) -> bool {
|
||||
matches!(err, IdxError::Http(_) | IdxError::RateLimited)
|
||||
}
|
||||
|
||||
fn restore(store: &SessionStore, previous: Option<Vec<u8>>) {
|
||||
match previous {
|
||||
Some(bytes) => {
|
||||
let _ = std::fs::write(store.path(), bytes);
|
||||
}
|
||||
None => {
|
||||
let _ = store.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, 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::UpstreamBlocked(
|
||||
"403".to_string()
|
||||
)));
|
||||
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.
|
||||
#[test]
|
||||
fn user_agent_is_honest_and_not_on_the_cdn_blocklist() {
|
||||
assert!(USER_AGENT.starts_with("idx-cli/"));
|
||||
assert!(USER_AGENT.contains("github.com/0xrsydn/idx-cli"));
|
||||
for blocked in ["curl/", "axios/", "Go-http-client/"] {
|
||||
assert!(
|
||||
!USER_AGENT.contains(blocked),
|
||||
"{USER_AGENT} contains {blocked}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
328
crates/idx-core/src/api/stockbit/movement.rs
Normal file
328
crates/idx-core/src/api/stockbit/movement.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
//! Shareholder movements from `GET /insider/company/majorholder`: reported
|
||||
//! changes in a holder's stake, newest first, 50 rows a page.
|
||||
//!
|
||||
//! Two sources share the feed: IDX insider disclosures (directors,
|
||||
//! commissioners and other insiders, any size; a trade price) and KSEI's
|
||||
//! daily changes of holders at or above 5% (no price; Stockbit estimates
|
||||
//! one). Each row (`data.movement[]`) carries `id` (the holder's id, not the
|
||||
//! row's), `name`, `symbol`, `date` (`"25 Mar 26"`), `previous` / `current` /
|
||||
//! `changes` (`value`: shares as `"+147,933"`, `percentage`: `"+0.0001"`),
|
||||
//! `action_type` (`ACTION_TYPE_BUY`, ..._SELL, ..._TRANSFER, ..._CROSS,
|
||||
//! ..._RIGHT_ISSUE, ..._PRIVATE_PLACEMENT, ..._TENDER_OFFER, ...),
|
||||
//! `nationality`, `badges`, `price.raw.value`, `value.raw.value`,
|
||||
//! `is_estimated_price`, `purpose` and `data_source.type`.
|
||||
//!
|
||||
//! Query parameters (all optional): `symbol`, `date_start` / `date_end`
|
||||
//! (YYYY-MM-DD), `insider` (holder id), `action_type`, `source_type`, `page`
|
||||
//! (from 1).
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::IdxError;
|
||||
use crate::movements::types::{
|
||||
ActionFilter, MovementAction, MovementPage, MovementQuery, MovementSource, ShareholderMovement,
|
||||
};
|
||||
|
||||
pub const PATH: &str = "/insider/company/majorholder";
|
||||
|
||||
/// Stockbit's `action_type` value for `filter`.
|
||||
pub fn action_type(filter: ActionFilter) -> &'static str {
|
||||
match filter {
|
||||
ActionFilter::Buy => "ACTION_TYPE_BUY",
|
||||
ActionFilter::Sell => "ACTION_TYPE_SELL",
|
||||
ActionFilter::Transfer => "ACTION_TYPE_TRANSFER",
|
||||
ActionFilter::Cross => "ACTION_TYPE_CROSS",
|
||||
ActionFilter::CorporateAction => "ACTION_TYPE_CORPACTION",
|
||||
}
|
||||
}
|
||||
|
||||
/// Stockbit's `source_type` value for `source`.
|
||||
pub fn source_type(source: MovementSource) -> &'static str {
|
||||
match source {
|
||||
MovementSource::Idx => "SOURCE_TYPE_IDX",
|
||||
MovementSource::Ksei => "SOURCE_TYPE_KSEI",
|
||||
}
|
||||
}
|
||||
|
||||
/// The request path for `page` of `query`; `code` is the bare IDX code.
|
||||
/// Every value is a date, a digit id, a code or a fixed enum name, so
|
||||
/// nothing needs escaping.
|
||||
pub fn request_path(query: &MovementQuery, code: Option<&str>, page: u32) -> String {
|
||||
let mut path = format!("{PATH}?page={page}");
|
||||
if let Some(code) = code {
|
||||
path.push_str(&format!("&symbol={code}"));
|
||||
}
|
||||
if let Some(from) = query.from {
|
||||
path.push_str(&format!("&date_start={from}"));
|
||||
}
|
||||
if let Some(to) = query.to {
|
||||
path.push_str(&format!("&date_end={to}"));
|
||||
}
|
||||
if let Some(id) = &query.holder_id {
|
||||
path.push_str(&format!("&insider={id}"));
|
||||
}
|
||||
if let Some(action) = query.action {
|
||||
path.push_str(&format!("&action_type={}", action_type(action)));
|
||||
}
|
||||
if let Some(source) = query.source {
|
||||
path.push_str(&format!("&source_type={}", source_type(source)));
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// One page of the feed. A row without a symbol, holder or readable date is
|
||||
/// skipped; a body without `data` is a parse error.
|
||||
pub fn parse_movements(body: &str) -> Result<MovementPage, IdxError> {
|
||||
let value: Value = serde_json::from_str(body)
|
||||
.map_err(|e| IdxError::ParseError(format!("Stockbit majorholder: {e}")))?;
|
||||
let data = value
|
||||
.get("data")
|
||||
.filter(|data| data.is_object())
|
||||
.ok_or_else(|| IdxError::ParseError("Stockbit majorholder: no data".to_string()))?;
|
||||
let movements = data
|
||||
.get("movement")
|
||||
.and_then(Value::as_array)
|
||||
.map(|rows| rows.iter().filter_map(parse_row).collect())
|
||||
.unwrap_or_default();
|
||||
Ok(MovementPage {
|
||||
movements,
|
||||
more: data
|
||||
.get("is_more")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_row(row: &Value) -> Option<ShareholderMovement> {
|
||||
let symbol = text(row.get("symbol"))?.to_ascii_uppercase();
|
||||
let holder = text(row.get("name"))?.to_string();
|
||||
let date = NaiveDate::parse_from_str(text(row.get("date"))?, "%d %b %y").ok()?;
|
||||
let shares = |key: &str| signed(row.get(key).and_then(|v| v.get("value"))).unwrap_or(0);
|
||||
let bps = |key: &str| {
|
||||
percent(row.get(key).and_then(|v| v.get("percentage")))
|
||||
.map(|p| (p * 100.0).round() as i64)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let raw = |key: &str| {
|
||||
signed(
|
||||
row.get(key)
|
||||
.and_then(|v| v.get("raw"))
|
||||
.and_then(|v| v.get("value")),
|
||||
)
|
||||
.filter(|n| *n != 0)
|
||||
};
|
||||
Some(ShareholderMovement {
|
||||
symbol,
|
||||
date,
|
||||
holder,
|
||||
holder_id: text(row.get("id")).unwrap_or_default().to_string(),
|
||||
action: action(text(row.get("action_type")).unwrap_or_default()),
|
||||
source: match text(row.get("data_source").and_then(|v| v.get("type"))) {
|
||||
Some("SOURCE_TYPE_IDX") => Some(MovementSource::Idx),
|
||||
Some("SOURCE_TYPE_KSEI") => Some(MovementSource::Ksei),
|
||||
_ => None,
|
||||
},
|
||||
locality: match text(row.get("nationality")) {
|
||||
Some("NATIONALITY_TYPE_LOCAL") => Some("Local".to_string()),
|
||||
Some("NATIONALITY_TYPE_FOREIGN") => Some("Foreign".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
roles: row
|
||||
.get("badges")
|
||||
.and_then(Value::as_array)
|
||||
.map(|badges| {
|
||||
badges
|
||||
.iter()
|
||||
.filter_map(|b| text(Some(b)))
|
||||
.map(role)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
shares_before: shares("previous"),
|
||||
shares_after: shares("current"),
|
||||
shares_change: shares("changes"),
|
||||
percentage_before_bps: bps("previous"),
|
||||
percentage_after_bps: bps("current"),
|
||||
percentage_change_bps: bps("changes"),
|
||||
price: raw("price"),
|
||||
price_estimated: row
|
||||
.get("is_estimated_price")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
value: raw("value"),
|
||||
purpose: text(row.get("purpose"))
|
||||
.filter(|p| *p != "-")
|
||||
.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
fn action(raw: &str) -> MovementAction {
|
||||
match raw.strip_prefix("ACTION_TYPE_").unwrap_or(raw) {
|
||||
"BUY" => MovementAction::Buy,
|
||||
"SELL" => MovementAction::Sell,
|
||||
"TRANSFER" => MovementAction::Transfer,
|
||||
"CROSS" => MovementAction::Cross,
|
||||
"RIGHT_ISSUE" => MovementAction::RightsIssue,
|
||||
"PRIVATE_PLACEMENT" => MovementAction::PrivatePlacement,
|
||||
"TENDER_OFFER" => MovementAction::TenderOffer,
|
||||
"BONUS_STOCK" => MovementAction::BonusStock,
|
||||
"DIVIDEND_STOCK" => MovementAction::StockDividend,
|
||||
"DIVIDEND_CASH" => MovementAction::CashDividend,
|
||||
_ => MovementAction::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// `SHAREHOLDER_BADGE_DIREKTUR` -> `director`; an unknown badge keeps its
|
||||
/// own name, lowercased.
|
||||
fn role(badge: &str) -> String {
|
||||
match badge.strip_prefix("SHAREHOLDER_BADGE_").unwrap_or(badge) {
|
||||
"DIREKTUR" => "director".to_string(),
|
||||
"KOMISARIS" => "commissioner".to_string(),
|
||||
other => other.to_ascii_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(value: Option<&Value>) -> Option<&str> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// `"+147,933"`, `"-10,000,000"`, `"6982"` or a JSON number.
|
||||
fn signed(value: Option<&Value>) -> Option<i64> {
|
||||
match value? {
|
||||
Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f.round() as i64)),
|
||||
Value::String(s) => s.trim().replace(',', "").parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `"+0.0001"`, `"-0.16"`, `"54.94"`.
|
||||
fn percent(value: Option<&Value>) -> Option<f64> {
|
||||
let n = match value? {
|
||||
Value::Number(n) => n.as_f64()?,
|
||||
Value::String(s) => s.trim().replace(',', "").parse().ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
n.is_finite().then_some(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
std::fs::read_to_string(format!(
|
||||
"{}/tests/fixtures/stockbit_exodus_{name}.json",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insider_buy_reads_shares_price_and_roles() {
|
||||
let page = parse_movements(&fixture("majorholder_bbca")).unwrap();
|
||||
assert!(page.more);
|
||||
let row = page
|
||||
.movements
|
||||
.iter()
|
||||
.find(|m| m.holder == "HENDRA TANUMIHARDJA")
|
||||
.expect("BBCA director row");
|
||||
assert_eq!(row.symbol, "BBCA");
|
||||
assert_eq!(row.date, NaiveDate::from_ymd_opt(2026, 3, 25).unwrap());
|
||||
assert_eq!(row.action, MovementAction::Buy);
|
||||
assert_eq!(row.source, Some(MovementSource::Idx));
|
||||
assert_eq!(row.roles, ["director"]);
|
||||
assert_eq!(
|
||||
(row.shares_before, row.shares_after, row.shares_change),
|
||||
(193_206, 341_139, 147_933)
|
||||
);
|
||||
// 0.0003% of BBCA rounds to 0 basis points; the shares stay exact.
|
||||
assert_eq!(row.percentage_after_bps, 0);
|
||||
assert_eq!(row.price, Some(6982));
|
||||
assert_eq!(row.value, Some(1_032_868_206));
|
||||
assert!(!row.price_estimated);
|
||||
assert_eq!(row.locality.as_deref(), Some("Local"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ksei_sale_is_negative_with_an_estimated_price() {
|
||||
let page = parse_movements(&fixture("majorholder")).unwrap();
|
||||
let row = page
|
||||
.movements
|
||||
.iter()
|
||||
.find(|m| m.holder == "RUDI R. SUTANTRA")
|
||||
.expect("ATLA KSEI sale");
|
||||
assert_eq!(row.action, MovementAction::Sell);
|
||||
assert_eq!(row.source, Some(MovementSource::Ksei));
|
||||
assert_eq!(row.holder_id, "1000004481");
|
||||
assert_eq!(row.shares_change, -10_000_000);
|
||||
assert_eq!(row.percentage_before_bps, 4081);
|
||||
assert_eq!(row.percentage_change_bps, -16);
|
||||
assert!(row.price_estimated);
|
||||
assert!(row.roles.is_empty());
|
||||
assert_eq!(row.purpose, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_labels_are_kept_not_dropped() {
|
||||
let body = r#"{"data":{"is_more":false,"movement":[
|
||||
{"id":"7","name":"X","symbol":"abcd","date":"02 Nov 26",
|
||||
"action_type":"ACTION_TYPE_SOMETHING_NEW","badges":["SHAREHOLDER_BADGE_CEO"],
|
||||
"data_source":{"type":"SOURCE_TYPE_NEW"},"purpose":"-",
|
||||
"previous":{"value":"0","percentage":"0"},
|
||||
"current":{"value":"1,000","percentage":"0.01"},
|
||||
"changes":{"value":"+1,000","percentage":"+0.01"},
|
||||
"price":{"raw":{"value":"0"}},"value":{"raw":{"value":"0"}}},
|
||||
{"id":"8","name":"","symbol":"ABCD","date":"02 Nov 26"},
|
||||
{"id":"9","name":"Y","symbol":"ABCD","date":"2026-11-02"}
|
||||
]}}"#;
|
||||
let page = parse_movements(body).unwrap();
|
||||
assert!(!page.more);
|
||||
assert_eq!(page.movements.len(), 1);
|
||||
let row = &page.movements[0];
|
||||
assert_eq!(row.symbol, "ABCD");
|
||||
assert_eq!(row.action, MovementAction::Other);
|
||||
assert_eq!(row.source, None);
|
||||
assert_eq!(row.roles, ["ceo"]);
|
||||
assert_eq!(
|
||||
(row.price, row.value, row.purpose.as_deref()),
|
||||
(None, None, None)
|
||||
);
|
||||
assert_eq!(row.percentage_after_bps, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_broken_bodies() {
|
||||
let empty = parse_movements(r#"{"data":{"is_more":false,"movement":null}}"#).unwrap();
|
||||
assert_eq!(empty, MovementPage::default());
|
||||
assert!(parse_movements(r#"{"message":"unauthorized"}"#).is_err());
|
||||
assert!(parse_movements("<html>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_path_carries_every_filter() {
|
||||
let mut query = MovementQuery::from_args(
|
||||
None,
|
||||
Some("2026-10-01"),
|
||||
Some("2026-10-15"),
|
||||
Some("14074"),
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
query.action = Some(ActionFilter::CorporateAction);
|
||||
query.source = Some(MovementSource::Ksei);
|
||||
assert_eq!(
|
||||
request_path(&query, Some("BBCA"), 2),
|
||||
"/insider/company/majorholder?page=2&symbol=BBCA&date_start=2026-10-01\
|
||||
&date_end=2026-10-15&insider=14074&action_type=ACTION_TYPE_CORPACTION\
|
||||
&source_type=SOURCE_TYPE_KSEI"
|
||||
);
|
||||
assert_eq!(
|
||||
request_path(&MovementQuery::default(), None, 1),
|
||||
"/insider/company/majorholder?page=1"
|
||||
);
|
||||
}
|
||||
}
|
||||
440
crates/idx-core/src/api/stockbit/parse.rs
Normal file
440
crates/idx-core/src/api/stockbit/parse.rs
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
//! Pure parsing of a Stockbit symbol page: the `__NEXT_DATA__` JSON that
|
||||
//! Next.js renders into `https://stockbit.com/symbol/{CODE}`. No I/O.
|
||||
//!
|
||||
//! Shapes (recorded 2026-10-22 after the close, see
|
||||
//! `tests/fixtures/stockbit_symbol_*.html`):
|
||||
//! - `props.pageProps.company`: the quote. Numbers are mostly strings
|
||||
//! (`price: "5175"`, an index `"5387.90478515625"`, `change: "+59.54"`),
|
||||
//! `percentage` is a JSON number, `orderbook.{bid,offer}.price` is
|
||||
//! `"5175.000000"` or `"N/A"`, `market_hour.status` is e.g. `close`, and
|
||||
//! `status` is `STATUS_ACTIVE`, `STATUS_SUSPENDED` or `STATUS_DELISTED`.
|
||||
//! `date`/`time` (`"22 Oct 2026"`, `"Thu 16:14"`) are the last trade only
|
||||
//! when the stock traded: a suspended one carries today's date.
|
||||
//! - `props.pageProps.chart.prices[]`: one row per minute,
|
||||
//! `formatted_date: "2026-10-22 16:14:00"` (WIB, no offset) and
|
||||
//! `value: "5175"`. The first row is a synthetic anchor at the previous
|
||||
//! close with an empty `formatted_date`. Per-minute OHLC and volume are
|
||||
//! empty strings, so only the price is kept.
|
||||
//! - An unknown code renders the 404 page: `page: "/404"`, empty pageProps.
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::api::idx_offset;
|
||||
use crate::api::types::{Intraday, IntradayPoint, Quote};
|
||||
use crate::error::IdxError;
|
||||
|
||||
/// Stockbit's market status while continuous trading runs. Bid and offer are
|
||||
/// reported only then: outside it they are the closing book.
|
||||
const MARKET_OPEN: &str = "open";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NextData {
|
||||
page: String,
|
||||
props: Props,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Props {
|
||||
#[serde(rename = "pageProps", default)]
|
||||
page_props: PageProps,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct PageProps {
|
||||
company: Option<RawCompany>,
|
||||
chart: Option<RawChart>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawCompany {
|
||||
price: Value,
|
||||
#[serde(default)]
|
||||
previous: Value,
|
||||
#[serde(default)]
|
||||
change: Value,
|
||||
#[serde(default)]
|
||||
percentage: Value,
|
||||
#[serde(default)]
|
||||
volume: Value,
|
||||
date: Option<String>,
|
||||
time: Option<String>,
|
||||
status: Option<String>,
|
||||
orderbook: Option<RawOrderbook>,
|
||||
market_hour: Option<RawMarketHour>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawOrderbook {
|
||||
bid: Option<RawLevel>,
|
||||
offer: Option<RawLevel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawLevel {
|
||||
#[serde(default)]
|
||||
price: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawMarketHour {
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawChart {
|
||||
#[serde(default)]
|
||||
prices: Vec<RawPoint>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawPoint {
|
||||
#[serde(default)]
|
||||
formatted_date: String,
|
||||
#[serde(default)]
|
||||
value: Value,
|
||||
}
|
||||
|
||||
/// Stockbit's code for a resolved symbol: `BBCA.JK` -> `BBCA`, and Yahoo's
|
||||
/// index tickers to Stockbit's (`^JKSE` -> `IHSG`, `^JKLQ45` -> `LQ45`).
|
||||
pub fn stockbit_code(resolved: &str) -> Result<String, IdxError> {
|
||||
let unsupported = || {
|
||||
IdxError::Unsupported(format!(
|
||||
"{resolved}: Stockbit covers IDX (.JK) symbols only"
|
||||
))
|
||||
};
|
||||
let code = resolved.strip_suffix(".JK").ok_or_else(unsupported)?;
|
||||
let code = match code.strip_prefix('^') {
|
||||
Some("JKSE") => "IHSG",
|
||||
Some("JKLQ45") => "LQ45",
|
||||
Some(_) => return Err(unsupported()),
|
||||
None => code,
|
||||
};
|
||||
if code.is_empty() || !code.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(unsupported());
|
||||
}
|
||||
Ok(code.to_string())
|
||||
}
|
||||
|
||||
/// The error for a non-success HTTP status from stockbit.com. A 403 is
|
||||
/// CloudFront's static block page (it refuses some user agents outright),
|
||||
/// not a challenge, so nothing is retried.
|
||||
pub fn status_error(status: u16, resolved: &str) -> IdxError {
|
||||
match status {
|
||||
403 => IdxError::UpstreamBlocked(
|
||||
"stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \
|
||||
block. Try again later or from another network; the msn provider (`--provider msn`, \
|
||||
or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"
|
||||
.to_string(),
|
||||
),
|
||||
404 => IdxError::SymbolNotFound(resolved.to_string()),
|
||||
429 => IdxError::RateLimited,
|
||||
_ => IdxError::Http(format!("stockbit: HTTP {status}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The quote and today's per-minute prices from a symbol page, stamped with
|
||||
/// `resolved` (e.g. `BBCA.JK`).
|
||||
pub fn parse_symbol_page(resolved: &str, html: &str) -> Result<Intraday, IdxError> {
|
||||
let data: NextData = serde_json::from_str(next_data(html)?)
|
||||
.map_err(|e| layout_error(format!("__NEXT_DATA__ is not the expected JSON: {e}")))?;
|
||||
if data.page == "/404" {
|
||||
return Err(IdxError::SymbolNotFound(resolved.to_string()));
|
||||
}
|
||||
let company = data
|
||||
.props
|
||||
.page_props
|
||||
.company
|
||||
.ok_or_else(|| layout_error("no company in the page".to_string()))?;
|
||||
let points = parse_points(&data.props.page_props.chart.unwrap_or_default())?;
|
||||
let quote = parse_quote(resolved, &company)?;
|
||||
Ok(Intraday { quote, points })
|
||||
}
|
||||
|
||||
fn layout_error(detail: String) -> IdxError {
|
||||
IdxError::ParseError(format!(
|
||||
"stockbit symbol page: {detail} (the page layout changed?)"
|
||||
))
|
||||
}
|
||||
|
||||
/// The JSON inside `<script id="__NEXT_DATA__" ...>...</script>`.
|
||||
fn next_data(html: &str) -> Result<&str, IdxError> {
|
||||
let missing = || layout_error("no __NEXT_DATA__ script".to_string());
|
||||
let tag = html.find("id=\"__NEXT_DATA__\"").ok_or_else(missing)?;
|
||||
let start = tag + html[tag..].find('>').ok_or_else(missing)? + 1;
|
||||
let len = html[start..].find("</script>").ok_or_else(missing)?;
|
||||
Ok(&html[start..start + len])
|
||||
}
|
||||
|
||||
fn parse_quote(resolved: &str, company: &RawCompany) -> Result<Quote, IdxError> {
|
||||
let price = number(&company.price)
|
||||
.ok_or_else(|| layout_error(format!("price {} is not a number", company.price)))?;
|
||||
let price = price.round() as i64;
|
||||
let prev_close = number(&company.previous).map(|p| p.round() as i64);
|
||||
let change = number(&company.change)
|
||||
.map(|c| c.round() as i64)
|
||||
.or_else(|| prev_close.map(|prev| price - prev));
|
||||
let volume = count(&company.volume);
|
||||
let listing = company.status.as_deref().unwrap_or_default();
|
||||
let market_status = company
|
||||
.market_hour
|
||||
.as_ref()
|
||||
.and_then(|m| m.status.clone())
|
||||
.filter(|s| !s.is_empty());
|
||||
let open = market_status.as_deref() == Some(MARKET_OPEN);
|
||||
let level = |side: Option<&RawLevel>| {
|
||||
side.and_then(|l| number(&l.price))
|
||||
.filter(|p| *p > 0.0)
|
||||
.map(|p| p.round() as i64)
|
||||
};
|
||||
let book = company.orderbook.as_ref();
|
||||
let (bid, offer) = if open {
|
||||
(
|
||||
level(book.and_then(|b| b.bid.as_ref())),
|
||||
level(book.and_then(|b| b.offer.as_ref())),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let traded = volume.is_some_and(|v| v > 0);
|
||||
let as_of = if listing == "STATUS_ACTIVE" && traded {
|
||||
last_trade(company.date.as_deref(), company.time.as_deref())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Facts about this symbol only; the market's state is `market_status`.
|
||||
// An untraded stock can still have a flat chart at the previous close.
|
||||
let session_note = match listing {
|
||||
"STATUS_SUSPENDED" => Some("Stockbit lists the stock as suspended".to_string()),
|
||||
"STATUS_DELISTED" => Some("Stockbit lists the stock as delisted".to_string()),
|
||||
_ if volume == Some(0) => Some("no trades this session".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
Ok(Quote {
|
||||
symbol: resolved.to_string(),
|
||||
price,
|
||||
change,
|
||||
change_pct: number(&company.percentage),
|
||||
volume,
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
week52_position: None,
|
||||
range_signal: None,
|
||||
prev_close,
|
||||
avg_volume: None,
|
||||
as_of,
|
||||
behind_session: false,
|
||||
bid,
|
||||
offer,
|
||||
market_status,
|
||||
session_note,
|
||||
})
|
||||
}
|
||||
|
||||
/// Today's prices, oldest first, one per minute. The anchor row (no
|
||||
/// timestamp) is dropped; any other row that does not parse means the
|
||||
/// layout changed.
|
||||
fn parse_points(chart: &RawChart) -> Result<Vec<IntradayPoint>, IdxError> {
|
||||
let mut points = Vec::with_capacity(chart.prices.len());
|
||||
for row in &chart.prices {
|
||||
if row.formatted_date.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let bad = || layout_error(format!("chart row {:?} {}", row.formatted_date, row.value));
|
||||
let local = NaiveDateTime::parse_from_str(&row.formatted_date, "%Y-%m-%d %H:%M:%S")
|
||||
.map_err(|_| bad())?;
|
||||
let time = wib(local).ok_or_else(bad)?;
|
||||
let price = number(&row.value).ok_or_else(bad)?;
|
||||
points.push(IntradayPoint { time, price });
|
||||
}
|
||||
points.sort_by_key(|p| p.time);
|
||||
// A repeated minute keeps its last row.
|
||||
points.reverse();
|
||||
points.dedup_by_key(|p| p.time);
|
||||
points.reverse();
|
||||
Ok(points)
|
||||
}
|
||||
|
||||
/// `"22 Oct 2026"` and `"Thu 16:14"` as a WIB time.
|
||||
fn last_trade(date: Option<&str>, time: Option<&str>) -> Option<DateTime<FixedOffset>> {
|
||||
let date = NaiveDate::parse_from_str(date?.trim(), "%d %b %Y").ok()?;
|
||||
let clock = time?.trim().rsplit(' ').next()?;
|
||||
let clock = NaiveTime::parse_from_str(clock, "%H:%M").ok()?;
|
||||
wib(date.and_time(clock))
|
||||
}
|
||||
|
||||
fn wib(local: NaiveDateTime) -> Option<DateTime<FixedOffset>> {
|
||||
idx_offset().from_local_datetime(&local).single()
|
||||
}
|
||||
|
||||
/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`,
|
||||
/// `"NA"` and empty strings are absent.
|
||||
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()?,
|
||||
_ => return None,
|
||||
};
|
||||
n.is_finite().then_some(n)
|
||||
}
|
||||
|
||||
fn count(value: &Value) -> Option<u64> {
|
||||
match value {
|
||||
Value::Number(n) => n.as_u64(),
|
||||
Value::String(s) => s.trim().parse::<u64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
std::fs::read_to_string(format!(
|
||||
"{}/tests/fixtures/{name}",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The recorded BBCA page with Stockbit's market status set to `status`.
|
||||
fn bbca_with_status(status: &str) -> String {
|
||||
let page = fixture("stockbit_symbol_bbca.html");
|
||||
assert!(page.contains(r#""status":"close""#));
|
||||
page.replace(r#""status":"close""#, &format!(r#""status":"{status}""#))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_session_keeps_the_series_and_hides_the_closing_book() {
|
||||
let day = parse_symbol_page("BBCA.JK", &fixture("stockbit_symbol_bbca.html")).unwrap();
|
||||
let q = &day.quote;
|
||||
assert_eq!(
|
||||
(q.price, q.prev_close, q.change),
|
||||
(5175, Some(5275), Some(-100))
|
||||
);
|
||||
assert_eq!(q.change_pct, Some(-1.9));
|
||||
assert_eq!(q.volume, Some(286_928_500));
|
||||
assert_eq!(q.market_status.as_deref(), Some("close"));
|
||||
assert_eq!((q.bid, q.offer), (None, None));
|
||||
assert_eq!(q.session_note, None);
|
||||
assert_eq!(q.as_of.unwrap().to_rfc3339(), "2026-10-22T16:14:00+07:00");
|
||||
|
||||
// 336 rows less the anchor at the previous close.
|
||||
assert_eq!(day.points.len(), 335);
|
||||
let first = &day.points[0];
|
||||
assert_eq!(first.time.to_rfc3339(), "2026-10-22T08:58:00+07:00");
|
||||
assert_eq!(first.price, 5125.0);
|
||||
let last = day.points.last().unwrap();
|
||||
assert_eq!(last.time.to_rfc3339(), "2026-10-22T16:14:00+07:00");
|
||||
assert_eq!(last.price, 5175.0);
|
||||
assert!(day.points.windows(2).all(|w| w[0].time < w[1].time));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_market_reports_best_bid_and_offer() {
|
||||
let day = parse_symbol_page("BBCA.JK", &bbca_with_status(MARKET_OPEN)).unwrap();
|
||||
assert_eq!((day.quote.bid, day.quote.offer), (Some(5175), Some(5200)));
|
||||
assert_eq!(day.quote.market_status.as_deref(), Some(MARKET_OPEN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_other_market_status_hides_bid_and_offer() {
|
||||
for status in ["pre_open", "break", "OPEN", ""] {
|
||||
let day = parse_symbol_page("BBCA.JK", &bbca_with_status(status)).unwrap();
|
||||
assert_eq!((day.quote.bid, day.quote.offer), (None, None), "{status:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_keeps_decimals_in_the_series_and_rounds_the_quote() {
|
||||
let day = parse_symbol_page("^JKSE.JK", &fixture("stockbit_symbol_ihsg.html")).unwrap();
|
||||
let q = &day.quote;
|
||||
assert_eq!(
|
||||
(q.price, q.prev_close, q.change),
|
||||
(5388, Some(5328), Some(60))
|
||||
);
|
||||
// An index has no book even while open.
|
||||
assert_eq!((q.bid, q.offer), (None, None));
|
||||
assert_eq!(day.points.last().unwrap().price, 5387.90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspended_and_delisted_stocks_are_empty_sessions_not_errors() {
|
||||
for (file, note) in [
|
||||
(
|
||||
"stockbit_symbol_maba.html",
|
||||
"Stockbit lists the stock as suspended",
|
||||
),
|
||||
(
|
||||
"stockbit_symbol_myrx.html",
|
||||
"Stockbit lists the stock as delisted",
|
||||
),
|
||||
] {
|
||||
let day = parse_symbol_page("X.JK", &fixture(file)).unwrap();
|
||||
assert!(day.points.is_empty(), "{file}");
|
||||
assert_eq!(day.quote.session_note.as_deref(), Some(note), "{file}");
|
||||
// The page's date/time is not a trade time when nothing traded.
|
||||
assert_eq!(day.quote.as_of, None, "{file}");
|
||||
}
|
||||
}
|
||||
|
||||
/// HOME and BIMA on 2026-10-22: active, a flat chart at the previous
|
||||
/// close, volume 0. Without a note they read as a traded flat session.
|
||||
#[test]
|
||||
fn an_untraded_active_stock_says_so_even_with_a_flat_chart() {
|
||||
let page = fixture("stockbit_symbol_bbca.html");
|
||||
assert!(page.contains(r#""volume":"286928500""#));
|
||||
let page = page.replace(r#""volume":"286928500""#, r#""volume":"0""#);
|
||||
let day = parse_symbol_page("BBCA.JK", &page).unwrap();
|
||||
assert!(!day.points.is_empty());
|
||||
assert_eq!(
|
||||
day.quote.session_note.as_deref(),
|
||||
Some("no trades this session")
|
||||
);
|
||||
assert_eq!(day.quote.as_of, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_code_is_symbol_not_found() {
|
||||
let err = parse_symbol_page("ZZZZ.JK", &fixture("stockbit_symbol_zzzz_404.html"));
|
||||
assert!(matches!(err, Err(IdxError::SymbolNotFound(s)) if s == "ZZZZ.JK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_without_next_data_is_a_layout_change() {
|
||||
let err = parse_symbol_page("BBCA.JK", &fixture("stockbit_blocked_403.html"));
|
||||
assert!(matches!(err, Err(IdxError::ParseError(_))), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_403_is_upstream_blocked_and_404_is_not_found() {
|
||||
assert!(matches!(
|
||||
status_error(403, "BBCA.JK"),
|
||||
IdxError::UpstreamBlocked(_)
|
||||
));
|
||||
assert!(
|
||||
matches!(status_error(404, "BBCA.JK"), IdxError::SymbolNotFound(s) if s == "BBCA.JK")
|
||||
);
|
||||
assert!(matches!(
|
||||
status_error(429, "BBCA.JK"),
|
||||
IdxError::RateLimited
|
||||
));
|
||||
assert!(matches!(status_error(502, "BBCA.JK"), IdxError::Http(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_map_idx_symbols_and_index_tickers() {
|
||||
assert_eq!(stockbit_code("BBCA.JK").unwrap(), "BBCA");
|
||||
assert_eq!(stockbit_code("^JKSE.JK").unwrap(), "IHSG");
|
||||
assert_eq!(stockbit_code("^JKLQ45.JK").unwrap(), "LQ45");
|
||||
for other in ["AAPL.US", "^GSPC.JK", "BRK-B.JK", ".JK"] {
|
||||
assert!(
|
||||
matches!(stockbit_code(other), Err(IdxError::Unsupported(_))),
|
||||
"{other}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
452
crates/idx-core/src/api/stockbit/signin.rs
Normal file
452
crates/idx-core/src/api/stockbit/signin.rs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
//! One Stockbit sign-in for every frontend. `idx stockbit login` and the
|
||||
//! `stockbit_login` MCP tool both start one here, and differ only in how
|
||||
//! they wait: the CLI in the foreground, idx-mcp on a thread it polls.
|
||||
//!
|
||||
//! Two ways a session reaches idx, both ending in `accept`:
|
||||
//! - **Browser** (`browser.rs`): idx opens a Chromium-family browser on
|
||||
//! Stockbit's own sign-in page, in a profile of its own, and reads the
|
||||
//! session cookie over DevTools. Nothing to copy; the default on a desktop.
|
||||
//! - **Page** (`loopback.rs`): a page on 127.0.0.1 that takes the cookie from
|
||||
//! the user's own browser (bookmarklet, clipboard or paste). For a shell
|
||||
//! over SSH, a machine with no display, or no supported browser.
|
||||
//!
|
||||
//! `Method::Auto` picks the browser unless it would not reach the user, and
|
||||
//! falls back to the page only when the browser cannot start. A sign-in that
|
||||
//! started and then timed out or was closed is an error, not a fallback.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::auth::SessionStatus;
|
||||
use super::{browser, loopback};
|
||||
use crate::error::IdxError;
|
||||
|
||||
/// How long the browser window waits: the user signs in there, one-time code
|
||||
/// and all.
|
||||
pub const BROWSER_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||
/// How long the page waits for a session from the user's browser.
|
||||
pub const PAGE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
/// Port the page prefers, so an `ssh -L` forward stays the same across runs.
|
||||
pub const PAGE_PORT: u16 = 7878;
|
||||
/// How long `shutdown` waits for a browser to close.
|
||||
const SHUTDOWN_GRACE: Duration = Duration::from_secs(6);
|
||||
|
||||
/// Which way to sign in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Method {
|
||||
/// The browser when it would reach the user, else the page.
|
||||
#[default]
|
||||
Auto,
|
||||
Browser,
|
||||
Page,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Options {
|
||||
pub method: Method,
|
||||
/// The page's port; `None` prefers `PAGE_PORT` and takes any free one
|
||||
/// when that is busy.
|
||||
pub port: Option<u16>,
|
||||
/// Open the page in the desktop's default browser, when this machine has
|
||||
/// a desktop the user is at.
|
||||
pub open_page: bool,
|
||||
}
|
||||
|
||||
/// How a sign-in reaches the user.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "method", rename_all = "snake_case")]
|
||||
pub enum Started {
|
||||
/// A stored session already works; nothing was opened.
|
||||
Existing,
|
||||
/// A browser window on Stockbit's sign-in page: sign in there.
|
||||
Browser { browser: String },
|
||||
/// The page on 127.0.0.1.
|
||||
Page {
|
||||
url: String,
|
||||
port: u16,
|
||||
/// The `ssh -L` command that brings the page to another machine.
|
||||
forward: String,
|
||||
/// The shell is over SSH, so the page is on another machine than the
|
||||
/// user's browser.
|
||||
remote: bool,
|
||||
/// Why the browser was not used, when `Auto` picked the page.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A sign-in that has started and not yet been waited on.
|
||||
pub struct SignIn {
|
||||
started: Started,
|
||||
timeout: Duration,
|
||||
waiting: Waiting,
|
||||
}
|
||||
|
||||
enum Waiting {
|
||||
Browser(browser::Window),
|
||||
Page(loopback::LoginPage),
|
||||
}
|
||||
|
||||
impl SignIn {
|
||||
/// Opens the browser window or the page, and returns once it is up.
|
||||
pub fn start(options: &Options) -> Result<Self, IdxError> {
|
||||
match options.method {
|
||||
Method::Browser => Self::browser(),
|
||||
Method::Page => Self::page(options, None),
|
||||
Method::Auto => match no_local_browser() {
|
||||
Some(reason) => Self::page(options, Some(reason.to_string())),
|
||||
None => Self::browser().or_else(|err| {
|
||||
Self::page(
|
||||
options,
|
||||
Some(format!("the browser sign-in could not start: {err}")),
|
||||
)
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn browser() -> Result<Self, IdxError> {
|
||||
let profile = browser::profile_dir()?;
|
||||
let browser = browser::Browser::find(&profile)?;
|
||||
let window = browser.open(&profile)?;
|
||||
Ok(Self {
|
||||
started: Started::Browser {
|
||||
browser: browser.name,
|
||||
},
|
||||
timeout: BROWSER_TIMEOUT,
|
||||
waiting: Waiting::Browser(window),
|
||||
})
|
||||
}
|
||||
|
||||
fn page(options: &Options, reason: Option<String>) -> Result<Self, IdxError> {
|
||||
let page = match options.port {
|
||||
Some(port) => loopback::LoginPage::bind(port)?,
|
||||
None => {
|
||||
loopback::LoginPage::bind(PAGE_PORT).or_else(|_| loopback::LoginPage::bind(0))?
|
||||
}
|
||||
};
|
||||
if options.open_page && no_local_browser().is_none() {
|
||||
open_in_browser(&page.url());
|
||||
}
|
||||
Ok(Self {
|
||||
started: Started::Page {
|
||||
url: page.url(),
|
||||
port: page.port(),
|
||||
forward: loopback::ssh_forward(page.port()),
|
||||
remote: over_ssh(),
|
||||
reason,
|
||||
},
|
||||
timeout: PAGE_TIMEOUT,
|
||||
waiting: Waiting::Page(page),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn started(&self) -> &Started {
|
||||
&self.started
|
||||
}
|
||||
|
||||
/// How long `wait` waits.
|
||||
pub fn timeout(&self) -> Duration {
|
||||
self.timeout
|
||||
}
|
||||
|
||||
/// Waits for the user to finish, and reports the session stored.
|
||||
pub fn wait(self) -> Result<SessionStatus, IdxError> {
|
||||
self.wait_until(&AtomicBool::new(false))
|
||||
}
|
||||
|
||||
/// `wait`, ending early with the browser closed when `cancel` is set.
|
||||
/// The page has nothing to close and stops with the process.
|
||||
fn wait_until(self, cancel: &AtomicBool) -> Result<SessionStatus, IdxError> {
|
||||
match self.waiting {
|
||||
Waiting::Browser(window) => window.wait(self.timeout, cancel, &accept)?,
|
||||
Waiting::Page(page) => page.wait(self.timeout, accept)?,
|
||||
}
|
||||
super::session_status()
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks a session value (the `credentialStorage` cookie or a refresh
|
||||
/// token) and stores it: the step every way in ends with.
|
||||
pub fn accept(value: &str) -> Result<(), IdxError> {
|
||||
super::login(value).map(|_| ())
|
||||
}
|
||||
|
||||
/// Why a browser opened here would not reach the user, if it would not.
|
||||
fn no_local_browser() -> Option<&'static str> {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let display =
|
||||
std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some();
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
let display = true;
|
||||
blocker(over_ssh(), display)
|
||||
}
|
||||
|
||||
fn blocker(ssh: bool, display: bool) -> Option<&'static str> {
|
||||
if ssh {
|
||||
Some("this shell is over SSH, so a browser started here opens on the remote machine")
|
||||
} else if !display {
|
||||
Some("this machine has no display (DISPLAY and WAYLAND_DISPLAY are unset)")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn over_ssh() -> bool {
|
||||
std::env::var_os("SSH_CONNECTION").is_some()
|
||||
}
|
||||
|
||||
/// Opens `url` with the desktop's default browser, best effort.
|
||||
fn open_in_browser(url: &str) {
|
||||
#[cfg(target_os = "macos")]
|
||||
let (program, args) = ("open", vec![url]);
|
||||
#[cfg(target_os = "windows")]
|
||||
let (program, args) = ("cmd", vec!["/C", "start", "", url]);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let (program, args) = ("xdg-open", vec![url]);
|
||||
let opened = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.is_ok();
|
||||
if !opened {
|
||||
crate::runtime::warn(format!("could not open a browser; open {url} yourself"));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a background sign-in is doing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum State {
|
||||
Waiting,
|
||||
SignedIn,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A background sign-in as `start_background` and `progress` report it.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Progress {
|
||||
pub state: State,
|
||||
#[serde(flatten)]
|
||||
pub started: Started,
|
||||
/// Seconds before a waiting sign-in gives up; 0 once it has ended.
|
||||
pub expires_in_secs: u64,
|
||||
/// True when `start_background` found this sign-in already waiting.
|
||||
pub already_open: bool,
|
||||
/// Why it failed: the error's code and message.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<ProgressError>,
|
||||
/// The latest warning while it waits, such as Stockbit refusing the
|
||||
/// session signed in so far; gone once it signs in.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProgressError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
struct Slot {
|
||||
id: u64,
|
||||
started: Started,
|
||||
opened: Instant,
|
||||
timeout: Duration,
|
||||
state: State,
|
||||
error: Option<ProgressError>,
|
||||
warning: Option<String>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
fn progress(&self, already_open: bool) -> Progress {
|
||||
let expires_in_secs = match self.state {
|
||||
State::Waiting => self.timeout.saturating_sub(self.opened.elapsed()).as_secs(),
|
||||
_ => 0,
|
||||
};
|
||||
Progress {
|
||||
state: self.state,
|
||||
started: self.started.clone(),
|
||||
expires_in_secs,
|
||||
already_open,
|
||||
error: self.error.clone(),
|
||||
warning: self.warning.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_waiting(&self) -> bool {
|
||||
self.state == State::Waiting && self.opened.elapsed() < self.timeout
|
||||
}
|
||||
}
|
||||
|
||||
/// This process's background sign-in, if one has run: one at a time.
|
||||
static SLOT: Mutex<Option<Slot>> = Mutex::new(None);
|
||||
|
||||
/// Starts a sign-in on a thread and returns at once, for a frontend that
|
||||
/// polls (`progress`) instead of blocking.
|
||||
///
|
||||
/// A stored session that still works means nothing to sign in to; a sign-in
|
||||
/// already waiting is returned again rather than opening a second one.
|
||||
pub fn start_background(options: &Options) -> Result<Progress, IdxError> {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||
// Held while the sign-in starts, so two calls never open two windows.
|
||||
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(open) = slot.as_ref().filter(|open| open.is_waiting()) {
|
||||
return Ok(open.progress(true));
|
||||
}
|
||||
if super::verify_session().is_ok() {
|
||||
return Ok(Progress {
|
||||
state: State::SignedIn,
|
||||
started: Started::Existing,
|
||||
expires_in_secs: 0,
|
||||
already_open: false,
|
||||
error: None,
|
||||
warning: None,
|
||||
});
|
||||
}
|
||||
let sign_in = SignIn::start(options)?;
|
||||
let id = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let mut open = Slot {
|
||||
id,
|
||||
started: sign_in.started().clone(),
|
||||
opened: Instant::now(),
|
||||
timeout: sign_in.timeout(),
|
||||
state: State::Waiting,
|
||||
error: None,
|
||||
warning: None,
|
||||
cancel: cancel.clone(),
|
||||
thread: None,
|
||||
};
|
||||
let progress = open.progress(false);
|
||||
open.thread = Some(std::thread::spawn(move || {
|
||||
// Nobody reads a polling frontend's stderr, so what the wait warns
|
||||
// about (a refused session) goes into the progress it polls.
|
||||
let sink = crate::runtime::on_warning(move |message| {
|
||||
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(open) = slot.as_mut().filter(|open| open.id == id) {
|
||||
open.warning = Some(message.to_string());
|
||||
}
|
||||
});
|
||||
let result = sign_in.wait_until(&cancel);
|
||||
drop(sink);
|
||||
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(open) = slot.as_mut().filter(|open| open.id == id) {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
open.state = State::SignedIn;
|
||||
open.warning = None;
|
||||
}
|
||||
Err(err) => {
|
||||
open.state = State::Failed;
|
||||
open.error = Some(ProgressError {
|
||||
code: err.code().to_string(),
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
*slot = Some(open);
|
||||
Ok(progress)
|
||||
}
|
||||
|
||||
/// Ends a background sign-in, for a process about to exit: a thread does not
|
||||
/// outlive `main`, so without this its browser window would stay open with
|
||||
/// nothing reading it.
|
||||
pub fn shutdown() {
|
||||
let (thread, browser) = {
|
||||
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let Some(open) = slot.as_mut() else {
|
||||
return;
|
||||
};
|
||||
open.cancel.store(true, Ordering::Relaxed);
|
||||
(
|
||||
open.thread.take(),
|
||||
matches!(open.started, Started::Browser { .. }),
|
||||
)
|
||||
};
|
||||
let Some(thread) = thread.filter(|_| browser) else {
|
||||
return;
|
||||
};
|
||||
let deadline = Instant::now() + SHUTDOWN_GRACE;
|
||||
while !thread.is_finished() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// The background sign-in this process ran last, if any.
|
||||
pub fn progress() -> Option<Progress> {
|
||||
SLOT.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.as_ref()
|
||||
.map(|open| open.progress(false))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The browser is skipped exactly when it would not reach the user.
|
||||
#[test]
|
||||
fn the_browser_is_skipped_over_ssh_and_without_a_display() {
|
||||
assert_eq!(blocker(false, true), None);
|
||||
assert!(blocker(true, true).unwrap().contains("SSH"));
|
||||
assert!(blocker(false, false).unwrap().contains("no display"));
|
||||
}
|
||||
|
||||
/// Frontends read the method from one field, whichever way was taken.
|
||||
#[test]
|
||||
fn progress_names_its_method_and_hides_what_it_does_not_have() {
|
||||
let page = Progress {
|
||||
state: State::Waiting,
|
||||
started: Started::Page {
|
||||
url: "http://127.0.0.1:7878/login/x".to_string(),
|
||||
port: 7878,
|
||||
forward: "ssh -L 7878:127.0.0.1:7878 me@host".to_string(),
|
||||
remote: false,
|
||||
reason: None,
|
||||
},
|
||||
expires_in_secs: 300,
|
||||
already_open: false,
|
||||
error: None,
|
||||
warning: None,
|
||||
};
|
||||
let value = serde_json::to_value(&page).unwrap();
|
||||
assert_eq!(value["state"], "waiting");
|
||||
assert_eq!(value["method"], "page");
|
||||
assert_eq!(value["port"], 7878);
|
||||
assert!(
|
||||
["reason", "error", "warning"]
|
||||
.iter()
|
||||
.all(|key| value.get(key).is_none())
|
||||
);
|
||||
|
||||
let failed = Progress {
|
||||
state: State::Failed,
|
||||
started: Started::Browser {
|
||||
browser: "Helium".to_string(),
|
||||
},
|
||||
expires_in_secs: 0,
|
||||
already_open: false,
|
||||
error: Some(ProgressError {
|
||||
code: "IO".to_string(),
|
||||
message: "the window was closed".to_string(),
|
||||
}),
|
||||
warning: Some("Stockbit refused the session".to_string()),
|
||||
};
|
||||
let value = serde_json::to_value(&failed).unwrap();
|
||||
assert_eq!(value["method"], "browser");
|
||||
assert_eq!(value["browser"], "Helium");
|
||||
assert_eq!(value["error"]["code"], "IO");
|
||||
assert_eq!(value["warning"], "Stockbit refused the session");
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,91 @@ pub struct Quote {
|
|||
/// quote is returned, not when it is cached.
|
||||
#[serde(default)]
|
||||
pub behind_session: bool,
|
||||
/// Best bid in IDR (whole Rupiah). Stockbit only, and only while its
|
||||
/// market status is `open`: outside trading it is the closing book, not
|
||||
/// a live one. Absent from JSON when unknown.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bid: Option<i64>,
|
||||
/// Best offer in IDR (whole Rupiah), gated like `bid`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offer: Option<i64>,
|
||||
/// 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,
|
||||
/// delisted, or no trades. Never inferred.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_note: Option<String>,
|
||||
}
|
||||
|
||||
/// One price of today's session, minute by minute (Stockbit's chart).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct IntradayPoint {
|
||||
/// The minute, in WIB.
|
||||
pub time: DateTime<FixedOffset>,
|
||||
/// Last price at that minute in IDR; indices carry two decimals.
|
||||
pub price: f64,
|
||||
}
|
||||
|
||||
/// The latest session of one IDX symbol: its quote and the price per minute.
|
||||
/// Stockbit's public page carries no per-minute OHLC or volume.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Intraday {
|
||||
pub quote: Quote,
|
||||
/// Oldest first; empty when the symbol did not trade (suspended,
|
||||
/// delisted, or no session yet), never an error.
|
||||
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; `None` while
|
||||
/// Stockbit marks them not current (in session they lag a day).
|
||||
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_SESSION_2`,
|
||||
/// `STATE_NAME_BREAK`, `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.
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
|
|||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.map(|t| t.with_timezone(&crate::api::idx_offset())),
|
||||
behind_session: false,
|
||||
bid: None,
|
||||
offer: None,
|
||||
market_status: None,
|
||||
session_note: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ impl CachePolicy {
|
|||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Fetched<T> {
|
||||
pub data: T,
|
||||
/// Provider that produced the data (`msn` or `yahoo`).
|
||||
/// Provider that produced the data (e.g. `msn`, `yahoo`, `ipot`, `stockbit`).
|
||||
pub source: &'static str,
|
||||
pub fetched_at: DateTime<Utc>,
|
||||
/// True when an expired cache entry was served (offline, or after a
|
||||
|
|
@ -94,6 +94,14 @@ impl Idx {
|
|||
crate::flow::Flow { idx: self }
|
||||
}
|
||||
|
||||
pub fn corporate(&self) -> crate::corporate::Corporate<'_> {
|
||||
crate::corporate::Corporate { idx: self }
|
||||
}
|
||||
|
||||
pub fn movements(&self) -> crate::movements::Movements<'_> {
|
||||
crate::movements::Movements { idx: self }
|
||||
}
|
||||
|
||||
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
||||
crate::financial::Financial { idx: self }
|
||||
}
|
||||
|
|
@ -191,7 +199,7 @@ where
|
|||
Err(err) => {
|
||||
if use_cache && let Some(stale) = cache.any::<T>(spec.bucket, spec.key)? {
|
||||
runtime::warn(format!(
|
||||
"network failed, serving stale cache for {}",
|
||||
"network failed ({err}), serving stale cache for {}",
|
||||
spec.subject
|
||||
));
|
||||
return Ok(hit(stale, true));
|
||||
|
|
|
|||
|
|
@ -13,10 +13,14 @@ use crate::error::IdxError;
|
|||
pub const OUTPUT_VALUES: &[&str] = &["auto", "table", "json", "jsonl"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[cfg_attr(feature = "clap", value(rename_all = "lower"))]
|
||||
pub enum ProviderKind {
|
||||
Yahoo,
|
||||
Msn,
|
||||
// Opt-in: quotes from Stockbit's public page (delayed, no login).
|
||||
Stockbit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -27,6 +31,8 @@ pub enum HistoryProviderKind {
|
|||
Auto,
|
||||
Yahoo,
|
||||
Msn,
|
||||
// Needs the user's Stockbit session (`idx stockbit login`).
|
||||
Stockbit,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
|
|
@ -34,6 +40,7 @@ impl ProviderKind {
|
|||
match self {
|
||||
Self::Yahoo => "yahoo",
|
||||
Self::Msn => "msn",
|
||||
Self::Stockbit => "stockbit",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,9 +49,11 @@ impl ProviderKind {
|
|||
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 provider '{value}' (expected yahoo or msn)"
|
||||
"invalid provider '{value}' (expected yahoo, msn, or stockbit)"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +65,7 @@ impl HistoryProviderKind {
|
|||
Self::Auto => "auto",
|
||||
Self::Yahoo => "yahoo",
|
||||
Self::Msn => "msn",
|
||||
Self::Stockbit => "stockbit",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,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)"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
109
crates/idx-core/src/corporate/mod.rs
Normal file
109
crates/idx-core/src/corporate/mod.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! Corporate actions (cash and stock dividends, bonus shares, rights issues,
|
||||
//! warrants, splits, shareholders' meetings, public exposes, IPOs, tender
|
||||
//! offers) from the user's Stockbit session: one stock's history, or the
|
||||
//! market's calendar. Stockbit serves one kind per request, so a query costs
|
||||
//! one request per kind (paced 1 s apart), each cached for 6 hours.
|
||||
|
||||
pub mod types;
|
||||
|
||||
use chrono::{NaiveDate, Utc};
|
||||
|
||||
use crate::api::{corporate_action_provider, stockbit, today_wib};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::error::IdxError;
|
||||
use types::{ActionKind, CalendarRange, CorporateAction};
|
||||
|
||||
/// Announcements change a few times a day at most.
|
||||
const TTL_SECS: u64 = 6 * 3600;
|
||||
const BUCKET: &str = "stockbit-corpaction";
|
||||
|
||||
pub struct Corporate<'a> {
|
||||
pub(crate) idx: &'a Idx,
|
||||
}
|
||||
|
||||
impl Corporate<'_> {
|
||||
/// Today in WIB.
|
||||
pub fn today(&self) -> NaiveDate {
|
||||
today_wib()
|
||||
}
|
||||
|
||||
/// One IDX stock's corporate actions of `kinds`, newest first.
|
||||
pub fn actions(
|
||||
&self,
|
||||
symbol: &str,
|
||||
kinds: &[ActionKind],
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||
let (resolved, _) = self.idx.idx_code(symbol, "corporate actions")?;
|
||||
let mut fetched = self.fetch(kinds, Some(&resolved), policy)?;
|
||||
fetched
|
||||
.data
|
||||
.sort_by(|a, b| b.date.cmp(&a.date).then(a.kind.cmp(&b.kind)));
|
||||
Ok(fetched)
|
||||
}
|
||||
|
||||
/// The market's corporate actions of `kinds` dated within `range` (see
|
||||
/// `CorporateAction::date`), plus offers still open in it (see
|
||||
/// `CalendarRange::includes`), oldest first.
|
||||
pub fn calendar(
|
||||
&self,
|
||||
range: CalendarRange,
|
||||
kinds: &[ActionKind],
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||
let fetched = self.fetch(kinds, None, policy)?;
|
||||
Ok(fetched.map(|mut actions| {
|
||||
actions.retain(|a| range.includes(a));
|
||||
actions.sort_by(|a, b| (a.date, &a.symbol, a.kind).cmp(&(b.date, &b.symbol, b.kind)));
|
||||
actions
|
||||
}))
|
||||
}
|
||||
|
||||
/// Every kind in turn, through the cache; the result is as old as its
|
||||
/// oldest part and stale if any part is.
|
||||
fn fetch(
|
||||
&self,
|
||||
kinds: &[ActionKind],
|
||||
resolved: Option<&str>,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||
if kinds.is_empty() {
|
||||
return Err(IdxError::InvalidInput(
|
||||
"name at least one kind of corporate action".to_string(),
|
||||
));
|
||||
}
|
||||
let provider = corporate_action_provider();
|
||||
let subject = resolved.unwrap_or("corporate actions");
|
||||
let mut merged = Fetched {
|
||||
data: Vec::new(),
|
||||
source: stockbit::SOURCE,
|
||||
fetched_at: Utc::now(),
|
||||
stale: false,
|
||||
};
|
||||
let mut seen = Vec::with_capacity(kinds.len());
|
||||
for &kind in kinds {
|
||||
if seen.contains(&kind) {
|
||||
continue;
|
||||
}
|
||||
seen.push(kind);
|
||||
let key = format!("{}:{}", kind.as_str(), resolved.unwrap_or("market"));
|
||||
let part = cached_fetch(
|
||||
&self.idx.cache,
|
||||
CacheSpec {
|
||||
bucket: BUCKET,
|
||||
key: &key,
|
||||
ttl_secs: TTL_SECS,
|
||||
subject,
|
||||
source: stockbit::SOURCE,
|
||||
},
|
||||
policy,
|
||||
|| provider.corporate_actions(kind, resolved),
|
||||
Ok,
|
||||
)?;
|
||||
merged.fetched_at = merged.fetched_at.min(part.fetched_at);
|
||||
merged.stale |= part.stale;
|
||||
merged.data.extend(part.data);
|
||||
}
|
||||
Ok(merged)
|
||||
}
|
||||
}
|
||||
419
crates/idx-core/src/corporate/types.rs
Normal file
419
crates/idx-core/src/corporate/types.rs
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
use chrono::{Duration, NaiveDate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::IdxError;
|
||||
|
||||
/// The kinds of corporate action Stockbit lists, one request each. JSON
|
||||
/// names them as `--kind` does (`as_str`), so an output kind can be passed
|
||||
/// back in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ActionKind {
|
||||
/// Cash dividend.
|
||||
Dividend,
|
||||
#[cfg_attr(
|
||||
feature = "clap",
|
||||
value(name = "stock-dividend", alias = "stock_dividend")
|
||||
)]
|
||||
#[serde(alias = "stock_dividend")]
|
||||
StockDividend,
|
||||
/// Bonus shares.
|
||||
Bonus,
|
||||
/// Rights issue (HMETD).
|
||||
Rights,
|
||||
Warrant,
|
||||
/// Stock split.
|
||||
Split,
|
||||
#[cfg_attr(
|
||||
feature = "clap",
|
||||
value(name = "reverse-split", alias = "reverse_split")
|
||||
)]
|
||||
#[serde(alias = "reverse_split")]
|
||||
ReverseSplit,
|
||||
/// Shareholders' meeting (RUPS).
|
||||
Meeting,
|
||||
/// Public expose.
|
||||
#[cfg_attr(
|
||||
feature = "clap",
|
||||
value(name = "expose", alias = "public-expose", alias = "public_expose")
|
||||
)]
|
||||
#[serde(rename = "expose", alias = "public_expose")]
|
||||
PublicExpose,
|
||||
Ipo,
|
||||
#[cfg_attr(
|
||||
feature = "clap",
|
||||
value(name = "tender", alias = "tender-offer", alias = "tender_offer")
|
||||
)]
|
||||
#[serde(rename = "tender", alias = "tender_offer")]
|
||||
TenderOffer,
|
||||
}
|
||||
|
||||
impl ActionKind {
|
||||
pub const ALL: [Self; 11] = [
|
||||
Self::Dividend,
|
||||
Self::StockDividend,
|
||||
Self::Bonus,
|
||||
Self::Rights,
|
||||
Self::Warrant,
|
||||
Self::Split,
|
||||
Self::ReverseSplit,
|
||||
Self::Meeting,
|
||||
Self::PublicExpose,
|
||||
Self::Ipo,
|
||||
Self::TenderOffer,
|
||||
];
|
||||
|
||||
/// What one stock's history covers by default: everything but its IPO.
|
||||
pub const STOCK_DEFAULT: [Self; 10] = [
|
||||
Self::Dividend,
|
||||
Self::StockDividend,
|
||||
Self::Bonus,
|
||||
Self::Rights,
|
||||
Self::Warrant,
|
||||
Self::Split,
|
||||
Self::ReverseSplit,
|
||||
Self::Meeting,
|
||||
Self::PublicExpose,
|
||||
Self::TenderOffer,
|
||||
];
|
||||
|
||||
/// The CLI's name (`--kind`), also used in MCP inputs.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Dividend => "dividend",
|
||||
Self::StockDividend => "stock-dividend",
|
||||
Self::Bonus => "bonus",
|
||||
Self::Rights => "rights",
|
||||
Self::Warrant => "warrant",
|
||||
Self::Split => "split",
|
||||
Self::ReverseSplit => "reverse-split",
|
||||
Self::Meeting => "meeting",
|
||||
Self::PublicExpose => "expose",
|
||||
Self::Ipo => "ipo",
|
||||
Self::TenderOffer => "tender",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the action entitles holders on a cum date (and so moves the
|
||||
/// price on the ex date).
|
||||
pub fn is_entitlement(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Dividend
|
||||
| Self::StockDividend
|
||||
| Self::Bonus
|
||||
| Self::Rights
|
||||
| Self::Split
|
||||
| Self::ReverseSplit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ActionKind {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
let wanted = value.trim().to_ascii_lowercase().replace('_', "-");
|
||||
// The names JSON carried before they matched `--kind`, which an
|
||||
// agent may pass back from an earlier answer.
|
||||
let wanted = match wanted.as_str() {
|
||||
"public-expose" => "expose",
|
||||
"tender-offer" => "tender",
|
||||
other => other,
|
||||
};
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|kind| kind.as_str() == wanted)
|
||||
.ok_or_else(|| {
|
||||
let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect();
|
||||
IdxError::InvalidInput(format!(
|
||||
"invalid kind '{value}' (expected one of: {})",
|
||||
names.join(", ")
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `old : new` as Stockbit states it: a 1:5 split turns 1 share into 5; a
|
||||
/// 655:104 rights issue gives 104 rights per 655 shares held; a 6:4 bonus
|
||||
/// gives 4 bonus shares per 6 held.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Ratio {
|
||||
pub old: u64,
|
||||
pub new: u64,
|
||||
}
|
||||
|
||||
/// Inclusive dates.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DateRange {
|
||||
pub start: NaiveDate,
|
||||
pub end: NaiveDate,
|
||||
}
|
||||
|
||||
/// One corporate action of one stock. Which fields are present depends on
|
||||
/// `kind`; absent ones are left out of JSON.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CorporateAction {
|
||||
/// `BBCA.JK`.
|
||||
pub symbol: String,
|
||||
pub kind: ActionKind,
|
||||
/// Where the action sits on a calendar: the cum date for entitlements
|
||||
/// (the last day to buy and be entitled), else the meeting or expose
|
||||
/// day, the listing day, a warrant's first trading day, or a tender
|
||||
/// offer's first day. An entitlement Stockbit gives no cum date for is
|
||||
/// placed on its ex date, and `cum_date` is then absent.
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cum_date: Option<NaiveDate>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ex_date: Option<NaiveDate>,
|
||||
/// Recording date; for a meeting, the date that decides who may attend.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub record_date: Option<NaiveDate>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pay_date: Option<NaiveDate>,
|
||||
/// Cash dividend per share as Stockbit gives it: IDR to the sen (e.g.
|
||||
/// 3.8), another `currency` to 6 decimals (e.g. USD 0.0012).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cash_per_share: Option<f64>,
|
||||
/// The dividend's currency (ISO code, e.g. `USD`) when it is not IDR;
|
||||
/// absent for IDR, which every recorded dividend was.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub currency: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ratio: Option<Ratio>,
|
||||
/// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a
|
||||
/// 6:4 bonus.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub factor: Option<f64>,
|
||||
/// Whole IDR: the rights exercise price, a warrant's exercise price, the
|
||||
/// tender offer price or the final IPO price.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub price: Option<i64>,
|
||||
/// When rights or warrants trade, a tender offer runs, or an IPO is
|
||||
/// offered.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub period: Option<DateRange>,
|
||||
/// When warrants can be exercised.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exercise: Option<DateRange>,
|
||||
/// Shares offered (IPO) or sought (tender offer).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shares: Option<u64>,
|
||||
/// Share of the company a tender offer seeks, basis points.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub percentage_bps: Option<i64>,
|
||||
/// Meeting or expose time, as Stockbit writes it (WIB).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub time: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub venue: Option<String>,
|
||||
/// The issuer's name, when Stockbit gives it (IPO, tender offer).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl CorporateAction {
|
||||
pub fn new(symbol: String, kind: ActionKind, date: NaiveDate) -> Self {
|
||||
Self {
|
||||
symbol,
|
||||
kind,
|
||||
date,
|
||||
cum_date: None,
|
||||
ex_date: None,
|
||||
record_date: None,
|
||||
pay_date: None,
|
||||
cash_per_share: None,
|
||||
currency: None,
|
||||
ratio: None,
|
||||
factor: None,
|
||||
price: None,
|
||||
period: None,
|
||||
exercise: None,
|
||||
shares: None,
|
||||
percentage_bps: None,
|
||||
time: None,
|
||||
venue: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default calendar span, days from today.
|
||||
pub const CALENDAR_DEFAULT_DAYS: i64 = 30;
|
||||
/// Longest calendar range, days.
|
||||
pub const CALENDAR_MAX_DAYS: i64 = 366;
|
||||
|
||||
/// The dates a market-wide calendar covers, inclusive.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CalendarRange {
|
||||
pub from: NaiveDate,
|
||||
pub to: NaiveDate,
|
||||
}
|
||||
|
||||
impl CalendarRange {
|
||||
/// `from` defaults to today, `to` to 30 days after `from`; at most
|
||||
/// `CALENDAR_MAX_DAYS` days, counting both ends.
|
||||
pub fn from_args(
|
||||
from: Option<&str>,
|
||||
to: Option<&str>,
|
||||
today: NaiveDate,
|
||||
) -> Result<Self, IdxError> {
|
||||
let parse = |name: &str, value: &str| {
|
||||
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| {
|
||||
IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD"))
|
||||
})
|
||||
};
|
||||
let from = from.map(|v| parse("from", v)).transpose()?.unwrap_or(today);
|
||||
let to = match to {
|
||||
Some(v) => parse("to", v)?,
|
||||
None => from
|
||||
.checked_add_signed(Duration::days(CALENDAR_DEFAULT_DAYS))
|
||||
.ok_or_else(|| IdxError::InvalidInput(format!("from ({from}) is out of range")))?,
|
||||
};
|
||||
if to < from {
|
||||
return Err(IdxError::InvalidInput(format!(
|
||||
"to ({to}) is before from ({from})"
|
||||
)));
|
||||
}
|
||||
if (to - from).num_days() >= CALENDAR_MAX_DAYS {
|
||||
return Err(IdxError::InvalidInput(format!(
|
||||
"a calendar covers at most {CALENDAR_MAX_DAYS} days"
|
||||
)));
|
||||
}
|
||||
Ok(Self { from, to })
|
||||
}
|
||||
|
||||
pub fn contains(&self, date: NaiveDate) -> bool {
|
||||
(self.from..=self.to).contains(&date)
|
||||
}
|
||||
|
||||
/// Whether `action` belongs on this calendar: dated in it, or an offer
|
||||
/// still open in it (rights trading, a tender offer, an IPO offer), so a
|
||||
/// calendar shows what can still be acted on. A warrant's windows run
|
||||
/// for years, so a warrant counts by its date alone.
|
||||
pub fn includes(&self, action: &CorporateAction) -> bool {
|
||||
let open_offer = matches!(
|
||||
action.kind,
|
||||
ActionKind::Rights | ActionKind::TenderOffer | ActionKind::Ipo
|
||||
) && action
|
||||
.period
|
||||
.is_some_and(|period| period.start <= self.to && period.end >= self.from);
|
||||
self.contains(action.date) || open_offer
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn day(s: &str) -> NaiveDate {
|
||||
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
|
||||
/// A calendar shows what is dated in it and the offers still open in it;
|
||||
/// a warrant's years-long windows do not put it on every calendar.
|
||||
#[test]
|
||||
fn test_calendar_includes_offers_still_open() {
|
||||
let range = CalendarRange {
|
||||
from: day("2026-11-03"),
|
||||
to: day("2026-11-12"),
|
||||
};
|
||||
let with_period = |kind, date: &str, start: &str, end: &str| {
|
||||
let mut action = CorporateAction::new("AUTO.JK".into(), kind, day(date));
|
||||
action.period = Some(DateRange {
|
||||
start: day(start),
|
||||
end: day(end),
|
||||
});
|
||||
action
|
||||
};
|
||||
let tender = with_period(
|
||||
ActionKind::TenderOffer,
|
||||
"2026-10-13",
|
||||
"2026-10-13",
|
||||
"2026-11-11",
|
||||
);
|
||||
assert!(range.includes(&tender), "open until 11-11");
|
||||
let rights = with_period(ActionKind::Rights, "2026-10-20", "2026-10-28", "2026-11-03");
|
||||
assert!(range.includes(&rights), "trades on the first day");
|
||||
let closed = with_period(
|
||||
ActionKind::TenderOffer,
|
||||
"2026-09-01",
|
||||
"2026-09-01",
|
||||
"2026-11-02",
|
||||
);
|
||||
assert!(!range.includes(&closed), "closed the day before");
|
||||
let warrant = with_period(
|
||||
ActionKind::Warrant,
|
||||
"2025-01-10",
|
||||
"2025-01-10",
|
||||
"2028-01-10",
|
||||
);
|
||||
assert!(!range.includes(&warrant), "a warrant counts by its date");
|
||||
let dated = CorporateAction::new("BBCA.JK".into(), ActionKind::Dividend, day("2026-11-12"));
|
||||
assert!(range.includes(&dated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calendar_range_defaults_and_bounds() {
|
||||
let today = day("2026-11-03");
|
||||
let range = CalendarRange::from_args(None, None, today).unwrap();
|
||||
assert_eq!((range.from, range.to), (today, day("2026-12-03")));
|
||||
let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap();
|
||||
assert_eq!(range.to, day("2026-12-10"));
|
||||
// 366 days counting both ends is the most.
|
||||
let range = CalendarRange::from_args(Some("2028-01-01"), Some("2028-12-31"), today);
|
||||
assert!(range.is_ok());
|
||||
for (from, to) in [
|
||||
(Some("2026-11-10"), Some("2026-11-09")),
|
||||
(Some("2026-01-01"), Some("2027-06-01")),
|
||||
(Some("2028-01-01"), Some("2029-01-01")),
|
||||
(Some("11/10/2026"), None),
|
||||
] {
|
||||
assert!(matches!(
|
||||
CalendarRange::from_args(from, to, today),
|
||||
Err(IdxError::InvalidInput(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// The last date there is: 30 days on is an error, not a panic.
|
||||
#[test]
|
||||
fn test_calendar_range_at_the_end_of_time() {
|
||||
let last = NaiveDate::MAX.format("%Y-%m-%d").to_string();
|
||||
let err = CalendarRange::from_args(Some(&last), None, day("2026-11-03")).unwrap_err();
|
||||
assert!(err.to_string().contains("out of range"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_kind_names_round_trip() {
|
||||
for kind in ActionKind::ALL {
|
||||
assert_eq!(kind.as_str().parse::<ActionKind>().unwrap(), kind);
|
||||
}
|
||||
assert!("rups".parse::<ActionKind>().is_err());
|
||||
assert_eq!(
|
||||
"stock_dividend".parse::<ActionKind>().unwrap(),
|
||||
ActionKind::StockDividend
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON's `kind` is the name `--kind` and MCP take.
|
||||
#[test]
|
||||
fn test_action_kind_json_is_the_input_name() {
|
||||
for kind in ActionKind::ALL {
|
||||
let json = serde_json::to_value(kind).unwrap();
|
||||
assert_eq!(json, kind.as_str(), "{kind:?}");
|
||||
assert_eq!(serde_json::from_value::<ActionKind>(json).unwrap(), kind);
|
||||
}
|
||||
// Entries cached before the names changed still read.
|
||||
let old: ActionKind = serde_json::from_str("\"public_expose\"").unwrap();
|
||||
assert_eq!(old, ActionKind::PublicExpose);
|
||||
// And as input, so a kind copied from an older answer works.
|
||||
assert_eq!("public_expose".parse::<ActionKind>().unwrap(), old);
|
||||
assert_eq!(
|
||||
"tender_offer".parse::<ActionKind>().unwrap(),
|
||||
ActionKind::TenderOffer
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,10 @@ pub enum IdxError {
|
|||
InvalidInput(String),
|
||||
#[error("no market data: {0}")]
|
||||
NoMarketData(String),
|
||||
/// The upstream refused this client outright (e.g. a CDN's HTTP 403
|
||||
/// block page). Retrying does not help; the message says what might.
|
||||
#[error("upstream blocked: {0}")]
|
||||
UpstreamBlocked(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
|
|
@ -53,6 +57,7 @@ pub enum ErrorCode {
|
|||
PdfParseError,
|
||||
InvalidInput,
|
||||
NoMarketData,
|
||||
UpstreamBlocked,
|
||||
}
|
||||
|
||||
/// The stable wire form frontends emit, e.g. `SYMBOLNOTFOUND`.
|
||||
|
|
@ -80,6 +85,7 @@ impl IdxError {
|
|||
Self::PdfParseError(_) => ErrorCode::PdfParseError,
|
||||
Self::InvalidInput(_) => ErrorCode::InvalidInput,
|
||||
Self::NoMarketData(_) => ErrorCode::NoMarketData,
|
||||
Self::UpstreamBlocked(_) => ErrorCode::UpstreamBlocked,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +99,8 @@ impl IdxError {
|
|||
| ErrorCode::RateLimited
|
||||
| ErrorCode::ProviderUnavailable
|
||||
| ErrorCode::Offline
|
||||
| ErrorCode::AuthError => 4,
|
||||
| ErrorCode::AuthError
|
||||
| ErrorCode::UpstreamBlocked => 4,
|
||||
ErrorCode::Unsupported => 5,
|
||||
ErrorCode::Io
|
||||
| ErrorCode::ParseError
|
||||
|
|
@ -119,4 +126,11 @@ mod tests {
|
|||
let unsupported = IdxError::Unsupported("history unavailable".to_string());
|
||||
assert_eq!(unsupported.code(), ErrorCode::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_blocked_has_its_own_wire_code_and_network_exit() {
|
||||
let blocked = IdxError::UpstreamBlocked("stockbit.com: HTTP 403".to_string());
|
||||
assert_eq!(blocked.code().to_string(), "UPSTREAMBLOCKED");
|
||||
assert_eq!(blocked.exit_code(), 4);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub mod broker;
|
|||
pub mod cache;
|
||||
mod client;
|
||||
pub mod config;
|
||||
pub mod corporate;
|
||||
pub mod curl_impersonate;
|
||||
pub mod error;
|
||||
pub mod financial;
|
||||
|
|
@ -15,6 +16,7 @@ pub mod flow;
|
|||
#[cfg(feature = "ownership")]
|
||||
pub mod investor;
|
||||
pub mod market;
|
||||
pub mod movements;
|
||||
#[cfg(feature = "ownership")]
|
||||
pub mod ownership;
|
||||
pub mod text;
|
||||
|
|
@ -22,12 +24,40 @@ pub mod text;
|
|||
pub use client::{CachePolicy, Fetched, Idx};
|
||||
|
||||
pub mod runtime {
|
||||
use std::cell::RefCell;
|
||||
use std::fmt::Display;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static QUIET: AtomicBool = AtomicBool::new(false);
|
||||
static JSON: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
type Sink = Box<dyn Fn(&str)>;
|
||||
|
||||
thread_local! {
|
||||
static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Sends this thread's warnings to `sink` too, until the guard drops: a
|
||||
/// frontend whose stderr nobody reads (idx-mcp) hands them on this way.
|
||||
/// Warnings still go to stderr, and `set_quiet` does not silence `sink`.
|
||||
pub fn on_warning(sink: impl Fn(&str) + 'static) -> WarningSink {
|
||||
let previous = SINK.with(|slot| slot.replace(Some(Box::new(sink))));
|
||||
WarningSink { previous }
|
||||
}
|
||||
|
||||
/// Restores the thread's previous sink when dropped.
|
||||
#[must_use = "warnings reach the sink only while this guard lives"]
|
||||
pub struct WarningSink {
|
||||
previous: Option<Sink>,
|
||||
}
|
||||
|
||||
impl Drop for WarningSink {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.previous.take();
|
||||
SINK.with(|slot| *slot.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_quiet(quiet: bool) {
|
||||
QUIET.store(quiet, Ordering::Relaxed);
|
||||
}
|
||||
|
|
@ -62,6 +92,40 @@ pub mod runtime {
|
|||
}
|
||||
|
||||
pub fn warn(message: impl Display) {
|
||||
let message = message.to_string();
|
||||
SINK.with(|slot| {
|
||||
if let Some(sink) = slot.borrow().as_ref() {
|
||||
sink(&message);
|
||||
}
|
||||
});
|
||||
emit("warning", message);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A sink sees this thread's warnings while its guard lives, and an
|
||||
/// inner one hands back to the outer when it drops.
|
||||
#[test]
|
||||
fn warnings_reach_the_sink_while_its_guard_lives() {
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let outer = {
|
||||
let seen = seen.clone();
|
||||
on_warning(move |m| seen.borrow_mut().push(format!("outer {m}")))
|
||||
};
|
||||
warn("one");
|
||||
{
|
||||
let seen = seen.clone();
|
||||
let _inner = on_warning(move |m| seen.borrow_mut().push(format!("inner {m}")));
|
||||
warn("two");
|
||||
}
|
||||
warn("three");
|
||||
drop(outer);
|
||||
warn("four");
|
||||
info("not a warning");
|
||||
assert_eq!(*seen.borrow(), ["outer one", "inner two", "outer three"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ use std::str::FromStr;
|
|||
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
|
||||
|
||||
use crate::api::types::{Interval, Ohlc, Period, Quote};
|
||||
use crate::api::{history_provider, history_source, idx_offset};
|
||||
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;
|
||||
|
|
@ -34,6 +36,10 @@ impl Market<'_> {
|
|||
) -> Result<Vec<Fetched<Quote>>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let bucket = idx.bucket("quote");
|
||||
let ttl_secs = match idx.provider.kind() {
|
||||
ProviderKind::Stockbit => idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
|
||||
_ => idx.config.quote_ttl,
|
||||
};
|
||||
let session = latest_session(Utc::now());
|
||||
let mut quotes = Vec::with_capacity(symbols.len());
|
||||
for symbol in symbols {
|
||||
|
|
@ -43,7 +49,7 @@ impl Market<'_> {
|
|||
CacheSpec {
|
||||
bucket: &bucket,
|
||||
key: &resolved,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
ttl_secs,
|
||||
subject: &resolved,
|
||||
source: idx.provider.kind().as_str(),
|
||||
},
|
||||
|
|
@ -60,12 +66,51 @@ impl Market<'_> {
|
|||
Ok(quotes)
|
||||
}
|
||||
|
||||
/// Today's session of one IDX symbol from Stockbit's public page,
|
||||
/// whichever market provider is selected: the quote (bid/offer only while
|
||||
/// the market is open) and the price per minute.
|
||||
pub fn intraday(
|
||||
&self,
|
||||
symbol: &str,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<Intraday>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let resolved = idx.resolve(symbol)?;
|
||||
let provider = intraday_provider();
|
||||
let mut day = cached_fetch(
|
||||
&idx.cache,
|
||||
CacheSpec {
|
||||
bucket: "stockbit-intraday",
|
||||
key: &resolved,
|
||||
ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
|
||||
subject: &resolved,
|
||||
source: stockbit::SOURCE,
|
||||
},
|
||||
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`
|
||||
/// 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,
|
||||
|
|
@ -82,22 +127,72 @@ 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 resolved = idx.resolve(symbol)?;
|
||||
let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str());
|
||||
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,
|
||||
/// 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: &bucket,
|
||||
key: &key,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
bucket: "stockbit-orderbook",
|
||||
key: &resolved,
|
||||
ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
|
||||
subject: &resolved,
|
||||
source: provider_kind.as_str(),
|
||||
source: stockbit::SOURCE,
|
||||
},
|
||||
policy,
|
||||
|| provider.history(&resolved, period, interval),
|
||||
|| provider.orderbook(&resolved),
|
||||
Ok,
|
||||
)
|
||||
}
|
||||
|
|
@ -380,6 +475,10 @@ mod tests {
|
|||
avg_volume: None,
|
||||
as_of: None,
|
||||
behind_session: false,
|
||||
bid: None,
|
||||
offer: None,
|
||||
market_status: None,
|
||||
session_note: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
165
crates/idx-core/src/movements/mod.rs
Normal file
165
crates/idx-core/src/movements/mod.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
//! Shareholder movements from the user's Stockbit session: reported changes
|
||||
//! in insiders' and major holders' stakes (IDX insider disclosures and KSEI's
|
||||
//! 5% holder changes), one stock's or the market's, newest first. Each page
|
||||
//! is 50 rows and one request (paced 1 s apart). A query's pages are read
|
||||
//! together and cached together for an hour, so they come from one moment
|
||||
//! of a feed that gains rows at the top.
|
||||
//!
|
||||
//! These are the reports as filed. Whether they predict anything is not
|
||||
//! claimed here: share plans read as a cluster of buys at one price, and a
|
||||
//! row appears days after the trade (see `docs/STOCKBIT.md`).
|
||||
|
||||
pub mod types;
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::api::{ShareholderMovementProvider, shareholder_movement_provider, stockbit};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::error::IdxError;
|
||||
use crate::runtime;
|
||||
use types::{MAX_PAGES, MovementList, MovementQuery};
|
||||
|
||||
/// The feed gains rows through the day; an hour keeps a session's repeated
|
||||
/// queries cheap without hiding a day's filings for long.
|
||||
const TTL_SECS: u64 = 3600;
|
||||
const BUCKET: &str = "stockbit-movements";
|
||||
|
||||
pub struct Movements<'a> {
|
||||
pub(crate) idx: &'a Idx,
|
||||
}
|
||||
|
||||
impl Movements<'_> {
|
||||
/// Up to `query.pages` pages of the feed (at most `MAX_PAGES`), newest
|
||||
/// first; `more` says whether rows remain past them.
|
||||
///
|
||||
/// When a page after the first fails and no cached answer is at hand,
|
||||
/// the rows read before it are returned with `incomplete` saying why,
|
||||
/// and are not cached.
|
||||
pub fn list(
|
||||
&self,
|
||||
query: &MovementQuery,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<MovementList>, IdxError> {
|
||||
let mut query = query.clone();
|
||||
query.pages = query.pages.clamp(1, MAX_PAGES);
|
||||
if let Some(symbol) = &query.symbol {
|
||||
let (resolved, _) = self.idx.idx_code(symbol, "shareholder movements")?;
|
||||
query.symbol = Some(resolved);
|
||||
}
|
||||
let provider = shareholder_movement_provider();
|
||||
let subject = query
|
||||
.symbol
|
||||
.clone()
|
||||
.unwrap_or_else(|| "shareholder movements".to_string());
|
||||
let key = cache_key(&query);
|
||||
let partial = RefCell::new(None);
|
||||
let result = cached_fetch(
|
||||
&self.idx.cache,
|
||||
CacheSpec {
|
||||
bucket: BUCKET,
|
||||
key: &key,
|
||||
ttl_secs: TTL_SECS,
|
||||
subject: &subject,
|
||||
source: stockbit::SOURCE,
|
||||
},
|
||||
policy,
|
||||
|| read_pages(provider.as_ref(), &query, &partial),
|
||||
Ok,
|
||||
);
|
||||
match (result, partial.into_inner()) {
|
||||
(Err(err), Some(mut list)) => {
|
||||
runtime::warn(format!(
|
||||
"{err}; showing the {} rows read before it",
|
||||
list.movements.len()
|
||||
));
|
||||
list.incomplete = Some(err.to_string());
|
||||
Ok(Fetched {
|
||||
data: list,
|
||||
source: stockbit::SOURCE,
|
||||
fetched_at: Utc::now(),
|
||||
stale: false,
|
||||
})
|
||||
}
|
||||
(result, _) => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the query's pages in turn. A failure after the first page leaves
|
||||
/// the rows read so far in `partial` and fails, so they are not cached.
|
||||
fn read_pages(
|
||||
provider: &dyn ShareholderMovementProvider,
|
||||
query: &MovementQuery,
|
||||
partial: &RefCell<Option<MovementList>>,
|
||||
) -> Result<MovementList, IdxError> {
|
||||
let mut list = MovementList::default();
|
||||
for page in 1..=query.pages {
|
||||
match provider.shareholder_movements(query, page) {
|
||||
Ok(part) => {
|
||||
list.movements.extend(part.movements);
|
||||
list.more = part.more;
|
||||
if !part.more {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) if page > 1 => {
|
||||
list.more = true;
|
||||
*partial.borrow_mut() = Some(list);
|
||||
return Err(err);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Every filter and the page count, so no two queries share an entry.
|
||||
fn cache_key(query: &MovementQuery) -> String {
|
||||
let opt = |value: Option<String>| value.unwrap_or_else(|| "-".to_string());
|
||||
format!(
|
||||
"{}:{}:{}:{}:{}:{}:{}p",
|
||||
query.symbol.as_deref().unwrap_or("market"),
|
||||
opt(query.from.map(|d| d.to_string())),
|
||||
opt(query.to.map(|d| d.to_string())),
|
||||
query.holder_id.as_deref().unwrap_or("-"),
|
||||
query.action.map(|a| a.as_str()).unwrap_or("-"),
|
||||
query.source.map(|s| s.as_str()).unwrap_or("-"),
|
||||
query.pages,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use types::{ActionFilter, MovementSource};
|
||||
|
||||
#[test]
|
||||
fn cache_keys_differ_by_every_filter() {
|
||||
let base = MovementQuery::from_args(None, None, None, None, 1).unwrap();
|
||||
let mut two = base.clone();
|
||||
two.pages = 2;
|
||||
let mut keys = vec![cache_key(&base), cache_key(&two)];
|
||||
let mut q = base.clone();
|
||||
q.symbol = Some("BBCA.JK".to_string());
|
||||
keys.push(cache_key(&q));
|
||||
let mut q = base.clone();
|
||||
q.from = chrono::NaiveDate::from_ymd_opt(2026, 10, 1);
|
||||
keys.push(cache_key(&q));
|
||||
let mut q = base.clone();
|
||||
q.to = chrono::NaiveDate::from_ymd_opt(2026, 10, 1);
|
||||
keys.push(cache_key(&q));
|
||||
let mut q = base.clone();
|
||||
q.holder_id = Some("14074".to_string());
|
||||
keys.push(cache_key(&q));
|
||||
let mut q = base.clone();
|
||||
q.action = Some(ActionFilter::Sell);
|
||||
keys.push(cache_key(&q));
|
||||
let mut q = base;
|
||||
q.source = Some(MovementSource::Ksei);
|
||||
keys.push(cache_key(&q));
|
||||
let unique: std::collections::HashSet<_> = keys.iter().collect();
|
||||
assert_eq!(unique.len(), keys.len(), "{keys:?}");
|
||||
}
|
||||
}
|
||||
339
crates/idx-core/src/movements/types.rs
Normal file
339
crates/idx-core/src/movements/types.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::IdxError;
|
||||
|
||||
/// What a holder's row records, as Stockbit labels it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MovementAction {
|
||||
Buy,
|
||||
Sell,
|
||||
/// Shares moved between accounts or parties, not a market trade.
|
||||
Transfer,
|
||||
/// A crossing: a negotiated trade between two parties.
|
||||
Cross,
|
||||
RightsIssue,
|
||||
PrivatePlacement,
|
||||
TenderOffer,
|
||||
BonusStock,
|
||||
StockDividend,
|
||||
CashDividend,
|
||||
/// A label Stockbit added after this release.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl MovementAction {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Buy => "buy",
|
||||
Self::Sell => "sell",
|
||||
Self::Transfer => "transfer",
|
||||
Self::Cross => "cross",
|
||||
Self::RightsIssue => "rights_issue",
|
||||
Self::PrivatePlacement => "private_placement",
|
||||
Self::TenderOffer => "tender_offer",
|
||||
Self::BonusStock => "bonus_stock",
|
||||
Self::StockDividend => "stock_dividend",
|
||||
Self::CashDividend => "cash_dividend",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `filter` (Stockbit's `action_type`) selects this action.
|
||||
pub fn matches(self, filter: ActionFilter) -> bool {
|
||||
match filter {
|
||||
ActionFilter::Buy => self == Self::Buy,
|
||||
ActionFilter::Sell => self == Self::Sell,
|
||||
ActionFilter::Transfer => self == Self::Transfer,
|
||||
ActionFilter::Cross => self == Self::Cross,
|
||||
ActionFilter::CorporateAction => matches!(
|
||||
self,
|
||||
Self::RightsIssue
|
||||
| Self::PrivatePlacement
|
||||
| Self::TenderOffer
|
||||
| Self::BonusStock
|
||||
| Self::StockDividend
|
||||
| Self::CashDividend
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The action filters Stockbit's feed takes (`action_type`). Corporate
|
||||
/// action covers rights issues, private placements and the like.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ActionFilter {
|
||||
Buy,
|
||||
Sell,
|
||||
Transfer,
|
||||
Cross,
|
||||
#[cfg_attr(feature = "clap", value(name = "corporate-action"))]
|
||||
CorporateAction,
|
||||
}
|
||||
|
||||
impl ActionFilter {
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Buy,
|
||||
Self::Sell,
|
||||
Self::Transfer,
|
||||
Self::Cross,
|
||||
Self::CorporateAction,
|
||||
];
|
||||
|
||||
/// The CLI's name (`--action`), also used in MCP inputs.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Buy => "buy",
|
||||
Self::Sell => "sell",
|
||||
Self::Transfer => "transfer",
|
||||
Self::Cross => "cross",
|
||||
Self::CorporateAction => "corporate-action",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ActionFilter {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
let wanted = value.trim().to_ascii_lowercase().replace('_', "-");
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|filter| filter.as_str() == wanted)
|
||||
.ok_or_else(|| {
|
||||
IdxError::InvalidInput(format!(
|
||||
"unknown action '{value}'; use one of: {}",
|
||||
Self::ALL.map(Self::as_str).join(", ")
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Who reported the change: IDX insider disclosures (directors,
|
||||
/// commissioners and other insiders, any size), or KSEI's daily changes of
|
||||
/// holders at or above 5%.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MovementSource {
|
||||
Idx,
|
||||
Ksei,
|
||||
}
|
||||
|
||||
impl MovementSource {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Idx => "idx",
|
||||
Self::Ksei => "ksei",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for MovementSource {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"idx" => Ok(Self::Idx),
|
||||
"ksei" => Ok(Self::Ksei),
|
||||
_ => Err(IdxError::InvalidInput(format!(
|
||||
"unknown source '{value}'; use idx or ksei"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One reported change in a holder's stake. Share counts are exact;
|
||||
/// percentages are of the company's shares, in basis points as Stockbit
|
||||
/// rounds them, so a small insider's stake can read 0.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ShareholderMovement {
|
||||
pub symbol: String,
|
||||
/// The day of the change (the trade or the register date).
|
||||
pub date: NaiveDate,
|
||||
pub holder: String,
|
||||
/// Stockbit's id for the holder; `--holder` takes it.
|
||||
pub holder_id: String,
|
||||
pub action: MovementAction,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<MovementSource>,
|
||||
/// `Local` or `Foreign`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub locality: Option<String>,
|
||||
/// Insider roles at the company: `director`, `commissioner`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub roles: Vec<String>,
|
||||
pub shares_before: i64,
|
||||
pub shares_after: i64,
|
||||
/// Signed: negative for a sale.
|
||||
pub shares_change: i64,
|
||||
pub percentage_before_bps: i64,
|
||||
pub percentage_after_bps: i64,
|
||||
pub percentage_change_bps: i64,
|
||||
/// Whole IDR per share.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub price: Option<i64>,
|
||||
/// The price is Stockbit's estimate (KSEI rows report no price).
|
||||
pub price_estimated: bool,
|
||||
/// Whole IDR: shares changed times price.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<i64>,
|
||||
/// The stated purpose, free text (`Investasi`, `Divestasi`, ...).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
|
||||
/// One page of Stockbit's feed (50 rows), newest first.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MovementPage {
|
||||
pub movements: Vec<ShareholderMovement>,
|
||||
/// Whether a further page exists.
|
||||
pub more: bool,
|
||||
}
|
||||
|
||||
/// Most pages one query reads (500 rows).
|
||||
pub const MAX_PAGES: u32 = 10;
|
||||
|
||||
/// What to list. `symbol` is resolved (`BBCA.JK`) once it reaches a
|
||||
/// provider; `None` is the whole market.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct MovementQuery {
|
||||
pub symbol: Option<String>,
|
||||
pub from: Option<NaiveDate>,
|
||||
pub to: Option<NaiveDate>,
|
||||
pub holder_id: Option<String>,
|
||||
pub action: Option<ActionFilter>,
|
||||
pub source: Option<MovementSource>,
|
||||
/// Pages to read, 1 to `MAX_PAGES`.
|
||||
pub pages: u32,
|
||||
}
|
||||
|
||||
impl MovementQuery {
|
||||
/// Validates user input: dates as YYYY-MM-DD with `from` not after
|
||||
/// `to`, a numeric holder id, and 1 to `MAX_PAGES` pages.
|
||||
pub fn from_args(
|
||||
symbol: Option<&str>,
|
||||
from: Option<&str>,
|
||||
to: Option<&str>,
|
||||
holder_id: Option<&str>,
|
||||
pages: u32,
|
||||
) -> Result<Self, IdxError> {
|
||||
let parse = |name: &str, value: &str| {
|
||||
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| {
|
||||
IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD"))
|
||||
})
|
||||
};
|
||||
let from = from.map(|v| parse("from", v)).transpose()?;
|
||||
let to = to.map(|v| parse("to", v)).transpose()?;
|
||||
if let (Some(from), Some(to)) = (from, to)
|
||||
&& to < from
|
||||
{
|
||||
return Err(IdxError::InvalidInput(format!(
|
||||
"to ({to}) is before from ({from})"
|
||||
)));
|
||||
}
|
||||
let holder_id = holder_id.map(str::trim).filter(|id| !id.is_empty());
|
||||
if let Some(id) = holder_id
|
||||
&& !id.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
return Err(IdxError::InvalidInput(format!(
|
||||
"holder '{id}' is not a holder id; ids are digits, shown in the HOLDER ID \
|
||||
column and as holder_id in JSON"
|
||||
)));
|
||||
}
|
||||
if !(1..=MAX_PAGES).contains(&pages) {
|
||||
return Err(IdxError::InvalidInput(format!(
|
||||
"pages must be 1 to {MAX_PAGES}"
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
symbol: symbol
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string),
|
||||
from,
|
||||
to,
|
||||
holder_id: holder_id.map(str::to_string),
|
||||
action: None,
|
||||
source: None,
|
||||
pages,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `movement` passes the query's filters (what Stockbit applies
|
||||
/// on its side; the mock provider uses it).
|
||||
pub fn admits(&self, movement: &ShareholderMovement) -> bool {
|
||||
self.from.is_none_or(|from| movement.date >= from)
|
||||
&& self.to.is_none_or(|to| movement.date <= to)
|
||||
&& self
|
||||
.holder_id
|
||||
.as_ref()
|
||||
.is_none_or(|id| &movement.holder_id == id)
|
||||
&& self
|
||||
.action
|
||||
.is_none_or(|filter| movement.action.matches(filter))
|
||||
&& self
|
||||
.source
|
||||
.is_none_or(|source| movement.source == Some(source))
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows a query read, newest first.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MovementList {
|
||||
pub movements: Vec<ShareholderMovement>,
|
||||
/// More rows exist past the pages read (raise `--pages`, or narrow the
|
||||
/// dates).
|
||||
pub more: bool,
|
||||
/// Why reading stopped before the pages asked for: a later page failed,
|
||||
/// and `movements` holds the rows read before it. Absent when every page
|
||||
/// asked for was read.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub incomplete: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn query_validates_dates_holder_and_pages() {
|
||||
let query =
|
||||
MovementQuery::from_args(Some(" bbca "), Some("2026-10-01"), None, Some("14074"), 2)
|
||||
.unwrap();
|
||||
assert_eq!(query.symbol.as_deref(), Some("bbca"));
|
||||
assert_eq!(query.from, NaiveDate::from_ymd_opt(2026, 10, 1));
|
||||
assert_eq!(query.holder_id.as_deref(), Some("14074"));
|
||||
|
||||
for (from, to, holder, pages) in [
|
||||
(Some("01-10-2026"), None, None, 1),
|
||||
(Some("2026-10-02"), Some("2026-10-01"), None, 1),
|
||||
(None, None, Some("RUDI"), 1),
|
||||
(None, None, Some("1&page=9"), 1),
|
||||
(None, None, None, 0),
|
||||
(None, None, None, MAX_PAGES + 1),
|
||||
] {
|
||||
assert!(
|
||||
MovementQuery::from_args(None, from, to, holder, pages).is_err(),
|
||||
"{from:?} {to:?} {holder:?} {pages}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_filter_names_round_trip() {
|
||||
for filter in ActionFilter::ALL {
|
||||
assert_eq!(filter.as_str().parse::<ActionFilter>().unwrap(), filter);
|
||||
}
|
||||
assert_eq!(
|
||||
"corporate_action".parse::<ActionFilter>().unwrap(),
|
||||
ActionFilter::CorporateAction
|
||||
);
|
||||
assert!("dividend".parse::<ActionFilter>().is_err());
|
||||
assert!(MovementAction::RightsIssue.matches(ActionFilter::CorporateAction));
|
||||
assert!(!MovementAction::Transfer.matches(ActionFilter::CorporateAction));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue