mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
feat: implement idx-cli MVP foundation, provider abstraction, and core quote/history commands
This commit is contained in:
parent
a4b8a1b64e
commit
2174b42cff
18 changed files with 2678 additions and 0 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -8,3 +8,8 @@ docs-internal/
|
||||||
|
|
||||||
# Large research data files
|
# Large research data files
|
||||||
research/
|
research/
|
||||||
|
|
||||||
|
|
||||||
|
# Added by cargo
|
||||||
|
|
||||||
|
/target
|
||||||
|
|
|
||||||
1675
Cargo.lock
generated
Normal file
1675
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "idx-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
clap_complete = "4"
|
||||||
|
ureq = { version = "3", features = ["json", "cookies"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
comfy-table = "7"
|
||||||
|
owo-colors = "4"
|
||||||
|
toml = "0.8"
|
||||||
|
directories = "5"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
predicates = "3"
|
||||||
85
src/api/mod.rs
Normal file
85
src/api/mod.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
pub mod types;
|
||||||
|
pub mod yahoo;
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use types::{Interval, Ohlc, Period, Quote};
|
||||||
|
|
||||||
|
pub trait MarketDataProvider {
|
||||||
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
|
||||||
|
fn history(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> Result<Vec<Ohlc>, IdxError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_symbol(symbol: &str, exchange: &str) -> String {
|
||||||
|
let trimmed = symbol.trim().to_uppercase();
|
||||||
|
if let Some((base, suffix)) = trimmed.rsplit_once('.')
|
||||||
|
&& !base.is_empty()
|
||||||
|
&& !suffix.is_empty()
|
||||||
|
{
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
format!("{trimmed}.{}", exchange.trim().to_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_provider() -> Box<dyn MarketDataProvider> {
|
||||||
|
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
|
||||||
|
Box::new(MockProvider)
|
||||||
|
} else {
|
||||||
|
Box::new(yahoo::YahooProvider::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockProvider;
|
||||||
|
|
||||||
|
impl MarketDataProvider for MockProvider {
|
||||||
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||||
|
Ok(Quote {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
price: 9875.0,
|
||||||
|
change: 117.0,
|
||||||
|
change_pct: 1.2,
|
||||||
|
volume: 12_300_000,
|
||||||
|
market_cap: Some(1_215_200_000_000_000.0),
|
||||||
|
week52_high: Some(10_250.0),
|
||||||
|
week52_low: Some(7_800.0),
|
||||||
|
week52_position: Some(0.73),
|
||||||
|
range_signal: Some("upper".to_string()),
|
||||||
|
prev_close: Some(9_758.0),
|
||||||
|
avg_volume: Some(10_000_000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history(
|
||||||
|
&self,
|
||||||
|
_symbol: &str,
|
||||||
|
_period: &Period,
|
||||||
|
_interval: &Interval,
|
||||||
|
) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
Ok(vec![Ohlc {
|
||||||
|
date: chrono::NaiveDate::from_ymd_opt(2026, 3, 1).expect("valid date"),
|
||||||
|
open: 9800.0,
|
||||||
|
high: 9900.0,
|
||||||
|
low: 9750.0,
|
||||||
|
close: 9875.0,
|
||||||
|
volume: 12_300_000,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::resolve_symbol;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_symbol_variants() {
|
||||||
|
assert_eq!(resolve_symbol("bbca", "JK"), "BBCA.JK");
|
||||||
|
assert_eq!(resolve_symbol("BBCA.JK", "JK"), "BBCA.JK");
|
||||||
|
assert_eq!(resolve_symbol("TLKM.us", "JK"), "TLKM.US");
|
||||||
|
assert_eq!(resolve_symbol("abcd.ef.gh", "JK"), "ABCD.EF.GH");
|
||||||
|
assert_eq!(resolve_symbol(" bbri ", "jk"), "BBRI.JK");
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/api/types.rs
Normal file
84
src/api/types.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use clap::ValueEnum;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Quote {
|
||||||
|
pub symbol: String,
|
||||||
|
pub price: f64,
|
||||||
|
pub change: f64,
|
||||||
|
pub change_pct: f64,
|
||||||
|
pub volume: u64,
|
||||||
|
pub market_cap: Option<f64>,
|
||||||
|
pub week52_high: Option<f64>,
|
||||||
|
pub week52_low: Option<f64>,
|
||||||
|
pub week52_position: Option<f64>,
|
||||||
|
pub range_signal: Option<String>,
|
||||||
|
pub prev_close: Option<f64>,
|
||||||
|
pub avg_volume: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Ohlc {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub open: f64,
|
||||||
|
pub high: f64,
|
||||||
|
pub low: f64,
|
||||||
|
pub close: f64,
|
||||||
|
pub volume: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ValueEnum)]
|
||||||
|
pub enum Period {
|
||||||
|
#[value(name = "1d")]
|
||||||
|
OneDay,
|
||||||
|
#[value(name = "5d")]
|
||||||
|
FiveDays,
|
||||||
|
#[value(name = "1mo")]
|
||||||
|
OneMonth,
|
||||||
|
#[value(name = "3mo")]
|
||||||
|
ThreeMonths,
|
||||||
|
#[value(name = "6mo")]
|
||||||
|
SixMonths,
|
||||||
|
#[value(name = "1y")]
|
||||||
|
OneYear,
|
||||||
|
#[value(name = "2y")]
|
||||||
|
TwoYears,
|
||||||
|
#[value(name = "5y")]
|
||||||
|
FiveYears,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Period {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::OneDay => "1d",
|
||||||
|
Self::FiveDays => "5d",
|
||||||
|
Self::OneMonth => "1mo",
|
||||||
|
Self::ThreeMonths => "3mo",
|
||||||
|
Self::SixMonths => "6mo",
|
||||||
|
Self::OneYear => "1y",
|
||||||
|
Self::TwoYears => "2y",
|
||||||
|
Self::FiveYears => "5y",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ValueEnum)]
|
||||||
|
pub enum Interval {
|
||||||
|
#[value(name = "1d")]
|
||||||
|
Day,
|
||||||
|
#[value(name = "1wk")]
|
||||||
|
Week,
|
||||||
|
#[value(name = "1mo")]
|
||||||
|
Month,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Interval {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Day => "1d",
|
||||||
|
Self::Week => "1wk",
|
||||||
|
Self::Month => "1mo",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
259
src/api/yahoo.rs
Normal file
259
src/api/yahoo.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::api::types::{Interval, Ohlc, Period, Quote};
|
||||||
|
use crate::api::MarketDataProvider;
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
pub struct YahooProvider {
|
||||||
|
agent: ureq::Agent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YahooProvider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
agent: ureq::Agent::new_with_defaults(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crumb(&self) -> Result<String, IdxError> {
|
||||||
|
let resp = self
|
||||||
|
.agent
|
||||||
|
.get("https://query1.finance.yahoo.com/v1/test/getcrumb")
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| IdxError::Http(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut body = resp.into_body();
|
||||||
|
body.read_to_string()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.map_err(|e| IdxError::Http(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chart_url(symbol: &str, period: &Period, interval: &Interval, crumb: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range={}&interval={}&crumb={crumb}",
|
||||||
|
period.as_str(),
|
||||||
|
interval.as_str()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_chart(&self, symbol: &str, period: &Period, interval: &Interval) -> Result<ChartResponse, IdxError> {
|
||||||
|
let crumb = self.crumb()?;
|
||||||
|
let mut wait = Duration::from_millis(250);
|
||||||
|
for _ in 0..3 {
|
||||||
|
let url = Self::chart_url(symbol, period, interval, &crumb);
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.call();
|
||||||
|
match response {
|
||||||
|
Ok(ok) => {
|
||||||
|
return ok
|
||||||
|
.into_body()
|
||||||
|
.read_json::<ChartResponse>()
|
||||||
|
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||||
|
}
|
||||||
|
Err(ureq::Error::StatusCode(429)) => {
|
||||||
|
std::thread::sleep(wait + jitter());
|
||||||
|
wait *= 2;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(IdxError::Http(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(IdxError::RateLimited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jitter() -> Duration {
|
||||||
|
let millis = (std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.subsec_millis() % 100)
|
||||||
|
.unwrap_or(42)) as u64;
|
||||||
|
Duration::from_millis(millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketDataProvider for YahooProvider {
|
||||||
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||||
|
let chart = self.fetch_chart(symbol, &Period::OneDay, &Interval::Day)?;
|
||||||
|
parse_quote(symbol, &chart)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history(&self, symbol: &str, period: &Period, interval: &Interval) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
let chart = self.fetch_chart(symbol, period, interval)?;
|
||||||
|
parse_history(&chart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||||
|
let result = chart
|
||||||
|
.chart
|
||||||
|
.result
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.first())
|
||||||
|
.ok_or(IdxError::ProviderUnavailable)?;
|
||||||
|
let meta = result.meta.as_ref().ok_or(IdxError::ProviderUnavailable)?;
|
||||||
|
let price = meta.regular_market_price.ok_or(IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||||
|
let prev_close = meta.previous_close;
|
||||||
|
let change = prev_close.map_or(0.0, |p| price - p);
|
||||||
|
let change_pct = prev_close.map_or(0.0, |p| if p != 0.0 { (change / p) * 100.0 } else { 0.0 });
|
||||||
|
|
||||||
|
let (week52_position, range_signal) = match (meta.fifty_two_week_low, meta.fifty_two_week_high) {
|
||||||
|
(Some(low), Some(high)) if high > low => {
|
||||||
|
let pos = (price - low) / (high - low);
|
||||||
|
let signal = if pos > 0.66 {
|
||||||
|
"upper"
|
||||||
|
} else if pos < 0.33 {
|
||||||
|
"lower"
|
||||||
|
} else {
|
||||||
|
"middle"
|
||||||
|
};
|
||||||
|
(Some(pos), Some(signal.to_string()))
|
||||||
|
}
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Quote {
|
||||||
|
symbol: meta.symbol.clone().unwrap_or_else(|| symbol.to_string()),
|
||||||
|
price,
|
||||||
|
change,
|
||||||
|
change_pct,
|
||||||
|
volume: meta.regular_market_volume.unwrap_or(0),
|
||||||
|
market_cap: meta.market_cap,
|
||||||
|
week52_high: meta.fifty_two_week_high,
|
||||||
|
week52_low: meta.fifty_two_week_low,
|
||||||
|
week52_position,
|
||||||
|
range_signal,
|
||||||
|
prev_close,
|
||||||
|
avg_volume: meta.average_daily_volume_3month,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_history(chart: &ChartResponse) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
let result = chart
|
||||||
|
.chart
|
||||||
|
.result
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.first())
|
||||||
|
.ok_or(IdxError::ProviderUnavailable)?;
|
||||||
|
let timestamps = result.timestamp.as_ref().ok_or(IdxError::ProviderUnavailable)?;
|
||||||
|
let quote = result
|
||||||
|
.indicators
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|i| i.quote.as_ref())
|
||||||
|
.and_then(|q| q.first())
|
||||||
|
.ok_or(IdxError::ProviderUnavailable)?;
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (i, ts) in timestamps.iter().enumerate() {
|
||||||
|
let open = quote.open.as_ref().and_then(|v| v.get(i).copied().flatten());
|
||||||
|
let high = quote.high.as_ref().and_then(|v| v.get(i).copied().flatten());
|
||||||
|
let low = quote.low.as_ref().and_then(|v| v.get(i).copied().flatten());
|
||||||
|
let close = quote.close.as_ref().and_then(|v| v.get(i).copied().flatten());
|
||||||
|
let volume = quote.volume.as_ref().and_then(|v| v.get(i).copied().flatten());
|
||||||
|
|
||||||
|
if let (Some(open), Some(high), Some(low), Some(close), Some(volume)) =
|
||||||
|
(open, high, low, close, volume)
|
||||||
|
&& let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0)
|
||||||
|
{
|
||||||
|
out.push(Ohlc {
|
||||||
|
date: dt.date_naive(),
|
||||||
|
open,
|
||||||
|
high,
|
||||||
|
low,
|
||||||
|
close,
|
||||||
|
volume,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChartResponse {
|
||||||
|
chart: ChartRoot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChartRoot {
|
||||||
|
result: Option<Vec<ChartResult>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChartResult {
|
||||||
|
meta: Option<ChartMeta>,
|
||||||
|
timestamp: Option<Vec<i64>>,
|
||||||
|
indicators: Option<Indicators>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ChartMeta {
|
||||||
|
symbol: Option<String>,
|
||||||
|
regular_market_price: Option<f64>,
|
||||||
|
previous_close: Option<f64>,
|
||||||
|
regular_market_volume: Option<u64>,
|
||||||
|
market_cap: Option<f64>,
|
||||||
|
fifty_two_week_high: Option<f64>,
|
||||||
|
fifty_two_week_low: Option<f64>,
|
||||||
|
average_daily_volume_3month: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Indicators {
|
||||||
|
quote: Option<Vec<IndicatorQuote>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IndicatorQuote {
|
||||||
|
open: Option<Vec<Option<f64>>>,
|
||||||
|
high: Option<Vec<Option<f64>>>,
|
||||||
|
low: Option<Vec<Option<f64>>>,
|
||||||
|
close: Option<Vec<Option<f64>>>,
|
||||||
|
volume: Option<Vec<Option<u64>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{parse_history, parse_quote, ChartResponse};
|
||||||
|
|
||||||
|
const SAMPLE: &str = r#"{
|
||||||
|
"chart": {
|
||||||
|
"result": [{
|
||||||
|
"meta": {
|
||||||
|
"symbol": "BBCA.JK",
|
||||||
|
"regularMarketPrice": 9875.0,
|
||||||
|
"previousClose": 9758.0,
|
||||||
|
"regularMarketVolume": 12300000,
|
||||||
|
"marketCap": 1215200000000000,
|
||||||
|
"fiftyTwoWeekHigh": 10250.0,
|
||||||
|
"fiftyTwoWeekLow": 7800.0,
|
||||||
|
"averageDailyVolume3Month": 10000000
|
||||||
|
},
|
||||||
|
"timestamp": [1709251200,1709337600],
|
||||||
|
"indicators": {"quote":[{
|
||||||
|
"open":[9800.0,9850.0],
|
||||||
|
"high":[9900.0,9900.0],
|
||||||
|
"low":[9750.0,9800.0],
|
||||||
|
"close":[9875.0,9880.0],
|
||||||
|
"volume":[12300000,11000000]
|
||||||
|
}]}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_quote_and_history() {
|
||||||
|
let chart: ChartResponse = serde_json::from_str(SAMPLE).expect("valid chart fixture");
|
||||||
|
let quote = parse_quote("BBCA.JK", &chart).expect("quote parsed");
|
||||||
|
assert_eq!(quote.symbol, "BBCA.JK");
|
||||||
|
assert_eq!(quote.price, 9875.0);
|
||||||
|
let history = parse_history(&chart).expect("history parsed");
|
||||||
|
assert_eq!(history.len(), 2);
|
||||||
|
assert_eq!(history[0].close, 9875.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/cache.rs
Normal file
1
src/cache.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// Cache module placeholder for MVP foundation.
|
||||||
13
src/cli/cache.rs
Normal file
13
src/cli/cache.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct CacheCmd {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: CacheSubcommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum CacheSubcommand {
|
||||||
|
Info,
|
||||||
|
Clear,
|
||||||
|
}
|
||||||
15
src/cli/config.rs
Normal file
15
src/cli/config.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct ConfigCmd {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: ConfigSubcommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum ConfigSubcommand {
|
||||||
|
Init,
|
||||||
|
Get { key: String },
|
||||||
|
Set { key: String, value: String },
|
||||||
|
Path,
|
||||||
|
}
|
||||||
38
src/cli/mod.rs
Normal file
38
src/cli/mod.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
pub mod cache;
|
||||||
|
pub mod config;
|
||||||
|
pub mod stocks;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
|
|
||||||
|
use crate::output::OutputFormat;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, ValueEnum)]
|
||||||
|
pub enum Shell {
|
||||||
|
Bash,
|
||||||
|
Zsh,
|
||||||
|
Fish,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(name = "idx", about = "Indonesian stock analysis CLI")]
|
||||||
|
pub struct Cli {
|
||||||
|
#[arg(short, long, value_enum, global = true, default_value_t = OutputFormat::Table)]
|
||||||
|
pub output: OutputFormat,
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub no_color: bool,
|
||||||
|
#[arg(short, long, global = true)]
|
||||||
|
pub quiet: bool,
|
||||||
|
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||||
|
pub verbose: u8,
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
Stocks(stocks::StocksCmd),
|
||||||
|
Config(config::ConfigCmd),
|
||||||
|
Cache(cache::CacheCmd),
|
||||||
|
Completions { shell: Shell },
|
||||||
|
Version,
|
||||||
|
}
|
||||||
51
src/cli/stocks.rs
Normal file
51
src/cli/stocks.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
use crate::api::types::{Interval, Period};
|
||||||
|
use crate::api::MarketDataProvider;
|
||||||
|
use crate::config::IdxConfig;
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use crate::output::{render_history, render_quotes};
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct StocksCmd {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: StocksSubcommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum StocksSubcommand {
|
||||||
|
Quote { symbols: Vec<String> },
|
||||||
|
History {
|
||||||
|
symbol: String,
|
||||||
|
#[arg(long, value_enum, default_value_t = Period::ThreeMonths)]
|
||||||
|
period: Period,
|
||||||
|
#[arg(long, value_enum, default_value_t = Interval::Day)]
|
||||||
|
interval: Interval,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(
|
||||||
|
cmd: &StocksCmd,
|
||||||
|
config: &IdxConfig,
|
||||||
|
provider: &dyn MarketDataProvider,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
match &cmd.command {
|
||||||
|
StocksSubcommand::Quote { symbols } => {
|
||||||
|
let mut quotes = Vec::new();
|
||||||
|
for sym in symbols.iter().flat_map(|s| s.split(',')) {
|
||||||
|
let resolved = crate::api::resolve_symbol(sym, &config.exchange);
|
||||||
|
quotes.push(provider.quote(&resolved)?);
|
||||||
|
}
|
||||||
|
render_quotes("es, &config.output, config.no_color)
|
||||||
|
}
|
||||||
|
StocksSubcommand::History {
|
||||||
|
symbol,
|
||||||
|
period,
|
||||||
|
interval,
|
||||||
|
} => {
|
||||||
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||||
|
let history = provider.history(&resolved, period, interval)?;
|
||||||
|
render_history(&resolved, &history, &config.output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/config.rs
Normal file
121
src/config.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::cli::Cli;
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use crate::output::OutputFormat;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IdxConfig {
|
||||||
|
pub exchange: String,
|
||||||
|
pub output: OutputFormat,
|
||||||
|
pub no_color: bool,
|
||||||
|
pub quote_ttl: u64,
|
||||||
|
pub fundamental_ttl: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct FileConfig {
|
||||||
|
general: Option<FileGeneral>,
|
||||||
|
cache: Option<FileCache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct FileGeneral {
|
||||||
|
exchange: Option<String>,
|
||||||
|
output: Option<OutputFormat>,
|
||||||
|
color: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct FileCache {
|
||||||
|
quote_ttl: Option<u64>,
|
||||||
|
fundamental_ttl: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IdxConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
exchange: "JK".to_string(),
|
||||||
|
output: OutputFormat::Table,
|
||||||
|
no_color: false,
|
||||||
|
quote_ttl: 300,
|
||||||
|
fundamental_ttl: 3600,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdxConfig {
|
||||||
|
pub fn load_with_cli(cli: &Cli) -> Result<Self, IdxError> {
|
||||||
|
let mut cfg = Self::load()?;
|
||||||
|
|
||||||
|
if let Ok(exchange) = std::env::var("IDX_EXCHANGE") {
|
||||||
|
cfg.exchange = exchange;
|
||||||
|
}
|
||||||
|
if let Ok(output) = std::env::var("IDX_OUTPUT") {
|
||||||
|
cfg.output = if output.eq_ignore_ascii_case("json") {
|
||||||
|
OutputFormat::Json
|
||||||
|
} else {
|
||||||
|
OutputFormat::Table
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Ok(no_color) = std::env::var("IDX_NO_COLOR") {
|
||||||
|
cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true");
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.output = cli.output;
|
||||||
|
cfg.no_color = cfg.no_color || cli.no_color;
|
||||||
|
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self, IdxError> {
|
||||||
|
let mut cfg = Self::default();
|
||||||
|
let path = config_path()?;
|
||||||
|
if path.exists() {
|
||||||
|
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
|
||||||
|
let parsed: FileConfig = toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
|
||||||
|
if let Some(general) = parsed.general {
|
||||||
|
if let Some(exchange) = general.exchange {
|
||||||
|
cfg.exchange = exchange;
|
||||||
|
}
|
||||||
|
if let Some(output) = general.output {
|
||||||
|
cfg.output = output;
|
||||||
|
}
|
||||||
|
if let Some(color) = general.color {
|
||||||
|
cfg.no_color = !color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(cache) = parsed.cache {
|
||||||
|
if let Some(v) = cache.quote_ttl {
|
||||||
|
cfg.quote_ttl = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = cache.fundamental_ttl {
|
||||||
|
cfg.fundamental_ttl = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_path() -> Result<PathBuf, IdxError> {
|
||||||
|
ProjectDirs::from("com", "idx", "idx")
|
||||||
|
.map(|d| d.config_dir().join("config.toml"))
|
||||||
|
.ok_or_else(|| IdxError::ConfigError("unable to resolve config dir".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::IdxConfig;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_values_are_sane() {
|
||||||
|
let cfg = IdxConfig::default();
|
||||||
|
assert_eq!(cfg.exchange, "JK");
|
||||||
|
assert_eq!(cfg.quote_ttl, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/error.rs
Normal file
69
src/error.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
use serde::Serialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum IdxError {
|
||||||
|
#[error("symbol not found: {0}")]
|
||||||
|
SymbolNotFound(String),
|
||||||
|
#[error("provider rate limited")]
|
||||||
|
RateLimited,
|
||||||
|
#[error("provider unavailable")]
|
||||||
|
ProviderUnavailable,
|
||||||
|
#[error("parse error: {0}")]
|
||||||
|
ParseError(String),
|
||||||
|
#[error("cache miss: {0}")]
|
||||||
|
CacheMiss(String),
|
||||||
|
#[error("config error: {0}")]
|
||||||
|
ConfigError(String),
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
#[error("http error: {0}")]
|
||||||
|
Http(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||||
|
pub enum ErrorCode {
|
||||||
|
SymbolNotFound,
|
||||||
|
RateLimited,
|
||||||
|
ProviderUnavailable,
|
||||||
|
ParseError,
|
||||||
|
CacheMiss,
|
||||||
|
ConfigError,
|
||||||
|
Io,
|
||||||
|
Http,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdxError {
|
||||||
|
pub fn code(&self) -> ErrorCode {
|
||||||
|
match self {
|
||||||
|
Self::SymbolNotFound(_) => ErrorCode::SymbolNotFound,
|
||||||
|
Self::RateLimited => ErrorCode::RateLimited,
|
||||||
|
Self::ProviderUnavailable => ErrorCode::ProviderUnavailable,
|
||||||
|
Self::ParseError(_) => ErrorCode::ParseError,
|
||||||
|
Self::CacheMiss(_) => ErrorCode::CacheMiss,
|
||||||
|
Self::ConfigError(_) => ErrorCode::ConfigError,
|
||||||
|
Self::Io(_) => ErrorCode::Io,
|
||||||
|
Self::Http(_) => ErrorCode::Http,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exit_code(&self) -> i32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{ErrorCode, IdxError};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_and_code_work() {
|
||||||
|
let err = IdxError::SymbolNotFound("BBCA".to_string());
|
||||||
|
assert_eq!(err.to_string(), "symbol not found: BBCA");
|
||||||
|
assert_eq!(err.code(), ErrorCode::SymbolNotFound);
|
||||||
|
|
||||||
|
let parse = IdxError::ParseError("bad json".to_string());
|
||||||
|
assert_eq!(parse.code(), ErrorCode::ParseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/main.rs
Normal file
54
src/main.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
mod api;
|
||||||
|
mod cache;
|
||||||
|
mod cli;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod output;
|
||||||
|
|
||||||
|
use clap::CommandFactory;
|
||||||
|
use clap::Parser;
|
||||||
|
use clap_complete::{generate, shells};
|
||||||
|
|
||||||
|
use crate::api::default_provider;
|
||||||
|
use crate::cli::{Cli, Commands};
|
||||||
|
use crate::config::IdxConfig;
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use crate::output::emit_error;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if let Err(err) = run() {
|
||||||
|
std::process::exit(err.exit_code());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<(), IdxError> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config = IdxConfig::load_with_cli(&cli)?;
|
||||||
|
|
||||||
|
match &cli.command {
|
||||||
|
Commands::Version => {
|
||||||
|
println!("{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
}
|
||||||
|
Commands::Completions { shell } => {
|
||||||
|
let mut cmd = Cli::command();
|
||||||
|
let name = cmd.get_name().to_owned();
|
||||||
|
match shell {
|
||||||
|
cli::Shell::Bash => generate(shells::Bash, &mut cmd, name, &mut std::io::stdout()),
|
||||||
|
cli::Shell::Zsh => generate(shells::Zsh, &mut cmd, name, &mut std::io::stdout()),
|
||||||
|
cli::Shell::Fish => generate(shells::Fish, &mut cmd, name, &mut std::io::stdout()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::Stocks(stocks) => {
|
||||||
|
let provider = default_provider();
|
||||||
|
if let Err(err) = cli::stocks::handle(stocks, &config, provider.as_ref()) {
|
||||||
|
emit_error(&err, &config.output);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::Config(_) | Commands::Cache(_) => {
|
||||||
|
println!("Not implemented yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
9
src/output/json.rs
Normal file
9
src/output/json.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
pub fn print_json<T: Serialize + ?Sized>(value: &T) -> Result<(), IdxError> {
|
||||||
|
let out = serde_json::to_string_pretty(value).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||||
|
println!("{out}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
44
src/output/mod.rs
Normal file
44
src/output/mod.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
pub mod json;
|
||||||
|
pub mod table;
|
||||||
|
|
||||||
|
use clap::ValueEnum;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::api::types::{Ohlc, Quote};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum OutputFormat {
|
||||||
|
#[default]
|
||||||
|
Table,
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_quotes(quotes: &[Quote], format: &OutputFormat, no_color: bool) -> Result<(), IdxError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => table::print_quotes(quotes, no_color),
|
||||||
|
OutputFormat::Json => json::print_json(quotes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_history(symbol: &str, history: &[Ohlc], format: &OutputFormat) -> Result<(), IdxError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => table::print_history(symbol, history),
|
||||||
|
OutputFormat::Json => json::print_json(history),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_error(err: &IdxError, format: &OutputFormat) {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => eprintln!("Error: {err}"),
|
||||||
|
OutputFormat::Json => {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"error": true,
|
||||||
|
"code": format!("{:?}", err.code()).to_uppercase(),
|
||||||
|
"message": err.to_string()
|
||||||
|
});
|
||||||
|
eprintln!("{}", serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/output/table.rs
Normal file
80
src/output/table.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
|
|
||||||
|
use crate::api::types::{Ohlc, Quote};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
pub fn format_idr(value: f64) -> String {
|
||||||
|
let rounded = value.round() as i64;
|
||||||
|
let chars: Vec<char> = rounded.to_string().chars().rev().collect();
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if i > 0 && i % 3 == 0 {
|
||||||
|
out.push(',');
|
||||||
|
}
|
||||||
|
out.push(*ch);
|
||||||
|
}
|
||||||
|
out.chars().rev().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec!["SYMBOL", "PRICE", "CHG", "CHG%", "VOLUME", "MKT CAP"]);
|
||||||
|
|
||||||
|
for q in quotes {
|
||||||
|
let pct = format!("{:+.2}%", q.change_pct);
|
||||||
|
let pct_cell = if no_color {
|
||||||
|
Cell::new(pct)
|
||||||
|
} else if q.change_pct >= 0.0 {
|
||||||
|
Cell::new(pct).fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Cell::new(pct).fg(Color::Red)
|
||||||
|
};
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new(&q.symbol),
|
||||||
|
Cell::new(format_idr(q.price)),
|
||||||
|
Cell::new(format!("{:+.2}", q.change)),
|
||||||
|
pct_cell,
|
||||||
|
Cell::new(format_idr(q.volume as f64)),
|
||||||
|
Cell::new(q.market_cap.map(format_idr).unwrap_or_else(|| "-".to_string())),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{table}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> {
|
||||||
|
println!("{}", format!("History for {symbol}").bold());
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec!["DATE", "OPEN", "HIGH", "LOW", "CLOSE", "VOLUME"]);
|
||||||
|
for item in history {
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new(item.date),
|
||||||
|
Cell::new(format_idr(item.open)),
|
||||||
|
Cell::new(format_idr(item.high)),
|
||||||
|
Cell::new(format_idr(item.low)),
|
||||||
|
Cell::new(format_idr(item.close)),
|
||||||
|
Cell::new(format_idr(item.volume as f64)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::format_idr;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_idr_numbers() {
|
||||||
|
assert_eq!(format_idr(9875.0), "9,875");
|
||||||
|
assert_eq!(format_idr(1_215_200_000_000_000.0), "1,215,200,000,000,000");
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tests/cli.rs
Normal file
54
tests/cli.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
use assert_cmd::Command;
|
||||||
|
use predicates::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_works() {
|
||||||
|
Command::cargo_bin("idx-cli")
|
||||||
|
.expect("binary exists")
|
||||||
|
.arg("--help")
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_prints_cargo_version() {
|
||||||
|
Command::cargo_bin("idx-cli")
|
||||||
|
.expect("binary exists")
|
||||||
|
.arg("version")
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quote_with_mock_provider_json() {
|
||||||
|
Command::cargo_bin("idx-cli")
|
||||||
|
.expect("binary exists")
|
||||||
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
|
.args(["-o", "json", "stocks", "quote", "BBCA,BBRI"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("BBCA.JK"))
|
||||||
|
.stdout(predicate::str::contains("BBRI.JK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_with_mock_provider_json() {
|
||||||
|
Command::cargo_bin("idx-cli")
|
||||||
|
.expect("binary exists")
|
||||||
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
|
.args([
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
"stocks",
|
||||||
|
"history",
|
||||||
|
"BBCA",
|
||||||
|
"--period",
|
||||||
|
"3mo",
|
||||||
|
"--interval",
|
||||||
|
"1d",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("2026-03-01"));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue