diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs new file mode 100644 index 0000000..09c14c3 --- /dev/null +++ b/src/analysis/mod.rs @@ -0,0 +1,2 @@ +pub mod signals; +pub mod technical; diff --git a/src/analysis/signals.rs b/src/analysis/signals.rs new file mode 100644 index 0000000..6564112 --- /dev/null +++ b/src/analysis/signals.rs @@ -0,0 +1,102 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Signal { + Bullish, + Bearish, + Neutral, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TechnicalSignal { + pub rsi: Signal, + pub macd: Signal, + pub trend: Signal, + pub overall: Signal, +} + +pub fn interpret_rsi(value: f64) -> Signal { + if value > 70.0 { + Signal::Bearish + } else if value < 30.0 { + Signal::Bullish + } else { + Signal::Neutral + } +} + +pub fn interpret_macd(histogram: f64, prev_histogram: Option) -> Signal { + if let Some(prev) = prev_histogram { + if histogram > 0.0 && histogram > prev { + Signal::Bullish + } else if histogram < 0.0 && histogram < prev { + Signal::Bearish + } else { + Signal::Neutral + } + } else { + Signal::Neutral + } +} + +pub fn interpret_trend(price: f64, sma50: Option, sma200: Option) -> Signal { + match (sma50, sma200) { + (Some(s50), Some(s200)) if price > s50 && price > s200 => Signal::Bullish, + (Some(s50), Some(s200)) if price < s50 && price < s200 => Signal::Bearish, + _ => Signal::Neutral, + } +} + +pub fn overall_signal(rsi: Signal, macd: Signal, trend: Signal) -> Signal { + let signals = [rsi, macd, trend]; + let bullish = signals.iter().filter(|&&s| s == Signal::Bullish).count(); + let bearish = signals.iter().filter(|&&s| s == Signal::Bearish).count(); + + if bullish >= 2 { + Signal::Bullish + } else if bearish >= 2 { + Signal::Bearish + } else { + Signal::Neutral + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interpret_rsi_thresholds() { + assert_eq!(interpret_rsi(75.0), Signal::Bearish); + assert_eq!(interpret_rsi(25.0), Signal::Bullish); + assert_eq!(interpret_rsi(50.0), Signal::Neutral); + } + + #[test] + fn interpret_trend_thresholds() { + assert_eq!( + interpret_trend(120.0, Some(100.0), Some(110.0)), + Signal::Bullish + ); + assert_eq!( + interpret_trend(80.0, Some(100.0), Some(90.0)), + Signal::Bearish + ); + } + + #[test] + fn overall_majority_vote() { + assert_eq!( + overall_signal(Signal::Bullish, Signal::Bullish, Signal::Neutral), + Signal::Bullish + ); + assert_eq!( + overall_signal(Signal::Bearish, Signal::Neutral, Signal::Bearish), + Signal::Bearish + ); + assert_eq!( + overall_signal(Signal::Bullish, Signal::Bearish, Signal::Neutral), + Signal::Neutral + ); + } +} diff --git a/src/analysis/technical.rs b/src/analysis/technical.rs new file mode 100644 index 0000000..359796d --- /dev/null +++ b/src/analysis/technical.rs @@ -0,0 +1,212 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct MacdResult { + pub macd_line: Vec>, + pub signal_line: Vec>, + pub histogram: Vec>, +} + +pub fn sma(data: &[f64], period: usize) -> Vec> { + let mut result = vec![None; data.len()]; + if period == 0 || period > data.len() { + return result; + } + + let mut window_sum: f64 = data[..period].iter().sum(); + result[period - 1] = Some(window_sum / period as f64); + + for idx in period..data.len() { + window_sum += data[idx] - data[idx - period]; + result[idx] = Some(window_sum / period as f64); + } + + result +} + +pub fn ema(data: &[f64], period: usize) -> Vec> { + let mut result = vec![None; data.len()]; + if period == 0 || period > data.len() { + return result; + } + + let multiplier = 2.0 / (period as f64 + 1.0); + let seed = data[..period].iter().sum::() / period as f64; + result[period - 1] = Some(seed); + + let mut prev = seed; + for idx in period..data.len() { + let current = ((data[idx] - prev) * multiplier) + prev; + result[idx] = Some(current); + prev = current; + } + + result +} + +pub fn rsi(closes: &[f64], period: usize) -> Vec> { + let mut result = vec![None; closes.len()]; + if period == 0 || closes.len() <= period { + return result; + } + + let mut gains = 0.0; + let mut losses = 0.0; + + for idx in 1..=period { + let change = closes[idx] - closes[idx - 1]; + if change >= 0.0 { + gains += change; + } else { + losses += -change; + } + } + + let mut avg_gain = gains / period as f64; + let mut avg_loss = losses / period as f64; + + result[period] = Some(rsi_from_averages(avg_gain, avg_loss)); + + for idx in (period + 1)..closes.len() { + let change = closes[idx] - closes[idx - 1]; + let gain = if change > 0.0 { change } else { 0.0 }; + let loss = if change < 0.0 { -change } else { 0.0 }; + + avg_gain = ((avg_gain * (period as f64 - 1.0)) + gain) / period as f64; + avg_loss = ((avg_loss * (period as f64 - 1.0)) + loss) / period as f64; + + result[idx] = Some(rsi_from_averages(avg_gain, avg_loss)); + } + + result +} + +fn rsi_from_averages(avg_gain: f64, avg_loss: f64) -> f64 { + if avg_loss == 0.0 { + return 100.0; + } + + let rs = avg_gain / avg_loss; + 100.0 - (100.0 / (1.0 + rs)) +} + +pub fn macd(closes: &[f64], fast: usize, slow: usize, signal: usize) -> MacdResult { + let len = closes.len(); + let mut macd_line = vec![None; len]; + let mut signal_line = vec![None; len]; + let mut histogram = vec![None; len]; + + if len == 0 || fast == 0 || slow == 0 || signal == 0 { + return MacdResult { + macd_line, + signal_line, + histogram, + }; + } + + let fast_ema = ema(closes, fast); + let slow_ema = ema(closes, slow); + + for idx in 0..len { + if let (Some(f), Some(s)) = (fast_ema[idx], slow_ema[idx]) { + macd_line[idx] = Some(f - s); + } + } + + let mut signal_seed = Vec::new(); + let signal_multiplier = 2.0 / (signal as f64 + 1.0); + let mut prev_signal = None; + + for idx in 0..len { + if let Some(value) = macd_line[idx] { + if prev_signal.is_none() { + signal_seed.push(value); + if signal_seed.len() == signal { + let seed = signal_seed.iter().sum::() / signal as f64; + signal_line[idx] = Some(seed); + prev_signal = Some(seed); + histogram[idx] = Some(value - seed); + } + } else if let Some(prev) = prev_signal { + let current = ((value - prev) * signal_multiplier) + prev; + signal_line[idx] = Some(current); + prev_signal = Some(current); + histogram[idx] = Some(value - current); + } + } + } + + MacdResult { + macd_line, + signal_line, + histogram, + } +} + +pub fn volume_ratio(volumes: &[f64], period: usize) -> Option { + if period == 0 || volumes.len() < period { + return None; + } + + let start = volumes.len() - period; + let avg = volumes[start..].iter().sum::() / period as f64; + if avg == 0.0 { + return None; + } + + volumes.last().map(|last| *last / avg) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx_eq(left: f64, right: f64, eps: f64) { + assert!((left - right).abs() <= eps, "left={left}, right={right}"); + } + + #[test] + fn sma_returns_expected_values() { + let data = [1.0, 2.0, 3.0, 4.0, 5.0]; + let values = sma(&data, 3); + assert_eq!(values, vec![None, None, Some(2.0), Some(3.0), Some(4.0)]); + } + + #[test] + fn rsi_returns_seeded_none_and_known_value() { + // Classic Wilder example dataset; RSI(14) first computed value ~= 70.46. + let closes = [ + 44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, + 45.61, 46.28, 46.28, + ]; + let period = 14; + let values = rsi(&closes, period); + + assert_eq!(values.len(), closes.len()); + assert!(values.iter().take(period).all(Option::is_none)); + + let rsi_14 = values[period].expect("expected first RSI value"); + approx_eq(rsi_14, 70.46, 0.05); + } + + #[test] + fn macd_shapes_are_correct() { + let closes: Vec = (1..=60).map(|n| n as f64).collect(); + let result = macd(&closes, 12, 26, 9); + + assert_eq!(result.macd_line.len(), closes.len()); + + let macd_nones = result.macd_line.iter().filter(|v| v.is_none()).count(); + let signal_nones = result.signal_line.iter().filter(|v| v.is_none()).count(); + assert!(signal_nones > macd_nones); + } + + #[test] + fn volume_ratio_checks() { + let volumes = [100.0, 120.0, 130.0, 150.0]; + let ratio = volume_ratio(&volumes, 3).expect("ratio should exist"); + approx_eq(ratio, 150.0 / ((120.0 + 130.0 + 150.0) / 3.0), 1e-10); + + assert_eq!(volume_ratio(&volumes, 5), None); + } +} diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 48b9abe..3230425 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -1,11 +1,15 @@ use clap::{Args, Subcommand}; +use crate::analysis::signals::{self, Signal, TechnicalSignal}; +use crate::analysis::technical; use crate::api::MarketDataProvider; -use crate::api::types::{Interval, Period}; +use crate::api::types::{Interval, Ohlc, Period}; use crate::cache::Cache; use crate::config::IdxConfig; use crate::error::IdxError; -use crate::output::{render_history, render_quotes}; +use crate::output::{ + MacdSnapshot, TechnicalReport, VolumeSnapshot, render_history, render_quotes, render_technical, +}; #[derive(Debug, Args)] #[command(about = "Stock data and analysis")] @@ -36,6 +40,14 @@ pub enum StocksSubcommand { #[arg(long, value_enum, default_value_t = Interval::Day)] interval: Interval, }, + #[command( + about = "Run technical analysis on a stock", + after_help = "Examples:\n idx stocks technical BBCA\n idx -o json stocks technical BBCA" + )] + Technical { + /// Single ticker symbol (e.g. BBCA). + symbol: String, + }, } pub fn handle( @@ -134,5 +146,155 @@ pub fn handle( } } } + StocksSubcommand::Technical { symbol } => { + let resolved = crate::api::resolve_symbol(symbol, &config.exchange); + if !no_cache + && let Some(report) = cache.get::("technical", &resolved)? + { + return render_technical(&report, &config.output, config.no_color); + } + if offline { + let stale = cache + .get_stale::("technical", &resolved)? + .ok_or_else(|| IdxError::CacheMiss(format!("technical/{resolved}")))?; + return render_technical(&stale, &config.output, config.no_color); + } + + match provider.history(&resolved, &Period::OneYear, &Interval::Day) { + Ok(history) => { + let report = build_technical_report(&resolved, &history)?; + if !no_cache { + cache.put("technical", &resolved, &report, config.quote_ttl)?; + } + render_technical(&report, &config.output, config.no_color) + } + Err(err) => { + if !no_cache + && let Some(stale) = + cache.get_stale::("technical", &resolved)? + { + eprintln!("warning: network failed, serving stale cache for {resolved}"); + return render_technical(&stale, &config.output, config.no_color); + } + Err(err) + } + } + } + } +} + +fn build_technical_report(symbol: &str, history: &[Ohlc]) -> Result { + let latest = history + .last() + .ok_or_else(|| IdxError::ParseError(format!("no history available for {symbol}")))?; + let closes: Vec = history.iter().map(|item| item.close as f64).collect(); + let volumes: Vec = history.iter().map(|item| item.volume as f64).collect(); + + let sma20 = last_value(&technical::sma(&closes, 20)); + let sma50 = last_value(&technical::sma(&closes, 50)); + let sma200 = last_value(&technical::sma(&closes, 200)); + let rsi14 = last_value(&technical::rsi(&closes, 14)); + let macd = technical::macd(&closes, 12, 26, 9); + let macd_line = last_value(&macd.macd_line); + let signal_line = last_value(&macd.signal_line); + let histogram = last_value(&macd.histogram); + let previous_histogram = previous_value(&macd.histogram); + let average_volume20 = average_last(&volumes, 20); + let volume_ratio20 = technical::volume_ratio(&volumes, 20); + + let rsi_signal = rsi14.map_or(Signal::Neutral, signals::interpret_rsi); + let macd_signal = histogram + .map(|value| signals::interpret_macd(value, previous_histogram)) + .unwrap_or(Signal::Neutral); + let trend_signal = signals::interpret_trend(latest.close as f64, sma50, sma200); + let overall = signals::overall_signal(rsi_signal, macd_signal, trend_signal); + + Ok(TechnicalReport { + symbol: symbol.to_string(), + as_of: latest.date, + current_price: latest.close, + sma20, + sma50, + sma200, + rsi14, + macd: MacdSnapshot { + line: macd_line, + signal: signal_line, + histogram, + }, + volume: VolumeSnapshot { + current: latest.volume, + average20: average_volume20, + ratio20: volume_ratio20, + }, + signals: TechnicalSignal { + rsi: rsi_signal, + macd: macd_signal, + trend: trend_signal, + overall, + }, + }) +} + +fn last_value(values: &[Option]) -> Option { + values.iter().rev().find_map(|value| *value) +} + +fn previous_value(values: &[Option]) -> Option { + let mut seen_latest = false; + for value in values.iter().rev() { + if value.is_some() { + if seen_latest { + return *value; + } + seen_latest = true; + } + } + None +} + +fn average_last(values: &[f64], period: usize) -> Option { + if period == 0 || values.len() < period { + return None; + } + + let start = values.len() - period; + Some(values[start..].iter().sum::() / period as f64) +} + +#[cfg(test)] +mod tests { + use chrono::{Days, NaiveDate}; + + use super::build_technical_report; + use crate::analysis::signals::Signal; + use crate::api::types::Ohlc; + + #[test] + fn technical_report_uses_latest_values() { + let start = NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); + let history: Vec = (0..60) + .map(|idx| Ohlc { + date: start + .checked_add_days(Days::new(idx as u64)) + .expect("valid offset"), + open: 100 + idx as i64, + high: 101 + idx as i64, + low: 99 + idx as i64, + close: 100 + idx as i64, + volume: 1_000 + idx as u64 * 10, + }) + .collect(); + + let report = build_technical_report("BBCA.JK", &history).expect("report should build"); + + assert_eq!(report.symbol, "BBCA.JK"); + assert_eq!(report.current_price, 159); + assert!(report.sma20.is_some()); + assert!(report.sma50.is_some()); + assert_eq!(report.sma200, None); + assert!(report.rsi14.is_some()); + assert!(report.volume.ratio20.is_some()); + assert_eq!(report.signals.trend, Signal::Neutral); } } diff --git a/src/main.rs b/src/main.rs index 59b82c4..243bcbc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +pub mod analysis; mod api; mod cache; mod cli; diff --git a/src/output/mod.rs b/src/output/mod.rs index 9d7a272..3b3e4b1 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,9 +1,11 @@ pub mod json; pub mod table; +use chrono::NaiveDate; use clap::ValueEnum; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use crate::analysis::signals::TechnicalSignal; use crate::api::types::{Ohlc, Quote}; use crate::error::IdxError; @@ -15,6 +17,34 @@ pub enum OutputFormat { Json, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TechnicalReport { + pub symbol: String, + pub as_of: NaiveDate, + pub current_price: i64, + pub sma20: Option, + pub sma50: Option, + pub sma200: Option, + pub rsi14: Option, + pub macd: MacdSnapshot, + pub volume: VolumeSnapshot, + pub signals: TechnicalSignal, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MacdSnapshot { + pub line: Option, + pub signal: Option, + pub histogram: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolumeSnapshot { + pub current: u64, + pub average20: Option, + pub ratio20: Option, +} + pub fn render_quotes( quotes: &[Quote], format: &OutputFormat, @@ -37,6 +67,17 @@ pub fn render_history( } } +pub fn render_technical( + report: &TechnicalReport, + format: &OutputFormat, + no_color: bool, +) -> Result<(), IdxError> { + match format { + OutputFormat::Table => table::print_technical(report, no_color), + OutputFormat::Json => json::print_json(report), + } +} + pub fn emit_error(err: &IdxError, format: &OutputFormat) { match format { OutputFormat::Table => eprintln!("Error: {err}"), diff --git a/src/output/table.rs b/src/output/table.rs index 540d15d..5641bec 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -1,8 +1,10 @@ use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL}; use owo_colors::OwoColorize; +use crate::analysis::signals::Signal; use crate::api::types::{Ohlc, Quote}; use crate::error::IdxError; +use crate::output::TechnicalReport; pub fn format_idr(value: i64) -> String { let chars: Vec = value.to_string().chars().rev().collect(); @@ -94,13 +96,160 @@ pub fn print_history(symbol: &str, history: &[Ohlc]) -> Result<(), IdxError> { Ok(()) } +pub fn print_technical(report: &TechnicalReport, no_color: bool) -> Result<(), IdxError> { + println!( + "{}", + format!( + "Technical Analysis for {} ({})", + report.symbol, report.as_of + ) + .bold() + ); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec!["METRIC", "VALUE", "SIGNAL"]); + + table.add_row(vec![ + Cell::new("Current Price"), + Cell::new(format_idr(report.current_price)), + Cell::new("-"), + ]); + table.add_row(vec![ + Cell::new("SMA 20"), + Cell::new(format_idr_option(report.sma20)), + Cell::new("-"), + ]); + table.add_row(vec![ + Cell::new("SMA 50"), + Cell::new(format_idr_option(report.sma50)), + Cell::new("-"), + ]); + table.add_row(vec![ + Cell::new("SMA 200"), + Cell::new(format_idr_option(report.sma200)), + Cell::new("-"), + ]); + table.add_row(vec![ + Cell::new("RSI (14)"), + Cell::new(format_float(report.rsi14, 2)), + Cell::new(format_signal(report.signals.rsi, no_color, false)), + ]); + table.add_row(vec![ + Cell::new("MACD (12,26,9)"), + Cell::new(format!( + "{}/{}/{}", + format_float(report.macd.line, 2), + format_float(report.macd.signal, 2), + format_float(report.macd.histogram, 2) + )), + Cell::new(format_signal(report.signals.macd, no_color, false)), + ]); + table.add_row(vec![ + Cell::new("Trend"), + Cell::new(trend_context(report)), + Cell::new(format_signal(report.signals.trend, no_color, false)), + ]); + table.add_row(vec![ + Cell::new("Volume Ratio (20)"), + Cell::new(format_volume_ratio(report)), + Cell::new("-"), + ]); + table.add_row(vec![ + Cell::new("Overall Signal"), + Cell::new("-"), + Cell::new(format_signal(report.signals.overall, no_color, true)), + ]); + + println!("{table}"); + Ok(()) +} + +fn format_idr_option(value: Option) -> String { + value + .map(|v| format_idr(v.round() as i64)) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_float(value: Option, precision: usize) -> String { + value + .map(|v| format!("{v:.prec$}", prec = precision)) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_signal(signal: Signal, no_color: bool, uppercase: bool) -> String { + let label = if uppercase { + signal_label_upper(signal) + } else { + signal_label(signal) + }; + + if no_color { + return label.to_string(); + } + + match signal { + Signal::Bullish => label.green().to_string(), + Signal::Bearish => label.red().to_string(), + Signal::Neutral => label.yellow().to_string(), + } +} + +fn signal_label(signal: Signal) -> &'static str { + match signal { + Signal::Bullish => "Bullish", + Signal::Bearish => "Bearish", + Signal::Neutral => "Neutral", + } +} + +fn signal_label_upper(signal: Signal) -> &'static str { + match signal { + Signal::Bullish => "BULLISH", + Signal::Bearish => "BEARISH", + Signal::Neutral => "NEUTRAL", + } +} + +fn trend_context(report: &TechnicalReport) -> String { + match (report.sma50, report.sma200) { + (Some(sma50), Some(sma200)) => format!( + "{} vs SMA50 {}, SMA200 {}", + format_idr(report.current_price), + format_idr(sma50.round() as i64), + format_idr(sma200.round() as i64) + ), + _ => "Insufficient data".to_string(), + } +} + +fn format_volume_ratio(report: &TechnicalReport) -> String { + match (report.volume.ratio20, report.volume.average20) { + (Some(ratio), Some(avg)) => format!( + "{ratio:.2}x ({} vs {} avg)", + format_u64(report.volume.current), + format_u64(avg.round() as u64) + ), + _ => "-".to_string(), + } +} + #[cfg(test)] mod tests { - use super::{format_idr, format_u64}; + use super::{format_idr, format_signal, format_u64}; + use crate::analysis::signals::Signal; #[test] fn formats_idr_numbers() { assert_eq!(format_idr(9875), "9,875"); assert_eq!(format_u64(1_215_200_000_000_000), "1,215,200,000,000,000"); } + + #[test] + fn formats_plain_signal_labels() { + assert_eq!(format_signal(Signal::Bullish, true, false), "Bullish"); + assert_eq!(format_signal(Signal::Bearish, true, true), "BEARISH"); + } } diff --git a/tests/cli.rs b/tests/cli.rs index f615726..510d4eb 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,10 +1,10 @@ use std::fs; -use assert_cmd::{Command, cargo::cargo_bin}; +use assert_cmd::Command; use predicates::prelude::*; fn bin() -> Command { - Command::new(cargo_bin("idx-cli")) + Command::new(assert_cmd::cargo::cargo_bin!("idx-cli")) } fn test_env_dir(name: &str) -> std::path::PathBuf { @@ -64,6 +64,30 @@ fn history_with_mock_provider_table_contains_columns() { .stdout(predicate::str::contains("VOLUME")); } +#[test] +fn technical_with_mock_provider_table_contains_expected_rows() { + bin() + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["stocks", "technical", "BBCA"]) + .assert() + .success() + .stdout(predicate::str::contains("Technical Analysis for")) + .stdout(predicate::str::contains("RSI (14)")) + .stdout(predicate::str::contains("Overall Signal")); +} + +#[test] +fn technical_with_mock_provider_json_contains_fields() { + bin() + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stocks", "technical", "BBCA"]) + .assert() + .success() + .stdout(predicate::str::contains("\"symbol\"")) + .stdout(predicate::str::contains("\"sma20\"")) + .stdout(predicate::str::contains("\"signals\"")); +} + #[test] fn config_path_prints_path() { bin() @@ -129,6 +153,30 @@ fn serves_stale_cache_on_provider_failure_with_warning() { .stderr(predicate::str::contains("warning: network failed")); } +#[test] +fn technical_serves_stale_cache_on_provider_failure_with_warning() { + let root = test_env_dir("technical-stale"); + let cache_home = root.join("cache"); + + bin() + .env("XDG_CACHE_HOME", &cache_home) + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_CACHE_QUOTE_TTL", "0") + .args(["stocks", "technical", "BBCA"]) + .assert() + .success(); + + bin() + .env("XDG_CACHE_HOME", &cache_home) + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_CACHE_QUOTE_TTL", "0") + .env("IDX_MOCK_ERROR", "1") + .args(["stocks", "technical", "BBCA"]) + .assert() + .success() + .stderr(predicate::str::contains("warning: network failed")); +} + #[test] fn invalid_symbol_returns_non_zero() { bin()