mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
334 lines
12 KiB
Rust
334 lines
12 KiB
Rust
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
|
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
|
|
|
use super::stockbit::{corpaction, exodus};
|
|
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
|
use super::{
|
|
BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider,
|
|
IntradayProvider, OrderBookProvider, QuoteProvider, 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;
|
|
|
|
pub(crate) fn enabled() -> bool {
|
|
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
|
|
}
|
|
|
|
/// `IDX_MOCK_TODAY` (YYYY-MM-DD) pins today's date under the mock provider.
|
|
pub(crate) fn today() -> Option<chrono::NaiveDate> {
|
|
if !enabled() {
|
|
return None;
|
|
}
|
|
let raw = std::env::var("IDX_MOCK_TODAY").ok()?;
|
|
chrono::NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok()
|
|
}
|
|
|
|
pub struct MockProvider {
|
|
quote: Result<Quote, IdxError>,
|
|
fundamentals: Result<Fundamentals, IdxError>,
|
|
history: Result<Vec<Bar>, IdxError>,
|
|
}
|
|
|
|
impl MockProvider {
|
|
pub fn from_fixtures(provider: ProviderKind) -> Self {
|
|
Self::from_fixtures_with_history_verbose(provider, false)
|
|
}
|
|
|
|
pub fn from_fixtures_with_history_verbose(
|
|
provider: ProviderKind,
|
|
history_verbose: bool,
|
|
) -> Self {
|
|
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
|
return Self::with_error(IdxError::ProviderUnavailable);
|
|
}
|
|
|
|
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(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn from_yahoo_fixtures(history_verbose: bool) -> Self {
|
|
let quote_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json")
|
|
.unwrap_or_else(|_| "{}".to_string());
|
|
let history_path = std::env::var("IDX_MOCK_YAHOO_HISTORY_FIXTURE")
|
|
.unwrap_or_else(|_| "tests/fixtures/chart_bbca_3mo.json".to_string());
|
|
let history_raw =
|
|
std::fs::read_to_string(&history_path).unwrap_or_else(|_| "{}".to_string());
|
|
let fundamentals_raw = std::fs::read_to_string("tests/fixtures/quotesummary_bbca.json")
|
|
.unwrap_or_else(|_| "{}".to_string());
|
|
|
|
let quote = yahoo::parse_quote_from_str("BBCA.JK", "e_raw)
|
|
.map_err(|e| IdxError::ParseError(e.to_string()));
|
|
let fundamentals = yahoo::parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw)
|
|
.map_err(|e| IdxError::ParseError(e.to_string()));
|
|
let history =
|
|
yahoo::parse_history_from_str_with_verbose("BBCA.JK", &history_raw, history_verbose)
|
|
.map_err(|e| IdxError::ParseError(e.to_string()));
|
|
|
|
Self {
|
|
quote,
|
|
fundamentals,
|
|
history,
|
|
}
|
|
}
|
|
|
|
fn from_msn_fixtures() -> Self {
|
|
let quote_raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json")
|
|
.unwrap_or_else(|_| "[]".to_string());
|
|
let fundamentals_path = std::env::var("IDX_MOCK_MSN_KEYRATIOS_FIXTURE")
|
|
.unwrap_or_else(|_| "tests/fixtures/msn_keyratios_bbca.json".to_string());
|
|
let fundamentals_raw =
|
|
std::fs::read_to_string(&fundamentals_path).unwrap_or_else(|_| "[]".to_string());
|
|
|
|
let quote = msn::parse_quote_from_str("BBCA.JK", "e_raw)
|
|
.map_err(|e| IdxError::ParseError(e.to_string()));
|
|
let fundamentals = msn::parse_fundamentals_from_str(&fundamentals_raw, Some("e_raw))
|
|
.map_err(|e| IdxError::ParseError(e.to_string()));
|
|
let history_raw = std::fs::read_to_string("tests/fixtures/msn_chart_bbca_3m.json")
|
|
.unwrap_or_else(|_| "[]".to_string());
|
|
let history = msn::parse_history_from_str("BBCA.JK", &history_raw)
|
|
.map_err(|e| IdxError::ParseError(e.to_string()));
|
|
|
|
Self {
|
|
quote,
|
|
fundamentals,
|
|
history,
|
|
}
|
|
}
|
|
|
|
pub fn with_error(err: IdxError) -> Self {
|
|
Self {
|
|
quote: Err(err.clone()),
|
|
fundamentals: Err(err.clone()),
|
|
history: Err(err),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl QuoteProvider for MockProvider {
|
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
|
let mut q = self.quote.clone()?;
|
|
q.symbol = symbol.to_string();
|
|
Ok(q)
|
|
}
|
|
}
|
|
|
|
impl FundamentalsProvider for MockProvider {
|
|
fn fundamentals(&self, _symbol: &str) -> Result<Fundamentals, IdxError> {
|
|
self.fundamentals.clone()
|
|
}
|
|
}
|
|
|
|
impl HistoryProvider for MockProvider {
|
|
fn history(
|
|
&self,
|
|
_symbol: &str,
|
|
_period: &Period,
|
|
_interval: &Interval,
|
|
) -> Result<Vec<Bar>, IdxError> {
|
|
self.history.clone()
|
|
}
|
|
}
|
|
|
|
/// 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(),
|
|
));
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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.
|
|
pub struct MockBrokerProvider;
|
|
|
|
impl BrokerSummaryProvider for MockBrokerProvider {
|
|
fn broker_summary(
|
|
&self,
|
|
code: &str,
|
|
query: &BrokerQuery,
|
|
) -> Result<ipot::BrokerSummaryRaw, IdxError> {
|
|
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
|
return Err(IdxError::ProviderUnavailable);
|
|
}
|
|
let mut name = format!(
|
|
"ipot_brokersummary_{}_{}",
|
|
code.to_ascii_lowercase(),
|
|
query.from.format("%Y%m%d")
|
|
);
|
|
if query.to != query.from {
|
|
name.push_str(&query.to.format("_%Y%m%d").to_string());
|
|
}
|
|
if (query.investor, query.board) != (BrokerInvestor::All, BrokerBoard::All) {
|
|
name.push_str(&format!(
|
|
"_{}_{}",
|
|
query.investor.as_str(),
|
|
query.board.as_str()
|
|
));
|
|
}
|
|
let html = std::fs::read_to_string(format!("tests/fixtures/{name}.html"))
|
|
.unwrap_or_else(|_| "Invalid stock code!".to_string());
|
|
ipot::summary_from_html(code, &html)
|
|
}
|
|
}
|
|
|
|
/// Trading summaries from `tests/fixtures/idx_stocksummary_<date>.json`; a
|
|
/// date without a fixture had no trading.
|
|
pub struct MockTradingSummaryProvider;
|
|
|
|
impl TradingSummaryProvider for MockTradingSummaryProvider {
|
|
fn stock_summary(
|
|
&self,
|
|
date: chrono::NaiveDate,
|
|
) -> Result<Vec<crate::flow::types::StockDay>, IdxError> {
|
|
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
|
return Err(IdxError::ProviderUnavailable);
|
|
}
|
|
let path = format!(
|
|
"tests/fixtures/idx_stocksummary_{}.json",
|
|
date.format("%Y%m%d")
|
|
);
|
|
match std::fs::read_to_string(path) {
|
|
Ok(raw) => idx::parse_stock_summary(&raw, date),
|
|
Err(_) => Ok(Vec::new()),
|
|
}
|
|
}
|
|
}
|